app,dash: render pre-history days as no data, not a currency zero (#765)

* fix(app,dash): render pre-history days as no data, not a currency zero

Days before the first recorded day in history.daily were painted as a real
0.00/0 calls in the daily activity heatmap and the daily spend charts. That
zero is unknown, not measured, so those cells and bars now read "No data
recorded" instead of a currency zero. Genuinely idle days within recorded
history stay as real zeros.

- app: heatmap cells, the daily spend bars, and the daily-by-model columns
  get a distinct no-data style plus a "No data recorded" hover for days before
  the first recorded day.
- dash: the granular line chart drops buckets before the first recorded day so
  no flat zero line is drawn before any history exists.
- data-start is derived in the UI layer from history.daily; payload shape is
  unchanged.

Verified: app vitest 418 pass (7 new for the no-data behavior); app, dash, and
root typecheck clean; dash and app renderer vite builds succeed.

* no-data: cap guard, timezone-free dash trim, StackedBars aria

Three hardening fixes from adversarial review of the no-data rendering:

dataStartKey returns null at the payload's 365-entry history cap, where
the oldest retained entry is no longer the true data start; classifying
past it would label real aged-out history as no data on long custom
ranges. Documented that the install-to-first-use gap reading as no data
is literally accurate: nothing was recorded then either.

The dash granular chart now trims leading zero-only buckets by value
instead of comparing bucket timestamps against date keys, so producer
and viewer timezone skew can never drop a real first-day bucket, and an
all-zero series lands in the established empty state.

StackedBars no-data columns expose their state via aria-label, not only
a title on a non-focusable div.
This commit is contained in:
Resham Joshi 2026-07-20 08:03:24 -07:00 committed by GitHub
parent b30818138e
commit dbd93fee51
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 262 additions and 146 deletions

View file

@ -0,0 +1,50 @@
// @vitest-environment jsdom
import { fireEvent, render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { DailyHistoryEntry } from '../lib/types'
import { ActivityHeatmap } from './ActivityHeatmap'
function entry(date: string, cost: number, calls: number): DailyHistoryEntry {
return { date, cost, savingsUSD: 0, calls, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, topModels: [] }
}
// A fixed "now" so the 26-week window and the day-under-test are deterministic.
beforeEach(() => vi.setSystemTime(new Date(2026, 6, 20, 12, 0, 0)))
afterEach(() => vi.useRealTimers())
describe('ActivityHeatmap no-data days (before recorded history)', () => {
// History begins 2026-07-10; earlier days predate any recorded data.
const daily = [entry('2026-07-10', 4, 20), entry('2026-07-15', 6, 30)]
it('marks days before the first recorded day as no data, not a currency zero', () => {
const { container } = render(<ActivityHeatmap daily={daily} />)
const preData = container.querySelector('[data-date="2026-07-05"]')!
expect(preData).toHaveClass('nodata')
expect(preData).toHaveAttribute('data-active', 'false')
expect(preData.getAttribute('aria-label')).toContain('no data recorded')
expect(preData.getAttribute('aria-label')).not.toContain('$0.00')
})
it('keeps a genuinely idle day within recorded history as a real zero', () => {
const { container } = render(<ActivityHeatmap daily={daily} />)
const idle = container.querySelector('[data-date="2026-07-12"]')!
expect(idle).not.toHaveClass('nodata')
expect(idle.getAttribute('aria-label')).toContain('$0.00, 0 calls')
})
it('shows "No data recorded" on hover for a pre-history day', () => {
const { container } = render(<ActivityHeatmap daily={daily} />)
fireEvent.mouseEnter(container.querySelector('[data-date="2026-07-05"]')!)
const tip = document.querySelector('.chart-tip')!
expect(tip.textContent).toContain('No data recorded')
expect(tip.textContent).not.toContain('$0.00')
})
it('shows the currency value on hover for an idle day within history', () => {
const { container } = render(<ActivityHeatmap daily={daily} />)
fireEvent.mouseEnter(container.querySelector('[data-date="2026-07-12"]')!)
const tip = document.querySelector('.chart-tip')!
expect(tip.textContent).toContain('$0.00')
})
})

View file

@ -2,7 +2,7 @@ import { useLayoutEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { formatUsd } from '../lib/format'
import { localDateKey } from '../lib/period'
import { dataStartKey, localDateKey } from '../lib/period'
import type { DailyHistoryEntry } from '../lib/types'
type HeatmapDay = {
@ -11,6 +11,9 @@ type HeatmapDay = {
calls: number
level: number
isFuture: boolean
// True for days that predate the first recorded day: a zero here is unknown,
// not a real zero, so it renders as "no data" rather than a currency zero.
noData: boolean
}
const WEEK_COUNT = 26
@ -45,6 +48,7 @@ function buildHeatmapDays(daily: DailyHistoryEntry[], now: Date): HeatmapDay[] {
const firstDay = new Date(startOfWeek)
firstDay.setDate(startOfWeek.getDate() - (WEEK_COUNT - 1) * 7)
const byDate = new Map(daily.map(day => [day.date, day]))
const dataStart = dataStartKey(daily)
const visibleCosts: number[] = []
for (let offset = 0; offset < WEEK_COUNT * 7; offset++) {
@ -57,15 +61,18 @@ function buildHeatmapDays(daily: DailyHistoryEntry[], now: Date): HeatmapDay[] {
return Array.from({ length: WEEK_COUNT * 7 }, (_, offset) => {
const date = new Date(firstDay)
date.setDate(firstDay.getDate() + offset)
const key = localDateKey(date)
const isFuture = date > today
const entry = byDate.get(localDateKey(date))
const cost = isFuture ? 0 : (entry?.cost ?? 0)
const noData = !isFuture && (dataStart === null || key < dataStart)
const entry = byDate.get(key)
const cost = isFuture || noData ? 0 : (entry?.cost ?? 0)
return {
date: localDateKey(date),
date: key,
cost,
calls: isFuture ? 0 : (entry?.calls ?? 0),
calls: isFuture || noData ? 0 : (entry?.calls ?? 0),
level: intensityLevel(cost, maxCost),
isFuture,
noData,
}
})
}
@ -115,11 +122,11 @@ export function ActivityHeatmap({ daily, bare = false }: { daily: DailyHistoryEn
type="button"
role="gridcell"
key={day.date}
className={`ov-heat-cell heat-level-${day.level}${day.isFuture ? ' future' : ''}`}
aria-label={`${formatDate(day.date)}: ${day.isFuture ? 'future day' : `${formatUsd(day.cost)}, ${day.calls} calls`}`}
className={`ov-heat-cell heat-level-${day.level}${day.isFuture ? ' future' : ''}${day.noData ? ' nodata' : ''}`}
aria-label={`${formatDate(day.date)}: ${day.noData ? 'no data recorded' : day.isFuture ? 'future day' : `${formatUsd(day.cost)}, ${day.calls} calls`}`}
data-date={day.date}
data-cost={day.cost}
data-active={!day.isFuture && day.cost > 0 ? 'true' : 'false'}
data-active={!day.isFuture && !day.noData && day.cost > 0 ? 'true' : 'false'}
onMouseEnter={event => setTip({ day, x: event.clientX, y: event.clientY })}
onMouseMove={event => setTip({ day, x: event.clientX, y: event.clientY })}
onMouseLeave={() => setTip(null)}
@ -138,8 +145,14 @@ export function ActivityHeatmap({ daily, bare = false }: { daily: DailyHistoryEn
role="tooltip"
>
<div className="chart-tip-d">{formatDate(tip.day.date)}</div>
<div className="chart-tip-v">{tip.day.isFuture ? 'Future day' : formatUsd(tip.day.cost)}</div>
<div className="chart-tip-s">{tip.day.isFuture ? 'No activity yet' : `${tip.day.calls} calls`}</div>
{tip.day.noData ? (
<div className="chart-tip-s">No data recorded</div>
) : (
<>
<div className="chart-tip-v">{tip.day.isFuture ? 'Future day' : formatUsd(tip.day.cost)}</div>
<div className="chart-tip-s">{tip.day.isFuture ? 'No activity yet' : `${tip.day.calls} calls`}</div>
</>
)}
</div>,
document.body,
)

View file

@ -29,6 +29,34 @@ describe('StackedBars', () => {
expect([...ticks].map(tick => tick.textContent)).toEqual(['Jul 1', 'Jul 5', 'Jul 9', 'Jul 13', 'Jul 16'])
})
it('renders days before recorded history as no data, not a $0.00 column', () => {
// Zero-filled window spanning a pre-history day and the first recorded day.
const daily = [
{ ...entry(23), cost: 0, calls: 0 },
entry(24),
]
const { container } = render(<StackedBars daily={daily} dataStart="2026-07-24" />)
const columns = container.querySelectorAll('.sbars .c')
expect(columns[0]).toHaveClass('nodata')
expect(columns[0]).toHaveAttribute('title', '2026-07-23 · No data recorded')
expect(columns[0].querySelector('.nodata-mark')).toBeInTheDocument()
expect(columns[0].querySelectorAll('.s')).toHaveLength(0)
expect(columns[1]).not.toHaveClass('nodata')
expect(columns[1].getAttribute('title')).toContain('$24.00')
})
it('leaves a genuinely idle day within history as an empty column, not no data', () => {
const daily = [entry(24), { ...entry(25), cost: 0, calls: 0 }]
const { container } = render(<StackedBars daily={daily} dataStart="2026-07-24" />)
const columns = container.querySelectorAll('.sbars .c')
expect(columns[1]).not.toHaveClass('nodata')
expect(columns[1].getAttribute('title')).toBe('2026-07-25 · $0.00')
expect(columns[1].querySelector('.nodata-mark')).not.toBeInTheDocument()
})
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.

View file

@ -12,7 +12,7 @@ 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', animateKey = '' }: { daily: DailyHistoryEntry[]; fallbackLabel?: string; animateKey?: string }) {
export function StackedBars({ daily, fallbackLabel = 'All models', animateKey = '', dataStart = null }: { daily: DailyHistoryEntry[]; fallbackLabel?: string; animateKey?: string; dataStart?: string | null }) {
const barsRef = useRef<HTMLDivElement>(null)
useBarGrowIn(barsRef, '.c', [animateKey])
const presentSeries = new Set<SeriesKey>()
@ -38,31 +38,46 @@ export function StackedBars({ daily, fallbackLabel = 'All models', animateKey =
return (
<div className="sbars-wrap">
<div className="sbars" aria-label="Daily spend by model" ref={barsRef}>
{daily.map(day => (
<div className="c" key={day.date} data-date={day.date} title={`${day.date} · ${formatUsd(day.cost)}`}>
{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 (
<span
key={`${day.date}-${model.name}`}
className={`s ${seriesClassForModel(model.name)}`}
style={{ height: `${pct}%` }}
title={`${model.name} · ${formatUsd(model.cost)}`}
/>
)
})
) : day.cost > 0 ? (
<span
className={`s ${seriesClassForKey('other')}`}
style={{ height: `${Math.max(1, (day.cost / maxTotal) * 100)}%` }}
title={`${fallbackLabel} · ${formatUsd(day.cost)}`}
/>
) : null}
</div>
))}
{daily.map(day => {
// Days before the first recorded day are unknown, not zero: no bar, and
// an honest "No data recorded" hover instead of a "$0.00" claim.
const noData = dataStart !== null && day.date < dataStart
return (
<div
className={`c${noData ? ' nodata' : ''}`}
key={day.date}
data-date={day.date}
data-nodata={noData ? 'true' : 'false'}
role="img"
aria-label={noData ? `${day.date}, no data recorded` : `${day.date}, ${formatUsd(day.cost)}`}
title={noData ? `${day.date} · No data recorded` : `${day.date} · ${formatUsd(day.cost)}`}
>
{noData ? (
<span className="nodata-mark" aria-hidden="true" />
) : 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 (
<span
key={`${day.date}-${model.name}`}
className={`s ${seriesClassForModel(model.name)}`}
style={{ height: `${pct}%` }}
title={`${model.name} · ${formatUsd(model.cost)}`}
/>
)
})
) : day.cost > 0 ? (
<span
className={`s ${seriesClassForKey('other')}`}
style={{ height: `${Math.max(1, (day.cost / maxTotal) * 100)}%` }}
title={`${fallbackLabel} · ${formatUsd(day.cost)}`}
/>
) : null}
</div>
)
})}
</div>
<div className="ov-xax">
{ticks.map(day => {

View file

@ -1,95 +1,30 @@
import { describe, expect, it } from 'vitest'
import type { DailyHistoryEntry, Period } from './types'
import { contiguousDailyWindow, formatChartDate, periodWindowStart, sliceDailyToPeriod } from './period'
import { dataStartKey } from './period'
import type { DailyHistoryEntry } from './types'
function entry(date: string): DailyHistoryEntry {
return {
date,
cost: 1,
savingsUSD: 0,
calls: 1,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
topModels: [],
}
function day(date: string): DailyHistoryEntry {
return { date, cost: 1, savingsUSD: 0, calls: 1, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, topModels: [] }
}
const NOW = new Date(2026, 6, 10, 12, 0, 0)
const DAILY = [
entry('2026-05-31'),
entry('2026-06-01'),
entry('2026-06-10'),
entry('2026-06-11'),
entry('2026-07-01'),
entry('2026-07-03'),
entry('2026-07-04'),
entry('2026-07-09'),
entry('2026-07-10'),
entry('2026-07-11'),
]
describe('dataStartKey', () => {
it('returns the earliest recorded day of a sparse history', () => {
expect(dataStartKey([day('2026-05-03'), day('2026-04-24'), day('2026-06-01')])).toBe('2026-04-24')
})
// All active-day entries at or before NOW's calendar day (the future 07-11 entry
// is always excluded). Reused by the widest windows ('all', 'lifetime').
const ALL_ACTIVE_THROUGH_NOW = [
'2026-05-31',
'2026-06-01',
'2026-06-10',
'2026-06-11',
'2026-07-01',
'2026-07-03',
'2026-07-04',
'2026-07-09',
'2026-07-10',
]
it('returns null for an empty history (no classification possible)', () => {
expect(dataStartKey([])).toBeNull()
})
describe('sliceDailyToPeriod', () => {
it.each<[Period, string[]]>([
['today', ['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', ALL_ACTIVE_THROUGH_NOW],
// lifetime is unbounded below (1970), so it holds every active day up to today.
['lifetime', ALL_ACTIVE_THROUGH_NOW],
])('returns only in-window entries for %s', (period, expectedDates) => {
expect(sliceDailyToPeriod(DAILY, period, NOW).map(day => day.date)).toEqual(expectedDates)
})
})
// Parity fixture: the inclusive window-start each period must produce, computed
// exactly as src/cli-date.ts getDateRange() does for the same NOW. If cli-date
// shifts a boundary, this table must move with it or the client will drift.
describe('periodWindowStart matches src/cli-date.ts getDateRange', () => {
// NOW = 2026-07-10. Values below are the local date-key of getDateRange().range.start.
it.each<[Period, string]>([
['today', '2026-07-10'], // new Date(y, m, d)
['week', '2026-07-03'], // new Date(y, m, d - 7)
['30days', '2026-06-10'], // new Date(y, m, d - 30)
['month', '2026-07-01'], // new Date(y, m, 1)
['all', '2026-01-01'], // new Date(y, m - 6, 1)
['lifetime', '1970-01-01'], // new Date(1970, 0, 1)
])('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')
it('returns null at the server-side 365-entry cap', () => {
// At the cap the oldest retained entry is not the true data start, so
// classification must switch off instead of labeling real aged-out
// history as "no data recorded" on long custom ranges.
const capped = Array.from({ length: 365 }, (_, i) => {
const d = new Date(Date.UTC(2026, 0, 1) + i * 24 * 60 * 60 * 1000)
return day(d.toISOString().slice(0, 10))
})
expect(dataStartKey(capped)).toBeNull()
expect(dataStartKey(capped.slice(0, 364))).toBe('2026-01-01')
})
})

View file

@ -31,6 +31,31 @@ export function periodWindowStart(period: Period, now = new Date()): string {
}
}
/** The payload's history.daily is capped to this many most-recent active days
* (menubar-json HISTORY_DAYS_LIMIT). At the cap the array's oldest entry is no
* longer the true data start, so no-data classification must switch off rather
* than mislabel real (aged-out) history on long custom ranges. */
const HISTORY_DAYS_CAP = 365
/**
* Earliest recorded day in the sparse `history.daily`, or null when it is
* empty or at the server-side cap (at the cap the true start is unknowable
* from the payload, and null disables no-data classification entirely rather
* than mislabeling aged-out history).
*
* Days before this key render as "no data recorded". That label is literally
* true even for the edge where CodeBurn was installed earlier but idle until
* its first recorded activity: nothing was recorded those days either.
*/
export function dataStartKey(daily: DailyHistoryEntry[]): string | null {
if (daily.length >= HISTORY_DAYS_CAP) return null
let earliest: string | null = null
for (const day of daily) {
if (earliest === null || day.date < earliest) earliest = day.date
}
return earliest
}
/** `history.daily` entries within the selected period's date window. */
export function sliceDailyToPeriod(daily: DailyHistoryEntry[], period: Period, now = new Date()): DailyHistoryEntry[] {
const start = periodWindowStart(period, now)

View file

@ -329,6 +329,27 @@ describe('Overview', () => {
expect([...bars].slice(0, 25).every(bar => bar.getAttribute('data-cost') === '0')).toBe(true)
})
it('renders days before recorded history as no data, not a $0.00 bar', async () => {
const now = new Date()
const payload = makePayload(now)
payload.history.daily = payload.history.daily.slice(-5)
getOverview.mockResolvedValue(payload)
const { container } = render(<Overview period="week" provider="all" />)
expect(await screen.findByText('parser-service')).toBeInTheDocument()
const bars = container.querySelectorAll('.chart .col')
expect(bars).toHaveLength(30)
// The 25 leading days predate the first recorded day: no data, not zero spend.
expect([...bars].slice(0, 25).every(bar => bar.classList.contains('nodata'))).toBe(true)
expect(bars[0].getAttribute('aria-label')).toContain('no data recorded')
// The five recorded days stay real (idle or spend), never marked no data.
expect([...bars].slice(-5).some(bar => bar.classList.contains('nodata'))).toBe(false)
fireEvent.mouseEnter(bars[0], { clientX: 100, clientY: 80 })
expect(screen.getByText('No data recorded')).toBeInTheDocument()
})
it('computes month-to-date, projection, and previous-month pace', async () => {
const now = new Date(2026, 6, 15, 12, 0, 0) // Wed Jul 15 2026, local
vi.useFakeTimers({ toFake: ['Date'] })

View file

@ -12,7 +12,7 @@ import { motionEnabled, useBarGrowIn } from '../lib/motion'
import { type Polled, usePolled } from '../hooks/usePolled'
import { formatCompact, formatUsd } from '../lib/format'
import { codeburn } from '../lib/ipc'
import { contiguousDailyWindow, formatChartDate, localDateKey, sliceDailyToPeriod, sliceDailyToRange } from '../lib/period'
import { contiguousDailyWindow, dataStartKey, formatChartDate, localDateKey, sliceDailyToPeriod, sliceDailyToRange } from '../lib/period'
import type {
ActReportJson,
DailyHistoryEntry,
@ -443,7 +443,8 @@ function ModelsTable({ models }: { models: AggregatedModel[] }) {
)
}
function DailyChart({ daily, animateKey = '' }: { daily: DailyHistoryEntry[]; animateKey?: string }) {
function DailyChart({ daily, dataStart = null, animateKey = '' }: { daily: DailyHistoryEntry[]; dataStart?: string | null; animateKey?: string }) {
const isNoData = (day: DailyHistoryEntry) => dataStart !== null && day.date < dataStart
const max = Math.max(...daily.map(day => day.cost), 0)
const peakIndex = daily.reduce((peak, day, index) => day.cost > (daily[peak]?.cost ?? -1) ? index : peak, 0)
const peak = daily[peakIndex]
@ -477,22 +478,26 @@ function DailyChart({ daily, animateKey = '' }: { daily: DailyHistoryEntry[]; an
return (
<>
<div className="chart" ref={chartRef}>
{daily.map((day, index) => (
<button
type="button"
aria-label={`${day.date}: ${formatUsd(day.cost)}`}
className={`col${index === peakIndex ? ' hi' : ''}`}
key={day.date}
style={{ height: `${max > 0 ? Math.max(2, day.cost / max * 100) : 2}%` }}
data-date={day.date}
data-cost={day.cost}
data-calls={day.calls}
data-led={day.topModels[0]?.name ?? ''}
onMouseEnter={event => setTip({ day, x: event.clientX, y: event.clientY })}
onMouseMove={event => setTip({ day, x: event.clientX, y: event.clientY })}
onMouseLeave={() => setTip(null)}
/>
))}
{daily.map((day, index) => {
const noData = isNoData(day)
return (
<button
type="button"
aria-label={`${day.date}: ${noData ? 'no data recorded' : formatUsd(day.cost)}`}
className={`col${index === peakIndex && !noData ? ' hi' : ''}${noData ? ' nodata' : ''}`}
key={day.date}
style={{ height: `${max > 0 ? Math.max(2, day.cost / max * 100) : 2}%` }}
data-date={day.date}
data-cost={day.cost}
data-calls={day.calls}
data-led={day.topModels[0]?.name ?? ''}
data-nodata={noData ? 'true' : 'false'}
onMouseEnter={event => setTip({ day, x: event.clientX, y: event.clientY })}
onMouseMove={event => setTip({ day, x: event.clientX, y: event.clientY })}
onMouseLeave={() => setTip(null)}
/>
)
})}
</div>
<div className="ov-xax">
{ticks.map(day => {
@ -513,8 +518,14 @@ function DailyChart({ daily, animateKey = '' }: { daily: DailyHistoryEntry[]; an
role="tooltip"
>
<div className="chart-tip-d">{formatChartDate(tip.day.date)}</div>
<div className="chart-tip-v">{formatUsd(tip.day.cost)}</div>
<div className="chart-tip-s">{tip.day.calls} calls · {tip.day.topModels[0]?.name ?? 'No model'} led</div>
{isNoData(tip.day) ? (
<div className="chart-tip-s">No data recorded</div>
) : (
<>
<div className="chart-tip-v">{formatUsd(tip.day.cost)}</div>
<div className="chart-tip-s">{tip.day.calls} calls · {tip.day.topModels[0]?.name ?? 'No model'} led</div>
</>
)}
</div>,
document.body,
)}
@ -645,7 +656,7 @@ export function OverviewContent({
<div className="ov-card ov-panel ov-chart-widget">
<div className="ov-panel-head"><h3>Daily spend</h3><span className="r">{topModel ? `Biggest driver: ${topModel.name}` : 'No model driver yet'}</span></div>
<div className="ov-panel-body">{data.history.daily.length ? <DailyChart daily={chartDaily} animateKey={animateKey} /> : <EmptyNote>No spend yet.</EmptyNote>}</div>
<div className="ov-panel-body">{data.history.daily.length ? <DailyChart daily={chartDaily} dataStart={dataStartKey(data.history.daily)} animateKey={animateKey} /> : <EmptyNote>No spend yet.</EmptyNote>}</div>
</div>
<div className="ov-insight-band">

View file

@ -11,7 +11,7 @@ import { StaleBanner } from '../components/StaleBanner'
import { type Polled, usePolled } from '../hooks/usePolled'
import { formatUsd } from '../lib/format'
import { codeburn } from '../lib/ipc'
import { contiguousDailyWindow, localDateKey } from '../lib/period'
import { contiguousDailyWindow, dataStartKey, localDateKey } from '../lib/period'
import type { CliError, DateRange, MenubarPayload, Period, SpendFlow } from '../lib/types'
type Project = MenubarPayload['current']['topProjects'][number]
@ -100,6 +100,7 @@ function SpendPage({
localDateKey(now),
)
const chartHasSpend = chartDaily.some(day => day.cost > 0)
const dataStart = dataStartKey(data.history.daily)
const projects = data.current.topProjects
const breakdowns = [
{
@ -153,7 +154,7 @@ function SpendPage({
{staleError && <StaleBanner error={staleError} />}
<div className="spend-top-row">
<Panel title="Daily spend by model" className="spend-chart-panel">
{chartHasSpend ? <StackedBars daily={chartDaily} fallbackLabel={providerLabel(provider)} animateKey={animateKey} /> : <EmptyNote>No model spend in this range yet.</EmptyNote>}
{chartHasSpend ? <StackedBars daily={chartDaily} fallbackLabel={providerLabel(provider)} animateKey={animateKey} dataStart={dataStart} /> : <EmptyNote>No model spend in this range yet.</EmptyNote>}
</Panel>
<ProjectBreakdown projects={projects} />
</div>

View file

@ -91,6 +91,8 @@ h2 { font-size: 20px; font-weight: 650; letter-spacing: -.015em; margin: 0 0 6px
.sbars-wrap { display: flex; flex: 1; min-height: 0; flex-direction: column; height: 100%; }
.sbars { position: relative; display: flex; flex: 0 0 150px; align-items: flex-end; gap: 4px; height: 150px; padding-top: 8px; border-bottom: 1px solid var(--line2); }
.sbars .c { flex: 1; display: flex; flex-direction: column-reverse; height: 100%; justify-content: flex-start; gap: 1.5px; }
/* Days before recorded history read as a faint dashed baseline, not an empty (zero-spend) column. */
.sbars .c.nodata .nodata-mark { width: 100%; height: 0; border-bottom: 1px dashed var(--line2); opacity: .8; }
.sbars .s { width: 100%; border-radius: 0; }
.sbars .s:first-child { border-radius: 0 0 2px 2px; }
.sbars .s:last-child { border-radius: 2px 2px 0 0; }

View file

@ -640,6 +640,9 @@ td:first-child { font-size: var(--fs-body); font-weight: var(--fw-body); }
.chart { position: relative; height: 150px; display: flex; align-items: flex-end; gap: 4px; padding-top: 8px; border-bottom: 1px solid var(--line2); }
.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); }
/* Days before recorded history: a faint dashed stub, never a solid zero-height bar reading as real spend. */
.chart .col.nodata { background: transparent; border-top: 1px dashed var(--line); opacity: .6; }
.chart .col.nodata:hover { background: transparent; }
.chart .col:focus-visible { outline: 1px solid var(--accent); outline-offset: 1px; }
.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; }
.chart-tip.on { opacity: 1; }
@ -666,6 +669,8 @@ td:first-child { font-size: var(--fs-body); font-weight: var(--fw-body); }
.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; }
/* Days before recorded history: an empty outlined slot, distinct from a filled zero-spend day. */
.ov-heat-cell.nodata { background: transparent; box-shadow: inset 0 0 0 1px var(--line); opacity: .5; }
.ov-heat-cell:hover, .ov-heat-cell:focus-visible { outline: 1px solid var(--accent); outline-offset: 1px; }
@media (max-width: 900px) {

View file

@ -141,8 +141,18 @@ function GranularLines({
: metadataById.get(key) ?? key,
color: CHART_COLORS[index % CHART_COLORS.length]!,
}))
// Trim LEADING zero-only buckets: the server zero-fills the whole range, so
// a flat zero line before the first real value asserts spend that was never
// recorded. Trimming by value needs no date comparison, so producer/viewer
// timezone skew cannot drop a real first-day bucket, and an all-zero series
// trims to nothing, landing in the established empty state. Idle buckets
// after the first real value stay: those zeros are true.
const firstValueIdx = rowData.findIndex(row =>
chartSeries.some(item => Number(row[item.key] ?? 0) > 0),
)
const rows = firstValueIdx > 0 ? rowData.slice(firstValueIdx) : firstValueIdx === 0 ? rowData : []
return {
rows: rowData,
rows,
series: chartSeries,
labels: Object.fromEntries(chartSeries.map(item => [item.key, item.label])),
}