From 9d906832f6567d8d3a42c23cb734e2584fce53f0 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Sat, 11 Jul 2026 11:12:41 -0700 Subject: [PATCH] =?UTF-8?q?feat(app):=20Overview=20v2=20phase=201=20?= =?UTF-8?q?=E2=80=94=20dashboard=20layout,=20KPI=20strip,=20activity=20hea?= =?UTF-8?q?tmap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Full-width responsive dashboard grid (2-col body, reflows <900px) so the Overview uses the window instead of a narrow column. - KPI strip: Spend/Calls/Sessions + one-shot rate (current.oneShotRate, the previously-missing metric; null → em dash) + cache-hit % + Saved. - New ActivityHeatmap: GitHub-style 26-week contribution grid from history.daily (5 cost levels, active-day count, portal tooltip), ported from the menubar's ContributionHeatmapInsight. - Top activities rail: name · cost · turns · one-shot % (current.topActivities). - All real payload data, no new bridge calls. typecheck clean, 87 tests pass. --- app/renderer/components/ActivityHeatmap.tsx | 147 ++++++++++++++++++++ app/renderer/sections/Overview.test.tsx | 26 +++- app/renderer/sections/Overview.tsx | 90 +++++++++--- app/renderer/styles/plain.css | 57 +++++++- 4 files changed, 294 insertions(+), 26 deletions(-) create mode 100644 app/renderer/components/ActivityHeatmap.tsx diff --git a/app/renderer/components/ActivityHeatmap.tsx b/app/renderer/components/ActivityHeatmap.tsx new file mode 100644 index 00000000..51f37f32 --- /dev/null +++ b/app/renderer/components/ActivityHeatmap.tsx @@ -0,0 +1,147 @@ +import { useLayoutEffect, useMemo, useRef, useState } from 'react' +import { createPortal } from 'react-dom' + +import { formatUsd } from '../lib/format' +import { localDateKey } from '../lib/period' +import type { DailyHistoryEntry } from '../lib/types' + +type HeatmapDay = { + date: string + cost: number + calls: number + level: number + isFuture: boolean +} + +const WEEK_COUNT = 26 +const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] + +function dateFromKey(key: string): Date { + const [year, month, day] = key.split('-').map(Number) + return new Date(year, month - 1, day) +} + +function formatDate(key: string): string { + return dateFromKey(key).toLocaleString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }) +} + +function intensityLevel(cost: number, maxCost: number): number { + if (cost <= 0 || maxCost <= 0) return 0 + const ratio = Math.min(1, cost / maxCost) + if (ratio < 0.25) return 1 + if (ratio < 0.5) return 2 + if (ratio < 0.75) return 3 + return 4 +} + +function buildHeatmapDays(daily: DailyHistoryEntry[], now: Date): HeatmapDay[] { + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()) + const startOfWeek = new Date(today) + startOfWeek.setDate(today.getDate() - today.getDay()) + const firstDay = new Date(startOfWeek) + firstDay.setDate(startOfWeek.getDate() - (WEEK_COUNT - 1) * 7) + const byDate = new Map(daily.map(day => [day.date, day])) + const visibleCosts: number[] = [] + + for (let offset = 0; offset < WEEK_COUNT * 7; offset++) { + const date = new Date(firstDay) + date.setDate(firstDay.getDate() + offset) + if (date <= today) visibleCosts.push(byDate.get(localDateKey(date))?.cost ?? 0) + } + const maxCost = Math.max(...visibleCosts, 0) + + return Array.from({ length: WEEK_COUNT * 7 }, (_, offset) => { + const date = new Date(firstDay) + date.setDate(firstDay.getDate() + offset) + const isFuture = date > today + const entry = byDate.get(localDateKey(date)) + const cost = isFuture ? 0 : (entry?.cost ?? 0) + return { + date: localDateKey(date), + cost, + calls: isFuture ? 0 : (entry?.calls ?? 0), + level: intensityLevel(cost, maxCost), + isFuture, + } + }) +} + +export function ActivityHeatmap({ daily }: { daily: DailyHistoryEntry[] }) { + const days = useMemo(() => buildHeatmapDays(daily, new Date()), [daily]) + const activeDays = days.filter(day => !day.isFuture && day.cost > 0).length + const [tip, setTip] = useState<{ day: HeatmapDay; x: number; y: number } | null>(null) + const [tipPosition, setTipPosition] = useState<{ left: number; top: number } | null>(null) + const tipRef = useRef(null) + + useLayoutEffect(() => { + if (!tip) { + setTipPosition(null) + return + } + const width = tipRef.current?.offsetWidth ?? 180 + const height = tipRef.current?.offsetHeight ?? 58 + const gutter = 8 + const cursorGap = 12 + let left = tip.x + cursorGap + if (left + width > window.innerWidth - gutter) left = tip.x - width - cursorGap + left = Math.max(gutter, Math.min(left, window.innerWidth - width - gutter)) + let top = tip.y - height - cursorGap + if (top < gutter) top = tip.y + cursorGap + top = Math.max(gutter, Math.min(top, window.innerHeight - height - gutter)) + setTipPosition({ left, top }) + }, [tip]) + + return ( +
+
+

