feat(desktop): add insight modes — trend chart, forecast, pulse tiles, and stats grid

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.
This commit is contained in:
AgentSeal 2026-04-29 14:24:39 -07:00 committed by iamtoruk
parent e2ebdc92e4
commit 026795cd34
7 changed files with 689 additions and 0 deletions

View file

@ -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<MenubarPayload>()
@ -32,6 +37,7 @@ export function App() {
const [currency, setCurrency] = useState<CurrencyState>(USD)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [insight, setInsight] = useState<InsightMode>('trend')
const refresh = useCallback(async (includeOptimize: boolean) => {
if (!includeOptimize) {
@ -141,6 +147,26 @@ export function App() {
))}
</nav>
<div className="insight-area">
<InsightPills
selected={insight}
onSelect={setInsight}
modes={['trend', 'forecast', 'pulse', 'stats']}
/>
{insight === 'trend' && (
<TrendInsight days={payload.history.daily} currency={currency} />
)}
{insight === 'forecast' && (
<ForecastInsight days={payload.history.daily} currency={currency} />
)}
{insight === 'pulse' && (
<PulseInsight payload={payload} currency={currency} />
)}
{insight === 'stats' && (
<StatsInsight payload={payload} currency={currency} />
)}
</div>
{!loading && payload.current.calls === 0 && payload.current.sessions === 0 ? (
<section className="empty-state">
<h2 className="section-title">No session data yet</h2>

View file

@ -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 (
<div className="forecast-insight">
<div className="forecast-header">
<div>
<div className="forecast-sublabel">Month-to-date</div>
<div className="forecast-mtd">{formatCurrency(s.mtd, currency)}</div>
</div>
<div className="forecast-right">
<div className="forecast-sublabel">On pace for</div>
<div className="forecast-projection">{formatCurrency(s.projection, currency)}</div>
</div>
</div>
<div className="trend-mini-stats">
<div className="mini-stat">
<div className="mini-stat-label">Avg/day (this wk)</div>
<div className="mini-stat-value">{formatCompactCurrency(s.weekAvg, currency)}</div>
</div>
<div className="mini-stat">
<div className="mini-stat-label">Yesterday</div>
<div className="mini-stat-value">{formatCompactCurrency(s.yesterday, currency)}</div>
</div>
<div className="mini-stat">
<div className="mini-stat-label">Last 7d</div>
<div className="mini-stat-value">{formatCompactCurrency(s.weekTotal, currency)}</div>
</div>
</div>
{prevDelta !== null && (
<div className="trend-delta" style={{ marginTop: '10px' }}>
<span className="trend-delta-arrow">{prevDelta >= 0 ? '↗' : '↘'}</span>
{prevDelta >= 0 ? '+' : ''}{Math.round(prevDelta)}% vs last month
({formatCompactCurrency(s.previousMonthTotal!, currency)})
</div>
)}
</div>
)
}

View file

@ -0,0 +1,30 @@
export type InsightMode = 'trend' | 'forecast' | 'pulse' | 'stats'
type Props = {
selected: InsightMode
onSelect: (m: InsightMode) => void
modes: InsightMode[]
}
const LABELS: Record<InsightMode, string> = {
trend: 'Trend',
forecast: 'Forecast',
pulse: 'Pulse',
stats: 'Stats',
}
export function InsightPills({ selected, onSelect, modes }: Props) {
return (
<div className="insight-pills">
{modes.map(m => (
<button
key={m}
className={`insight-pill ${selected === m ? 'insight-pill-active' : ''}`}
onClick={() => onSelect(m)}
>
{LABELS[m]}
</button>
))}
</div>
)
}

View file

@ -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 (
<div className="pulse-tiles">
<div className="pulse-tile">
<div className="pulse-label">Cache hit</div>
<div className="pulse-value pulse-value-accent">{cacheText}</div>
</div>
<div className="pulse-tile">
<div className="pulse-label">1-shot</div>
<div className="pulse-value pulse-value-accent">{oneShotText}</div>
</div>
<div className="pulse-tile">
<div className="pulse-label">Cost / session</div>
<div className="pulse-value">{costPerSession}</div>
</div>
</div>
)
}

View file

@ -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 (
<div className="stats-insight">
<div className="stats-grid">
<div className="stats-col">
<StatRow label="Favorite model" value={s.favoriteModel} />
<StatRow label="Active days (month)" value={s.activeDaysFraction} />
<StatRow label="Most active day" value={s.mostActiveDay} />
<StatRow label="Peak day spend" value={s.peakDaySpend} />
</div>
<div className="stats-col">
<StatRow label="Sessions today" value={`${payload.current.sessions}`} />
<StatRow label="Calls today" value={payload.current.calls.toLocaleString()} />
<StatRow label="Current streak" value={s.currentStreak} />
<StatRow label="Longest streak" value={s.longestStreak} />
</div>
</div>
{s.lifetimeTotal !== null && (
<div className="stats-lifetime">
<span className="stats-lifetime-label">
Tracked spend (last {s.historyDayCount} days)
</span>
<span className="stats-lifetime-value">
{formatCurrency(s.lifetimeTotal, currency)}
</span>
</div>
)}
</div>
)
}
function StatRow({ label, value }: { label: string; value: string }) {
return (
<div className="stat-row">
<div className="stat-row-label">{label}</div>
<div className="stat-row-value">{value}</div>
</div>
)
}

View file

@ -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<number | null>(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 (
<div className="trend-insight">
<div className="trend-header">
<div className="trend-header-left">
<div className="trend-sublabel">Last {TREND_DAYS} days</div>
<div className="trend-hero-value">{heroText}</div>
</div>
{delta !== null && (
<div className="trend-delta">
<span className="trend-delta-arrow">{delta >= 0 ? '↗' : '↘'}</span>
{delta >= 0 ? '+' : ''}{Math.round(delta)}% vs prior {TREND_DAYS}d
</div>
)}
</div>
<div className="trend-chart" onMouseLeave={() => setHoveredIdx(null)}>
<div className="trend-bars">
{bars.map((bar, i) => {
const val = metric(bar)
const pct = maxVal > 0 ? (val / maxVal) * 100 : 0
const isHovered = hoveredIdx === i
return (
<div
key={bar.date}
className="trend-bar-col"
onMouseEnter={() => setHoveredIdx(i)}
>
<div className="trend-bar-spacer" />
<div
className={`trend-bar ${bar.isToday ? 'trend-bar-today' : ''} ${isHovered ? 'trend-bar-hovered' : ''}`}
style={{ height: `${Math.max(2, pct)}%` }}
/>
</div>
)
})}
</div>
<div
className="trend-avg-line"
style={{ bottom: `${Math.min((avgVal / maxVal) * 100, 100)}%` }}
/>
{hoveredIdx !== null && bars[hoveredIdx] && (
<div className="trend-tooltip">
<div className="trend-tooltip-header">
<span>{prettyDate(bars[hoveredIdx].date)}</span>
<span className="trend-tooltip-value">{fmtVal(metric(bars[hoveredIdx]))}</span>
</div>
{bars[hoveredIdx].topModels.slice(0, 4).map(m => (
<div key={m.name} className="trend-tooltip-model">
<span className="trend-tooltip-dot" />
<span className="trend-tooltip-name">{m.name}</span>
<span className="trend-tooltip-tokens">{formatTokens(m.totalTokens ?? 0)} tok</span>
</div>
))}
</div>
)}
</div>
<div className="trend-mini-stats">
<div className="mini-stat">
<div className="mini-stat-label">Avg/day</div>
<div className="mini-stat-value">{fmtVal(avgVal)}</div>
</div>
<div className="mini-stat">
<div className="mini-stat-label">Peak</div>
<div className="mini-stat-value">
{peak ? `${fmtVal(metric(peak))} on ${shortDate(peak.date)}` : '—'}
</div>
</div>
<div className="mini-stat">
<div className="mini-stat-label">Yesterday</div>
<div className="mini-stat-value">{yesterday ? fmtVal(metric(yesterday)) : '—'}</div>
</div>
</div>
</div>
)
}

View file

@ -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);
}