feat: Smart Folders sidebar, category heatmap, sidebar counts API
- Replace plain activity heatmap with CategoryHeatmapWidget on dashboard - Add collapsible Smart Folders sidebar with Favorites, Mailbox, Smart Folders sections - Favorites: customisable pinned shortcuts (default: Inbox, Unread, Large, Old, Read Later); pin/unpin smart folders via buttons; persists to localStorage - Mailbox section: 11 Gmail mailbox items with icons and live count badges - Smart Folders section: 10 category folders sorted by count desc - Live count badges via new GET /api/v1/analytics/sidebar-counts endpoint - Backend: SidebarCountsDto, GetSidebarCountsAsync with label lookups for SENT/DRAFT/SPAM, size/age filters, EmailCategory mapping - Sidebar collapses to icon-only; section states persist to localStorage Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,7 @@ import { AnalyticsApi, LayoutApi, ExportApi, SyncApi } from '../api/client.js';
|
||||
import SyncSplash from '../components/SyncSplash.jsx';
|
||||
import {
|
||||
HealthWidget, StatCard, TopSendersWidget, VolumeWidget,
|
||||
HeatmapWidget, AttachmentsWidget, StorageWidget
|
||||
CategoryHeatmapWidget, AttachmentsWidget, StorageWidget
|
||||
} from '../components/widgets.jsx';
|
||||
|
||||
// Default grid geometry; overridden by the user's saved layout.
|
||||
@@ -17,8 +17,8 @@ const DEFAULT_LAYOUT = [
|
||||
{ i: 'storage', x: 7, y: 0, w: 2, h: 2 },
|
||||
{ i: 'top-senders', x: 3, y: 2, w: 3, h: 5 },
|
||||
{ i: 'volume', x: 6, y: 2, w: 6, h: 4 },
|
||||
{ i: 'heatmap', x: 0, y: 5, w: 6, h: 4 },
|
||||
{ i: 'attachments', x: 6, y: 6, w: 4, h: 4 }
|
||||
{ i: 'category-heatmap', x: 0, y: 5, w: 6, h: 5 },
|
||||
{ i: 'attachments', x: 6, y: 6, w: 4, h: 4 },
|
||||
];
|
||||
|
||||
const ALL_WIDGETS = DEFAULT_LAYOUT.map((l) => l.i);
|
||||
@@ -36,7 +36,11 @@ export default function Dashboard() {
|
||||
useEffect(() => {
|
||||
LayoutApi.get().then((saved) => {
|
||||
if (saved?.length) {
|
||||
setLayout(saved.map((w) => ({ i: w.widgetKey, x: w.x, y: w.y, w: w.w, h: w.h })));
|
||||
const savedLayout = saved.map((w) => ({ i: w.widgetKey, x: w.x, y: w.y, w: w.w, h: w.h }));
|
||||
const savedKeys = new Set(savedLayout.map((l) => l.i));
|
||||
// Append any newer default widgets the saved layout doesn't know about yet.
|
||||
const merged = [...savedLayout, ...DEFAULT_LAYOUT.filter((d) => !savedKeys.has(d.i))];
|
||||
setLayout(merged);
|
||||
setHidden(saved.filter((w) => !w.visible).map((w) => w.widgetKey));
|
||||
}
|
||||
}).catch(() => {});
|
||||
@@ -54,6 +58,13 @@ export default function Dashboard() {
|
||||
}).catch(loadData);
|
||||
}, [loadData]);
|
||||
|
||||
// Show the splash when a sync is triggered from the header "Sync now" button.
|
||||
useEffect(() => {
|
||||
const onStart = () => setSyncing(true);
|
||||
window.addEventListener('inboxintel:sync-started', onStart);
|
||||
return () => window.removeEventListener('inboxintel:sync-started', onStart);
|
||||
}, []);
|
||||
|
||||
const onSyncDone = useCallback(() => {
|
||||
setSyncing(false);
|
||||
loadData();
|
||||
@@ -83,7 +94,7 @@ export default function Dashboard() {
|
||||
case 'storage': return <StorageWidget bytes={data?.storageEstimateBytes} />;
|
||||
case 'top-senders': return <TopSendersWidget senders={data?.topSenders} />;
|
||||
case 'volume': return <VolumeWidget volume={data?.volumeOverTime} />;
|
||||
case 'heatmap': return <HeatmapWidget heatmap={data?.heatmap} />;
|
||||
case 'category-heatmap': return <CategoryHeatmapWidget />;
|
||||
case 'attachments': return <AttachmentsWidget attachments={data?.attachmentBreakdown} />;
|
||||
default: return null;
|
||||
}
|
||||
@@ -112,6 +123,11 @@ export default function Dashboard() {
|
||||
cols={12}
|
||||
rowHeight={60}
|
||||
width={1200}
|
||||
isResizable
|
||||
isDraggable
|
||||
resizeHandles={['se']}
|
||||
compactType="vertical"
|
||||
margin={[12, 12]}
|
||||
onLayoutChange={onLayoutChange}
|
||||
draggableHandle=".widget h3, .widget .stat-label"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { AnalyticsApi } from '../api/client.js';
|
||||
|
||||
// Volume tiers, highest first.
|
||||
const TIERS = [
|
||||
{ key: '1000+', label: '1000+ emails', min: 1000, max: Infinity, accent: '#eb5757' },
|
||||
{ key: '500-1000', label: '500 – 1000', min: 500, max: 999, accent: '#f2994a' },
|
||||
{ key: '250-500', label: '250 – 500', min: 250, max: 499, accent: '#f2c94c' },
|
||||
{ key: '100-250', label: '100 – 250', min: 100, max: 249, accent: '#56ccf2' },
|
||||
{ key: '<100', label: 'Under 100', min: 0, max: 99, accent: '#6fcf97' }
|
||||
];
|
||||
|
||||
const PER_TIER = 60; // cap visible blocks per tier to keep it light
|
||||
|
||||
export default function Senders() {
|
||||
const [senders, setSenders] = useState(null);
|
||||
|
||||
useEffect(() => { AnalyticsApi.allSenders().then(setSenders).catch(() => setSenders([])); }, []);
|
||||
|
||||
const buckets = useMemo(() => {
|
||||
const map = Object.fromEntries(TIERS.map((t) => [t.key, []]));
|
||||
(senders || []).forEach((s) => {
|
||||
const tier = TIERS.find((t) => s.emailCount >= t.min && s.emailCount <= t.max);
|
||||
if (tier) map[tier.key].push(s);
|
||||
});
|
||||
Object.values(map).forEach((arr) => arr.sort((a, b) => b.emailCount - a.emailCount));
|
||||
return map;
|
||||
}, [senders]);
|
||||
|
||||
if (!senders) return <div className="page"><h2>Senders by Volume</h2><p className="muted">Loading…</p></div>;
|
||||
|
||||
return (
|
||||
<div className="page senders-page">
|
||||
<h2>Senders by Volume</h2>
|
||||
<p className="muted">Who fills your inbox, grouped by how many emails they've sent you.</p>
|
||||
|
||||
{TIERS.map((tier) => {
|
||||
const list = buckets[tier.key];
|
||||
if (!list.length) return null;
|
||||
const shown = list.slice(0, PER_TIER);
|
||||
return (
|
||||
<section className="tier" key={tier.key}>
|
||||
<div className="tier-head">
|
||||
<span className="tier-dot" style={{ background: tier.accent }} />
|
||||
<h3>{tier.label}</h3>
|
||||
<span className="tier-count">{list.length} sender{list.length === 1 ? '' : 's'}</span>
|
||||
</div>
|
||||
<div className="sender-blocks">
|
||||
{shown.map((s) => (
|
||||
<div className="sender-block" key={s.senderId} style={{ borderTopColor: tier.accent }}>
|
||||
<div className="sb-name" title={s.address}>{s.displayName || s.address}</div>
|
||||
<div className="sb-domain">{s.domain}</div>
|
||||
<div className="sb-stats">
|
||||
<span className="sb-count">{s.emailCount.toLocaleString()}</span>
|
||||
{s.unreadCount > 0 && <span className="sb-unread">{s.unreadCount} unread</span>}
|
||||
{s.hasUnsubscribe && <span className="sb-unsub">unsub</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{list.length > PER_TIER && <div className="muted tier-more">+ {list.length - PER_TIER} more</div>}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
{senders.length === 0 && <p className="muted">No senders yet — run a sync first.</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user