fix(panel): days-view timeseries charts show dates, not '02:00' (#622)

formatBucket guessed hourly vs daily from the timestamp string
(bucket.endsWith('T00:00:00.000Z')) and then rendered LOCAL getHours(), so
every daily midnight-UTC bucket rendered as the viewer's local hour — '02:00'
at UTC+2 — across the 7d/30d/90d windows. Granularity is now derived from the
data's own bucket spacing (bucketGranularity: min gap >2h = daily) and daily
buckets render as a short UTC date. The 24h/hourly view is unchanged. Added
minTickGap so the 90d axis doesn't crowd.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-21 03:51:41 +02:00
committed by GitHub
co-authored by Renn F
parent 7b84162ae9
commit 775872cac0
6 changed files with 87 additions and 33 deletions
@@ -23,7 +23,7 @@ import { GitBranch, BookOpen } from "lucide-react";
import { useUsageTimeSeries } from "@/hooks/use-usage";
import { useWorkSessions } from "@/hooks/use-work-sessions";
import { useAgentJournalEntries } from "@/hooks/use-journals";
import { formatTokens, formatBucket } from "@/lib/format";
import { formatTokens, formatBucket, bucketGranularity } from "@/lib/format";
import { chartTooltipStyle } from "@/components/charts/chart-tooltip";
import { WorkSessionStatus, type UsageTimePoint } from "@/types";
@@ -109,6 +109,7 @@ export function AgentActivityPanel({
const hasTokens = (series ?? []).some(
(p: UsageTimePoint) => p.total_tokens > 0,
);
const granularity = bucketGranularity((series ?? []).map((p) => p.bucket));
return (
<div className="grid gap-4 md:grid-cols-2">
@@ -145,7 +146,7 @@ export function AgentActivityPanel({
</defs>
<XAxis
dataKey="bucket"
tickFormatter={formatBucket}
tickFormatter={(b) => formatBucket(String(b), granularity)}
tick={{ fontSize: 9 }}
axisLine={false}
tickLine={false}
@@ -163,7 +164,9 @@ export function AgentActivityPanel({
formatTokens(typeof value === "number" ? value : 0),
"Tokens",
]}
labelFormatter={(label) => formatBucket(String(label))}
labelFormatter={(label) =>
formatBucket(String(label), granularity)
}
/>
<Area
type="monotone"
@@ -12,7 +12,7 @@ import {
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { HelpTip } from "@/components/ui/help-tip";
import { formatBucket } from "@/lib/format";
import { formatBucket, bucketGranularity } from "@/lib/format";
import { chartTooltipStyle } from "@/components/charts/chart-tooltip";
import type { UsageTimePoint } from "@/types";
@@ -31,8 +31,9 @@ function fmtCost(n: number): string {
* series-shaped endpoint the Overview page's CostTrendChart draws from.
*/
export function SpendTrendChart({ data, isLoading }: SpendTrendChartProps) {
const granularity = bucketGranularity((data ?? []).map((p) => p.bucket));
const chartData = (data ?? []).map((p) => ({
day: formatBucket(p.bucket),
day: formatBucket(p.bucket, granularity),
Spend: p.cost_usd,
}));
@@ -12,7 +12,7 @@ import {
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { HelpTip } from "@/components/ui/help-tip";
import { formatBucket } from "@/lib/format";
import { formatBucket, bucketGranularity } from "@/lib/format";
import { chartTooltipStyle } from "@/components/charts/chart-tooltip";
import type { UsageTimePoint } from "@/types";
@@ -31,8 +31,9 @@ function fmtCost(n: number): string {
* the current-period totals in UsageOverviewPanel came from at a glance.
*/
export function CostTrendChart({ data, isLoading }: CostTrendChartProps) {
const granularity = bucketGranularity((data ?? []).map((p) => p.bucket));
const chartData = (data ?? []).map((p) => ({
day: formatBucket(p.bucket),
day: formatBucket(p.bucket, granularity),
Cost: p.cost_usd,
}));
@@ -14,7 +14,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { HelpTip } from "@/components/ui/help-tip";
import { useIsMobile } from "@/hooks/use-is-mobile";
import { formatTokens, formatBucket } from "@/lib/format";
import { formatTokens, formatBucket, bucketGranularity } from "@/lib/format";
import { chartTooltipStyle } from "@/components/charts/chart-tooltip";
import type { UsageTimePoint } from "@/types";
@@ -28,8 +28,9 @@ export function UsageTimeSeriesChart({
isLoading,
}: UsageTimeSeriesChartProps) {
const isMobile = useIsMobile();
const granularity = bucketGranularity((data ?? []).map((p) => p.bucket));
const chartData = (data ?? []).map((p) => ({
hour: formatBucket(p.bucket),
hour: formatBucket(p.bucket, granularity),
Input: p.tokens_input,
Output: p.tokens_output,
}));
@@ -85,6 +86,7 @@ export function UsageTimeSeriesChart({
dataKey="hour"
tick={{ fontSize: isMobile ? 9 : 10 }}
interval="preserveStartEnd"
minTickGap={40}
axisLine={false}
tickLine={false}
/>
+40 -14
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { formatTokens, formatBucket } from "@/lib/format";
import { formatTokens, formatBucket, bucketGranularity } from "@/lib/format";
describe("formatTokens", () => {
it("renders raw digits below 1000", () => {
@@ -19,21 +19,47 @@ describe("formatTokens", () => {
});
});
describe("formatBucket", () => {
it("formats a midnight-UTC daily bucket as MM/DD", () => {
// Regression guard: a plain "T00:00:00.000Z" bucket must never be
// mistaken for an hourly bucket just because minutes/seconds are 0.
expect(formatBucket("2026-07-15T00:00:00.000Z")).toBe(
new Date("2026-07-15T00:00:00.000Z").getMonth() +
1 +
"/" +
new Date("2026-07-15T00:00:00.000Z").getDate(),
);
describe("bucketGranularity", () => {
it("hourly when buckets sit ~1h apart", () => {
expect(
bucketGranularity([
"2026-07-15T00:00:00Z",
"2026-07-15T01:00:00Z",
"2026-07-15T02:00:00Z",
]),
).toBe("hour");
});
it("formats a non-midnight hourly bucket as HH:00", () => {
it("daily when buckets sit ~24h apart", () => {
expect(
bucketGranularity([
"2026-07-13T00:00:00Z",
"2026-07-14T00:00:00Z",
"2026-07-15T00:00:00Z",
]),
).toBe("day");
});
it("defaults to hourly with too few points to tell", () => {
expect(bucketGranularity(["2026-07-15T00:00:00Z"])).toBe("hour");
});
});
describe("formatBucket", () => {
it("renders a daily bucket as a date, never a bare local hour", () => {
// Regression: a midnight-UTC daily bucket used to render as the viewer's
// local hour ("02:00" at UTC+2) for every tick. It must be a date.
const out = formatBucket("2026-07-15T00:00:00.000Z", "day");
expect(out).not.toMatch(/^\d{2}:00$/);
expect(out).toContain("15");
});
it("renders an hourly bucket as HH:00", () => {
const bucket = "2026-07-15T14:00:00.000Z";
const expectedHour = new Date(bucket).getHours().toString().padStart(2, "0");
expect(formatBucket(bucket)).toBe(`${expectedHour}:00`);
const expectedHour = new Date(bucket)
.getHours()
.toString()
.padStart(2, "0");
expect(formatBucket(bucket, "hour")).toBe(`${expectedHour}:00`);
});
});
+31 -10
View File
@@ -9,15 +9,36 @@ export function formatTokens(n: number): string {
return String(n);
}
/** Format a usage time-bucket ISO string as "HH:00" (hourly) or "MM/DD" (daily). */
export function formatBucket(bucket: string): string {
const d = new Date(bucket);
// If the bucket has a non-zero time component it is an hourly bucket → show HH:00.
// Otherwise it is a daily bucket → show MM/DD.
const isHourly =
d.getMinutes() === 0 && (d.getHours() !== 0 || bucket.includes("T"));
if (isHourly && d.getSeconds() === 0 && !bucket.endsWith("T00:00:00.000Z")) {
return d.getHours().toString().padStart(2, "0") + ":00";
const _TWO_HOURS_MS = 2 * 60 * 60 * 1000;
/** Whether a time-bucket series is hourly or daily, from the smallest gap
* between consecutive buckets — hourly buckets sit ~1h apart, daily ~24h.
* Derived from the data (not the timestamp's string shape or the viewer's
* timezone), so it can't misread a midnight-UTC daily bucket as an hour. */
export function bucketGranularity(buckets: string[]): "hour" | "day" {
if (buckets.length < 2) return "hour";
const times = buckets.map((b) => new Date(b).getTime()).sort((a, b) => a - b);
let minGap = Infinity;
for (let i = 1; i < times.length; i++) {
minGap = Math.min(minGap, times[i] - times[i - 1]);
}
return d.getMonth() + 1 + "/" + d.getDate();
return minGap > _TWO_HOURS_MS ? "day" : "hour";
}
/** Format one time-bucket: "HH:00" for hourly (viewer-local), a short UTC
* date ("Jul 15") for daily. Daily buckets are midnight UTC, so render them in
* UTC — otherwise a non-UTC viewer sees the previous day or a bare "02:00". */
export function formatBucket(
bucket: string,
granularity: "hour" | "day",
): string {
const d = new Date(bucket);
if (granularity === "day") {
return d.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
timeZone: "UTC",
});
}
return d.getHours().toString().padStart(2, "0") + ":00";
}