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:
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Bar, Line, Doughnut } from 'react-chartjs-2';
|
||||
import { AnalyticsApi } from '../api/client.js';
|
||||
import {
|
||||
@@ -15,9 +16,10 @@ const fmtBytes = (b) => {
|
||||
return `${n.toFixed(1)} ${u[i]}`;
|
||||
};
|
||||
|
||||
export function StatCard({ label, value }) {
|
||||
export function StatCard({ label, value, to }) {
|
||||
const navigate = useNavigate();
|
||||
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-label">{label}</div>
|
||||
</div>
|
||||
@@ -37,6 +39,7 @@ export function HealthWidget({ health }) {
|
||||
}
|
||||
|
||||
export function TopSendersWidget({ senders }) {
|
||||
const navigate = useNavigate();
|
||||
if (!senders) return <Empty />;
|
||||
return (
|
||||
<div className="widget">
|
||||
@@ -44,7 +47,12 @@ export function TopSendersWidget({ senders }) {
|
||||
<table className="mini">
|
||||
<tbody>
|
||||
{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 className="num">{s.emailCount}</td>
|
||||
</tr>
|
||||
@@ -95,8 +103,21 @@ export function HeatmapWidget({ heatmap }) {
|
||||
|
||||
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() {
|
||||
const [cells, setCells] = useState(null);
|
||||
const navigate = useNavigate();
|
||||
useEffect(() => { AnalyticsApi.categoryHeatmap().then(setCells).catch(() => setCells([])); }, []);
|
||||
|
||||
if (!cells) return <div className="widget"><div className="muted">Loading…</div></div>;
|
||||
@@ -115,15 +136,22 @@ export function CategoryHeatmapWidget() {
|
||||
<span className="chm-label" />
|
||||
{DOW.map((d) => <span key={d} className="chm-col">{d}</span>)}
|
||||
</div>
|
||||
{categories.map((cat) => (
|
||||
<div className="chm-row" key={cat}>
|
||||
<span className="chm-label" title={cat}>{cat}</span>
|
||||
{DOW.map((_, dow) => {
|
||||
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>;
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
{categories.map((cat) => {
|
||||
const dest = CAT_SLUG[cat];
|
||||
return (
|
||||
<div className="chm-row" key={cat}>
|
||||
<span
|
||||
className={`chm-label${dest ? ' chm-label--link' : ''}`}
|
||||
title={dest ? `View ${cat} emails` : cat}
|
||||
onClick={dest ? () => navigate(dest) : undefined}
|
||||
>{cat}</span>
|
||||
{DOW.map((_, dow) => {
|
||||
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>;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -89,8 +89,8 @@ export default function Dashboard() {
|
||||
const render = (key) => {
|
||||
switch (key) {
|
||||
case 'inbox-health': return <HealthWidget health={data?.health} />;
|
||||
case 'total-emails': return <StatCard label="Total Emails" value={(data?.totalEmails ?? 0).toLocaleString()} />;
|
||||
case 'unread-emails': return <StatCard label="Unread" value={(data?.unreadEmails ?? 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()} to="/app/folder/unread" />;
|
||||
case 'storage': return <StorageWidget bytes={data?.storageEstimateBytes} />;
|
||||
case 'top-senders': return <TopSendersWidget senders={data?.topSenders} />;
|
||||
case 'volume': return <VolumeWidget volume={data?.volumeOverTime} />;
|
||||
|
||||
@@ -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; }
|
||||
.widget { padding: 14px; height: 100%; display: flex; flex-direction: column; }
|
||||
.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; }
|
||||
|
||||
.stat { align-items: flex-start; justify-content: center; }
|
||||
|
||||
Reference in New Issue
Block a user