feat: dashboard drillthrough — widgets navigate to filtered views

- Total Emails stat card -> /app/folder/allmail
- Unread stat card -> /app/folder/unread
- Top Senders rows -> /app/search?q=from:{address}
- Category heatmap labels -> matching folder or search (Finance, Social, Notification, Promotional, Spam, Newsletter, Personal)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-06-30 20:20:09 +02:00
parent 2f6d6abdd3
commit 80c2167b89
3 changed files with 48 additions and 14 deletions
+34 -6
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Bar, Line, Doughnut } from 'react-chartjs-2'; import { Bar, Line, Doughnut } from 'react-chartjs-2';
import { AnalyticsApi } from '../api/client.js'; import { AnalyticsApi } from '../api/client.js';
import { import {
@@ -15,9 +16,10 @@ const fmtBytes = (b) => {
return `${n.toFixed(1)} ${u[i]}`; return `${n.toFixed(1)} ${u[i]}`;
}; };
export function StatCard({ label, value }) { export function StatCard({ label, value, to }) {
const navigate = useNavigate();
return ( return (
<div className="widget stat"> <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-value">{value}</div>
<div className="stat-label">{label}</div> <div className="stat-label">{label}</div>
</div> </div>
@@ -37,6 +39,7 @@ export function HealthWidget({ health }) {
} }
export function TopSendersWidget({ senders }) { export function TopSendersWidget({ senders }) {
const navigate = useNavigate();
if (!senders) return <Empty />; if (!senders) return <Empty />;
return ( return (
<div className="widget"> <div className="widget">
@@ -44,7 +47,12 @@ export function TopSendersWidget({ senders }) {
<table className="mini"> <table className="mini">
<tbody> <tbody>
{senders.map((s) => ( {senders.map((s) => (
<tr key={s.senderId}> <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 title={s.address}>{s.displayName || s.address}</td>
<td className="num">{s.emailCount}</td> <td className="num">{s.emailCount}</td>
</tr> </tr>
@@ -95,8 +103,21 @@ export function HeatmapWidget({ heatmap }) {
const DOW = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; 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',
Social: '/app/folder/social',
Notification: '/app/folder/automated',
Promotional: '/app/folder/shopping',
Spam: '/app/folder/spam',
Newsletter: '/app/search?q=newsletter',
Personal: '/app/search?q=is:unread',
Unknown: '/app/folder/allmail',
};
export function CategoryHeatmapWidget() { export function CategoryHeatmapWidget() {
const [cells, setCells] = useState(null); const [cells, setCells] = useState(null);
const navigate = useNavigate();
useEffect(() => { AnalyticsApi.categoryHeatmap().then(setCells).catch(() => setCells([])); }, []); useEffect(() => { AnalyticsApi.categoryHeatmap().then(setCells).catch(() => setCells([])); }, []);
if (!cells) return <div className="widget"><div className="muted">Loading</div></div>; if (!cells) return <div className="widget"><div className="muted">Loading</div></div>;
@@ -115,15 +136,22 @@ export function CategoryHeatmapWidget() {
<span className="chm-label" /> <span className="chm-label" />
{DOW.map((d) => <span key={d} className="chm-col">{d}</span>)} {DOW.map((d) => <span key={d} className="chm-col">{d}</span>)}
</div> </div>
{categories.map((cat) => ( {categories.map((cat) => {
const dest = CAT_SLUG[cat];
return (
<div className="chm-row" key={cat}> <div className="chm-row" key={cat}>
<span className="chm-label" title={cat}>{cat}</span> <span
className={`chm-label${dest ? ' chm-label--link' : ''}`}
title={dest ? `View ${cat} emails` : cat}
onClick={dest ? () => navigate(dest) : undefined}
>{cat}</span>
{DOW.map((_, dow) => { {DOW.map((_, dow) => {
const v = grid[`${cat}-${dow}`] || 0; const v = grid[`${cat}-${dow}`] || 0;
return <span key={dow} className="chm-cell" style={{ opacity: 0.12 + 0.88 * (v / max) }} title={`${cat} · ${DOW[dow]}${v}`}>{v || ''}</span>; return <span key={dow} className="chm-cell" style={{ opacity: 0.12 + 0.88 * (v / max) }} title={`${cat} · ${DOW[dow]}${v}`}>{v || ''}</span>;
})} })}
</div> </div>
))} );
})}
</div> </div>
</div> </div>
); );
+2 -2
View File
@@ -89,8 +89,8 @@ export default function Dashboard() {
const render = (key) => { const render = (key) => {
switch (key) { switch (key) {
case 'inbox-health': return <HealthWidget health={data?.health} />; case 'inbox-health': return <HealthWidget health={data?.health} />;
case 'total-emails': return <StatCard label="Total Emails" value={(data?.totalEmails ?? 0).toLocaleString()} />; case 'total-emails': return <StatCard label="Total Emails" value={(data?.totalEmails ?? 0).toLocaleString()} to="/app/folder/allmail" />;
case 'unread-emails': return <StatCard label="Unread" value={(data?.unreadEmails ?? 0).toLocaleString()} />; case 'unread-emails': return <StatCard label="Unread" value={(data?.unreadEmails ?? 0).toLocaleString()} to="/app/folder/unread" />;
case 'storage': return <StorageWidget bytes={data?.storageEstimateBytes} />; case 'storage': return <StorageWidget bytes={data?.storageEstimateBytes} />;
case 'top-senders': return <TopSendersWidget senders={data?.topSenders} />; case 'top-senders': return <TopSendersWidget senders={data?.topSenders} />;
case 'volume': return <VolumeWidget volume={data?.volumeOverTime} />; case 'volume': return <VolumeWidget volume={data?.volumeOverTime} />;
+6
View File
@@ -111,6 +111,12 @@ button:disabled { opacity: 0.5; cursor: default; }
.grid-item { background: var(--panel); border: 1px solid #2c3550; border-radius: 10px; overflow: hidden; } .grid-item { background: var(--panel); border: 1px solid #2c3550; border-radius: 10px; overflow: hidden; }
.widget { padding: 14px; height: 100%; display: flex; flex-direction: column; } .widget { padding: 14px; height: 100%; display: flex; flex-direction: column; }
.widget h3 { margin: 0 0 10px; font-size: 14px; cursor: move; } .widget h3 { margin: 0 0 10px; font-size: 14px; cursor: move; }
.widget--link { cursor: pointer; }
.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; } .widget canvas { flex: 1; min-height: 0; }
.stat { align-items: flex-start; justify-content: center; } .stat { align-items: flex-start; justify-content: center; }