codeburn/app/renderer/components/StackedBars.tsx
Resham Joshi dbd93fee51
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.
2026-07-20 08:03:24 -07:00

108 lines
4.5 KiB
TypeScript

import { useRef } from 'react'
import { formatUsd } from '../lib/format'
import { useBarGrowIn } from '../lib/motion'
import { SERIES_LABELS, type SeriesKey, seriesClassForKey, seriesClassForModel, seriesKeyForModel } from '../lib/modelSeries'
import { formatChartDate } from '../lib/period'
import type { DailyHistoryEntry } from '../lib/types'
const SERIES_ORDER: readonly SeriesKey[] = ['opus', 'fable', 'haiku', 'gpt', 'sonnet', 'other']
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 = '', 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>()
let usesFallback = false
for (const day of daily) {
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)
if (lastDay && ticks.at(-1) !== lastDay) ticks.push(lastDay)
return (
<div className="sbars-wrap">
<div className="sbars" aria-label="Daily spend by model" ref={barsRef}>
{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 => {
const index = daily.indexOf(day)
return (
<span key={day.date} style={{ left: `${daily.length > 1 ? index / (daily.length - 1) * 100 : 0}%` }}>
{formatChartDate(day.date)}
</span>
)
})}
</div>
<div className="legend">
{legendSeries.map(series => (
<span key={series}>
<i className={seriesClassForKey(series)} />
{SERIES_LABELS[series]}
</span>
))}
{usesFallback && !presentSeries.has('other') && (
<span key="fallback">
<i className={seriesClassForKey('other')} />
{fallbackLabel}
</span>
)}
</div>
</div>
)
}