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();
+}