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 { 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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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
@@ -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";
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user