mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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
@@ -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";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user