mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-17 20:44:31 +00:00
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:
parent
b30818138e
commit
dbd93fee51
12 changed files with 262 additions and 146 deletions
50
app/renderer/components/ActivityHeatmap.test.tsx
Normal file
50
app/renderer/components/ActivityHeatmap.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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'] })
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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])),
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue