From 026795cd3400dbe199d0ee37931fddf3f2ef5471 Mon Sep 17 00:00:00 2001 From: AgentSeal Date: Wed, 29 Apr 2026 14:24:39 -0700 Subject: [PATCH] =?UTF-8?q?feat(desktop):=20add=20insight=20modes=20?= =?UTF-8?q?=E2=80=94=20trend=20chart,=20forecast,=20pulse=20tiles,=20and?= =?UTF-8?q?=20stats=20grid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds InsightPills mode switcher and four insight panels: 19-day bar chart with hover tooltips (TrendInsight), month-to-date + projection with prior-month delta (ForecastInsight), three efficiency tiles for cache hit / 1-shot / cost-per-session (PulseInsight), and a 2-column stats grid with streaks and lifetime total (StatsInsight). Wired into App.tsx with an insight-area section between period tabs and the activity section. --- desktop/src/App.tsx | 26 +++ desktop/src/components/ForecastInsight.tsx | 98 ++++++++ desktop/src/components/InsightPills.tsx | 30 +++ desktop/src/components/PulseInsight.tsx | 32 +++ desktop/src/components/StatsInsight.tsx | 101 +++++++++ desktop/src/components/TrendInsight.tsx | 156 +++++++++++++ desktop/src/styles.css | 246 +++++++++++++++++++++ 7 files changed, 689 insertions(+) create mode 100644 desktop/src/components/ForecastInsight.tsx create mode 100644 desktop/src/components/InsightPills.tsx create mode 100644 desktop/src/components/PulseInsight.tsx create mode 100644 desktop/src/components/StatsInsight.tsx create mode 100644 desktop/src/components/TrendInsight.tsx diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index b93f727..979fcad 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -10,6 +10,11 @@ import { PayloadCache } from './lib/cache' import { AgentTabStrip } from './components/AgentTabStrip' import type { Provider } from './components/AgentTabStrip' import { ModelsSection } from './components/ModelsSection' +import { InsightPills, type InsightMode } from './components/InsightPills' +import { TrendInsight } from './components/TrendInsight' +import { ForecastInsight } from './components/ForecastInsight' +import { PulseInsight } from './components/PulseInsight' +import { StatsInsight } from './components/StatsInsight' const payloadCache = new PayloadCache() @@ -32,6 +37,7 @@ export function App() { const [currency, setCurrency] = useState(USD) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) + const [insight, setInsight] = useState('trend') const refresh = useCallback(async (includeOptimize: boolean) => { if (!includeOptimize) { @@ -141,6 +147,26 @@ export function App() { ))} +
+ + {insight === 'trend' && ( + + )} + {insight === 'forecast' && ( + + )} + {insight === 'pulse' && ( + + )} + {insight === 'stats' && ( + + )} +
+ {!loading && payload.current.calls === 0 && payload.current.sessions === 0 ? (

No session data yet

diff --git a/desktop/src/components/ForecastInsight.tsx b/desktop/src/components/ForecastInsight.tsx new file mode 100644 index 0000000..cd5e82c --- /dev/null +++ b/desktop/src/components/ForecastInsight.tsx @@ -0,0 +1,98 @@ +import type { DailyEntry } from '../lib/payload' +import type { CurrencyState } from '../lib/currency' +import { formatCurrency, formatCompactCurrency } from '../lib/currency' +import { + formatDateKey, addDays, startOfDay, + firstOfMonth, daysInMonth, dayOfMonth, +} from '../lib/dates' + +type Props = { + days: DailyEntry[] + currency: CurrencyState +} + +type ForecastStats = { + mtd: number + projection: number + weekAvg: number + weekTotal: number + yesterday: number + previousMonthTotal: number | null +} + +function compute(days: DailyEntry[]): ForecastStats { + const now = new Date() + const fom = firstOfMonth(now) + const fomStr = formatDateKey(fom) + const totalDays = daysInMonth(now) + const dom = dayOfMonth(now) + + const mtd = days.filter(d => d.date >= fomStr).reduce((s, d) => s + d.cost, 0) + const avgPerDay = dom > 0 ? mtd / dom : 0 + const projection = avgPerDay * totalDays + + const today = startOfDay(now) + const weekStartStr = formatDateKey(addDays(today, -6)) + const weekTotal = days.filter(d => d.date >= weekStartStr).reduce((s, d) => s + d.cost, 0) + const weekAvg = weekTotal / 7 + + const yesterdayStr = formatDateKey(addDays(today, -1)) + const yesterday = days.find(d => d.date === yesterdayStr)?.cost ?? 0 + + let previousMonthTotal: number | null = null + const prevMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1)) + const prevFirstStr = formatDateKey(prevMonth) + const prevLastDay = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 0)) + const prevLastStr = formatDateKey(prevLastDay) + const prevEntries = days.filter(d => d.date >= prevFirstStr && d.date <= prevLastStr) + if (prevEntries.length > 0) { + previousMonthTotal = prevEntries.reduce((s, d) => s + d.cost, 0) + } + + return { mtd, projection, weekAvg, weekTotal, yesterday, previousMonthTotal } +} + +export function ForecastInsight({ days, currency }: Props) { + const s = compute(days) + const prevDelta = s.previousMonthTotal && s.previousMonthTotal > 0 + ? ((s.projection - s.previousMonthTotal) / s.previousMonthTotal) * 100 + : null + + return ( +
+
+
+
Month-to-date
+
{formatCurrency(s.mtd, currency)}
+
+
+
On pace for
+
{formatCurrency(s.projection, currency)}
+
+
+ +
+
+
Avg/day (this wk)
+
{formatCompactCurrency(s.weekAvg, currency)}
+
+
+
Yesterday
+
{formatCompactCurrency(s.yesterday, currency)}
+
+
+
Last 7d
+
{formatCompactCurrency(s.weekTotal, currency)}
+
+
+ + {prevDelta !== null && ( +
+ {prevDelta >= 0 ? '↗' : '↘'} + {prevDelta >= 0 ? '+' : ''}{Math.round(prevDelta)}% vs last month + ({formatCompactCurrency(s.previousMonthTotal!, currency)}) +
+ )} +
+ ) +} diff --git a/desktop/src/components/InsightPills.tsx b/desktop/src/components/InsightPills.tsx new file mode 100644 index 0000000..a8bf9e5 --- /dev/null +++ b/desktop/src/components/InsightPills.tsx @@ -0,0 +1,30 @@ +export type InsightMode = 'trend' | 'forecast' | 'pulse' | 'stats' + +type Props = { + selected: InsightMode + onSelect: (m: InsightMode) => void + modes: InsightMode[] +} + +const LABELS: Record = { + trend: 'Trend', + forecast: 'Forecast', + pulse: 'Pulse', + stats: 'Stats', +} + +export function InsightPills({ selected, onSelect, modes }: Props) { + return ( +
+ {modes.map(m => ( + + ))} +
+ ) +} diff --git a/desktop/src/components/PulseInsight.tsx b/desktop/src/components/PulseInsight.tsx new file mode 100644 index 0000000..efb3e1e --- /dev/null +++ b/desktop/src/components/PulseInsight.tsx @@ -0,0 +1,32 @@ +import type { MenubarPayload } from '../lib/payload' +import type { CurrencyState } from '../lib/currency' +import { formatCompactCurrency } from '../lib/currency' + +type Props = { + payload: MenubarPayload + currency: CurrencyState +} + +export function PulseInsight({ payload, currency }: Props) { + const { cacheHitPercent, oneShotRate, cost, sessions } = payload.current + const cacheText = cacheHitPercent <= 0 ? '—' : `${Math.round(cacheHitPercent)}%` + const oneShotText = oneShotRate == null ? '—' : `${Math.round(oneShotRate * 100)}%` + const costPerSession = sessions > 0 ? formatCompactCurrency(cost / sessions, currency) : '—' + + return ( +
+
+
Cache hit
+
{cacheText}
+
+
+
1-shot
+
{oneShotText}
+
+
+
Cost / session
+
{costPerSession}
+
+
+ ) +} diff --git a/desktop/src/components/StatsInsight.tsx b/desktop/src/components/StatsInsight.tsx new file mode 100644 index 0000000..f45c7ca --- /dev/null +++ b/desktop/src/components/StatsInsight.tsx @@ -0,0 +1,101 @@ +import type { MenubarPayload } from '../lib/payload' +import type { CurrencyState } from '../lib/currency' +import { formatCurrency, formatCompactCurrency } from '../lib/currency' +import { formatDateKey, addDays, startOfDay, firstOfMonth, daysInMonth } from '../lib/dates' + +type Props = { + payload: MenubarPayload + currency: CurrencyState +} + +function computeStats(payload: MenubarPayload, currency: CurrencyState) { + const history = payload.history.daily + const now = new Date() + const today = startOfDay(now) + + const favoriteModel = payload.current.topModels[0]?.name ?? '—' + + const fom = firstOfMonth(now) + const fomStr = formatDateKey(fom) + const mtdActive = history.filter(d => d.date >= fomStr && d.cost > 0).length + const activeDaysFraction = `${mtdActive}/${daysInMonth(now)}` + + const peak = history.reduce<{ date: string; cost: number } | null>( + (best, d) => (!best || d.cost > best.cost) ? d : best, null + ) + const mostActiveDay = peak && peak.cost > 0 + ? new Date(peak.date + 'T00:00:00Z').toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' }) + : '—' + const peakDaySpend = peak && peak.cost > 0 ? formatCompactCurrency(peak.cost, currency) : '—' + + const costByDate = new Map(history.map(d => [d.date, d.cost])) + let currentStreak = 0 + for (let i = 0; i < 400; i++) { + const key = formatDateKey(addDays(today, -i)) + if ((costByDate.get(key) ?? 0) > 0) currentStreak++ + else break + } + + let longestStreak = 0 + let running = 0 + const sorted = [...history].sort((a, b) => a.date.localeCompare(b.date)) + for (const d of sorted) { + if (d.cost > 0) { running++; longestStreak = Math.max(longestStreak, running) } + else running = 0 + } + + const lifetimeTotal = history.length > 0 ? history.reduce((s, d) => s + d.cost, 0) : null + + return { + favoriteModel, + activeDaysFraction, + mostActiveDay, + peakDaySpend, + currentStreak: currentStreak > 0 ? `${currentStreak} days` : '—', + longestStreak: longestStreak > 0 ? `${longestStreak} days` : '—', + lifetimeTotal, + historyDayCount: history.length, + } +} + +export function StatsInsight({ payload, currency }: Props) { + const s = computeStats(payload, currency) + + return ( +
+
+
+ + + + +
+
+ + + + +
+
+ {s.lifetimeTotal !== null && ( +
+ + Tracked spend (last {s.historyDayCount} days) + + + {formatCurrency(s.lifetimeTotal, currency)} + +
+ )} +
+ ) +} + +function StatRow({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ) +} diff --git a/desktop/src/components/TrendInsight.tsx b/desktop/src/components/TrendInsight.tsx new file mode 100644 index 0000000..6d63d7f --- /dev/null +++ b/desktop/src/components/TrendInsight.tsx @@ -0,0 +1,156 @@ +import { useState } from 'react' +import type { DailyEntry } from '../lib/payload' +import type { CurrencyState } from '../lib/currency' +import { formatCompactCurrency, formatCurrency } from '../lib/currency' +import { todayKey, formatDateKey, addDays, startOfDay, prettyDate, shortDate } from '../lib/dates' + +const TREND_DAYS = 19 + +type TrendBar = { + date: string + cost: number + inputTokens: number + outputTokens: number + isToday: boolean + topModels: Array<{ name: string; totalTokens?: number; inputTokens?: number; outputTokens?: number }> +} + +function formatTokens(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M` + if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K` + return `${Math.round(n)}` +} + +function buildBars(days: DailyEntry[]): TrendBar[] { + const byDate = new Map(days.map(d => [d.date, d])) + const today = startOfDay(new Date()) + const tk = todayKey() + const bars: TrendBar[] = [] + for (let i = TREND_DAYS - 1; i >= 0; i--) { + const d = addDays(today, -i) + const key = formatDateKey(d) + const entry = byDate.get(key) + bars.push({ + date: key, + cost: entry?.cost ?? 0, + inputTokens: entry?.inputTokens ?? 0, + outputTokens: entry?.outputTokens ?? 0, + isToday: key === tk, + topModels: entry?.topModels ?? [], + }) + } + return bars +} + +function computeDelta(bars: TrendBar[], allDays: DailyEntry[]): number | null { + const thisTotal = bars.reduce((s, b) => s + b.cost, 0) + const today = startOfDay(new Date()) + const priorStart = formatDateKey(addDays(today, -(2 * TREND_DAYS - 1))) + const thisStart = formatDateKey(addDays(today, -(TREND_DAYS - 1))) + const priorTotal = allDays + .filter(d => d.date >= priorStart && d.date < thisStart) + .reduce((s, d) => s + d.cost, 0) + if (priorTotal <= 0) return null + return ((thisTotal - priorTotal) / priorTotal) * 100 +} + +type Props = { + days: DailyEntry[] + currency: CurrencyState +} + +export function TrendInsight({ days, currency }: Props) { + const [hoveredIdx, setHoveredIdx] = useState(null) + const bars = buildBars(days) + const totalTokens = bars.reduce((s, b) => s + b.inputTokens + b.outputTokens, 0) + const useTokens = totalTokens > 0 + const metric = (b: TrendBar) => useTokens ? b.inputTokens + b.outputTokens : b.cost + const maxVal = Math.max(...bars.map(metric), 0.01) + const avgVal = bars.length > 0 ? bars.reduce((s, b) => s + metric(b), 0) / bars.length : 0 + const totalCost = bars.reduce((s, b) => s + b.cost, 0) + const peak = bars.filter(b => metric(b) > 0).sort((a, b) => metric(b) - metric(a))[0] + const yesterday = bars.find(b => { + const yd = formatDateKey(addDays(startOfDay(new Date()), -1)) + return b.date === yd + }) + const delta = computeDelta(bars, days) + + const fmtVal = (v: number) => useTokens ? `${formatTokens(v)} tok` : formatCompactCurrency(v, currency) + const heroText = useTokens ? `${formatTokens(totalTokens)} tokens` : formatCurrency(totalCost, currency) + + return ( +
+
+
+
Last {TREND_DAYS} days
+
{heroText}
+
+ {delta !== null && ( +
+ {delta >= 0 ? '↗' : '↘'} + {delta >= 0 ? '+' : ''}{Math.round(delta)}% vs prior {TREND_DAYS}d +
+ )} +
+ +
setHoveredIdx(null)}> +
+ {bars.map((bar, i) => { + const val = metric(bar) + const pct = maxVal > 0 ? (val / maxVal) * 100 : 0 + const isHovered = hoveredIdx === i + return ( +
setHoveredIdx(i)} + > +
+
+
+ ) + })} +
+
+ {hoveredIdx !== null && bars[hoveredIdx] && ( +
+
+ {prettyDate(bars[hoveredIdx].date)} + {fmtVal(metric(bars[hoveredIdx]))} +
+ {bars[hoveredIdx].topModels.slice(0, 4).map(m => ( +
+ + {m.name} + {formatTokens(m.totalTokens ?? 0)} tok +
+ ))} +
+ )} +
+ +
+
+
Avg/day
+
{fmtVal(avgVal)}
+
+
+
Peak
+
+ {peak ? `${fmtVal(metric(peak))} on ${shortDate(peak.date)}` : '—'} +
+
+
+
Yesterday
+
{yesterday ? fmtVal(metric(yesterday)) : '—'}
+
+
+
+ ) +} diff --git a/desktop/src/styles.css b/desktop/src/styles.css index 4b93cb4..506033c 100644 --- a/desktop/src/styles.css +++ b/desktop/src/styles.css @@ -342,3 +342,249 @@ html, body, #root { font-size: 11px; border-radius: var(--radius-md); } + +/* ---- insight pills ---- */ +.insight-pills { + display: flex; + gap: 4px; + padding: 0 var(--spacing-lg); + margin-bottom: 10px; +} +.insight-pill { + border: 0; + background: rgba(0, 0, 0, 0.06); + padding: 4px 10px; + border-radius: var(--radius-md); + font-size: 11px; + font-weight: 500; + cursor: pointer; + color: var(--text-secondary); +} +.insight-pill-active { + background: var(--brand-accent); + color: #fff; +} + +/* ---- insight area ---- */ +.insight-area { + padding: var(--spacing-md) var(--spacing-lg); + border-bottom: 1px solid rgba(0, 0, 0, 0.06); +} + +/* ---- trend ---- */ +.trend-insight { padding: 0; } +.trend-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 10px; +} +.trend-sublabel { + font-size: 10px; + font-weight: 500; + color: var(--text-tertiary); +} +.trend-hero-value { + font-family: var(--font-rounded); + font-size: 18px; + font-weight: 600; + font-variant-numeric: tabular-nums; + color: var(--text-primary); + margin-top: 1px; +} +.trend-delta { + font-size: 10.5px; + font-variant-numeric: tabular-nums; + color: var(--brand-accent); +} +.trend-delta-arrow { + font-size: 9px; + font-weight: 700; + margin-right: 3px; +} +.trend-chart { + position: relative; + height: 90px; +} +.trend-bars { + display: flex; + align-items: flex-end; + gap: 4px; + height: 100%; +} +.trend-bar-col { + display: flex; + flex-direction: column; + width: 13px; + height: 100%; + cursor: pointer; +} +.trend-bar-spacer { flex: 1; } +.trend-bar { + width: 100%; + background: rgba(201, 82, 29, 0.55); + border-radius: 2px; + min-height: 2px; + transition: transform 0.12s ease-out; +} +.trend-bar-today { background: var(--brand-accent); } +.trend-bar-hovered { + background: rgba(201, 82, 29, 0.85); + transform: scaleX(1.08); +} +.trend-avg-line { + position: absolute; + left: 0; + right: 0; + border-top: 1px dashed rgba(128, 128, 128, 0.5); + pointer-events: none; +} +.trend-tooltip { + position: absolute; + top: calc(100% + 6px); + left: 0; + right: 0; + background: var(--text-primary); + color: var(--surface); + border-radius: 8px; + padding: 11px; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.35); + z-index: 10; +} +.trend-tooltip-header { + display: flex; + justify-content: space-between; + font-size: 11px; + font-weight: 600; + margin-bottom: 5px; +} +.trend-tooltip-value { color: var(--brand-accent); font-family: var(--font-mono); font-size: 10.5px; } +.trend-tooltip-model { + display: flex; + align-items: center; + gap: 6px; + font-size: 10px; + padding: 1px 0; +} +.trend-tooltip-dot { + width: 4px; + height: 4px; + border-radius: 50%; + background: rgba(201, 82, 29, 0.7); + flex-shrink: 0; +} +.trend-tooltip-name { flex: 1; font-weight: 500; } +.trend-tooltip-tokens { font-family: var(--font-mono); font-size: 9.5px; opacity: 0.7; } +.trend-mini-stats { + display: flex; + gap: 14px; + margin-top: 10px; +} +.mini-stat { flex: 1; } +.mini-stat-label { + font-size: 9.5px; + font-weight: 500; + color: var(--text-tertiary); +} +.mini-stat-value { + font-size: 11.5px; + font-weight: 600; + font-variant-numeric: tabular-nums; + color: var(--text-primary); + margin-top: 1px; +} + +/* ---- forecast ---- */ +.forecast-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 10px; +} +.forecast-sublabel { + font-size: 10px; + font-weight: 500; + color: var(--text-tertiary); +} +.forecast-mtd { + font-family: var(--font-rounded); + font-size: 22px; + font-weight: 600; + font-variant-numeric: tabular-nums; + color: var(--brand-accent); + margin-top: 2px; +} +.forecast-right { text-align: right; } +.forecast-projection { + font-size: 16px; + font-weight: 600; + font-variant-numeric: tabular-nums; + color: var(--text-primary); + margin-top: 2px; +} + +/* ---- pulse ---- */ +.pulse-tiles { + display: flex; + gap: 10px; +} +.pulse-tile { + flex: 1; + padding: 8px 10px; + background: rgba(0, 0, 0, 0.04); + border-radius: var(--radius-md); +} +.pulse-label { + font-size: 10px; + font-weight: 500; + color: var(--text-tertiary); +} +.pulse-value { + font-family: var(--font-rounded); + font-size: 18px; + font-weight: 600; + font-variant-numeric: tabular-nums; + color: var(--text-secondary); + margin-top: 3px; +} +.pulse-value-accent { color: var(--brand-accent); } + +/* ---- stats ---- */ +.stats-grid { + display: flex; + gap: 14px; +} +.stats-col { flex: 1; } +.stat-row { margin-bottom: 8px; } +.stat-row-label { + font-size: 9.5px; + font-weight: 500; + color: var(--text-tertiary); +} +.stat-row-value { + font-size: 12px; + font-weight: 600; + font-variant-numeric: tabular-nums; + color: var(--text-primary); + margin-top: 1px; +} +.stats-lifetime { + display: flex; + justify-content: space-between; + align-items: center; + padding-top: 8px; + margin-top: 2px; + border-top: 1px solid rgba(0, 0, 0, 0.06); +} +.stats-lifetime-label { + font-size: 10.5px; + font-weight: 500; + color: var(--text-tertiary); +} +.stats-lifetime-value { + font-family: var(--font-rounded); + font-size: 13px; + font-weight: 600; + font-variant-numeric: tabular-nums; + color: var(--brand-accent); +}