From 23529344bc9b6032a575b32f285d8dbc9d6c9161 Mon Sep 17 00:00:00 2001 From: Vader Yang Date: Fri, 15 May 2026 17:27:23 +0800 Subject: [PATCH] feat(console/charts): x-axis tick adapts to time-range duration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every chart had its own copy of: function formatAxisTime(epoch) { const d = new Date(epoch * 1000) return `${HH}:${MM}` } Result: a 7-day window rendered ticks as a wrapping clock face ("00:00", "12:00", "00:00", "12:00", ...) with no day attached. Same problem at 24h. Easy to mis-read. Centralize the formatter in lib/format as `formatAxisTime(epoch, span)` and have it pick the right shape based on the visible window: span < 24h → HH:MM (5m / 15m / 1h / 6h presets) 24h ≤ span < 7d → MM-DD HH:MM (24h preset) span ≥ 7d → MM-DD (7d preset; time-of-day is noise when ticks come ~daily) Each chart derives span from its data (last timestamp − first), so the formatter requires no toolbar dependency and naturally handles partial ranges (e.g. tail of a 7d window after retention trimmed the head). Replaces the inline copies in: - timeseries-line-chart (Overview latency, Models, Performance) - request-volume-chart (Overview) - latency-overview-chart (Overview) - stacked-bar-chart (Performance, Traffic) 6 unit tests in lib/format.test.ts cover each duration bucket plus the 24h / 7d inclusive boundaries and the single-point fallback (span = 0 → HH:MM). Tests assert *shape* not literal values so they pass under any TZ. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../charts/latency-overview-chart.tsx | 13 +++-- .../charts/request-volume-chart.tsx | 15 +++--- .../components/charts/stacked-bar-chart.tsx | 13 +++-- .../charts/timeseries-line-chart.tsx | 15 +++--- console/src/lib/format.test.ts | 48 +++++++++++++++++++ console/src/lib/format.ts | 32 +++++++++++++ 6 files changed, 107 insertions(+), 29 deletions(-) create mode 100644 console/src/lib/format.test.ts diff --git a/console/src/components/charts/latency-overview-chart.tsx b/console/src/components/charts/latency-overview-chart.tsx index 6fc6af2..a949de1 100644 --- a/console/src/components/charts/latency-overview-chart.tsx +++ b/console/src/components/charts/latency-overview-chart.tsx @@ -8,7 +8,7 @@ import { ResponsiveContainer, Legend, } from "recharts" -import { formatMs } from "@/lib/format" +import { formatMs, formatAxisTime } from "@/lib/format" import type { TimeseriesData } from "@/types/api" const SERIES_CONFIG = [ @@ -18,11 +18,6 @@ const SERIES_CONFIG = [ { key: "e2e_p95", label: "E2E p95", color: "#3b82f6", dash: "5 3" }, ] -function formatAxisTime(epoch: number): string { - const d = new Date(epoch * 1000) - return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}` -} - interface Props { data: TimeseriesData | null } @@ -44,6 +39,10 @@ export function LatencyOverviewChart({ data }: Props) { } return point }) + const spanSec = + data.timestamps.length > 1 + ? data.timestamps[data.timestamps.length - 1] - data.timestamps[0] + : 0 return ( @@ -51,7 +50,7 @@ export function LatencyOverviewChart({ data }: Props) { formatAxisTime(v, spanSec)} className="text-[11px] fill-muted-foreground" tickLine={false} axisLine={false} diff --git a/console/src/components/charts/request-volume-chart.tsx b/console/src/components/charts/request-volume-chart.tsx index 3ff9875..058fb82 100644 --- a/console/src/components/charts/request-volume-chart.tsx +++ b/console/src/components/charts/request-volume-chart.tsx @@ -8,7 +8,7 @@ import { ResponsiveContainer, Legend, } from "recharts" -import { formatNumber } from "@/lib/format" +import { formatNumber, formatAxisTime } from "@/lib/format" import type { TimeseriesData } from "@/types/api" // Stable color palette for wire APIs @@ -23,13 +23,6 @@ const SERIES_COLORS = [ "#84cc16", // lime ] -function formatAxisTime(epoch: number): string { - const d = new Date(epoch * 1000) - const hh = String(d.getHours()).padStart(2, "0") - const mm = String(d.getMinutes()).padStart(2, "0") - return `${hh}:${mm}` -} - interface Props { data: TimeseriesData | null } @@ -55,6 +48,10 @@ export function RequestVolumeChart({ data }: Props) { } return point }) + const spanSec = + data.timestamps.length > 1 + ? data.timestamps[data.timestamps.length - 1] - data.timestamps[0] + : 0 return ( @@ -62,7 +59,7 @@ export function RequestVolumeChart({ data }: Props) { formatAxisTime(v, spanSec)} className="text-[11px] fill-muted-foreground" tickLine={false} axisLine={false} diff --git a/console/src/components/charts/stacked-bar-chart.tsx b/console/src/components/charts/stacked-bar-chart.tsx index 14aad05..f93142f 100644 --- a/console/src/components/charts/stacked-bar-chart.tsx +++ b/console/src/components/charts/stacked-bar-chart.tsx @@ -8,7 +8,7 @@ import { ResponsiveContainer, Legend, } from "recharts" -import { formatNumber } from "@/lib/format" +import { formatNumber, formatAxisTime } from "@/lib/format" import type { TimeseriesData } from "@/types/api" const GROUP_COLORS = [ @@ -22,11 +22,6 @@ const GROUP_COLORS = [ "#84cc16", // lime ] -function formatAxisTime(epoch: number): string { - const d = new Date(epoch * 1000) - return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}` -} - interface Props { data: TimeseriesData | null field: string @@ -56,6 +51,10 @@ export function StackedBarChart({ data, field, height = 240, yFormatter = format } return point }) + const spanSec = + data.timestamps.length > 1 + ? data.timestamps[data.timestamps.length - 1] - data.timestamps[0] + : 0 return ( @@ -63,7 +62,7 @@ export function StackedBarChart({ data, field, height = 240, yFormatter = format formatAxisTime(v, spanSec)} className="text-[11px] fill-muted-foreground" tickLine={false} axisLine={false} diff --git a/console/src/components/charts/timeseries-line-chart.tsx b/console/src/components/charts/timeseries-line-chart.tsx index 1cef818..8012ea7 100644 --- a/console/src/components/charts/timeseries-line-chart.tsx +++ b/console/src/components/charts/timeseries-line-chart.tsx @@ -11,6 +11,7 @@ import { Legend, } from "recharts" import type { TimeseriesData } from "@/types/api" +import { formatAxisTime } from "@/lib/format" interface SeriesConfig { key: string @@ -27,11 +28,6 @@ interface Props { variant?: "line" | "area" } -function formatAxisTime(epoch: number): string { - const d = new Date(epoch * 1000) - return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}` -} - export function TimeseriesLineChart({ data, series, @@ -57,6 +53,13 @@ export function TimeseriesLineChart({ } return point }) + // Span of the visible data window — drives axis tick density (HH:MM + // vs MM-DD HH:MM vs MM-DD). Falls back to 0 (sub-day format) for + // single-point series. + const spanSec = + data.timestamps.length > 1 + ? data.timestamps[data.timestamps.length - 1] - data.timestamps[0] + : 0 const ChartComponent = variant === "area" ? AreaChart : LineChart @@ -66,7 +69,7 @@ export function TimeseriesLineChart({ formatAxisTime(v, spanSec)} className="text-[11px] fill-muted-foreground" tickLine={false} axisLine={false} diff --git a/console/src/lib/format.test.ts b/console/src/lib/format.test.ts new file mode 100644 index 0000000..8682773 --- /dev/null +++ b/console/src/lib/format.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "bun:test" +import { formatAxisTime } from "./format" + +// Note: formatAxisTime renders in the local timezone (via Date.getHours() +// / getMonth() etc.). Tests assert the *shape* (number of segments, +// presence of date) rather than literal values, so they pass regardless +// of the runner's TZ. + +const MINUTE = 60 +const HOUR = 60 * MINUTE +const DAY = 24 * HOUR +const EPOCH = 1_780_000_000 // mid-2026, arbitrary + +describe("formatAxisTime", () => { + it("renders HH:MM only when the span is under 24h", () => { + for (const span of [15 * MINUTE, HOUR, 6 * HOUR, 23 * HOUR]) { + const s = formatAxisTime(EPOCH, span) + expect(s).toMatch(/^\d{2}:\d{2}$/) + } + }) + + it("renders MM-DD HH:MM when the span is between 24h and 7d", () => { + for (const span of [DAY, 2 * DAY, 3 * DAY, 6 * DAY]) { + const s = formatAxisTime(EPOCH, span) + expect(s).toMatch(/^\d{2}-\d{2} \d{2}:\d{2}$/) + } + }) + + it("renders date-only (MM-DD) at 7d or longer", () => { + for (const span of [7 * DAY, 14 * DAY, 30 * DAY]) { + const s = formatAxisTime(EPOCH, span) + expect(s).toMatch(/^\d{2}-\d{2}$/) + } + }) + + it("falls back to HH:MM when the span is 0 (single-point data)", () => { + expect(formatAxisTime(EPOCH, 0)).toMatch(/^\d{2}:\d{2}$/) + }) + + it("treats the 24h boundary inclusively as multi-day", () => { + // Exactly 24h: still in the [24h, 7d) bucket → date prefix included. + expect(formatAxisTime(EPOCH, DAY)).toMatch(/^\d{2}-\d{2} \d{2}:\d{2}$/) + }) + + it("treats the 7d boundary inclusively as date-only", () => { + expect(formatAxisTime(EPOCH, 7 * DAY)).toMatch(/^\d{2}-\d{2}$/) + }) +}) diff --git a/console/src/lib/format.ts b/console/src/lib/format.ts index f7df4c1..95baf69 100644 --- a/console/src/lib/format.ts +++ b/console/src/lib/format.ts @@ -1,3 +1,35 @@ +const DAY_SECONDS = 86_400 + +/** + * Chart x-axis tick label that adapts to the visible window's duration. + * + * spanSec < 24h → `HH:MM` (5m / 15m / 1h / 6h presets) + * 24h ≤ < 7d → `MM-DD HH:MM` (24h preset; gives the date on a + * few ticks so multi-day windows + * don't all read like the same + * wrapping clock face) + * ≥ 7d → `MM-DD` (7d preset; ticks come ~daily, + * the time-of-day is noise) + * + * Caller supplies `spanSec` (typically `end - start` of the toolbar + * window, or `lastTs - firstTs` of the data). Centralized here so the + * four chart components share one rule. + */ +export function formatAxisTime(epochSec: number, spanSec: number): string { + const d = new Date(epochSec * 1000) + const hh = String(d.getHours()).padStart(2, "0") + const mm = String(d.getMinutes()).padStart(2, "0") + if (spanSec < DAY_SECONDS) { + return `${hh}:${mm}` + } + const mo = String(d.getMonth() + 1).padStart(2, "0") + const da = String(d.getDate()).padStart(2, "0") + if (spanSec < 7 * DAY_SECONDS) { + return `${mo}-${da} ${hh}:${mm}` + } + return `${mo}-${da}` +} + export function formatTime(epochMs: number): string { const d = new Date(epochMs) const hh = String(d.getHours()).padStart(2, "0")