feat(ui): email dashboard UX refactor (PHASES 1/3/4) (#44)
CI / backend (push) Successful in 1m33s
CI / frontend (push) Successful in 33s
CI / format (push) Successful in 2m2s
CI / db-tests (push) Successful in 1m45s
Deploy Staging / deploy (push) Successful in 38s
Security / secrets (push) Successful in 6s
Security / dependencies (push) Successful in 1m17s
Security / sast (push) Successful in 56s
CI / backend (pull_request) Successful in 1m9s
CI / frontend (pull_request) Successful in 21s
CI / format (pull_request) Successful in 1m4s
CI / db-tests (pull_request) Successful in 1m27s
Security / secrets (pull_request) Successful in 6s
Security / dependencies (pull_request) Successful in 1m21s
Security / sast (pull_request) Successful in 1m8s

This commit was merged in pull request #44.
This commit is contained in:
2026-07-04 21:16:21 +02:00
parent 04ff72097b
commit 2f173dc3e5
15 changed files with 712 additions and 192 deletions
+62 -56
View File
@@ -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 <Empty />;
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 (
<div className="widget">
<h3>Activity Heatmap</h3>
<div className="heatmap">
{days.map((d, dow) => (
<div className="hm-row" key={dow}>
<span className="hm-day">{d}</span>
{Array.from({ length: 24 }, (_, h) => {
const v = grid[`${dow}-${h}`] || 0;
return <span key={h} className="hm-cell" style={{ opacity: 0.1 + 0.9 * (v / max) }} title={`${d} ${h}:00 — ${v}`} />;
})}
</div>
))}
</div>
</div>
);
}
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 <div className="widget"><div className="muted">Loading</div></div>;
if (cells.length === 0) return <div className="widget"><h3>Category Heatmap</h3><div className="muted">No data yet run a sync.</div></div>;
if (!cells) {
return (
<div className="widget">
<h3>Emails by Category</h3>
<div className="cat-bars">
{Array.from({ length: 6 }, (_, i) => <Skeleton key={i} className="h-6 w-full" />)}
</div>
</div>
);
}
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 (
<div className="widget">
<h3>Emails by Category</h3>
<EmptyState icon={Inbox} title="No data yet — run a sync" description="Category totals appear once your inbox has been synced." />
</div>
);
}
const max = Math.max(1, ...ranked.map((r) => r.count));
return (
<div className="widget">
<h3>Category Heatmap</h3>
<div className="cat-heatmap">
<div className="chm-row chm-head">
<span className="chm-label" />
{DOW.map((d) => <span key={d} className="chm-col">{d}</span>)}
</div>
{categories.map((cat) => {
const dest = CAT_SLUG[cat];
<h3>Emails by Category</h3>
<div className="cat-bars">
{ranked.map(({ category, count }) => {
const dest = CAT_SLUG[category];
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>
<button
type="button"
className={`cat-bar${dest ? ' cat-bar--link' : ''}`}
key={category}
disabled={!dest}
title={dest ? `View ${category} emails` : category}
onClick={dest ? () => navigate(dest) : undefined}
>
<span className="cat-bar-label">{category}</span>
<span className="cat-bar-track">
<span className="cat-bar-fill" style={{ width: `${(count / max) * 100}%` }} />
</span>
<span className="cat-bar-count">{count.toLocaleString()}</span>
</button>
);
})}
</div>
@@ -196,4 +196,10 @@ export function StorageWidget({ bytes }) {
return <StatCard label="Estimated Storage" value={fmtBytes(bytes)} />;
}
function Empty() { return <div className="widget"><div className="muted">No data yet run a sync.</div></div>; }
function Empty() {
return (
<div className="widget">
<EmptyState icon={Inbox} title="No data yet — run a sync" description="This widget populates after your inbox has been synced." />
</div>
);
}