diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 915e771..6d8fb7e 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -38,7 +38,6 @@ export const AnalyticsApi = { health: () => api.get('/analytics/health').then((r) => r.data), topSenders: (take = 20) => api.get(`/analytics/top-senders?take=${take}`).then((r) => r.data), volume: (days = 90) => api.get(`/analytics/volume?days=${days}`).then((r) => r.data), - heatmap: () => api.get('/analytics/heatmap').then((r) => r.data), categoryHeatmap: () => api.get('/analytics/category-heatmap').then((r) => r.data), attachments: () => api.get('/analytics/attachments').then((r) => r.data), sidebarCounts: () => api.get('/analytics/sidebar-counts').then((r) => r.data), diff --git a/frontend/src/components/widgets.jsx b/frontend/src/components/widgets.jsx index 641f1a9..f459dc3 100644 --- a/frontend/src/components/widgets.jsx +++ b/frontend/src/components/widgets.jsx @@ -1,13 +1,16 @@ import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Bar, Line, Doughnut } from 'react-chartjs-2'; +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, BarElement, PointElement, + Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, ArcElement, Tooltip, Legend } from 'chart.js'; -ChartJS.register(CategoryScale, LinearScale, BarElement, PointElement, LineElement, ArcElement, Tooltip, Legend); +ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, ArcElement, Tooltip, Legend); const fmtBytes = (b) => { if (!b) return '0 B'; @@ -77,32 +80,6 @@ export function VolumeWidget({ volume }) { ); } -export function HeatmapWidget({ heatmap }) { - if (!heatmap) return ; - const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; - const max = Math.max(1, ...heatmap.map((c) => c.count)); - const grid = {}; - heatmap.forEach((c) => { grid[`${c.dayOfWeek}-${c.hour}`] = c.count; }); - return ( -
-

Activity Heatmap

-
- {days.map((d, dow) => ( -
- {d} - {Array.from({ length: 24 }, (_, h) => { - const v = grid[`${dow}-${h}`] || 0; - return ; - })} -
- ))} -
-
- ); -} - -const DOW = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; - // Maps EmailCategory enum name → folder slug or search query const CAT_SLUG = { Finance: '/app/folder/finance', @@ -135,41 +112,64 @@ const CAT_SLUG = { Unknown: '/app/folder/allmail', }; -export function CategoryHeatmapWidget() { +// "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
Loading…
; - if (cells.length === 0) return

Category Heatmap

