diff --git a/app/renderer/components/ActivityHeatmap.test.tsx b/app/renderer/components/ActivityHeatmap.test.tsx new file mode 100644 index 00000000..59db5ed4 --- /dev/null +++ b/app/renderer/components/ActivityHeatmap.test.tsx @@ -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() + 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() + 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() + 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() + fireEvent.mouseEnter(container.querySelector('[data-date="2026-07-12"]')!) + const tip = document.querySelector('.chart-tip')! + expect(tip.textContent).toContain('$0.00') + }) +}) diff --git a/app/renderer/components/ActivityHeatmap.tsx b/app/renderer/components/ActivityHeatmap.tsx index e4b2b5fc..1a8c4933 100644 --- a/app/renderer/components/ActivityHeatmap.tsx +++ b/app/renderer/components/ActivityHeatmap.tsx @@ -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" >
{formatDate(tip.day.date)}
-
{tip.day.isFuture ? 'Future day' : formatUsd(tip.day.cost)}
-
{tip.day.isFuture ? 'No activity yet' : `${tip.day.calls} calls`}
+ {tip.day.noData ? ( +
No data recorded
+ ) : ( + <> +
{tip.day.isFuture ? 'Future day' : formatUsd(tip.day.cost)}
+
{tip.day.isFuture ? 'No activity yet' : `${tip.day.calls} calls`}
+ + )} , document.body, ) diff --git a/app/renderer/components/StackedBars.test.tsx b/app/renderer/components/StackedBars.test.tsx index c0c2ce3f..3ebd7c74 100644 --- a/app/renderer/components/StackedBars.test.tsx +++ b/app/renderer/components/StackedBars.test.tsx @@ -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() + + 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() + + 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. diff --git a/app/renderer/components/StackedBars.tsx b/app/renderer/components/StackedBars.tsx index f484c431..3f58d4bb 100644 --- a/app/renderer/components/StackedBars.tsx +++ b/app/renderer/components/StackedBars.tsx @@ -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(null) useBarGrowIn(barsRef, '.c', [animateKey]) const presentSeries = new Set() @@ -38,31 +38,46 @@ export function StackedBars({ daily, fallbackLabel = 'All models', animateKey = return (
- {daily.map(day => ( -
- {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 ( - - ) - }) - ) : day.cost > 0 ? ( - - ) : null} -
- ))} + {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 ( +
+ {noData ? ( +
+ ) + })}
{ticks.map(day => { diff --git a/app/renderer/lib/period.test.ts b/app/renderer/lib/period.test.ts index 24567cb1..c1768d61 100644 --- a/app/renderer/lib/period.test.ts +++ b/app/renderer/lib/period.test.ts @@ -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') }) }) diff --git a/app/renderer/lib/period.ts b/app/renderer/lib/period.ts index 0c81c47c..01511ac6 100644 --- a/app/renderer/lib/period.ts +++ b/app/renderer/lib/period.ts @@ -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) diff --git a/app/renderer/sections/Overview.test.tsx b/app/renderer/sections/Overview.test.tsx index eefff525..f76e4984 100644 --- a/app/renderer/sections/Overview.test.tsx +++ b/app/renderer/sections/Overview.test.tsx @@ -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() + + 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'] }) diff --git a/app/renderer/sections/Overview.tsx b/app/renderer/sections/Overview.tsx index 36c23843..66ac77ed 100644 --- a/app/renderer/sections/Overview.tsx +++ b/app/renderer/sections/Overview.tsx @@ -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 ( <>
- {daily.map((day, index) => ( -
{ticks.map(day => { @@ -513,8 +518,14 @@ function DailyChart({ daily, animateKey = '' }: { daily: DailyHistoryEntry[]; an role="tooltip" >
{formatChartDate(tip.day.date)}
-
{formatUsd(tip.day.cost)}
-
{tip.day.calls} calls · {tip.day.topModels[0]?.name ?? 'No model'} led
+ {isNoData(tip.day) ? ( +
No data recorded
+ ) : ( + <> +
{formatUsd(tip.day.cost)}
+
{tip.day.calls} calls · {tip.day.topModels[0]?.name ?? 'No model'} led
+ + )}
, document.body, )} @@ -645,7 +656,7 @@ export function OverviewContent({

Daily spend

{topModel ? `Biggest driver: ${topModel.name}` : 'No model driver yet'}
-
{data.history.daily.length ? : No spend yet.}
+
{data.history.daily.length ? : No spend yet.}
diff --git a/app/renderer/sections/Spend.tsx b/app/renderer/sections/Spend.tsx index 7929f08f..364b7703 100644 --- a/app/renderer/sections/Spend.tsx +++ b/app/renderer/sections/Spend.tsx @@ -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 && }
- {chartHasSpend ? : No model spend in this range yet.} + {chartHasSpend ? : No model spend in this range yet.}
diff --git a/app/renderer/styles/indigo.css b/app/renderer/styles/indigo.css index 60022cd7..d9f75817 100644 --- a/app/renderer/styles/indigo.css +++ b/app/renderer/styles/indigo.css @@ -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; } diff --git a/app/renderer/styles/plain.css b/app/renderer/styles/plain.css index 32b52659..f666e056 100644 --- a/app/renderer/styles/plain.css +++ b/app/renderer/styles/plain.css @@ -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) { diff --git a/dash/src/components/UsageChart.tsx b/dash/src/components/UsageChart.tsx index 905173f7..1c1ce1b1 100644 --- a/dash/src/components/UsageChart.tsx +++ b/dash/src/components/UsageChart.tsx @@ -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])), }