From a8351bfd1ae0e8194dba73ecb5a65f9e8d03e468 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Sun, 12 Jul 2026 17:06:46 -0700 Subject: [PATCH] =?UTF-8?q?fix(app):=20daily=20charts=20empty/flat=20on=20?= =?UTF-8?q?real=20data=20=E2=80=94=20use=20real=20last-N=20days?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit contiguousDailyWindow rebuilt a calendar window from the client clock and looked up days by date key; on real CLI data every lookup missed and zero-filled, so the Spend chart went empty and the Overview chart went flat. Use the real last-N backfilled entries of history.daily directly (Spend last-15, Overview last-max(30, periodDaily)) and remove the dead helper. typecheck clean; 147/147 tests pass. --- app/renderer/lib/period.test.ts | 19 +----------------- app/renderer/lib/period.ts | 26 ------------------------- app/renderer/sections/Overview.test.tsx | 16 +++++++++------ app/renderer/sections/Overview.tsx | 4 ++-- app/renderer/sections/Spend.test.tsx | 25 +++++++----------------- app/renderer/sections/Spend.tsx | 8 +++++--- 6 files changed, 25 insertions(+), 73 deletions(-) diff --git a/app/renderer/lib/period.test.ts b/app/renderer/lib/period.test.ts index e083dff5..ad331230 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 { contiguousDailyWindow, formatChartDate, sliceDailyToPeriod } from './period' +import { formatChartDate, sliceDailyToPeriod } from './period' function entry(date: string): DailyHistoryEntry { return { @@ -56,23 +56,6 @@ describe('sliceDailyToPeriod', () => { }) }) -describe('contiguousDailyWindow', () => { - it('returns exactly the requested calendar days ending today and zero-fills gaps', () => { - const window = contiguousDailyWindow(DAILY, 5, NOW) - - expect(window.map(day => day.date)).toEqual([ - '2026-07-06', - '2026-07-07', - '2026-07-08', - '2026-07-09', - '2026-07-10', - ]) - expect(window[0]).toMatchObject({ cost: 0, calls: 0, topModels: [] }) - expect(window[3]).toBe(DAILY[7]) - expect(window[4]).toBe(DAILY[8]) - }) -}) - 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 5d65169f..350d2aab 100644 --- a/app/renderer/lib/period.ts +++ b/app/renderer/lib/period.ts @@ -31,32 +31,6 @@ export function sliceDailyToPeriod(daily: DailyHistoryEntry[], period: Period, n return daily.filter(d => (start === null || d.date >= start) && d.date <= todayKey) } -/** A contiguous calendar-day window ending today, with missing days zero-filled. */ -export function contiguousDailyWindow( - daily: DailyHistoryEntry[], - days: number, - now = new Date(), -): DailyHistoryEntry[] { - const byDate = new Map(daily.map(day => [day.date, day])) - const window: DailyHistoryEntry[] = [] - for (let offset = days - 1; offset >= 0; offset--) { - const date = new Date(now.getFullYear(), now.getMonth(), now.getDate() - offset) - const key = localDateKey(date) - window.push(byDate.get(key) ?? { - date: key, - cost: 0, - calls: 0, - savingsUSD: 0, - inputTokens: 0, - outputTokens: 0, - cacheReadTokens: 0, - cacheWriteTokens: 0, - topModels: [], - }) - } - return window -} - /** Format a local date key for compact chart-axis labels such as "Jul 1". */ export function formatChartDate(dateKey: string): string { const [year, month, day] = dateKey.split('-').map(Number) diff --git a/app/renderer/sections/Overview.test.tsx b/app/renderer/sections/Overview.test.tsx index 0ab73b4f..789d16a4 100644 --- a/app/renderer/sections/Overview.test.tsx +++ b/app/renderer/sections/Overview.test.tsx @@ -41,7 +41,7 @@ function makeYieldReport(): YieldJsonReport { * A fully-typed payload anchored to `now` so today/MTD/projected are stable. * `history.daily` deliberately spans 30 backfill days (the real CLI emits up to * 365 regardless of period) so tests can prove period aggregation while the - * trend chart keeps its contiguous 30-day window. + * trend chart keeps the real last 30 entries. */ function makePayload(now: Date): MenubarPayload { const DAYS = 30 @@ -276,17 +276,21 @@ describe('Overview', () => { expect(screen.getByText('$0.00')).toBeInTheDocument() }) - it('always shows at least a 30-day window regardless of the selected period', async () => { + it('shows the available real history entries without zero-filling a 30-day window', async () => { const now = new Date() - getOverview.mockResolvedValue(makePayload(now)) + const payload = makePayload(now) + payload.history.daily = payload.history.daily.slice(-5) + getOverview.mockResolvedValue(payload) // The daily chart is a trend, not scoped to the period selector: even for a - // short period like "week" it renders a contiguous >= 30-day window, - // backfilling days with no activity as zero-height bars. + // short period like "week" it uses the real history entries and does not + // synthesize zero-height bars to reach 30 days. const { container } = render() expect(await screen.findByText('parser-service')).toBeInTheDocument() - expect(container.querySelectorAll('.chart .col')).toHaveLength(30) + const bars = container.querySelectorAll('.chart .col') + expect(bars).toHaveLength(5) + expect([...bars].map(bar => bar.getAttribute('data-cost'))).toEqual(['5', '5', '5', '5', '6.2']) }) it('computes month-to-date, projection, and previous-month pace', async () => { diff --git a/app/renderer/sections/Overview.tsx b/app/renderer/sections/Overview.tsx index bba6767f..3a823a42 100644 --- a/app/renderer/sections/Overview.tsx +++ b/app/renderer/sections/Overview.tsx @@ -8,7 +8,7 @@ import { Panel } from '../components/Panel' import { type Polled, usePolled } from '../hooks/usePolled' import { formatUsd } from '../lib/format' import { codeburn } from '../lib/ipc' -import { contiguousDailyWindow, formatChartDate, localDateKey, sliceDailyToPeriod } from '../lib/period' +import { formatChartDate, localDateKey, sliceDailyToPeriod } from '../lib/period' import type { ActReportJson, DailyHistoryEntry, @@ -472,7 +472,7 @@ export function OverviewContent({ const now = new Date() const stats = deriveStats(data, now) const periodDaily = sliceDailyToPeriod(data.history.daily, period, now) - const chartDaily = contiguousDailyWindow(data.history.daily, Math.max(30, periodDaily.length), now) + const chartDaily = data.history.daily.slice(-Math.max(30, periodDaily.length)) const models = aggregateModels(periodDaily) const recent14 = data.history.daily.slice(-14) const weekNow = mean(recent14.slice(-7).map(day => day.cost)) diff --git a/app/renderer/sections/Spend.test.tsx b/app/renderer/sections/Spend.test.tsx index 7fd6d005..e90c1c0f 100644 --- a/app/renderer/sections/Spend.test.tsx +++ b/app/renderer/sections/Spend.test.tsx @@ -135,7 +135,7 @@ describe('Spend', () => { vi.useRealTimers() }) - it('renders a contiguous 15-day spend window, date axis, projects, and Sankey ribbons', async () => { + it('renders the real last-15 spend entries, date axis, projects, and Sankey ribbons', async () => { getOverview.mockResolvedValue(makePayload(new Date())) getSpendFlow.mockResolvedValue(makeFlow()) @@ -147,27 +147,17 @@ describe('Spend', () => { expect(screen.getByText('top 2')).toBeInTheDocument() const barColumns = container.querySelectorAll('.sbars .c') - expect(barColumns).toHaveLength(15) + expect(barColumns).toHaveLength(5) expect([...barColumns].map(col => col.getAttribute('data-date'))).toEqual([ - '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(5) - expect([...ticks].map(tick => tick.textContent)).toEqual(['Jun 26', 'Jun 30', 'Jul 4', 'Jul 8', 'Jul 10']) + expect(ticks).toHaveLength(2) + expect([...ticks].map(tick => tick.textContent)).toEqual(['Jun 30', 'Jul 10']) expect(container.querySelectorAll('[data-testid="sankey-ribbon"]')).toHaveLength(makeFlow().links.length) }) @@ -253,7 +243,7 @@ describe('Spend', () => { expect(container.querySelector('.spend-breakdowns')?.children).toHaveLength(4) }) - it('renders an empty 15-day chart window and empty flow state', async () => { + it('renders empty chart and flow states when no daily spend exists', async () => { const payload = makePayload(new Date()) payload.history.daily = [] getOverview.mockResolvedValue(payload) @@ -261,9 +251,8 @@ describe('Spend', () => { const { container } = render() - expect(await screen.findByLabelText('Daily spend by model')).toBeInTheDocument() - expect(container.querySelectorAll('.sbars .c')).toHaveLength(15) - expect(container.querySelectorAll('.sbars .s')).toHaveLength(0) + expect(await screen.findByText('No model spend in this range yet.')).toBeInTheDocument() + expect(container.querySelector('.sbars')).not.toBeInTheDocument() expect(await screen.findByText('No model-project flow in this range yet.')).toBeInTheDocument() }) diff --git a/app/renderer/sections/Spend.tsx b/app/renderer/sections/Spend.tsx index 10ec4ab5..17669e86 100644 --- a/app/renderer/sections/Spend.tsx +++ b/app/renderer/sections/Spend.tsx @@ -6,7 +6,6 @@ import { StackedBars } from '../components/StackedBars' import { type Polled, usePolled } from '../hooks/usePolled' import { formatUsd } from '../lib/format' import { codeburn } from '../lib/ipc' -import { contiguousDailyWindow } from '../lib/period' import type { DateRange, MenubarPayload, Period, SpendFlow } from '../lib/types' function EmptyNote({ children }: { children: React.ReactNode }) { @@ -58,7 +57,10 @@ function SpendPage({ data: MenubarPayload flow: ReturnType> }) { - const chartDaily = contiguousDailyWindow(data.history.daily, 15) + // 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) const projects = data.current.topProjects const breakdowns = [ { @@ -111,7 +113,7 @@ function SpendPage({ <>
- + {chartDaily.length ? : No model spend in this range yet.} {projects.length ? (