No data yet — run a sync.
; + if (!cells) { + return ( +
+

Emails by Category

+
+ {Array.from({ length: 6 }, (_, i) => )} +
+
+ ); + } - const categories = [...new Set(cells.map((c) => c.category))].sort(); - const max = Math.max(1, ...cells.map((c) => c.count)); - const grid = {}; - cells.forEach((c) => { grid[`${c.category}-${c.dayOfWeek}`] = c.count; }); + 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 ( +
+

Emails by Category

+ +
+ ); + } + + const max = Math.max(1, ...ranked.map((r) => r.count)); return (
-

Category Heatmap

-
-
- - {DOW.map((d) => {d})} -
- {categories.map((cat) => { - const dest = CAT_SLUG[cat]; +

Emails by Category

+
+ {ranked.map(({ category, count }) => { + const dest = CAT_SLUG[category]; return ( -
- navigate(dest) : undefined} - >{cat} - {DOW.map((_, dow) => { - const v = grid[`${cat}-${dow}`] || 0; - return {v || ''}; - })} -
+ ); })}
@@ -196,4 +196,10 @@ export function StorageWidget({ bytes }) { return ; } -function Empty() { return
No data yet — run a sync.
; } +function Empty() { + return ( +
+ +
+ ); +} diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index 83ffeda..057f020 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -6,8 +6,9 @@ import { AnalyticsApi, LayoutApi, ExportApi, SyncApi } from '../api/client.js'; import SyncSplash from '../components/SyncSplash.jsx'; import { HealthWidget, StatCard, TopSendersWidget, VolumeWidget, - CategoryHeatmapWidget, AttachmentsWidget, StorageWidget + CategoryBreakdownWidget, AttachmentsWidget, StorageWidget } from '../components/widgets.jsx'; +import { Skeleton } from '../components/ui/skeleton.jsx'; // Default grid geometry; overridden by the user's saved layout. const DEFAULT_LAYOUT = [ @@ -17,12 +18,23 @@ const DEFAULT_LAYOUT = [ { i: 'storage', x: 7, y: 0, w: 2, h: 2 }, { i: 'top-senders', x: 3, y: 2, w: 3, h: 5 }, { i: 'volume', x: 6, y: 2, w: 6, h: 4 }, - { i: 'category-heatmap', x: 0, y: 5, w: 6, h: 5 }, + { i: 'category-breakdown', x: 0, y: 5, w: 6, h: 5 }, { i: 'attachments', x: 6, y: 6, w: 4, h: 4 }, ]; const ALL_WIDGETS = DEFAULT_LAYOUT.map((l) => l.i); +function WidgetSkeleton() { + return ( +
+ + + + +
+ ); +} + export default function Dashboard() { const [data, setData] = useState(null); const [layout, setLayout] = useState(DEFAULT_LAYOUT); @@ -87,15 +99,18 @@ export default function Dashboard() { const visibleLayout = useMemo(() => layout.filter((l) => !hidden.includes(l.i)), [layout, hidden]); const render = (key) => { + // category-breakdown self-fetches, so it renders regardless of dashboard load state. + if (key === 'category-breakdown') return ; + // While the dashboard payload loads, show a skeleton placeholder per widget. + if (!data) return ; switch (key) { - case 'inbox-health': return ; - case 'total-emails': return ; - case 'unread-emails': return ; - case 'storage': return ; - case 'top-senders': return ; - case 'volume': return ; - case 'category-heatmap': return ; - case 'attachments': return ; + case 'inbox-health': return ; + case 'total-emails': return ; + case 'unread-emails': return ; + case 'storage': return ; + case 'top-senders': return ; + case 'volume': return ; + case 'attachments': return ; default: return null; } }; diff --git a/frontend/src/styles.css b/frontend/src/styles.css index ea59c5f..a879faf 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -115,8 +115,6 @@ button:disabled { opacity: 0.5; cursor: default; } .widget--link:hover { border-color: var(--accent); } .mini-row--link { cursor: pointer; } .mini-row--link:hover td { color: var(--accent); } -.chm-label--link { cursor: pointer; text-decoration: underline dotted; } -.chm-label--link:hover { color: var(--accent); } .widget canvas { flex: 1; min-height: 0; } .stat { align-items: flex-start; justify-content: center; } @@ -135,17 +133,21 @@ table.grid th, table.grid td { padding: 8px; border-bottom: 1px solid #2c3550; t .num { text-align: right; } .muted { color: var(--muted); font-size: 12px; } -.heatmap { display: flex; flex-direction: column; gap: 2px; } -.hm-row { display: flex; align-items: center; gap: 2px; } -.hm-day { width: 30px; font-size: 10px; color: var(--muted); } -.hm-cell { width: 10px; height: 10px; background: var(--accent); border-radius: 2px; } - -/* Category heatmap */ -.cat-heatmap { display: flex; flex-direction: column; gap: 3px; overflow: auto; } -.chm-row { display: grid; grid-template-columns: 92px repeat(7, 1fr); gap: 3px; align-items: stretch; } -.chm-head .chm-col { font-size: 10px; color: var(--muted); text-align: center; } -.chm-label { font-size: 11px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.chm-cell { background: var(--accent); border-radius: 3px; min-height: 22px; display: flex; align-items: center; justify-content: center; font-size: 10px; color: #fff; } +/* Emails by Category — ranked horizontal bars */ +.cat-bars { display: flex; flex-direction: column; gap: 6px; overflow: auto; } +.cat-bar { + display: grid; grid-template-columns: 92px 1fr 40px; gap: 8px; align-items: center; + width: 100%; padding: 3px 4px; margin: 0; border: none; background: transparent; + border-radius: 6px; text-align: left; font: inherit; color: inherit; +} +.cat-bar--link { cursor: pointer; } +.cat-bar--link:hover { background: rgba(255, 255, 255, 0.04); } +.cat-bar:disabled { cursor: default; } +.cat-bar-label { font-size: 11px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.cat-bar-track { height: 14px; background: rgba(255, 255, 255, 0.06); border-radius: 4px; overflow: hidden; } +.cat-bar-fill { display: block; height: 100%; background: var(--accent); border-radius: 4px; min-width: 2px; } +.cat-bar-count { font-size: 11px; color: var(--muted); text-align: right; font-variant-numeric: tabular-nums; } +.cat-bar--link:hover .cat-bar-label { color: var(--accent); } /* react-grid-layout resize handle — make it clearly visible on the dark theme */ .react-resizable-handle {