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:
cesnimda
2026-06-30 19:40:54 +02:00
parent fe5920919f
commit ca02f40841
17 changed files with 2866 additions and 66 deletions
+69
View File
@@ -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>
);
}