codeburn/app/renderer/components/ActivityHeatmap.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

171 lines
6.1 KiB
TypeScript

import { useLayoutEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { formatUsd } from '../lib/format'
import { dataStartKey, localDateKey } from '../lib/period'
import type { DailyHistoryEntry } from '../lib/types'
type HeatmapDay = {
date: string
cost: number
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
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
function dateFromKey(key: string): Date {
const [year, month, day] = key.split('-').map(Number)
return new Date(year, month - 1, day)
}
function formatDate(key: string): string {
return dateFromKey(key).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})
}
function intensityLevel(cost: number, maxCost: number): number {
if (cost <= 0 || maxCost <= 0) return 0
const ratio = Math.min(1, cost / maxCost)
if (ratio < 0.25) return 1
if (ratio < 0.5) return 2
if (ratio < 0.75) return 3
return 4
}
function buildHeatmapDays(daily: DailyHistoryEntry[], now: Date): HeatmapDay[] {
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const startOfWeek = new Date(today)
startOfWeek.setDate(today.getDate() - today.getDay())
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++) {
const date = new Date(firstDay)
date.setDate(firstDay.getDate() + offset)
if (date <= today) visibleCosts.push(byDate.get(localDateKey(date))?.cost ?? 0)
}
const maxCost = Math.max(...visibleCosts, 0)
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 noData = !isFuture && (dataStart === null || key < dataStart)
const entry = byDate.get(key)
const cost = isFuture || noData ? 0 : (entry?.cost ?? 0)
return {
date: key,
cost,
calls: isFuture || noData ? 0 : (entry?.calls ?? 0),
level: intensityLevel(cost, maxCost),
isFuture,
noData,
}
})
}
export function ActivityHeatmap({ daily, bare = false }: { daily: DailyHistoryEntry[]; bare?: boolean }) {
const days = useMemo(() => buildHeatmapDays(daily, new Date()), [daily])
const activeDays = days.filter(day => !day.isFuture && day.cost > 0).length
const [tip, setTip] = useState<{ day: HeatmapDay; x: number; y: number } | null>(null)
const [tipPosition, setTipPosition] = useState<{ left: number; top: number } | null>(null)
const tipRef = useRef<HTMLDivElement>(null)
useLayoutEffect(() => {
if (!tip) {
setTipPosition(null)
return
}
const width = tipRef.current?.offsetWidth ?? 180
const height = tipRef.current?.offsetHeight ?? 58
const gutter = 8
const cursorGap = 12
let left = tip.x + cursorGap
if (left + width > window.innerWidth - gutter) left = tip.x - width - cursorGap
left = Math.max(gutter, Math.min(left, window.innerWidth - width - gutter))
let top = tip.y - height - cursorGap
if (top < gutter) top = tip.y + cursorGap
top = Math.max(gutter, Math.min(top, window.innerHeight - height - gutter))
setTipPosition({ left, top })
}, [tip])
const head = (
<div className={bare ? 'ov-activity-head' : 'ov-panel-head'}>
{bare ? <span className="ov-label">Daily activity</span> : <h3>Daily activity</h3>}
<span className="r ov-active-days">{activeDays} active days</span>
</div>
)
const grid = (
<div className="ov-heatmap-scroll">
<div className="ov-heatmap" role="grid" aria-label="Daily activity contribution heatmap">
<div className="ov-heatmap-labels" aria-hidden="true">
{WEEKDAYS.map((weekday, index) => (
<span key={weekday}>{index === 1 || index === 3 || index === 5 ? weekday : ''}</span>
))}
</div>
<div className="ov-heatmap-cells">
{days.map(day => (
<button
type="button"
role="gridcell"
key={day.date}
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.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)}
/>
))}
</div>
</div>
</div>
)
const tooltip = tip
? createPortal(
<div
ref={tipRef}
className={`chart-tip${tipPosition ? ' on' : ''}`}
style={{ position: 'fixed', ...(tipPosition ?? { left: 0, top: 0 }) }}
role="tooltip"
>
<div className="chart-tip-d">{formatDate(tip.day.date)}</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,
)
: null
if (bare) {
return <div className="ov-heatmap-bare">{head}{grid}{tooltip}</div>
}
return (
<div className="ov-card ov-panel ov-heatmap-panel">
{head}
<div className="ov-panel-body">{grid}</div>
{tooltip}
</div>
)
}