-
-
- {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 {