fix(webapp): use stable chart bucket timestamps (#4717)

## Summary

Uses explicit bucket timestamps when rendering usage charts instead of
anchoring missing timestamps to the current render time. Tooltips now
remain stable across rerenders, and examples use a deterministic
timestamp.
This commit is contained in:
Chris Arderne
2026-08-20 09:59:40 +01:00
committed by GitHub
parent 7ea02716fc
commit 11ea1f8ba9
3 changed files with 16 additions and 12 deletions
@@ -44,7 +44,7 @@ export type MiniLineChartProps = {
throttled?: number[];
/** Tooltip wording for the overlay buckets. Null omits the overlay line. */
overlayLabel?: string | null;
/** Epoch ms of the first bucket's start. When omitted, the last bucket is anchored to now. */
/** Epoch ms of the first bucket's start. */
bucketStartMs?: number;
/** Width of each bucket in ms. Defaults to one hour. */
bucketIntervalMs?: number;
@@ -92,7 +92,12 @@ export function MiniLineChart({
showPeak = true,
}: MiniLineChartProps) {
const hasPeakOverride = peakOverride !== undefined;
if (!data || data.length === 0 || (data.every((v) => v === 0) && !hasPeakOverride)) {
if (
!data ||
data.length === 0 ||
bucketStartMs === undefined ||
(data.every((v) => v === 0) && !hasPeakOverride)
) {
return <span className="text-text-dimmed"></span>;
}
@@ -103,11 +108,9 @@ export function MiniLineChart({
const max = Math.max(...data);
const peak = peakOverride ?? max;
// Map each bucket to a dated point so the tooltip can show the window it represents. Buckets are
// `intervalMs` wide; if the caller didn't pass the first bucket's start, anchor the last bucket to
// now (hourly default).
// Map each bucket to a dated point so the tooltip can show the window it represents.
const intervalMs = bucketIntervalMs ?? 3600_000;
const startMs = bucketStartMs ?? Date.now() - (data.length - 1) * intervalMs;
const startMs = bucketStartMs;
const chartData: MiniLineChartDatum[] = data.map((count, i) => {
const t = throttled?.[i] ?? 0;
// Extend the mask one bucket forward (a segment needs both endpoints non-null), so even a
@@ -19,8 +19,8 @@ type UnitLabel = { singular: string; plural: string };
export type UsageSparklineProps = {
/** Equal-width time buckets, oldest first. */
data?: number[];
/** Epoch ms of the first bucket's start. When omitted, the last bucket is anchored to now. */
bucketStartMs?: number;
/** Epoch ms of the first bucket's start. */
bucketStartMs: number;
/** Width of each bucket in ms. Defaults to one hour. */
bucketIntervalMs?: number;
/** Bar colour. Defaults to blue. */
@@ -64,11 +64,9 @@ export function UsageSparkline({
const total = totalOverride ?? data.reduce((a, b) => a + b, 0);
const max = Math.max(...data);
// Map each bucket to a dated point so the tooltip can show the window it
// represents. Buckets are `intervalMs` wide; if the caller didn't pass the
// first bucket's start, anchor the last bucket to now (hourly default).
// Map each bucket to a dated point so the tooltip can show the window it represents.
const intervalMs = bucketIntervalMs ?? 3600_000;
const startMs = bucketStartMs ?? Date.now() - (data.length - 1) * intervalMs;
const startMs = bucketStartMs;
const chartData: UsageDatum[] = data.map((count, i) => ({
date: new Date(startMs + i * intervalMs),
count,
@@ -21,6 +21,7 @@ import SegmentedControl from "~/components/primitives/SegmentedControl";
// Date formatters for chart display
const xAxisTickFormatter = (value: string) => formatISODate(value);
const tooltipLabelFormatter = (label: string) => formatISODateLong(label);
const MINI_LINE_BUCKET_START_MS = Date.UTC(2025, 0, 1);
/**
* Helper function to filter chart data by date range.
@@ -361,6 +362,7 @@ function ChartsDashboard() {
<td className="py-1.5">
<MiniLineChart
data={API_DATA.miniLineData}
bucketStartMs={MINI_LINE_BUCKET_START_MS}
peak={Math.max(...API_DATA.miniLineData)}
unitLabel={{ singular: "queued", plural: "queued" }}
color="var(--color-tasks)"
@@ -372,6 +374,7 @@ function ChartsDashboard() {
<td className="py-1.5">
<MiniLineChart
data={API_DATA.miniLineThrottledData}
bucketStartMs={MINI_LINE_BUCKET_START_MS}
throttled={API_DATA.miniLineThrottledBuckets}
peak={Math.max(...API_DATA.miniLineThrottledData)}
unitLabel={{ singular: "queued", plural: "queued" }}