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