2f173dc3e5
CI / backend (push) Successful in 1m33s
CI / frontend (push) Successful in 33s
CI / format (push) Successful in 2m2s
CI / db-tests (push) Successful in 1m45s
Deploy Staging / deploy (push) Successful in 38s
Security / secrets (push) Successful in 6s
Security / dependencies (push) Successful in 1m17s
Security / sast (push) Successful in 56s
CI / backend (pull_request) Successful in 1m9s
CI / frontend (pull_request) Successful in 21s
CI / format (pull_request) Successful in 1m4s
CI / db-tests (pull_request) Successful in 1m27s
Security / secrets (pull_request) Successful in 6s
Security / dependencies (pull_request) Successful in 1m21s
Security / sast (pull_request) Successful in 1m8s
206 lines
7.0 KiB
React
206 lines
7.0 KiB
React
import { useEffect, useState } from 'react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import { Line, Doughnut } from 'react-chartjs-2';
|
||
import { Inbox } from 'lucide-react';
|
||
import { AnalyticsApi } from '../api/client.js';
|
||
import { Skeleton } from './ui/skeleton.jsx';
|
||
import { EmptyState } from './ui/misc.jsx';
|
||
import {
|
||
Chart as ChartJS, CategoryScale, LinearScale, PointElement,
|
||
LineElement, ArcElement, Tooltip, Legend
|
||
} from 'chart.js';
|
||
|
||
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, ArcElement, Tooltip, Legend);
|
||
|
||
const fmtBytes = (b) => {
|
||
if (!b) return '0 B';
|
||
const u = ['B', 'KB', 'MB', 'GB']; let i = 0; let n = b;
|
||
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
|
||
return `${n.toFixed(1)} ${u[i]}`;
|
||
};
|
||
|
||
export function StatCard({ label, value, to }) {
|
||
const navigate = useNavigate();
|
||
return (
|
||
<div className={`widget stat${to ? ' widget--link' : ''}`} onClick={to ? () => navigate(to) : undefined} title={to ? `View ${label}` : undefined}>
|
||
<div className="stat-value">{value}</div>
|
||
<div className="stat-label">{label}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function HealthWidget({ health }) {
|
||
if (!health) return <Empty />;
|
||
return (
|
||
<div className="widget">
|
||
<h3>Inbox Health</h3>
|
||
<div className={`health-score grade-${health.grade}`}>{health.score}<span>/100</span></div>
|
||
<div className="grade">Grade {health.grade}</div>
|
||
<ul className="recs">{health.recommendations.map((r, i) => <li key={i}>{r}</li>)}</ul>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function TopSendersWidget({ senders }) {
|
||
const navigate = useNavigate();
|
||
if (!senders) return <Empty />;
|
||
return (
|
||
<div className="widget">
|
||
<h3>Top Senders</h3>
|
||
<table className="mini">
|
||
<tbody>
|
||
{senders.map((s) => (
|
||
<tr
|
||
key={s.senderId}
|
||
className="mini-row--link"
|
||
onClick={() => navigate(`/app/search?q=${encodeURIComponent(`from:${s.address}`)}`)}
|
||
title={`Search emails from ${s.address}`}
|
||
>
|
||
<td title={s.address}>{s.displayName || s.address}</td>
|
||
<td className="num">{s.emailCount}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function VolumeWidget({ volume }) {
|
||
if (!volume) return <Empty />;
|
||
const data = {
|
||
labels: volume.map((p) => p.day),
|
||
datasets: [{ label: 'Emails', data: volume.map((p) => p.count), borderColor: '#4f8cff', tension: 0.3 }]
|
||
};
|
||
return (
|
||
<div className="widget">
|
||
<h3>Email Volume</h3>
|
||
<Line data={data} options={{ plugins: { legend: { display: false } }, maintainAspectRatio: false }} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Maps EmailCategory enum name → folder slug or search query
|
||
const CAT_SLUG = {
|
||
Finance: '/app/folder/finance',
|
||
Social: '/app/folder/social',
|
||
Notification: '/app/folder/automated',
|
||
Promotional: '/app/folder/shopping',
|
||
Shopping: '/app/folder/shopping',
|
||
Gaming: '/app/folder/gaming',
|
||
RideSharing: '/app/folder/ridesharing',
|
||
FoodDelivery: '/app/folder/food',
|
||
SeasonalSales: '/app/folder/sales',
|
||
Wellness: '/app/folder/wellness',
|
||
Travel: '/app/folder/travel',
|
||
Subscriptions: '/app/folder/subscriptions',
|
||
Parcels: '/app/folder/parcels',
|
||
Recruitment: '/app/folder/recruitment',
|
||
Events: '/app/folder/events',
|
||
SecurityAlerts: '/app/folder/security',
|
||
Healthcare: '/app/folder/healthcare',
|
||
Education: '/app/folder/education',
|
||
NewsMedia: '/app/folder/news',
|
||
PropertyUtilities:'/app/folder/property',
|
||
Charity: '/app/folder/charity',
|
||
Government: '/app/folder/government',
|
||
CryptoInvesting: '/app/folder/crypto',
|
||
FamilySchool: '/app/folder/family',
|
||
Spam: '/app/folder/spam',
|
||
Newsletter: '/app/search?q=newsletter',
|
||
Personal: '/app/search?q=is:unread',
|
||
Unknown: '/app/folder/allmail',
|
||
};
|
||
|
||
// "Emails by Category" — ranked horizontal bar list. Aggregates the
|
||
// category×day-of-week cells into per-category totals; each bar links to
|
||
// that category's folder via CAT_SLUG.
|
||
export function CategoryBreakdownWidget() {
|
||
const [cells, setCells] = useState(null);
|
||
const navigate = useNavigate();
|
||
useEffect(() => { AnalyticsApi.categoryHeatmap().then(setCells).catch(() => setCells([])); }, []);
|
||
|
||
if (!cells) {
|
||
return (
|
||
<div className="widget">
|
||
<h3>Emails by Category</h3>
|
||
<div className="cat-bars">
|
||
{Array.from({ length: 6 }, (_, i) => <Skeleton key={i} className="h-6 w-full" />)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const totals = {};
|
||
cells.forEach((c) => { totals[c.category] = (totals[c.category] || 0) + c.count; });
|
||
const ranked = Object.entries(totals)
|
||
.map(([category, count]) => ({ category, count }))
|
||
.sort((a, b) => b.count - a.count)
|
||
.slice(0, 8);
|
||
|
||
if (ranked.length === 0) {
|
||
return (
|
||
<div className="widget">
|
||
<h3>Emails by Category</h3>
|
||
<EmptyState icon={Inbox} title="No data yet — run a sync" description="Category totals appear once your inbox has been synced." />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const max = Math.max(1, ...ranked.map((r) => r.count));
|
||
|
||
return (
|
||
<div className="widget">
|
||
<h3>Emails by Category</h3>
|
||
<div className="cat-bars">
|
||
{ranked.map(({ category, count }) => {
|
||
const dest = CAT_SLUG[category];
|
||
return (
|
||
<button
|
||
type="button"
|
||
className={`cat-bar${dest ? ' cat-bar--link' : ''}`}
|
||
key={category}
|
||
disabled={!dest}
|
||
title={dest ? `View ${category} emails` : category}
|
||
onClick={dest ? () => navigate(dest) : undefined}
|
||
>
|
||
<span className="cat-bar-label">{category}</span>
|
||
<span className="cat-bar-track">
|
||
<span className="cat-bar-fill" style={{ width: `${(count / max) * 100}%` }} />
|
||
</span>
|
||
<span className="cat-bar-count">{count.toLocaleString()}</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function AttachmentsWidget({ attachments }) {
|
||
if (!attachments) return <Empty />;
|
||
const data = {
|
||
labels: attachments.map((a) => a.mimeBucket),
|
||
datasets: [{ data: attachments.map((a) => a.totalBytes), backgroundColor: ['#4f8cff', '#6fcf97', '#f2c94c', '#eb5757', '#bb6bd9', '#56ccf2', '#a0a0a0'] }]
|
||
};
|
||
return (
|
||
<div className="widget">
|
||
<h3>Attachments by Type</h3>
|
||
<Doughnut data={data} options={{ plugins: { legend: { position: 'right' } }, maintainAspectRatio: false }} />
|
||
<div className="muted">Total: {fmtBytes(attachments.reduce((s, a) => s + a.totalBytes, 0))}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function StorageWidget({ bytes }) {
|
||
return <StatCard label="Estimated Storage" value={fmtBytes(bytes)} />;
|
||
}
|
||
|
||
function Empty() {
|
||
return (
|
||
<div className="widget">
|
||
<EmptyState icon={Inbox} title="No data yet — run a sync" description="This widget populates after your inbox has been synced." />
|
||
</div>
|
||
);
|
||
}
|