mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-01 20:35:09 +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.
171 lines
6.1 KiB
TypeScript
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>
|
|
)
|
|
}
|