From cbddd3a9d71202f2d7781dcf33e2094dd073b310 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Thu, 16 Jul 2026 01:14:41 -0700 Subject: [PATCH] =?UTF-8?q?fix(app):=20accuracy=20batch=20A=20=E2=80=94=20?= =?UTF-8?q?provider=20scoping,=20honest=20chart=20axes,=20range=20threadin?= =?UTF-8?q?g,=20stale-error=20banner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Provider filter: Models-this-period sources current.topModels (history days carry empty topModels per provider); StackedBars falls back to a cost-proportional single segment like the Swift menubar - Chart windows align to the CLI (week -7, 30days -30, all = 6 months) and zero-fill a contiguous calendar window; history.daily is sparse and both sides key local YYYY-MM-DD, so lookups are safe (verified on real CLI output) - Custom range threads into Overview panels (chart, models table) and suppresses MTD/projected and vs-last-week comparisons; Compare states that custom dates fall back to the period - usePolled clears errors per attempt and sections show a StaleBanner instead of silently rendering last-good data after a failed refresh - Cache-hit denominator matches the CLI; hero savings relabeled 'Saved by applied fixes' + new 'Saved via local models' line; formatCompact everywhere; Optimize 'Fixes' count matches the rendered list App suite 163/163 (was 147), root 1613/1613. --- app/renderer/App.tsx | 4 +- app/renderer/components/StackedBars.test.tsx | 19 ++++ app/renderer/components/StackedBars.tsx | 63 +++++++++---- app/renderer/components/StaleBanner.test.tsx | 14 +++ app/renderer/components/StaleBanner.tsx | 24 +++++ app/renderer/hooks/usePolled.test.ts | 19 ++++ app/renderer/hooks/usePolled.ts | 3 + app/renderer/lib/period.test.ts | 30 +++++- app/renderer/lib/period.ts | 63 +++++++++++-- app/renderer/sections/Compare.test.tsx | 21 +++++ app/renderer/sections/Compare.tsx | 18 +++- app/renderer/sections/Models.tsx | 2 + app/renderer/sections/Optimize.test.tsx | 19 ++++ app/renderer/sections/Optimize.tsx | 5 +- app/renderer/sections/Overview.test.tsx | 99 ++++++++++++++++++-- app/renderer/sections/Overview.tsx | 87 +++++++++++------ app/renderer/sections/Plans.tsx | 2 + app/renderer/sections/Sessions.tsx | 2 + app/renderer/sections/Spend.test.tsx | 17 ++-- app/renderer/sections/Spend.tsx | 42 +++++++-- 20 files changed, 463 insertions(+), 90 deletions(-) create mode 100644 app/renderer/components/StaleBanner.test.tsx create mode 100644 app/renderer/components/StaleBanner.tsx diff --git a/app/renderer/App.tsx b/app/renderer/App.tsx index ed44cb7..18c2739 100644 --- a/app/renderer/App.tsx +++ b/app/renderer/App.tsx @@ -173,7 +173,7 @@ export function App() { />
{section === 'overview' ? ( - + ) : section === 'sessions' ? ( ) : section === 'spend' ? ( @@ -183,7 +183,7 @@ export function App() { ) : section === 'models' ? ( ) : section === 'compare' ? ( - + ) : ( )} diff --git a/app/renderer/components/StackedBars.test.tsx b/app/renderer/components/StackedBars.test.tsx index 5a260a5..c0c2ce3 100644 --- a/app/renderer/components/StackedBars.test.tsx +++ b/app/renderer/components/StackedBars.test.tsx @@ -28,4 +28,23 @@ describe('StackedBars', () => { const ticks = container.querySelectorAll('.sbars-wrap > .ov-xax span') expect([...ticks].map(tick => tick.textContent)).toEqual(['Jul 1', 'Jul 5', 'Jul 9', 'Jul 13', 'Jul 16']) }) + + it('draws a single cost-only fallback bar and a provider legend when a day has cost but no model breakdown', () => { + // Provider-filtered days: cost present, topModels empty (the Swift menubar + // draws these from day.cost). A zero-cost day stays empty. + const daily = [ + { ...entry(9), cost: 0 }, + { ...entry(10), cost: 12 }, + ] + const { container } = render() + + const columns = container.querySelectorAll('.sbars .c') + expect(columns[0].querySelectorAll('.s')).toHaveLength(0) + expect(columns[1].querySelectorAll('.s')).toHaveLength(1) + expect(columns[1].querySelector('.s-other')).toBeInTheDocument() + + const legend = container.querySelector('.legend')! + expect(legend.querySelectorAll('span')).toHaveLength(1) + expect(legend).toHaveTextContent('Claude') + }) }) diff --git a/app/renderer/components/StackedBars.tsx b/app/renderer/components/StackedBars.tsx index 7b68fba..5d3d101 100644 --- a/app/renderer/components/StackedBars.tsx +++ b/app/renderer/components/StackedBars.tsx @@ -5,17 +5,26 @@ import type { DailyHistoryEntry } from '../lib/types' const SERIES_ORDER: readonly SeriesKey[] = ['opus', 'fable', 'haiku', 'gpt', 'sonnet', 'other'] -export function StackedBars({ daily }: { daily: DailyHistoryEntry[] }) { +function modelSpend(day: DailyHistoryEntry): number { + return day.topModels.reduce((sum, model) => sum + Math.max(0, model.cost), 0) +} + +export function StackedBars({ daily, fallbackLabel = 'All models' }: { daily: DailyHistoryEntry[]; fallbackLabel?: string }) { const presentSeries = new Set() - const maxTotal = Math.max( - 1, - ...daily.map(day => day.topModels.reduce((sum, model) => sum + Math.max(0, model.cost), 0)), - ) + let usesFallback = false for (const day of daily) { - for (const model of day.topModels) { - if (model.cost > 0) presentSeries.add(seriesKeyForModel(model.name)) + if (modelSpend(day) > 0) { + for (const model of day.topModels) { + if (model.cost > 0) presentSeries.add(seriesKeyForModel(model.name)) + } + } else if (day.cost > 0) { + // Provider-filtered days carry day.cost but no per-model breakdown; the + // bar must still reflect spend (the Swift menubar draws from day.cost). + usesFallback = true } } + // Fallback days contribute day.cost to the scale so their single segment is proportional. + const maxTotal = Math.max(1, ...daily.map(day => (modelSpend(day) > 0 ? modelSpend(day) : Math.max(0, day.cost)))) const legendSeries = SERIES_ORDER.filter(series => presentSeries.has(series)) const ticks = daily.filter((_, index) => index % 4 === 0) const lastDay = daily.at(-1) @@ -26,19 +35,27 @@ export function StackedBars({ daily }: { daily: DailyHistoryEntry[] }) {
{daily.map(day => (
- {[...day.topModels].sort( - (a, b) => SERIES_ORDER.indexOf(seriesKeyForModel(a.name)) - SERIES_ORDER.indexOf(seriesKeyForModel(b.name)), - ).map(model => { - const pct = Math.max(1, (Math.max(0, model.cost) / maxTotal) * 100) - return ( - - ) - })} + {modelSpend(day) > 0 ? ( + [...day.topModels].sort( + (a, b) => SERIES_ORDER.indexOf(seriesKeyForModel(a.name)) - SERIES_ORDER.indexOf(seriesKeyForModel(b.name)), + ).map(model => { + const pct = Math.max(1, (Math.max(0, model.cost) / maxTotal) * 100) + return ( + + ) + }) + ) : day.cost > 0 ? ( + + ) : null}
))}
@@ -59,6 +76,12 @@ export function StackedBars({ daily }: { daily: DailyHistoryEntry[] }) { {SERIES_LABELS[series]} ))} + {usesFallback && !presentSeries.has('other') && ( + + + {fallbackLabel} + + )}
) diff --git a/app/renderer/components/StaleBanner.test.tsx b/app/renderer/components/StaleBanner.test.tsx new file mode 100644 index 0000000..098f4f1 --- /dev/null +++ b/app/renderer/components/StaleBanner.test.tsx @@ -0,0 +1,14 @@ +// @vitest-environment jsdom +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import { StaleBanner } from './StaleBanner' + +describe('StaleBanner', () => { + it('shows the last-good notice with the error summary', () => { + render() + + const banner = screen.getByRole('status') + expect(banner).toHaveTextContent('Refresh failed, showing last good data · codeburn exited 1') + }) +}) diff --git a/app/renderer/components/StaleBanner.tsx b/app/renderer/components/StaleBanner.tsx new file mode 100644 index 0000000..08a0d9d --- /dev/null +++ b/app/renderer/components/StaleBanner.tsx @@ -0,0 +1,24 @@ +import type { CliError } from '../lib/types' + +/** + * One-line notice shown when a section still has last-good data but the latest + * background poll failed. Muted so it never competes with the data below it. + */ +export function StaleBanner({ error }: { error: CliError }) { + return ( +
+ Refresh failed, showing last good data · {error.message} +
+ ) +} diff --git a/app/renderer/hooks/usePolled.test.ts b/app/renderer/hooks/usePolled.test.ts index 080d4ce..3e89de5 100644 --- a/app/renderer/hooks/usePolled.test.ts +++ b/app/renderer/hooks/usePolled.test.ts @@ -36,4 +36,23 @@ describe('usePolled', () => { await act(async () => { resolvers[1]!('A-stale') }) expect(result.current.data).toBe('B-fresh') }) + + it('keeps last-good data and exposes the error when a background reload fails', async () => { + const calls: Array<{ resolve: (v: string) => void; reject: (e: unknown) => void }> = [] + const fetcher = vi.fn(() => new Promise((resolve, reject) => { calls.push({ resolve, reject }) })) + const { result } = renderHook(() => usePolled(fetcher, [])) + + // Establish last-good data. + await act(async () => { calls[0]!.resolve('good') }) + expect(result.current.data).toBe('good') + expect(result.current.error).toBeNull() + + // A reload clears the error up front; if it fails, data is retained and the + // error is surfaced alongside it (the StaleBanner condition). + act(() => { result.current.refresh() }) + expect(result.current.error).toBeNull() + await act(async () => { calls[1]!.reject({ kind: 'nonzero', message: 'boom' }) }) + expect(result.current.data).toBe('good') + expect(result.current.error).toMatchObject({ kind: 'nonzero', message: 'boom' }) + }) }) diff --git a/app/renderer/hooks/usePolled.ts b/app/renderer/hooks/usePolled.ts index 14f8223..a29a85c 100644 --- a/app/renderer/hooks/usePolled.ts +++ b/app/renderer/hooks/usePolled.ts @@ -32,6 +32,9 @@ export function usePolled(fetcher: () => Promise, deps: unknown[], interva const load = useCallback(() => { const epoch = ++epochRef.current setLoading(true) + // Clear any prior error at the start of each attempt so a fresh poll never + // shows a stale banner while it is still in flight; last-good `data` stays. + setError(null) fetcher() .then(result => { if (epochRef.current !== epoch) return diff --git a/app/renderer/lib/period.test.ts b/app/renderer/lib/period.test.ts index ad33123..6a32ff2 100644 --- a/app/renderer/lib/period.test.ts +++ b/app/renderer/lib/period.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import type { DailyHistoryEntry, Period } from './types' -import { formatChartDate, sliceDailyToPeriod } from './period' +import { contiguousDailyWindow, formatChartDate, periodWindowStart, sliceDailyToPeriod } from './period' function entry(date: string): DailyHistoryEntry { return { @@ -34,8 +34,9 @@ const DAILY = [ describe('sliceDailyToPeriod', () => { it.each<[Period, string[]]>([ ['today', ['2026-07-10']], - ['week', ['2026-07-04', '2026-07-09', '2026-07-10']], - ['30days', ['2026-06-11', '2026-07-01', '2026-07-03', '2026-07-04', '2026-07-09', '2026-07-10']], + // Window boundaries mirror src/cli-date.ts: week = now-7, 30days = now-30. + ['week', ['2026-07-03', '2026-07-04', '2026-07-09', '2026-07-10']], + ['30days', ['2026-06-10', '2026-06-11', '2026-07-01', '2026-07-03', '2026-07-04', '2026-07-09', '2026-07-10']], ['month', ['2026-07-01', '2026-07-03', '2026-07-04', '2026-07-09', '2026-07-10']], [ 'all', @@ -56,6 +57,29 @@ describe('sliceDailyToPeriod', () => { }) }) +describe('periodWindowStart', () => { + it.each<[Period, string]>([ + ['today', '2026-07-10'], + ['week', '2026-07-03'], + ['30days', '2026-06-10'], + ['month', '2026-07-01'], + ['all', '2026-01-01'], + ])('aligns %s to the CLI window start', (period, expected) => { + expect(periodWindowStart(period, NOW)).toBe(expected) + }) +}) + +describe('contiguousDailyWindow', () => { + it('zero-fills inactive calendar days between sparse real entries', () => { + const sparse = [entry('2026-07-08'), entry('2026-07-10')] + const window = contiguousDailyWindow(sparse, '2026-07-07', '2026-07-10') + + expect(window.map(day => day.date)).toEqual(['2026-07-07', '2026-07-08', '2026-07-09', '2026-07-10']) + // The real entries keep their cost; the two gaps are zero-filled. + expect(window.map(day => day.cost)).toEqual([0, 1, 0, 1]) + }) +}) + describe('formatChartDate', () => { it('formats date keys without shifting the local calendar day', () => { expect(formatChartDate('2026-07-01')).toBe('Jul 1') diff --git a/app/renderer/lib/period.ts b/app/renderer/lib/period.ts index 350d2aa..c024f25 100644 --- a/app/renderer/lib/period.ts +++ b/app/renderer/lib/period.ts @@ -1,26 +1,30 @@ import type { DailyHistoryEntry, Period } from './types' +// The CLI emits `history.daily` as a SPARSE list of active days only (not a +// backfilled calendar). Charts must zero-fill inactive days client-side to keep +// the time axis honest; helpers here own that windowing. + /** Local calendar date key "YYYY-MM-DD", matching the CLI's `dateKey` (src/day-aggregator.ts). */ export function localDateKey(d: Date): string { return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` } -/** - * Shared period-window helper for backfilled `history.daily` arrays. T8 should - * migrate Overview.tsx to this helper so both sections use one source of truth. - */ -export function periodWindowStart(period: Period, now = new Date()): string | null { +// Aligned to src/cli-date.ts:getDateRange so client windows match CLI totals. +const ALL_TIME_MONTHS = 6 + +/** Inclusive lower bound (date key) of the selected period's window, matching the CLI. */ +export function periodWindowStart(period: Period, now = new Date()): string { switch (period) { case 'today': return localDateKey(now) case 'week': - return localDateKey(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 6)) + return localDateKey(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7)) case '30days': - return localDateKey(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 29)) + return localDateKey(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 30)) case 'month': return localDateKey(new Date(now.getFullYear(), now.getMonth(), 1)) case 'all': - return null + return localDateKey(new Date(now.getFullYear(), now.getMonth() - ALL_TIME_MONTHS, 1)) } } @@ -28,7 +32,48 @@ export function periodWindowStart(period: Period, now = new Date()): string | nu export function sliceDailyToPeriod(daily: DailyHistoryEntry[], period: Period, now = new Date()): DailyHistoryEntry[] { const start = periodWindowStart(period, now) const todayKey = localDateKey(now) - return daily.filter(d => (start === null || d.date >= start) && d.date <= todayKey) + return daily.filter(d => d.date >= start && d.date <= todayKey) +} + +/** `history.daily` entries within an explicit [from..to] date-key range (custom range). */ +export function sliceDailyToRange(daily: DailyHistoryEntry[], from: string, to: string): DailyHistoryEntry[] { + return daily.filter(d => d.date >= from && d.date <= to) +} + +function zeroEntry(date: string): DailyHistoryEntry { + return { + date, + cost: 0, + savingsUSD: 0, + calls: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + topModels: [], + } +} + +/** + * Contiguous calendar window [fromKey..toKey] with exactly one entry per day. + * Real (sparse) `history.daily` entries fill their day; inactive days are + * zero-filled so the chart axis reflects true calendar spacing. Keys are local + * YYYY-MM-DD (localDateKey / the CLI's dateKey), so lookups always match. + */ +export function contiguousDailyWindow(daily: DailyHistoryEntry[], fromKey: string, toKey: string): DailyHistoryEntry[] { + if (fromKey > toKey) return [] + const byDate = new Map(daily.map(d => [d.date, d])) + const [fy, fm, fd] = fromKey.split('-').map(Number) + const [ty, tm, td] = toKey.split('-').map(Number) + const cursor = new Date(fy, fm - 1, fd) + const end = new Date(ty, tm - 1, td) + const out: DailyHistoryEntry[] = [] + while (cursor <= end) { + const key = localDateKey(cursor) + out.push(byDate.get(key) ?? zeroEntry(key)) + cursor.setDate(cursor.getDate() + 1) + } + return out } /** Format a local date key for compact chart-axis labels such as "Jul 1". */ diff --git a/app/renderer/sections/Compare.test.tsx b/app/renderer/sections/Compare.test.tsx index 6693c84..c68c84a 100644 --- a/app/renderer/sections/Compare.test.tsx +++ b/app/renderer/sections/Compare.test.tsx @@ -82,6 +82,27 @@ describe('Compare', () => { expect(mocks.getCompare).toHaveBeenCalledWith('30days', 'all', 'Sonnet 5', 'Opus 4.8') }) + it('computes cache hit rate over input + cache reads (excludes cache writes)', async () => { + mocks.getCompareModels.mockResolvedValue([modelA, modelB]) + mocks.getCompare.mockResolvedValue(report) + render() + + const context = (await screen.findByText('Context')).closest('.cmp-card')! + const row = within(context).getByText('Cache hit rate').closest('.cmp-metric')! + // 119.4M / (152.6M + 119.4M) = 44%, not 119.4 / (152.6 + 119.4 + 16) = 41%. + expect(row).toHaveTextContent('44%') + expect(row).not.toHaveTextContent('41%') + }) + + it('notes that custom ranges are unsupported and still compares by period', async () => { + mocks.getCompareModels.mockResolvedValue([modelA, modelB]) + mocks.getCompare.mockResolvedValue(report) + render() + + expect(await screen.findByText('Compare uses the selected period, custom dates are not supported yet.')).toBeInTheDocument() + expect(mocks.getCompareModels).toHaveBeenCalledWith('30days', 'all') + }) + it('renders the need-two-models note without requesting a report', async () => { mocks.getCompareModels.mockResolvedValue([modelA]) render() diff --git a/app/renderer/sections/Compare.tsx b/app/renderer/sections/Compare.tsx index b1981f6..5c3831c 100644 --- a/app/renderer/sections/Compare.tsx +++ b/app/renderer/sections/Compare.tsx @@ -6,7 +6,7 @@ import { Panel } from '../components/Panel' import { usePolled } from '../hooks/usePolled' import { formatCompact, formatUsd } from '../lib/format' import { codeburn } from '../lib/ipc' -import type { CompareJsonReport, ComparisonRow, ModelStats, Period, WorkingStyleRow } from '../lib/types' +import type { CompareJsonReport, ComparisonRow, DateRange, ModelStats, Period, WorkingStyleRow } from '../lib/types' function fmtMetric(v: number | null, fn: 'cost' | 'number' | 'percent' | 'decimal'): string { if (v === null) return '—' @@ -20,13 +20,25 @@ function EmptyNote({ children }: { children: React.ReactNode }) { return

{children}

} +// The CLI `compare` command has no --from/--to, so a custom range falls back to +// the selected period. Say so instead of silently ignoring the dates. +function RangeNote() { + return ( +

+ Compare uses the selected period, custom dates are not supported yet. +

+ ) +} + export function Compare({ period, provider, + range = null, refreshToken = 0, }: { period: Period provider: string + range?: DateRange | null refreshToken?: number }) { const models = usePolled( @@ -71,6 +83,7 @@ export function Compare({ return ( <> + {range && }
0 ? `${Math.round(model.cacheReadTokens / total * 100)}%` : '—' } diff --git a/app/renderer/sections/Models.tsx b/app/renderer/sections/Models.tsx index c32b94e..01e0989 100644 --- a/app/renderer/sections/Models.tsx +++ b/app/renderer/sections/Models.tsx @@ -4,6 +4,7 @@ import { CliErrorPanel } from '../components/CliErrorPanel' import { seriesColorForModel } from '../components/ListRow' import { Panel } from '../components/Panel' import { SegTabs } from '../components/SegTabs' +import { StaleBanner } from '../components/StaleBanner' import type { Section } from '../components/Sidebar' import { usePolled } from '../hooks/usePolled' import { formatCompact, formatUsd } from '../lib/format' @@ -56,6 +57,7 @@ export function Models({ return ( <> + {report.error && }
setLens(value as ModelsLens)} />
diff --git a/app/renderer/sections/Plans.tsx b/app/renderer/sections/Plans.tsx index efa8ba0..1e74ede 100644 --- a/app/renderer/sections/Plans.tsx +++ b/app/renderer/sections/Plans.tsx @@ -1,6 +1,7 @@ import { CliErrorPanel } from '../components/CliErrorPanel' import { Panel } from '../components/Panel' import type { Section } from '../components/Sidebar' +import { StaleBanner } from '../components/StaleBanner' import { usePolled } from '../hooks/usePolled' import { formatUsd } from '../lib/format' import { codeburn } from '../lib/ipc' @@ -71,6 +72,7 @@ export function Plans({ period, refreshToken = 0, onNavigate }: { period: Period
+ {budgetReport.data && budgetReport.error && } {renderQuota(quota.data, quota.error)} {renderBudgetPlans(budgetReport.data, budgetReport.error, manualPlans)}
diff --git a/app/renderer/sections/Sessions.tsx b/app/renderer/sections/Sessions.tsx index a6bfd03..5ec2578 100644 --- a/app/renderer/sections/Sessions.tsx +++ b/app/renderer/sections/Sessions.tsx @@ -3,6 +3,7 @@ import { Fragment, useEffect, useMemo, useState } from 'react' import { CliErrorPanel } from '../components/CliErrorPanel' import { Panel } from '../components/Panel' import { SegTabs } from '../components/SegTabs' +import { StaleBanner } from '../components/StaleBanner' import { Stat } from '../components/Stat' import { usePolled } from '../hooks/usePolled' import { formatCompact, formatDayLong, formatDayShort, formatDuration, formatUsd, shortenProjectPath } from '../lib/format' @@ -159,6 +160,7 @@ export function Sessions({ return (
+ {report.error && }
{ vi.useRealTimers() }) - it('renders the real last-15 spend entries, date axis, projects, and Sankey ribbons', async () => { + it('zero-fills a contiguous 15-day calendar window with a real date axis, projects, and Sankey ribbons', async () => { getOverview.mockResolvedValue(makePayload(new Date())) getSpendFlow.mockResolvedValue(makeFlow()) @@ -146,18 +146,17 @@ describe('Spend', () => { expect(screen.getByText('agentseal-dash')).toBeInTheDocument() expect(screen.getByText('top 2')).toBeInTheDocument() + // Sparse history is zero-filled into 15 contiguous days ending today (Jul 10), + // so the axis reflects real calendar spacing rather than compressing gaps. const barColumns = container.querySelectorAll('.sbars .c') - expect(barColumns).toHaveLength(5) + expect(barColumns).toHaveLength(15) expect([...barColumns].map(col => col.getAttribute('data-date'))).toEqual([ - '2026-06-30', - '2026-07-01', - '2026-07-04', - '2026-07-06', - '2026-07-10', + '2026-06-26', '2026-06-27', '2026-06-28', '2026-06-29', '2026-06-30', + '2026-07-01', '2026-07-02', '2026-07-03', '2026-07-04', '2026-07-05', + '2026-07-06', '2026-07-07', '2026-07-08', '2026-07-09', '2026-07-10', ]) const ticks = container.querySelectorAll('.sbars-wrap > .ov-xax span') - expect(ticks).toHaveLength(2) - expect([...ticks].map(tick => tick.textContent)).toEqual(['Jun 30', 'Jul 10']) + expect([...ticks].map(tick => tick.textContent)).toEqual(['Jun 26', 'Jun 30', 'Jul 4', 'Jul 8', 'Jul 10']) expect(container.querySelectorAll('[data-testid="sankey-ribbon"]')).toHaveLength(makeFlow().links.length) }) diff --git a/app/renderer/sections/Spend.tsx b/app/renderer/sections/Spend.tsx index 17669e8..0e0d7c8 100644 --- a/app/renderer/sections/Spend.tsx +++ b/app/renderer/sections/Spend.tsx @@ -3,10 +3,23 @@ import { ListRow } from '../components/ListRow' import { Panel } from '../components/Panel' import { Sankey } from '../components/Sankey' import { StackedBars } from '../components/StackedBars' +import { StaleBanner } from '../components/StaleBanner' import { type Polled, usePolled } from '../hooks/usePolled' import { formatUsd } from '../lib/format' import { codeburn } from '../lib/ipc' -import type { DateRange, MenubarPayload, Period, SpendFlow } from '../lib/types' +import { contiguousDailyWindow, localDateKey } from '../lib/period' +import type { CliError, DateRange, MenubarPayload, Period, SpendFlow } from '../lib/types' + +const SPEND_CHART_DAYS = 15 + +function providerLabel(provider: string): string { + if (provider === 'all') return 'All models' + return provider + .split(/[-\s]+/) + .filter(Boolean) + .map(part => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' ') +} function EmptyNote({ children }: { children: React.ReactNode }) { return

{children}

@@ -47,20 +60,34 @@ export function SpendContent({ ) } - return + return } function SpendPage({ data, flow, + provider, + range, + staleError, }: { data: MenubarPayload flow: ReturnType> + provider: string + range: DateRange | null + staleError: CliError | null }) { - // Use the real last-15 backfilled days directly. Rebuilding a calendar window - // client-side (contiguousDailyWindow) mismatched the CLI's date keys and - // zero-filled every day, leaving the chart empty. - const chartDaily = data.history.daily.slice(-15) + // `history.daily` is SPARSE (active days only), so zero-fill a contiguous + // calendar window client-side; date keys are localDateKey / the CLI dateKey, + // which match exactly, so real days always land in place. + const now = new Date() + const chartDaily = range + ? contiguousDailyWindow(data.history.daily, range.from, range.to) + : contiguousDailyWindow( + data.history.daily, + localDateKey(new Date(now.getFullYear(), now.getMonth(), now.getDate() - (SPEND_CHART_DAYS - 1))), + localDateKey(now), + ) + const chartHasSpend = chartDaily.some(day => day.cost > 0) const projects = data.current.topProjects const breakdowns = [ { @@ -111,9 +138,10 @@ function SpendPage({ return ( <> + {staleError && }
- {chartDaily.length ? : No model spend in this range yet.} + {chartHasSpend ? : No model spend in this range yet.} {projects.length ? (