fix(app): daily charts empty/flat on real data — use real last-N days

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.
This commit is contained in:
iamtoruk 2026-07-12 17:06:46 -07:00
parent a216b841ae
commit a8351bfd1a
6 changed files with 25 additions and 73 deletions

View file

@ -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')

View file

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

View file

@ -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(<Overview period="week" provider="all" />)
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 () => {

View file

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

View file

@ -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(<Spend period="week" provider="all" />)
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()
})

View file

@ -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<typeof usePolled<SpendFlow>>
}) {
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({
<>
<div className="spend-top-row">
<Panel title="Daily spend by model" className="spend-chart-panel">
<StackedBars daily={chartDaily} />
{chartDaily.length ? <StackedBars daily={chartDaily} /> : <EmptyNote>No model spend in this range yet.</EmptyNote>}
</Panel>
<Panel title="By project" right={projects.length ? `top ${projects.length}` : undefined} className="spend-scroll">
{projects.length ? (