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 { useUsageTimeSeries } from "@/hooks/use-usage";
import { useWorkSessions } from "@/hooks/use-work-sessions"; import { useWorkSessions } from "@/hooks/use-work-sessions";
import { useAgentJournalEntries } from "@/hooks/use-journals"; 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 { chartTooltipStyle } from "@/components/charts/chart-tooltip";
import { WorkSessionStatus, type UsageTimePoint } from "@/types"; import { WorkSessionStatus, type UsageTimePoint } from "@/types";
@@ -109,6 +109,7 @@ export function AgentActivityPanel({
const hasTokens = (series ?? []).some( const hasTokens = (series ?? []).some(
(p: UsageTimePoint) => p.total_tokens > 0, (p: UsageTimePoint) => p.total_tokens > 0,
); );
const granularity = bucketGranularity((series ?? []).map((p) => p.bucket));
return ( return (
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
@@ -145,7 +146,7 @@ export function AgentActivityPanel({
</defs> </defs>
<XAxis <XAxis
dataKey="bucket" dataKey="bucket"
tickFormatter={formatBucket} tickFormatter={(b) => formatBucket(String(b), granularity)}
tick={{ fontSize: 9 }} tick={{ fontSize: 9 }}
axisLine={false} axisLine={false}
tickLine={false} tickLine={false}
@@ -163,7 +164,9 @@ export function AgentActivityPanel({
formatTokens(typeof value === "number" ? value : 0), formatTokens(typeof value === "number" ? value : 0),
"Tokens", "Tokens",
]} ]}
labelFormatter={(label) => formatBucket(String(label))} labelFormatter={(label) =>
formatBucket(String(label), granularity)
}
/> />
<Area <Area
type="monotone" type="monotone"
@@ -12,7 +12,7 @@ import {
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { HelpTip } from "@/components/ui/help-tip"; 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 { chartTooltipStyle } from "@/components/charts/chart-tooltip";
import type { UsageTimePoint } from "@/types"; import type { UsageTimePoint } from "@/types";
@@ -31,8 +31,9 @@ function fmtCost(n: number): string {
* series-shaped endpoint the Overview page's CostTrendChart draws from. * series-shaped endpoint the Overview page's CostTrendChart draws from.
*/ */
export function SpendTrendChart({ data, isLoading }: SpendTrendChartProps) { export function SpendTrendChart({ data, isLoading }: SpendTrendChartProps) {
const granularity = bucketGranularity((data ?? []).map((p) => p.bucket));
const chartData = (data ?? []).map((p) => ({ const chartData = (data ?? []).map((p) => ({
day: formatBucket(p.bucket), day: formatBucket(p.bucket, granularity),
Spend: p.cost_usd, Spend: p.cost_usd,
})); }));
@@ -12,7 +12,7 @@ import {
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { HelpTip } from "@/components/ui/help-tip"; 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 { chartTooltipStyle } from "@/components/charts/chart-tooltip";
import type { UsageTimePoint } from "@/types"; 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. * the current-period totals in UsageOverviewPanel came from at a glance.
*/ */
export function CostTrendChart({ data, isLoading }: CostTrendChartProps) { export function CostTrendChart({ data, isLoading }: CostTrendChartProps) {
const granularity = bucketGranularity((data ?? []).map((p) => p.bucket));
const chartData = (data ?? []).map((p) => ({ const chartData = (data ?? []).map((p) => ({
day: formatBucket(p.bucket), day: formatBucket(p.bucket, granularity),
Cost: p.cost_usd, Cost: p.cost_usd,
})); }));
@@ -14,7 +14,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { HelpTip } from "@/components/ui/help-tip"; import { HelpTip } from "@/components/ui/help-tip";
import { useIsMobile } from "@/hooks/use-is-mobile"; 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 { chartTooltipStyle } from "@/components/charts/chart-tooltip";
import type { UsageTimePoint } from "@/types"; import type { UsageTimePoint } from "@/types";
@@ -28,8 +28,9 @@ export function UsageTimeSeriesChart({
isLoading, isLoading,
}: UsageTimeSeriesChartProps) { }: UsageTimeSeriesChartProps) {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const granularity = bucketGranularity((data ?? []).map((p) => p.bucket));
const chartData = (data ?? []).map((p) => ({ const chartData = (data ?? []).map((p) => ({
hour: formatBucket(p.bucket), hour: formatBucket(p.bucket, granularity),
Input: p.tokens_input, Input: p.tokens_input,
Output: p.tokens_output, Output: p.tokens_output,
})); }));
@@ -85,6 +86,7 @@ export function UsageTimeSeriesChart({
dataKey="hour" dataKey="hour"
tick={{ fontSize: isMobile ? 9 : 10 }} tick={{ fontSize: isMobile ? 9 : 10 }}
interval="preserveStartEnd" interval="preserveStartEnd"
minTickGap={40}
axisLine={false} axisLine={false}
tickLine={false} tickLine={false}
/> />
+40 -14
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { formatTokens, formatBucket } from "@/lib/format"; import { formatTokens, formatBucket, bucketGranularity } from "@/lib/format";
describe("formatTokens", () => { describe("formatTokens", () => {
it("renders raw digits below 1000", () => { it("renders raw digits below 1000", () => {
@@ -19,21 +19,47 @@ describe("formatTokens", () => {
}); });
}); });
describe("formatBucket", () => { describe("bucketGranularity", () => {
it("formats a midnight-UTC daily bucket as MM/DD", () => { it("hourly when buckets sit ~1h apart", () => {
// Regression guard: a plain "T00:00:00.000Z" bucket must never be expect(
// mistaken for an hourly bucket just because minutes/seconds are 0. bucketGranularity([
expect(formatBucket("2026-07-15T00:00:00.000Z")).toBe( "2026-07-15T00:00:00Z",
new Date("2026-07-15T00:00:00.000Z").getMonth() + "2026-07-15T01:00:00Z",
1 + "2026-07-15T02:00:00Z",
"/" + ]),
new Date("2026-07-15T00:00:00.000Z").getDate(), ).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 bucket = "2026-07-15T14:00:00.000Z";
const expectedHour = new Date(bucket).getHours().toString().padStart(2, "0"); const expectedHour = new Date(bucket)
expect(formatBucket(bucket)).toBe(`${expectedHour}:00`); .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); return String(n);
} }
/** Format a usage time-bucket ISO string as "HH:00" (hourly) or "MM/DD" (daily). */ const _TWO_HOURS_MS = 2 * 60 * 60 * 1000;
export function formatBucket(bucket: string): string {
const d = new Date(bucket); /** Whether a time-bucket series is hourly or daily, from the smallest gap
// If the bucket has a non-zero time component it is an hourly bucket → show HH:00. * between consecutive buckets — hourly buckets sit ~1h apart, daily ~24h.
// Otherwise it is a daily bucket → show MM/DD. * Derived from the data (not the timestamp's string shape or the viewer's
const isHourly = * timezone), so it can't misread a midnight-UTC daily bucket as an hour. */
d.getMinutes() === 0 && (d.getHours() !== 0 || bucket.includes("T")); export function bucketGranularity(buckets: string[]): "hour" | "day" {
if (isHourly && d.getSeconds() === 0 && !bucket.endsWith("T00:00:00.000Z")) { if (buckets.length < 2) return "hour";
return d.getHours().toString().padStart(2, "0") + ":00"; 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";
} }