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 { 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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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} />;
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user