diff --git a/web/src/components/layout/AppNav.tsx b/web/src/components/layout/AppNav.tsx index 8c082410..95408614 100644 --- a/web/src/components/layout/AppNav.tsx +++ b/web/src/components/layout/AppNav.tsx @@ -605,18 +605,20 @@ function Section({ section, first = false }: { section: NavSection; first?: bool * Anatomy: * * ┌──────────────────────────────────┐ - * │ ● LIVE 42 of 50 / day │ ← status dot + label + cap pace - * │ 8 mailboxes sending now │ ← mailbox composition - * │ ▂▃▅▇▆▄▂ ▂▃▅▇▆▄▂ │ ← optional 24h sparkline + * │ 128 of 400 sent today │ ← hero number (scrubs on hover) + * │ ━━━━━━━───────── │ ← capacity meter (today vs cap) + * │ ∿∿∿∿∿∿ │ ← 14-day area sparkline + * │ ✉ 8 ● 5 ⬇ 3 │ ← mailboxes · active · unread * └──────────────────────────────────┘ * - * Reads as ambient telemetry: even when idle, it tells you "system is - * up, n mailboxes ready." Clicking jumps to analytics. The dot pulses - * when at least one mailbox is actively warming or sending. + * Reads as ambient telemetry: even when idle, it tells you "n mailboxes, + * n sent today." Clicking jumps to analytics; hovering a day on the + * sparkline swaps the hero number to that day. There is deliberately no + * LIVE/OFFLINE status row: the numbers ticking realtime already say the + * system is up, so the panel spends its pixels on the data instead. * * Data sources at this layer: * - useAppStore.emails → mailbox count, active count - * - useAppStore.connectionStatus → online/offline state * - useDashboard("30d") daily_trend → today's sent volume + the sparkline * (shares the dashboard page's query cache; realtime invalidation keeps * it current) @@ -626,10 +628,9 @@ function Section({ section, first = false }: { section: NavSection; first?: bool */ function LivePanel() { const emails = useAppStore((s) => s.emails); - const connection = useAppStore((s) => s.connectionStatus); - const latencyMs = useAppStore((s) => s.wsLatencyMs); const unseenCount = useAppStore((s) => s.unseenCount); const dash = useDashboard("30d"); + const [hovered, setHovered] = useState(null); const { active, mailboxes, capacity } = useMemo(() => { const m = emails.length; @@ -641,163 +642,275 @@ function LivePanel() { }, [emails]); const { sentToday, trend } = useMemo(() => { - const days = dash.data?.daily_trend ?? []; - const todayKey = new Date().toISOString().slice(0, 10); - const today = days.find((d) => d.date?.slice(0, 10) === todayKey); - return { - sentToday: today?.sent ?? 0, - trend: days.slice(-14).map((d) => d.sent), - }; + // daily_trend only contains days that had sends; rebuild a continuous + // last-14-days axis (zero-filling the gaps) so the sparkline's x + // spacing is honest — otherwise a quiet week would be silently + // squeezed out and two distant days would read as adjacent. + const byDate = new Map( + (dash.data?.daily_trend ?? []).map((d) => [d.date?.slice(0, 10), d.sent]), + ); + const out: { date: string; sent: number }[] = []; + const now = new Date(); + for (let i = 13; i >= 0; i--) { + const d = new Date(now); + d.setUTCDate(now.getUTCDate() - i); + const key = d.toISOString().slice(0, 10); + out.push({ date: key, sent: byDate.get(key) ?? 0 }); + } + return { sentToday: out[out.length - 1].sent, trend: out }; }, [dash.data]); - const live = connection === "connected"; - // Connected == green, always. When quiet we say READY (not the old "IDLE", - // which with a gray dot read as "not connected"); when a mailbox is warming - // or sending we say LIVE and pulse. Only a real disconnect is gray. - const label = - connection === "disconnected" - ? "OFFLINE" - : connection === "connecting" - ? "CONNECTING" - : active > 0 - ? "LIVE" - : "READY"; - const dotClass = - connection === "disconnected" - ? "bg-slate-300" - : connection === "connecting" - ? "bg-amber-500" - : "bg-emerald-500"; - const labelTone = - connection === "disconnected" - ? "text-slate-400" - : connection === "connecting" - ? "text-amber-600" - : "text-emerald-600"; - - // Latency bucketing: <100ms great, <300ms okay, ≥300ms poor. - const latencyTone = - latencyMs == null - ? "text-slate-400" - : latencyMs < 100 - ? "text-emerald-600" - : latencyMs < 300 - ? "text-amber-600" - : "text-red-500"; + const scrub = hovered != null ? trend[hovered] : undefined; + const pct = capacity > 0 ? Math.min(100, (sentToday / capacity) * 100) : 0; return ( -
- - - {/* Active mailboxes ping; a quiet-but-connected workspace gets a - slow breathing glow so "READY" reads alive, not stuck. */} - {live && active > 0 ? ( - - ) : live ? ( - - ) : null} - - - {label} - - - {latencyMs != null ? `${latencyMs}ms` : "—"} - -
- -
- - {mailboxes} - - - {mailboxes === 1 ? "mailbox" : "mailboxes"} - - {active > 0 && ( - - {active} active - + {/* Hero: today's sends against the derived daily cap. While the + sparkline is being scrubbed it shows the hovered day instead. */} +
+ {scrub ? ( + <> + + {scrub.sent.toLocaleString()} + + + sent {formatTrendDay(scrub.date)} + + + ) : ( + <> + + + {capacity > 0 + ? `of ${capacity.toLocaleString()} sent today` + : "sent today"} + + )}
-
- Inbox + {/* Capacity meter: same-ramp track so the unfilled part still reads + as "room left today", not as a broken bar. */} +
0 + ? `${sentToday} of ${capacity} daily capacity used` + : "Connect a mailbox to start sending" + } + > +
+
+
+
+ + + + {/* Glance chips: mailboxes · active senders · unread inbox. Icons + carry the labels (title attrs spell them out) so this stays one + quiet row instead of two label/value text lines. */} +
+ + + {mailboxes} + + {active > 0 && ( + + + {active} + + )} 0 ? "text-sky-600" : "text-slate-400", )} + title={`${unseenCount} unread in inbox`} > - {unseenCount > 99 ? "99+" : unseenCount} unread + + + {unseenCount > 99 ? "99+" : unseenCount} +
- -
- Today - 0 ? "text-slate-600" : "text-slate-400", - )} - > - {sentToday}/{capacity || "—"} - -
- - ); } +/** "2026-08-30" → "Aug 30" for the sparkline scrub readout. */ +function formatTrendDay(iso: string): string { + const d = new Date(iso); + return Number.isNaN(d.getTime()) + ? iso + : d.toLocaleDateString(undefined, { month: "short", day: "numeric", timeZone: "UTC" }); +} + +// Sparkline geometry. Width matches the card's inner width (sidebar w-64 +// minus mx-2 and borders) so preserveAspectRatio="none" barely distorts +// the dots; side padding keeps markers clear of the overflow-hidden edges. +const SPARK_W = 238; +const SPARK_H = 34; +const SPARK_PAD_X = 6; +const SPARK_PAD_TOP = 6; +const SPARK_PAD_BOTTOM = 3; + /** - * Sparkline — 14 thin vertical bars, the last two weeks of send volume - * from the dashboard daily trend, normalized to the busiest day. Days - * with volume render sky; empty days stay a faint slate baseline. + * Sparkline — the last two weeks of send volume as a smooth area line + * (Catmull-Rom smoothing, gradient wash under the stroke, end-of-series + * dot with a surface ring). Full-bleed across the card; the chips row's + * top border underneath doubles as the baseline. Invisible per-day hit + * columns report the hovered day via onHover so the hero number above + * scrubs with the cursor. */ -function Sparkline({ values }: { values: number[] }) { - const bars = useMemo(() => { - const padded = - values.length >= 14 - ? values.slice(-14) - : [...Array.from({ length: 14 - values.length }, () => 0), ...values]; - const max = Math.max(...padded, 1); - return padded.map((v) => Math.round((v / max) * 100)); - }, [values]); +function Sparkline({ + points, + hovered, + onHover, +}: { + points: { date: string; sent: number }[]; + hovered: number | null; + onHover: (i: number | null) => void; +}) { + const { linePath, areaPath, dots, hasVolume } = useMemo(() => { + const n = points.length; + const baseY = SPARK_H - SPARK_PAD_BOTTOM; + if (n < 2) { + return { + linePath: "", + areaPath: "", + dots: [] as { x: number; y: number }[], + hasVolume: false, + }; + } + const max = Math.max(...points.map((p) => p.sent), 1); + const span = SPARK_W - SPARK_PAD_X * 2; + const usable = baseY - SPARK_PAD_TOP; + const pts = points.map((p, i) => ({ + x: SPARK_PAD_X + (i / (n - 1)) * span, + y: baseY - (p.sent / max) * usable, + })); + // Catmull-Rom → cubic bezier; control ys are clamped so a spike next + // to a flat run never overshoots the frame. + const clamp = (y: number) => + Math.min(baseY, Math.max(SPARK_PAD_TOP, y)); + let d = `M ${pts[0].x} ${pts[0].y}`; + for (let i = 0; i < n - 1; i++) { + const p0 = pts[i - 1] ?? pts[i]; + const p1 = pts[i]; + const p2 = pts[i + 1]; + const p3 = pts[i + 2] ?? p2; + const c1x = p1.x + (p2.x - p0.x) / 6; + const c1y = clamp(p1.y + (p2.y - p0.y) / 6); + const c2x = p2.x - (p3.x - p1.x) / 6; + const c2y = clamp(p2.y - (p3.y - p1.y) / 6); + d += ` C ${c1x} ${c1y}, ${c2x} ${c2y}, ${p2.x} ${p2.y}`; + } + return { + linePath: d, + areaPath: `${d} L ${pts[n - 1].x} ${baseY} L ${pts[0].x} ${baseY} Z`, + dots: pts, + hasVolume: points.some((p) => p.sent > 0), + }; + }, [points]); + + const n = points.length; + const step = n > 1 ? (SPARK_W - SPARK_PAD_X * 2) / (n - 1) : 0; + const hoverDot = hovered != null ? dots[hovered] : undefined; + const endDot = dots[dots.length - 1]; + return ( -
- {bars.map((v, i) => ( -
0 - ? "bg-sky-300 group-hover:bg-sky-400" - : "bg-slate-200 group-hover:bg-slate-300", - )} - style={{ height: `${Math.max(8, v)}%`, minHeight: "2px" }} + onHover(null)} + > + + + + + + + {linePath && hasVolume && ( + + )} + {linePath && ( + - ))} -
+ )} + {/* Hover scrub: hairline + marker on the hovered day. */} + {hoverDot && ( + <> + + + + )} + {/* End-of-series marker (today), ringed in the surface color. */} + {endDot && hovered == null && ( + + )} + {/* Invisible per-day hit columns driving the scrub. */} + {n >= 2 && + points.map((_, i) => ( + onHover(i)} + /> + ))} + ); }