Daily activity

+ {activeDays} active days +
+
+
+
+ +
+ {days.map(day => ( +
+
+
+
+ {tip && createPortal( +
+
{formatDate(tip.day.date)}
+
{tip.day.isFuture ? 'Future day' : formatUsd(tip.day.cost)}
+
{tip.day.isFuture ? 'No activity yet' : `${tip.day.calls} calls`}
+
, + document.body, + )} +
+ ) +} diff --git a/app/renderer/sections/Overview.test.tsx b/app/renderer/sections/Overview.test.tsx index 751e6ee3..04aa29e6 100644 --- a/app/renderer/sections/Overview.test.tsx +++ b/app/renderer/sections/Overview.test.tsx @@ -71,14 +71,17 @@ function makePayload(now: Date): MenubarPayload { cost: 312.4, calls: 4200, sessions: 88, - oneShotRate: null, + oneShotRate: 0.74, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, - cacheHitPercent: 0, + cacheHitPercent: 63.4, codexCredits: 0, - topActivities: [], + topActivities: [ + { name: 'coding', cost: 92.5, savingsUSD: 7.2, turns: 120, oneShotRate: 0.8 }, + { name: 'debugging', cost: 41.25, savingsUSD: 2.1, turns: 64, oneShotRate: null }, + ], topModels: [{ name: 'claude-opus-4', cost: 200, savingsUSD: 0, savingsBaselineModel: '', calls: 100 }], localModelSavings: { totalUSD: 0, calls: 0, byModel: [], byProvider: [] }, providers: {}, @@ -152,6 +155,23 @@ describe('Overview', () => { expect(screen.getByText('Last 30 days')).toBeInTheDocument() expect(container.querySelector('.ov-streak')).toHaveTextContent('30-day streak') + // The KPI strip surfaces the payload's previously hidden success/cache + // metrics, and the saved KPI remains backed by the ACT report poll. + const kpis = screen.getByLabelText('Key performance indicators') + expect(within(kpis).getByText('74%')).toBeInTheDocument() + expect(within(kpis).getByText('63%')).toBeInTheDocument() + expect(within(kpis).getByText('$84.20')).toBeInTheDocument() + + // The contribution grid contains the real active history days, and the + // right rail renders real, cost-sorted activity data including one-shot. + const heatmap = screen.getByRole('grid', { name: 'Daily activity contribution heatmap' }) + expect(heatmap.querySelectorAll('[data-active="true"]')).toHaveLength(30) + expect(screen.getByText('30 active days')).toBeInTheDocument() + expect(screen.getByText('coding')).toBeInTheDocument() + expect(screen.getByText('$92.50')).toBeInTheDocument() + expect(screen.getByText('120 turns')).toBeInTheDocument() + expect(screen.getByText('80% one-shot')).toBeInTheDocument() + // Session row title = the session's project (topSessions has no title field). expect(screen.getByText('parser-service')).toBeInTheDocument() diff --git a/app/renderer/sections/Overview.tsx b/app/renderer/sections/Overview.tsx index 131daa1a..ab39f6cf 100644 --- a/app/renderer/sections/Overview.tsx +++ b/app/renderer/sections/Overview.tsx @@ -2,6 +2,7 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { CliErrorPanel } from '../components/CliErrorPanel' +import { ActivityHeatmap } from '../components/ActivityHeatmap' import { ListRow, seriesColorForModel } from '../components/ListRow' import { Panel } from '../components/Panel' import { type Polled, usePolled } from '../hooks/usePolled' @@ -350,6 +351,36 @@ function EmptyNote({ children }: { children: React.ReactNode }) { return

{children}

} +function formatRate(rate: number | null): string { + return rate === null ? '—' : `${Math.round(rate * 100)}%` +} + +function TopActivities({ activities }: { activities: MenubarPayload['current']['topActivities'] }) { + const rows = [...activities].sort((a, b) => b.cost - a.cost).slice(0, 6) + if (!rows.length) return No activity in this range yet. + const maxCost = rows[0].cost + + return ( +
+ {rows.map(activity => ( +
+ +
+ {activity.name} + {formatUsd(activity.cost)} +
+
+ {activity.turns.toLocaleString('en-US')} turns + {formatRate(activity.oneShotRate)} one-shot +
+
+ ))} +
+ ) +} + export function Overview({ period, provider }: { period: Period; provider: string }) { const overview = usePolled(() => codeburn.getOverview(period, provider), [period, provider]) return @@ -388,7 +419,16 @@ export function OverviewContent({ const saved = actReport.data?.totals.realizedCostUSD ?? 0 const applied = saved > 0 ? (actReport.data?.totals.measuredActions ?? 0) : 0 return ( - <> +
+
+
Spend{formatUsd(data.current.cost)}
+
Calls{data.current.calls.toLocaleString('en-US')}
+
Sessions{data.current.sessions.toLocaleString('en-US')}
+
One-shot{formatRate(data.current.oneShotRate)}
+
Cache hit{Math.round(data.current.cacheHitPercent)}%
+
Saved{formatUsd(saved)}from {applied} applied fixes
+
+
{data.current.label}{streakDays(data.history.daily, now)}-day streak
@@ -409,29 +449,41 @@ export function OverviewContent({
Month to date
{formatUsd(stats.mtd)}
{stats.pacePct === null ? `No ${stats.prevMonthName} pace yet` : `${stats.pacePct >= 0 ? '+' : ''}${Math.round(stats.pacePct)}% vs ${stats.prevMonthName} pace`}
Projected month
{formatUsd(stats.projected)} est
{formatUsd(Math.max(0, stats.projected - stats.mtd))} to go
-
Saved to date
{formatUsd(saved)}
from {applied} applied fixes
-
-

Models this period

Sorted by cost
-
-
+
+
+
+

Models this period

Sorted by cost
+
+
-
-

Daily spend

{topModel ? `Biggest driver: ${topModel.name}` : 'No model driver yet'}
-
{data.history.daily.length ? : No spend yet.}
-
+
+

Daily spend

{topModel ? `Biggest driver: ${topModel.name}` : 'No model driver yet'}
+
{data.history.daily.length ? : No spend yet.}
+
+
-
-

Most expensive sessions

-
- {data.current.topSessions.length ? data.current.topSessions.map((session, index) => { - const model = modelIndex.get(sessionModelKey(session.project, session.date, session.calls, session.cost)) - const sub = [formatDay(session.date), model, `${session.calls} calls`].filter(Boolean).join(' · ') - return - }) : No sessions in this range.} +
+
+

Top activities

Sorted by cost
+
+
+ +
+

Most expensive sessions

+
+ {data.current.topSessions.length ? data.current.topSessions.map((session, index) => { + const model = modelIndex.get(sessionModelKey(session.project, session.date, session.calls, session.cost)) + const sub = [formatDay(session.date), model, `${session.calls} calls`].filter(Boolean).join(' · ') + return + }) : No sessions in this range.} +
+
- + + +
) } diff --git a/app/renderer/styles/plain.css b/app/renderer/styles/plain.css index e706b201..c8f763ca 100644 --- a/app/renderer/styles/plain.css +++ b/app/renderer/styles/plain.css @@ -196,6 +196,16 @@ body { overflow: hidden; background: var(--bg); color: var(--ink); } .track i.over { background: var(--bad); } .track i.mut { background: var(--bar); } +.ov-dashboard { display: grid; width: 100%; gap: 12px; } +.ov-kpis { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); overflow: hidden; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; } +.ov-kpi { position: relative; display: flex; min-width: 0; flex-direction: column; justify-content: center; gap: 4px; min-height: 64px; padding: 9px 13px; border-right: 1px solid var(--line2); } +.ov-kpi:last-child { border-right: 0; } +.ov-kpi > span { color: var(--mut); font-size: 11px; font-weight: 520; } +.ov-kpi > strong { overflow: hidden; color: var(--ink); font-family: var(--mono); font-size: 18px; font-weight: 650; font-variant-numeric: tabular-nums; line-height: 1.15; text-overflow: ellipsis; white-space: nowrap; } +.ov-kpi > small { overflow: hidden; color: var(--mut); font-size: 9.5px; text-overflow: ellipsis; white-space: nowrap; } +.ov-kpi-primary { box-shadow: inset 0 2px 0 var(--accent); } +.ov-kpi-primary > span, .ov-kpi-primary > strong { color: var(--accent); } +.ov-kpi-saved > strong { color: var(--ok); } .ov-hero-row { display: grid; grid-template-columns: minmax(0, 1fr) 196px; gap: 12px; } .ov-card { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; } .ov-hero { display: flex; flex-direction: column; gap: 6px; padding: 14px 16px; } @@ -227,7 +237,7 @@ body { overflow: hidden; background: var(--bg); color: var(--ink); } .ov-coach-tx { font-size: 12.5px; color: var(--mut); flex: 1; line-height: 1.45; } .ov-coach-tx .num { color: var(--ink); font-weight: 600; } .ov-coach-cta { white-space: nowrap; color: var(--accent); text-decoration: none; font-size: 11.5px; font-weight: 540; border: 0; background: none; cursor: pointer; } -.ov-stats3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; } +.ov-stats3 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } .ov-stat { padding: 11px 13px; } .ov-stat .v { font-size: 23px; font-weight: 640; letter-spacing: -.02em; margin-top: 6px; font-variant-numeric: tabular-nums; } .ov-stat .v small { font-size: 12px; color: var(--mut2); font-weight: 500; } @@ -252,7 +262,21 @@ body { overflow: hidden; background: var(--bg); color: var(--ink); } .ov-models th:nth-child(2), .ov-models td:nth-child(2) { width: 100%; text-align: left; } .ov-models .ov-model-name { overflow: hidden; color: var(--ink); font-weight: 550; text-overflow: ellipsis; } .ov-models td.mono { font-family: var(--mono); color: var(--ink); } -.chart { position: relative; height: 150px; display: flex; align-items: flex-end; gap: 4px; padding-top: 8px; border-bottom: 1px solid var(--line2); background: repeating-linear-gradient(to top, transparent 0, transparent 36px, var(--line2) 36px, var(--line2) 37px); } +.ov-body-grid { display: grid; grid-template-columns: minmax(0, 2fr) minmax(300px, 1fr); align-items: start; gap: 12px; } +.ov-main-column, .ov-side-column { display: grid; min-width: 0; gap: 12px; } +.ov-activities { display: grid; gap: 0; } +.ov-activity { position: relative; padding: 9px 0; border-top: 1px solid var(--line2); } +.ov-activity:first-child { padding-top: 0; border-top: 0; } +.ov-activity:last-child { padding-bottom: 0; } +.ov-activity-bar { height: 3px; overflow: hidden; margin-bottom: 6px; border-radius: 2px; background: var(--fill); } +.ov-activity-bar span { display: block; height: 100%; border-radius: inherit; background: var(--accent); } +.ov-activity-main, .ov-activity-meta { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; } +.ov-activity-main { font-size: 12px; } +.ov-activity-name { overflow: hidden; color: var(--ink); font-weight: 560; text-overflow: ellipsis; white-space: nowrap; } +.ov-activity-main strong { flex: 0 0 auto; font-family: var(--mono); font-size: 11.5px; font-variant-numeric: tabular-nums; } +.ov-activity-meta { margin-top: 3px; color: var(--mut); font-size: 10.5px; font-variant-numeric: tabular-nums; } +.chart { position: relative; height: 150px; display: flex; align-items: flex-end; gap: 4px; padding-top: 8px; border-bottom: 1px solid var(--line2); } +.chart::before { content: ''; position: absolute; right: 0; bottom: 36px; left: 0; height: 1px; background: var(--line2); box-shadow: 0 -37px var(--line2), 0 -74px var(--line2); pointer-events: none; } .chart .col { flex: 1; background: var(--bar); border-radius: 3px 3px 0 0; min-height: 3px; transition: background .14s ease; border: 0; padding: 0; } .chart .col.hi { background: var(--bar-hi); } .chart .col:hover { background: var(--accent); } .chart-tip { position: fixed; pointer-events: none; max-width: calc(100vw - 16px); opacity: 0; background: var(--tip-bg); color: var(--tip-ink); border: 1px solid color-mix(in srgb, var(--tip-ink) 14%, transparent); border-radius: 7px; padding: 7px 10px; line-height: 1.35; white-space: nowrap; box-shadow: 0 6px 20px rgba(0,0,0,.22); transition: opacity .1s ease; z-index: 1000; } @@ -268,6 +292,31 @@ body { overflow: hidden; background: var(--bg); color: var(--ink); } .ov-summary-chip { display: flex; align-items: baseline; gap: 7px; padding: 5px 9px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); font-size: 10.5px; } .ov-summary-chip span { color: var(--mut2); } .ov-summary-chip strong { color: var(--ink); font-family: var(--mono); font-size: 11px; font-weight: 600; font-variant-numeric: tabular-nums; } +.ov-active-days { color: var(--accent) !important; font-family: var(--mono); font-variant-numeric: tabular-nums; } +.ov-heatmap-scroll { max-width: 100%; overflow-x: auto; padding: 2px 0 3px; } +.ov-heatmap { display: grid; grid-template-columns: 28px max-content; align-items: start; gap: 7px; min-width: max-content; } +.ov-heatmap-labels { display: grid; grid-template-rows: repeat(7, 11px); gap: 3px; color: var(--mut2); font-size: 9px; font-weight: 520; line-height: 11px; } +.ov-heatmap-labels span { text-align: right; } +.ov-heatmap-cells { display: grid; grid-template-rows: repeat(7, 11px); grid-auto-flow: column; grid-auto-columns: 11px; gap: 3px; } +.ov-heat-cell { width: 11px; height: 11px; padding: 0; border: 0; border-radius: 2px; background: var(--fill); cursor: default; } +.ov-heat-cell.heat-level-1 { background: color-mix(in srgb, var(--accent) 18%, var(--fill)); } +.ov-heat-cell.heat-level-2 { background: color-mix(in srgb, var(--accent) 38%, var(--fill)); } +.ov-heat-cell.heat-level-3 { background: color-mix(in srgb, var(--accent) 64%, var(--fill)); } +.ov-heat-cell.heat-level-4 { background: var(--accent); } +.ov-heat-cell.future { opacity: .42; } +.ov-heat-cell:hover, .ov-heat-cell:focus-visible { outline: 1px solid var(--accent); outline-offset: 1px; } -@media (max-width: 900px) { .ov-hero-row { grid-template-columns: 1fr; } } -@media (max-width: 720px) { .ov-stats3 { grid-template-columns: 1fr; } } +@media (max-width: 900px) { + .ov-kpis { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .ov-kpi:nth-child(3) { border-right: 0; } + .ov-kpi:nth-child(-n+3) { border-bottom: 1px solid var(--line2); } + .ov-body-grid, .ov-hero-row { grid-template-columns: 1fr; } +} +@media (max-width: 600px) { + .ov-kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .ov-kpi:nth-child(3) { border-right: 1px solid var(--line2); } + .ov-kpi:nth-child(even) { border-right: 0; } + .ov-kpi:nth-child(-n+4) { border-bottom: 1px solid var(--line2); } + .ov-stats3 { grid-template-columns: 1fr; } + .ov-coach { align-items: flex-start; } +}