mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-07 07:34:39 +00:00
* 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.
108 lines
4.5 KiB
TypeScript
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>
|
|
)
|
|
}
|