From 7248e5b72215cf0367ef40edca18b58f6555d586 Mon Sep 17 00:00:00 2001 From: Renzo F <45401804+rennf93@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:29:45 +0200 Subject: [PATCH] fix(panel): charts get real axes, humanized ticks, and dark-theme tooltips (#611) Every chart hand-rolled its own k-only formatter (22596k-style ticks, left- clipped y labels), used recharts' default white tooltip (invisible header on the dark theme), and two charts pinned a numeric XAxis interval that collapses short series to a single tick. The agent Token Activity chart had no axes at all and blanked its tooltip date on purpose. One shared formatTokens/formatBucket (lib/format.ts) and one shared themed tooltip style (components/charts/chart-tooltip.tsx) now feed all 10 charts; axis widths/margins sized to the labels; preserveStartEnd tick intervals. Co-authored-by: Renn F --- panel/src/app/(dashboard)/metrics/page.tsx | 24 ++++---- .../agents/agent-activity-panel.tsx | 58 ++++++++++++++----- .../components/business/spend-trend-chart.tsx | 17 +++--- panel/src/components/charts/chart-tooltip.tsx | 18 ++++++ .../components/dashboard/cost-trend-chart.tsx | 9 +-- .../components/metrics/agent-usage-chart.tsx | 17 +++--- panel/src/components/metrics/delivery-tab.tsx | 3 +- .../components/metrics/model-usage-donut.tsx | 3 +- .../src/components/metrics/sessions-table.tsx | 34 ++++++----- .../components/metrics/task-status-chart.tsx | 5 +- .../components/metrics/team-usage-chart.tsx | 17 +++--- .../metrics/usage-time-series-chart.tsx | 29 +++------- .../work-sessions/session-trend-chart.tsx | 9 ++- panel/src/lib/__tests__/format.test.ts | 39 +++++++++++++ panel/src/lib/format.ts | 23 ++++++++ 15 files changed, 200 insertions(+), 105 deletions(-) create mode 100644 panel/src/components/charts/chart-tooltip.tsx create mode 100644 panel/src/lib/__tests__/format.test.ts create mode 100644 panel/src/lib/format.ts diff --git a/panel/src/app/(dashboard)/metrics/page.tsx b/panel/src/app/(dashboard)/metrics/page.tsx index cb4a4f61..83f26d8c 100644 --- a/panel/src/app/(dashboard)/metrics/page.tsx +++ b/panel/src/app/(dashboard)/metrics/page.tsx @@ -18,6 +18,7 @@ import { useUsageSessions, } from "@/hooks/use-usage"; import type { UsagePeriod } from "@/lib/api/usage"; +import { formatTokens } from "@/lib/format"; import { TaskStatus, Team } from "@/types"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Progress } from "@/components/ui/progress"; @@ -78,13 +79,6 @@ function humanizeCount(n: number): string { return String(n); } -/** Format token counts (same as humanizeCount but used for token display). */ -function fmtTokens(n: number): string { - if (n >= 1_000_000) return (n / 1_000_000).toFixed(2) + "M"; - if (n >= 1_000) return (n / 1_000).toFixed(1) + "K"; - return String(n); -} - // ─── Shared sub-components ──────────────────────────────────────────────────── interface MetricCardProps { @@ -493,14 +487,14 @@ function TokenUsageCostsSection() {
} isLoading={loadingSnap} tip="Prompt/context tokens sent to the model in the selected window" /> } isLoading={loadingSnap} tip="Tokens generated by the model in response, in the selected window" @@ -531,7 +525,7 @@ function TokenUsageCostsSection() { /> } isLoading={loadingSnap} tip="Input + output tokens combined in the selected window" @@ -697,8 +691,8 @@ function CacheEfficiencyCard({
{pct.toFixed(1)}%

- {cacheStats ? fmtTokens(cacheStats.tokens_cache_read) : "—"} cache - reads · saved $ + {cacheStats ? formatTokens(cacheStats.tokens_cache_read) : "—"}{" "} + cache reads · saved $ {cacheStats?.cost_saved_by_cache_usd.toFixed(4) ?? "—"}

@@ -892,8 +886,10 @@ const VALID_METRICS_TABS: MetricsTab[] = [ const METRICS_TAB_HINTS: Record = { performance: "Task velocity, status counts, agent load, and team health", - "token-usage": "Token spend, cost projections, cache efficiency, and per-session detail", - delivery: "Cycle time, bottlenecks, and rework rate reconstructed from the audit log", + "token-usage": + "Token spend, cost projections, cache efficiency, and per-session detail", + delivery: + "Cycle time, bottlenecks, and rework rate reconstructed from the audit log", scorecards: "Per-agent and per-team delivery scorecards", }; diff --git a/panel/src/components/agents/agent-activity-panel.tsx b/panel/src/components/agents/agent-activity-panel.tsx index 3b9f96d8..fb6c8471 100644 --- a/panel/src/components/agents/agent-activity-panel.tsx +++ b/panel/src/components/agents/agent-activity-panel.tsx @@ -7,6 +7,8 @@ import { AreaChart, ResponsiveContainer, Tooltip as RTooltip, + XAxis, + YAxis, } from "recharts"; import { Card, @@ -21,6 +23,8 @@ 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 { chartTooltipStyle } from "@/components/charts/chart-tooltip"; import { WorkSessionStatus, type UsageTimePoint } from "@/types"; interface AgentActivityPanelProps { @@ -36,11 +40,6 @@ const SESSION_STATUS_LABEL: Record = { [WorkSessionStatus.ABANDONED]: "Abandoned", }; -function fmtK(n: number): string { - if (n >= 1_000) return (n / 1_000).toFixed(0) + "k"; - return String(n); -} - type TimelineItem = { kind: "session" | "journal"; title: string; @@ -49,8 +48,18 @@ type TimelineItem = { }; function mergeTimeline( - sessions: { task_id: string; branch_name: string; status: WorkSessionStatus; started_at: string }[], - journals: { title: string; type: string; timestamp: string; task_id: string | null }[], + sessions: { + task_id: string; + branch_name: string; + status: WorkSessionStatus; + started_at: string; + }[], + journals: { + title: string; + type: string; + timestamp: string; + task_id: string | null; + }[], ): TimelineItem[] { const items: TimelineItem[] = [ ...sessions.map((s) => ({ @@ -62,12 +71,17 @@ function mergeTimeline( ...journals.map((j) => ({ kind: "journal" as const, title: j.title, - subtitle: j.task_id ? `Task ${j.task_id.slice(0, 8)} · ${j.type}` : j.type, + subtitle: j.task_id + ? `Task ${j.task_id.slice(0, 8)} · ${j.type}` + : j.type, timestamp: j.timestamp, })), ]; return items - .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) + .sort( + (a, b) => + new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(), + ) .slice(0, 8); } @@ -92,7 +106,9 @@ export function AgentActivityPanel({ [sessions, journals], ); const timelineLoading = sessionsLoading || journalsLoading; - const hasTokens = (series ?? []).some((p: UsageTimePoint) => p.total_tokens > 0); + const hasTokens = (series ?? []).some( + (p: UsageTimePoint) => p.total_tokens > 0, + ); return (
@@ -127,13 +143,27 @@ export function AgentActivityPanel({ /> + + [ - fmtK(typeof value === "number" ? value : 0), + formatTokens(typeof value === "number" ? value : 0), "Tokens", ]} - contentStyle={{ fontSize: 12 }} - labelFormatter={() => ""} + labelFormatter={(label) => formatBucket(String(label))} />
); -} \ No newline at end of file +} diff --git a/panel/src/components/business/spend-trend-chart.tsx b/panel/src/components/business/spend-trend-chart.tsx index 15d5d0b2..a309d96d 100644 --- a/panel/src/components/business/spend-trend-chart.tsx +++ b/panel/src/components/business/spend-trend-chart.tsx @@ -12,6 +12,8 @@ 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 { chartTooltipStyle } from "@/components/charts/chart-tooltip"; import type { UsageTimePoint } from "@/types"; interface SpendTrendChartProps { @@ -19,11 +21,6 @@ interface SpendTrendChartProps { isLoading: boolean; } -function formatBucket(bucket: string): string { - const d = new Date(bucket); - return d.getMonth() + 1 + "/" + d.getDate(); -} - function fmtCost(n: number): string { return "$" + n.toFixed(2); } @@ -63,7 +60,7 @@ export function SpendTrendChart({ data, isLoading }: SpendTrendChartProps) { @@ -75,13 +72,17 @@ export function SpendTrendChart({ data, isLoading }: SpendTrendChartProps) { width={44} /> [ fmtCost(typeof value === "number" ? value : 0), "Spend", ]} - contentStyle={{ fontSize: 12 }} /> - + )} diff --git a/panel/src/components/charts/chart-tooltip.tsx b/panel/src/components/charts/chart-tooltip.tsx new file mode 100644 index 00000000..a99a3a3a --- /dev/null +++ b/panel/src/components/charts/chart-tooltip.tsx @@ -0,0 +1,18 @@ +import type { CSSProperties } from "react"; + +// Shared recharts styling — a bare `contentStyle={{fontSize:12}}` +// renders recharts' default white content box with the default (also-white) +// label text, so on the dark theme the tooltip is a white box with an +// invisible date header. Pull both colors from the popover CSS vars instead. +export const chartTooltipStyle: { + contentStyle: CSSProperties; + labelStyle: CSSProperties; +} = { + contentStyle: { + backgroundColor: "var(--popover)", + color: "var(--popover-foreground)", + border: "1px solid var(--border)", + fontSize: 12, + }, + labelStyle: { color: "var(--popover-foreground)" }, +}; diff --git a/panel/src/components/dashboard/cost-trend-chart.tsx b/panel/src/components/dashboard/cost-trend-chart.tsx index ea4549b0..0cd8d3a7 100644 --- a/panel/src/components/dashboard/cost-trend-chart.tsx +++ b/panel/src/components/dashboard/cost-trend-chart.tsx @@ -12,6 +12,8 @@ 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 { chartTooltipStyle } from "@/components/charts/chart-tooltip"; import type { UsageTimePoint } from "@/types"; interface CostTrendChartProps { @@ -19,11 +21,6 @@ interface CostTrendChartProps { isLoading: boolean; } -function formatBucket(bucket: string): string { - const d = new Date(bucket); - return d.getMonth() + 1 + "/" + d.getDate(); -} - function fmtCost(n: number): string { return "$" + n.toFixed(2); } @@ -88,11 +85,11 @@ export function CostTrendChart({ data, isLoading }: CostTrendChartProps) { width={44} /> [ fmtCost(typeof value === "number" ? value : 0), "Cost", ]} - contentStyle={{ fontSize: 12 }} /> = 1_000) return (n / 1_000).toFixed(0) + "k"; - return String(n); -} - const VIEW_OPTIONS = [ { value: "chart", label: "Chart" }, { value: "table", label: "Table" }, @@ -101,7 +98,7 @@ export function AgentUsageChart({ data, isLoading }: AgentUsageChartProps) { [ - fmtK(typeof value === "number" ? value : 0), + formatTokens(typeof value === "number" ? value : 0), "Tokens", ]} - contentStyle={{ fontSize: 12 }} /> v + "h"} /> [value + "h", "Avg"]} - contentStyle={{ fontSize: 12 }} /> [ (typeof value === "number" ? value : 0).toLocaleString() + " tokens", name, ]} - contentStyle={{ fontSize: 12 }} /> diff --git a/panel/src/components/metrics/sessions-table.tsx b/panel/src/components/metrics/sessions-table.tsx index cab287fc..8b70ea92 100644 --- a/panel/src/components/metrics/sessions-table.tsx +++ b/panel/src/components/metrics/sessions-table.tsx @@ -21,6 +21,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; import { HelpTip } from "@/components/ui/help-tip"; import { ChevronUp, ChevronDown } from "lucide-react"; +import { formatTokens } from "@/lib/format"; import type { UsageSession } from "@/types"; const PAGE_SIZE = 10; @@ -46,8 +47,16 @@ interface Column { } const COLUMNS: Column[] = [ - { key: "agent_slug", label: "Agent", tip: "The agent slug that ran this session — click to sort" }, - { key: "model", label: "Model", tip: "Claude/Grok model used for this session — click to sort" }, + { + key: "agent_slug", + label: "Agent", + tip: "The agent slug that ran this session — click to sort", + }, + { + key: "model", + label: "Model", + tip: "Claude/Grok model used for this session — click to sort", + }, { key: "started_at", label: "Started", @@ -87,11 +96,6 @@ function formatTime(ts: string): string { }); } -function fmtK(n: number): string { - if (n >= 1_000) return (n / 1_000).toFixed(1) + "k"; - return String(n); -} - interface SessionsTableProps { data: UsageSession[] | undefined; isLoading: boolean; @@ -198,16 +202,16 @@ export function SessionsTable({ data, isLoading }: SessionsTableProps) { {formatTime(s.started_at)} - {fmtK(s.total_tokens)} + {formatTokens(s.total_tokens)} - {fmtK(s.tokens_input)} + {formatTokens(s.tokens_input)} - {fmtK(s.tokens_output)} + {formatTokens(s.tokens_output)} - {fmtK(s.tokens_cache)} + {formatTokens(s.tokens_cache)} ${s.cost.toFixed(4)} @@ -241,16 +245,16 @@ export function SessionsTable({ data, isLoading }: SessionsTableProps) { {formatTime(s.started_at)} - {fmtK(s.total_tokens)} + {formatTokens(s.total_tokens)} - {fmtK(s.tokens_input)} + {formatTokens(s.tokens_input)} - {fmtK(s.tokens_output)} + {formatTokens(s.tokens_output)} - {fmtK(s.tokens_cache)} + {formatTokens(s.tokens_cache)} ${s.cost.toFixed(4)} diff --git a/panel/src/components/metrics/task-status-chart.tsx b/panel/src/components/metrics/task-status-chart.tsx index 7abfd71f..71998421 100644 --- a/panel/src/components/metrics/task-status-chart.tsx +++ b/panel/src/components/metrics/task-status-chart.tsx @@ -11,6 +11,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 { chartTooltipStyle } from "@/components/charts/chart-tooltip"; interface TaskStatusSlice { name: string; @@ -73,11 +74,11 @@ export function TaskStatusChart({ slices, isLoading }: TaskStatusChartProps) { ))} [ `${typeof value === "number" ? value : 0} tasks`, name, ]} - contentStyle={{ fontSize: 12 }} /> @@ -86,4 +87,4 @@ export function TaskStatusChart({ slices, isLoading }: TaskStatusChartProps) { ); -} \ No newline at end of file +} diff --git a/panel/src/components/metrics/team-usage-chart.tsx b/panel/src/components/metrics/team-usage-chart.tsx index c8718f54..5b3c1571 100644 --- a/panel/src/components/metrics/team-usage-chart.tsx +++ b/panel/src/components/metrics/team-usage-chart.tsx @@ -15,6 +15,8 @@ import { Skeleton } from "@/components/ui/skeleton"; import { SegmentedControl } from "@/components/ui/segmented-control"; import { HelpTip } from "@/components/ui/help-tip"; import { useIsMobile } from "@/hooks/use-is-mobile"; +import { formatTokens } from "@/lib/format"; +import { chartTooltipStyle } from "@/components/charts/chart-tooltip"; import type { TeamUsageRow } from "@/types"; interface TeamUsageChartProps { @@ -22,11 +24,6 @@ interface TeamUsageChartProps { isLoading: boolean; } -function fmtK(n: number): string { - if (n >= 1_000) return (n / 1_000).toFixed(0) + "k"; - return String(n); -} - const VIEW_OPTIONS = [ { value: "chart", label: "Chart" }, { value: "table", label: "Table" }, @@ -104,7 +101,7 @@ export function TeamUsageChart({ data, isLoading }: TeamUsageChartProps) { top: 4, right: 8, left: 0, - bottom: isMobile ? 24 : 8, + bottom: isMobile ? 40 : 8, }} > @@ -118,18 +115,18 @@ export function TeamUsageChart({ data, isLoading }: TeamUsageChartProps) { tickLine={false} /> [ - fmtK(typeof value === "number" ? value : 0), + formatTokens(typeof value === "number" ? value : 0), "Tokens", ]} - contentStyle={{ fontSize: 12 }} /> = 1_000) return (n / 1_000).toFixed(0) + "k"; - return String(n); -} - export function UsageTimeSeriesChart({ data, isLoading, @@ -99,23 +84,23 @@ export function UsageTimeSeriesChart({ [ - fmtK(typeof value === "number" ? value : 0), + formatTokens(typeof value === "number" ? value : 0), name, ]} - contentStyle={{ fontSize: 12 }} /> [ typeof value === "number" ? value : 0, "Sessions started", ]} - contentStyle={{ fontSize: 12 }} /> - + )} diff --git a/panel/src/lib/__tests__/format.test.ts b/panel/src/lib/__tests__/format.test.ts new file mode 100644 index 00000000..54d56327 --- /dev/null +++ b/panel/src/lib/__tests__/format.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from "vitest"; +import { formatTokens, formatBucket } from "@/lib/format"; + +describe("formatTokens", () => { + it("renders raw digits below 1000", () => { + expect(formatTokens(0)).toBe("0"); + expect(formatTokens(999)).toBe("999"); + }); + + it("renders K with one decimal at the 1000 boundary", () => { + expect(formatTokens(1_000)).toBe("1.0K"); + expect(formatTokens(22_596)).toBe("22.6K"); + expect(formatTokens(999_999)).toBe("1000.0K"); + }); + + it("renders M with two decimals at the 1,000,000 boundary", () => { + expect(formatTokens(1_000_000)).toBe("1.00M"); + expect(formatTokens(22_596_000)).toBe("22.60M"); + }); +}); + +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(), + ); + }); + + it("formats a non-midnight 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`); + }); +}); diff --git a/panel/src/lib/format.ts b/panel/src/lib/format.ts new file mode 100644 index 00000000..2b338399 --- /dev/null +++ b/panel/src/lib/format.ts @@ -0,0 +1,23 @@ +// Shared number/time formatters for usage & metrics charts — was +// reimplemented per-chart (inconsistent K/M rounding caused "22596k"-style +// ticks); one copy so every chart humanizes the same way. + +/** Format token counts with a K/M suffix — M keeps 2 decimals, K keeps 1. */ +export function formatTokens(n: number): string { + if (n >= 1_000_000) return (n / 1_000_000).toFixed(2) + "M"; + if (n >= 1_000) return (n / 1_000).toFixed(1) + "K"; + 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"; + } + return d.getMonth() + 1 + "/" + d.getDate(); +}