From f41732d04b2298aa437ff081c7a9821cd0866a09 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 18 Jun 2026 15:27:57 -0700 Subject: [PATCH] FN-6652: add live token usage timeline Adds live token-over-time analytics and charting to Command Center. - Add optional token analytics time-series buckets with hour, day, and week granularity. - Surface live-polled token series data through the Command Center tokens API. - Render an animated, reduced-motion-safe tokens-over-time chart with granularity controls. - Cover analytics bucketing, API query parsing, and Command Center chart behavior with tests. Files changed: .changeset/fn-6652-token-usage-over-time.md | 5 + docs/dashboard-guide.md | 4 +- .../core/src/__tests__/token-analytics.test.ts | 76 ++++++++++++ packages/core/src/index.ts | 2 + packages/core/src/token-analytics.ts | 65 ++++++++++- .../components/command-center/CommandCenter.css | 24 +++- .../components/command-center/CommandCenter.tsx | 12 +- .../__tests__/CommandCenter.test.tsx | 39 ++++++- .../command-center/__tests__/charts.test.tsx | 44 +++++++ .../components/command-center/areas/TokensArea.tsx | 46 +++++++- .../command-center/areas/__tests__/areas.test.tsx | 128 ++++++++++++++++++++- .../app/components/command-center/areas/areas.css | 57 +++++++++ .../command-center/areas/useAnalyticsArea.ts | 26 ++++- .../command-center/charts/TokenSeriesChart.tsx | 52 +++++++++ .../components/command-center/charts/charts.css | 76 ++++++++++++ .../register-command-center-routes.test.ts | 38 +++++- .../src/routes/register-command-center-routes.ts | 15 +++ 17 files changed, 691 insertions(+), 18 deletions(-) Fusion-Task-Id: FN-6652 Fusion-Task-Lineage: ebfd1b5d-5b6c-48ef-bce3-f18bbc1605fa --- .changeset/fn-6652-token-usage-over-time.md | 5 + docs/dashboard-guide.md | 4 +- .../src/__tests__/token-analytics.test.ts | 76 +++++++++++ packages/core/src/index.ts | 2 + packages/core/src/token-analytics.ts | 65 ++++++++- .../command-center/CommandCenter.css | 24 +++- .../command-center/CommandCenter.tsx | 12 +- .../__tests__/CommandCenter.test.tsx | 39 +++++- .../command-center/__tests__/charts.test.tsx | 44 ++++++ .../command-center/areas/TokensArea.tsx | 46 ++++++- .../areas/__tests__/areas.test.tsx | 128 +++++++++++++++++- .../components/command-center/areas/areas.css | 57 ++++++++ .../command-center/areas/useAnalyticsArea.ts | 26 +++- .../charts/TokenSeriesChart.tsx | 52 +++++++ .../command-center/charts/charts.css | 76 +++++++++++ .../register-command-center-routes.test.ts | 38 +++++- .../routes/register-command-center-routes.ts | 15 ++ 17 files changed, 691 insertions(+), 18 deletions(-) create mode 100644 .changeset/fn-6652-token-usage-over-time.md create mode 100644 packages/dashboard/app/components/command-center/charts/TokenSeriesChart.tsx diff --git a/.changeset/fn-6652-token-usage-over-time.md b/.changeset/fn-6652-token-usage-over-time.md new file mode 100644 index 0000000000..3293996cff --- /dev/null +++ b/.changeset/fn-6652-token-usage-over-time.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Add a live-updating, animated Command Center token-usage-over-time view with hour/day/week granularity and bounded polling for token totals. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index a945acd50f..116516488c 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -663,8 +663,8 @@ Navigation: Features: - Global date-range picker in the header scopes the analytics tabs; **Mission Control** remains live rather than historical. -- **Overview** summarizes token usage/cost, autonomy, active nodes, tasks done, model breadth, and open signals, and includes the SDLC throughput funnel for the selected range. Its Live activity snapshot shows the current board-state count for tasks in progress, independent of the selected analytics date range. It also shows a graph-rich software-factory snapshot with tokens-by-model, tool-category, and daily activity trend charts that reuse the already-loaded tokens, tools, and activity analytics. The chart reveal/glow accents are decorative and disabled when reduced-motion preferences are active. The SDLC completion rate is shown as a radial gauge and is calculated as cohort conversion from in-range triage entrants, so the rate is capped at 100% even when older tasks finish during the range. -- **Tokens** breaks down token totals, estimated cost, tasks, and per-model usage. +- **Overview** summarizes token usage/cost, autonomy, active nodes, tasks done, model breadth, and open signals, and includes the SDLC throughput funnel for the selected range. Its token total and Live activity snapshot token metric refresh on a bounded live cadence and animate number changes while preserving reduced-motion preferences. The Live activity snapshot also shows the current board-state count for tasks in progress, independent of the selected analytics date range. Overview includes a graph-rich software-factory snapshot with tokens-by-model, tool-category, and daily activity trend charts that reuse the already-loaded tokens, tools, and activity analytics. The chart reveal/glow accents are decorative and disabled when reduced-motion preferences are active. The SDLC completion rate is shown as a radial gauge and is calculated as cohort conversion from in-range triage entrants, so the rate is capped at 100% even when older tasks finish during the range. +- **Tokens** breaks down token totals, estimated cost, tasks, and per-model usage. It also includes a live token-usage-over-time chart backed by per-task token timestamps; use the granularity control to switch the chart between hourly, daily, and weekly buckets. The token total and chart poll on a bounded cadence, keep the previous data visible during refresh, animate decorative count/bar transitions, and disable those animations for reduced-motion users. - **Tools** shows autonomy ratio, tool-call volume, intervention counts, sessions, and tool categories. - **Activity** tracks sessions, messages, active nodes, active agents, and stickiness, then renders live animated line charts for messages/day, active agents/day, active nodes/day, and combined throughput/day (`messages + active agents + active nodes`). These charts reuse the existing activity analytics endpoint, refresh on a bounded 15-second cadence while mounted, keep the previous data visible during refreshes, and disable decorative draw-on motion for reduced-motion users. - **Productivity** separates outcome counters (commits and pull requests) from volume proxies such as modified files, lines changed, and files by language. diff --git a/packages/core/src/__tests__/token-analytics.test.ts b/packages/core/src/__tests__/token-analytics.test.ts index 81e3d6e499..7ebdbc2d62 100644 --- a/packages/core/src/__tests__/token-analytics.test.ts +++ b/packages/core/src/__tests__/token-analytics.test.ts @@ -154,4 +154,80 @@ describe("token-analytics", () => { const result = aggregateTokenAnalytics(db, {}); expect(result.totals.totalTokens).toBe(36); }); + + it("omits series unless granularity is requested while preserving totals", () => { + insertTask(db, { id: "t1", inputTokens: 10, totalTokens: 10, lastUsedAt: "2026-03-01T00:00:00.000Z", modelId: "model-A" }); + + const result = aggregateTokenAnalytics(db, {}); + + expect(result.totals.totalTokens).toBe(10); + expect(result).not.toHaveProperty("series"); + }); + + it("buckets token usage by UTC day in ascending order with inclusive bounds", () => { + insertTask(db, { id: "before", inputTokens: 1, totalTokens: 1, lastUsedAt: "2026-02-29T23:59:59.999Z", modelId: "model-A" }); + insertTask(db, { id: "from", inputTokens: 100, outputTokens: 10, totalTokens: 110, lastUsedAt: "2026-03-01T00:00:00.000Z", modelId: "model-A" }); + insertTask(db, { id: "same-day", inputTokens: 200, outputTokens: 20, totalTokens: 220, lastUsedAt: "2026-03-01T12:00:00.000Z", modelId: "model-A" }); + insertTask(db, { id: "to", inputTokens: 300, outputTokens: 30, totalTokens: 330, lastUsedAt: "2026-03-02T00:00:00.000Z", modelId: "model-A" }); + insertTask(db, { id: "after", inputTokens: 1, totalTokens: 1, lastUsedAt: "2026-03-02T00:00:00.001Z", modelId: "model-A" }); + + const result = aggregateTokenAnalytics(db, { + from: "2026-03-01T00:00:00.000Z", + to: "2026-03-02T00:00:00.000Z", + granularity: "day", + }); + + expect(result.series?.map((p) => p.bucket)).toEqual(["2026-03-01", "2026-03-02"]); + expect(result.series?.map((p) => p.totalTokens)).toEqual([330, 330]); + expect(result.totals.totalTokens).toBe(660); + }); + + it("buckets token usage by UTC hour", () => { + insertTask(db, { id: "h1a", inputTokens: 10, totalTokens: 10, lastUsedAt: "2026-03-01T01:05:00.000Z", modelId: "model-A" }); + insertTask(db, { id: "h1b", inputTokens: 20, totalTokens: 20, lastUsedAt: "2026-03-01T01:59:00.000Z", modelId: "model-A" }); + insertTask(db, { id: "h2", inputTokens: 30, totalTokens: 30, lastUsedAt: "2026-03-01T02:00:00.000Z", modelId: "model-A" }); + + const result = aggregateTokenAnalytics(db, { granularity: "hour" }); + + expect(result.series?.map((p) => [p.bucket, p.totalTokens])).toEqual([ + ["2026-03-01T01", 30], + ["2026-03-01T02", 30], + ]); + }); + + it("buckets token usage by ISO week across year boundaries", () => { + insertTask(db, { id: "w1", inputTokens: 10, totalTokens: 10, lastUsedAt: "2026-12-31T12:00:00.000Z", modelId: "model-A" }); + insertTask(db, { id: "w1b", inputTokens: 20, totalTokens: 20, lastUsedAt: "2027-01-01T12:00:00.000Z", modelId: "model-A" }); + insertTask(db, { id: "w2", inputTokens: 30, totalTokens: 30, lastUsedAt: "2027-01-04T00:00:00.000Z", modelId: "model-A" }); + + const result = aggregateTokenAnalytics(db, { granularity: "week" }); + + expect(result.series?.map((p) => [p.bucket, p.totalTokens])).toEqual([ + ["2026-W53", 30], + ["2027-W01", 30], + ]); + }); + + it("computes per-bucket cost with priced and unavailable models", () => { + insertTask(db, { id: "priced", inputTokens: 1_000_000, outputTokens: 1_000_000, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 2_000_000, lastUsedAt: "2026-03-01T00:00:00.000Z", modelProvider: "openai", modelId: "gpt-4o" }); + insertTask(db, { id: "unknown", inputTokens: 100, totalTokens: 100, lastUsedAt: "2026-03-01T10:00:00.000Z", modelProvider: "unknown", modelId: "mystery" }); + + const result = aggregateTokenAnalytics(db, { granularity: "day" }); + + expect(result.series).toHaveLength(1); + expect(result.series?.[0].cost).toEqual({ usd: 12.5, unavailable: true, stale: false }); + }); + + it("returns an empty series for an empty requested range", () => { + insertTask(db, { id: "t1", inputTokens: 100, totalTokens: 100, lastUsedAt: "2026-03-01T00:00:00.000Z", modelId: "model-A" }); + + const result = aggregateTokenAnalytics(db, { + from: "2027-01-01T00:00:00.000Z", + to: "2027-12-31T00:00:00.000Z", + granularity: "day", + }); + + expect(result.series).toEqual([]); + expect(result.totals.totalTokens).toBe(0); + }); }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d61bff21cb..6bcdce1615 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -550,6 +550,8 @@ export type { TokenAnalyticsQuery, TokenGroupBy, TokenGroupSummary, + TokenTimeGranularity, + TokenTimePoint, TokenTotals, } from "./token-analytics.js"; export { aggregateToolAnalytics, countInterventions } from "./tool-analytics.js"; diff --git a/packages/core/src/token-analytics.ts b/packages/core/src/token-analytics.ts index 07303a9c9e..3019b66646 100644 --- a/packages/core/src/token-analytics.ts +++ b/packages/core/src/token-analytics.ts @@ -17,6 +17,9 @@ import { costFor, type CostResult } from "./model-pricing.js"; /** Dimension to group token totals by. */ export type TokenGroupBy = "model" | "provider" | "node" | "agent"; +/** Bucket size for optional token-usage time-series analytics. */ +export type TokenTimeGranularity = "hour" | "day" | "week"; + /** Summed token counts for a group (or the grand total). */ export interface TokenTotals { inputTokens: number; @@ -41,6 +44,14 @@ export interface TokenGroupSummary extends TokenTotals { cost: CostResult; } +/** One time bucket in the optional token-usage series. */ +export interface TokenTimePoint extends TokenTotals { + /** UTC bucket key (`YYYY-MM-DDTHH`, `YYYY-MM-DD`, or ISO week `YYYY-Www`). */ + bucket: string; + /** Derived USD cost for this bucket, summed per contributing task. */ + cost: CostResult; +} + /** Result of {@link aggregateTokenAnalytics}. */ export interface TokenAnalytics { from: string | null; @@ -56,6 +67,8 @@ export interface TokenAnalytics { cost: CostResult; /** Per-group totals; empty array when no `groupBy` requested. */ groups: TokenGroupSummary[]; + /** Optional token-usage totals over time, present only when requested. */ + series?: TokenTimePoint[]; } export interface TokenAnalyticsQuery { @@ -64,6 +77,8 @@ export interface TokenAnalyticsQuery { /** ISO-8601 upper bound (inclusive) on `tokenUsageLastUsedAt`. */ to?: string; groupBy?: TokenGroupBy; + /** Optional UTC bucket size for a token-usage time series. */ + granularity?: TokenTimeGranularity; /** * Epoch ms "now" used only for pricing-staleness (U3). When omitted, derived * cost is never marked stale. Pure: the module never reads the clock itself. @@ -92,6 +107,7 @@ interface TaskTokenRow { modelId: string | null; checkoutNodeId: string | null; assignedAgentId: string | null; + tokenUsageLastUsedAt: string; } function groupKeyFor(row: TaskTokenRow, groupBy: TokenGroupBy): string | null { @@ -170,12 +186,36 @@ function addRow(totals: TokenTotals, row: TaskTokenRow): void { totals.nTasks += 1; } +function isoWeekBucket(isoTimestamp: string): string { + const date = new Date(isoTimestamp); + if (!Number.isFinite(date.getTime())) return isoTimestamp.slice(0, 10); + const day = date.getUTCDay() || 7; + const thursday = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate() + 4 - day)); + const yearStart = new Date(Date.UTC(thursday.getUTCFullYear(), 0, 1)); + const week = Math.ceil(((thursday.getTime() - yearStart.getTime()) / 86400000 + 1) / 7); + return `${thursday.getUTCFullYear()}-W${String(week).padStart(2, "0")}`; +} + +function bucketFor(row: TaskTokenRow, granularity: TokenTimeGranularity): string { + switch (granularity) { + case "hour": + return row.tokenUsageLastUsedAt.slice(0, 13); + case "day": + return row.tokenUsageLastUsedAt.slice(0, 10); + case "week": + return isoWeekBucket(row.tokenUsageLastUsedAt); + } +} + /** * Aggregate per-task token usage over a date range, optionally grouped. * * Tasks are matched by `tokenUsageLastUsedAt` within `[from, to]` (inclusive). * Tasks with no token usage (`tokenUsageLastUsedAt IS NULL`) are excluded. An * empty range yields zeroed `totals` and an empty `groups` array — never nulls. + * + * FNXC:CommandCenter 2026-06-18-00:00: + * The Command Center token view needs a live, scalable, animated token-over-time chart without changing existing CSV/OTel consumers. Keep `series` opt-in via `granularity`, bucket ISO timestamps in UTC (substring for hour/day, ISO-week in JS), and reuse per-task cost accumulation so each bucket prices mixed known/unknown models correctly. */ export function aggregateTokenAnalytics( db: Database, @@ -204,7 +244,8 @@ export function aggregateTokenAnalytics( modelProvider, modelId, checkoutNodeId, - assignedAgentId + assignedAgentId, + tokenUsageLastUsedAt FROM tasks ${where}`, ) .all(...params) as TaskTokenRow[]; @@ -213,7 +254,10 @@ export function aggregateTokenAnalytics( const totalCost = emptyCostAccumulator(); const groupMap = new Map(); const groupCostMap = new Map(); + const seriesMap = new Map(); + const seriesCostMap = new Map(); const groupBy = query.groupBy; + const granularity = query.granularity; const now = query.now; for (const row of rows) { @@ -230,6 +274,17 @@ export function aggregateTokenAnalytics( addRow(group, row); addRowCost(groupCostMap.get(key)!, row, now); } + if (granularity) { + const bucket = bucketFor(row, granularity); + let point = seriesMap.get(bucket); + if (!point) { + point = { bucket, ...emptyTotals(), cost: { usd: null, unavailable: false, stale: false } }; + seriesMap.set(bucket, point); + seriesCostMap.set(bucket, emptyCostAccumulator()); + } + addRow(point, row); + addRowCost(seriesCostMap.get(bucket)!, row, now); + } } // Finalize per-group cost from each group's accumulator. @@ -241,6 +296,13 @@ export function aggregateTokenAnalytics( (a, b) => b.totalTokens - a.totalTokens, ); + for (const [bucket, point] of seriesMap) { + point.cost = finalizeCost(seriesCostMap.get(bucket)!); + } + const series = granularity + ? [...seriesMap.values()].sort((a, b) => a.bucket.localeCompare(b.bucket)) + : undefined; + return { from: query.from ?? null, to: query.to ?? null, @@ -248,5 +310,6 @@ export function aggregateTokenAnalytics( totals, cost: finalizeCost(totalCost), groups, + ...(granularity ? { series } : {}), }; } diff --git a/packages/dashboard/app/components/command-center/CommandCenter.css b/packages/dashboard/app/components/command-center/CommandCenter.css index 2cdd45cc28..f64bdce583 100644 --- a/packages/dashboard/app/components/command-center/CommandCenter.css +++ b/packages/dashboard/app/components/command-center/CommandCenter.css @@ -166,7 +166,7 @@ The Command Center must remain scrollable on mobile inside the overflow-hidden . .cc-live-strip-metrics { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-2, 0.5rem); } @@ -188,6 +188,25 @@ The Command Center must remain scrollable on mobile inside the overflow-hidden . font-variant-numeric: tabular-nums; } +/* +FNXC:CommandCenterTokens 2026-06-18-15:14: +Overview token totals now live-poll and should visibly count up on change in both the stat card and live strip. The animation is decorative and must be disabled for reduced-motion users. +*/ +.cc-token-count-live { + animation: cc-token-count-pop var(--duration-normal) ease-out both; +} + +@keyframes cc-token-count-pop { + from { + transform: translateY(var(--space-1)); + opacity: 0.7; + } + to { + transform: translateY(0); + opacity: 1; + } +} + .cc-live-metric-label, .cc-live-trend-label { color: var(--text-muted); @@ -233,7 +252,8 @@ The Command Center must remain scrollable on mobile inside the overflow-hidden . @media (prefers-reduced-motion: reduce) { .cc-live-strip::before, .cc-live-metric, - .cc-live-trend .cc-sparkline-bar { + .cc-live-trend .cc-sparkline-bar, + .cc-token-count-live { animation: none; } } diff --git a/packages/dashboard/app/components/command-center/CommandCenter.tsx b/packages/dashboard/app/components/command-center/CommandCenter.tsx index 714a8de960..ff9e66194e 100644 --- a/packages/dashboard/app/components/command-center/CommandCenter.tsx +++ b/packages/dashboard/app/components/command-center/CommandCenter.tsx @@ -59,9 +59,13 @@ interface OverviewStatCard { FNXC:CommandCenter 2026-06-17-00:00: Overview is the Command Center landing surface, so it must reflect real analytics instead of shell placeholders. Show loading while core analytics have not settled, show the empty state only after settled zero data, and treat Signals as best-effort because that endpoint can be absent without invalidating tokens/tools/activity metrics. */ +const OVERVIEW_TOKEN_REFRESH_MS = 15_000; + function OverviewTab({ range }: { range: DateRange }) { const { t } = useTranslation("app"); - const tokens = useAnalyticsArea("/command-center/tokens?groupBy=model", range); + const tokens = useAnalyticsArea("/command-center/tokens?groupBy=model", range, { + pollMs: OVERVIEW_TOKEN_REFRESH_MS, + }); const tools = useAnalyticsArea("/command-center/tools", range); const activity = useAnalyticsArea("/command-center/activity", range); const [signals, setSignals] = useState(null); @@ -257,7 +261,7 @@ function OverviewTab({ range }: { range: DateRange }) { {cards.map((card) => (
{card.label}
-
{card.value}
+
{card.value}
{card.subLabel ? {card.subLabel} : null}
))} @@ -283,6 +287,10 @@ function OverviewTab({ range }: { range: DateRange }) { {formatCount(activeAgents)} {t("commandCenter.overview.agentsWorking", "agents working")} + + {formatCount(tokenTotal)} + {t("commandCenter.overview.liveTokens", "tokens")} + {signalsLoading ? "—" : signals ? formatCount(signals.open ?? 0) : "—"} {t("commandCenter.overview.openSignals", "open signals")} diff --git a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx index 86db2dc851..098ccc3283 100644 --- a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx +++ b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx @@ -2,8 +2,8 @@ FNXC:CommandCenter 2026-06-17-00:00: Command Center Overview must consume the same analytics endpoints as the detail tabs. These tests reproduce the prior always-empty landing page, then pin loading-before-empty, range re-derivation, and best-effort Signals behavior so Overview cannot regress into shell placeholders again. */ -import { beforeEach, describe, it, expect, vi } from "vitest"; -import { render, screen, fireEvent, within, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent, within, waitFor, act } from "@testing-library/react"; import { CommandCenter } from "../CommandCenter"; const apiMock = vi.fn(); @@ -171,6 +171,10 @@ beforeEach(() => { mockEmptyOverviewApi(); }); +afterEach(() => { + vi.useRealTimers(); +}); + describe("CommandCenter shell", () => { it("renders with the Overview tab active by default", () => { render(); @@ -207,6 +211,7 @@ describe("CommandCenter shell", () => { expect(screen.getByTestId("command-center-live-snapshot")).toBeTruthy(); await waitFor(() => expect(liveMetricValue()).toBe("3")); expect(screen.getByTestId("command-center-live-agents-working").textContent).toContain("2"); + expect(screen.getByTestId("command-center-live-tokens").textContent).toContain("1,500"); expect(screen.getByTestId("command-center-live-open-signals").textContent).toContain("2"); expect(screen.getByTestId("command-center-throughput-trend")).toBeTruthy(); expect(screen.getByRole("img", { name: "Recent activity throughput trend" })).toBeTruthy(); @@ -219,6 +224,36 @@ describe("CommandCenter shell", () => { expect(screen.getByRole("img", { name: "Daily activity trend" })).toBeTruthy(); }); + it("live-polls token totals for the Overview card and live strip", async () => { + vi.useFakeTimers(); + let tokenTotal = 1_500; + apiMock.mockImplementation((path: string) => { + if (path.startsWith("/command-center/tokens")) return Promise.resolve(tokenFixture(tokenTotal)); + if (path.startsWith("/command-center/tools")) return Promise.resolve(toolsFixture()); + if (path.startsWith("/command-center/activity")) return Promise.resolve(activityFixture()); + if (path.startsWith("/command-center/signals")) return Promise.resolve(signalsFixture(2)); + if (path === "/command-center/live") return Promise.resolve(liveFixture([{ column: "in-progress", count: 3 }])); + return Promise.reject(new Error(`Unhandled api path: ${path}`)); + }); + + render(); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(statValue("command-center-stat-tokens")).toBe("1,500"); + + tokenTotal = 1_700; + await act(async () => { + vi.advanceTimersByTime(15_000); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(statValue("command-center-stat-tokens")).toBe("1,700"); + expect(screen.getByTestId("command-center-live-tokens").textContent).toContain("1,700"); + }); + it("sources live tasks in progress from current column counts instead of funnel entered", async () => { mockOverviewApi({ activity: activityFixture({ inProgress: 12 }), diff --git a/packages/dashboard/app/components/command-center/__tests__/charts.test.tsx b/packages/dashboard/app/components/command-center/__tests__/charts.test.tsx index e20fdd524b..6b542330be 100644 --- a/packages/dashboard/app/components/command-center/__tests__/charts.test.tsx +++ b/packages/dashboard/app/components/command-center/__tests__/charts.test.tsx @@ -8,6 +8,7 @@ import { Sparkline } from "../charts/Sparkline"; import { Funnel } from "../charts/Funnel"; import { RadialGauge } from "../charts/RadialGauge"; import { LineChart } from "../charts/LineChart"; +import { TokenSeriesChart } from "../charts/TokenSeriesChart"; function widthOf(el: HTMLElement): string { return el.style.width; @@ -101,6 +102,49 @@ describe("Sparkline", () => { }); }); +describe("TokenSeriesChart", () => { + it("renders proportional token buckets with an accessible label", () => { + render( + , + ); + + const chart = screen.getByRole("img", { name: "tokens over time" }); + const bars = chart.querySelectorAll(".cc-token-series-bar"); + expect(bars).toHaveLength(2); + expect(heightOf(bars[0])).toBe("100%"); + expect(heightOf(bars[1])).toBe("50%"); + }); + + it("renders an empty zero state without NaN geometry", () => { + render(); + + const chart = screen.getByRole("img", { name: "empty tokens" }); + expect(screen.getByTestId("cc-token-series-empty")).toBeTruthy(); + expect(chart.innerHTML).not.toMatch(/NaN|Infinity/); + }); + + it("renders all-zero buckets as zero-height bars", () => { + render( + , + ); + + const bar = screen.getByRole("img", { name: "zero tokens" }).querySelector(".cc-token-series-bar"); + expect(bar?.style.height).toBe("0%"); + expect(bar?.outerHTML).not.toMatch(/NaN|Infinity/); + }); +}); + describe("LineChart", () => { it("renders a populated finite SVG line with an accessible label", () => { render(); diff --git a/packages/dashboard/app/components/command-center/areas/TokensArea.tsx b/packages/dashboard/app/components/command-center/areas/TokensArea.tsx index 7fe15aba13..82a0018f64 100644 --- a/packages/dashboard/app/components/command-center/areas/TokensArea.tsx +++ b/packages/dashboard/app/components/command-center/areas/TokensArea.tsx @@ -8,15 +8,20 @@ import type { CostResult, TokenAnalytics, TokenGroupSummary, + TokenTimeGranularity, } from "@fusion/core"; import type { DateRange } from "../DateRangePicker"; import { Bar } from "../charts/Bar"; +import { TokenSeriesChart } from "../charts/TokenSeriesChart"; import { AreaShell } from "./AreaShell"; import { useAnalyticsArea } from "./useAnalyticsArea"; import { formatCost, formatCount } from "./areaShared"; type SortKey = "key" | "totalTokens" | "cost"; +const TOKENS_LIVE_REFRESH_MS = 15_000; +const GRANULARITIES: TokenTimeGranularity[] = ["hour", "day", "week"]; + function costSortValue(cost: CostResult): number { return cost.unavailable || cost.usd === null ? -1 : cost.usd; } @@ -46,12 +51,14 @@ function sortGroups(groups: TokenGroupSummary[], key: SortKey, dir: 1 | -1): Tok */ export function TokensArea({ range }: { range: DateRange }) { const { t } = useTranslation("app"); - const { data, isLoading, error } = useAnalyticsArea( - "/command-center/tokens?groupBy=model", - range, - ); + const [granularity, setGranularity] = useState("day"); + const endpoint = `/command-center/tokens?groupBy=model&granularity=${granularity}`; + const { data, isLoading, error } = useAnalyticsArea(endpoint, range, { + pollMs: TOKENS_LIVE_REFRESH_MS, + }); const groups = useMemo(() => data?.groups ?? [], [data?.groups]); + const series = useMemo(() => data?.series ?? [], [data?.series]); const [sortKey, setSortKey] = useState("totalTokens"); const [sortDir, setSortDir] = useState<1 | -1>(-1); @@ -104,7 +111,9 @@ export function TokensArea({ range }: { range: DateRange }) { } const totals = data?.totals; - const isEmpty = !data || (totals?.totalTokens ?? 0) === 0; + const seriesBucketsSig = useMemo(() => series.map((point) => point.bucket).join(" "), [series]); + const totalTokenValue = totals?.totalTokens ?? 0; + const isEmpty = !data || totalTokenValue === 0; return ( @@ -113,7 +122,7 @@ export function TokensArea({ range }: { range: DateRange }) {
{t("commandCenter.tokens.totalTokens", "Total tokens")}
-
{formatCount(totals?.totalTokens ?? 0)}
+
{formatCount(totalTokenValue)}
{t("commandCenter.tokens.cost", "Estimated cost")}
@@ -131,6 +140,31 @@ export function TokensArea({ range }: { range: DateRange }) {
+
+
+

{t("commandCenter.tokens.overTimeChart", "Tokens over time")}

+
+ {GRANULARITIES.map((option) => ( + + ))} +
+
+ +
+

{t("commandCenter.tokens.byModelChart", "Tokens by model")}

diff --git a/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx b/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx index c0988e464b..e8818b54a6 100644 --- a/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx +++ b/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx @@ -3,7 +3,7 @@ FNXC:CommandCenter 2026-06-16-09:42: Command Center area component tests (PR #1683). Pin loading/error/unavailable-vs-zero rendering for each analytics area against mocked fixtures so the "—" sentinel and cost-unavailable contracts can't regress. */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, fireEvent, waitFor, within, act } from "@testing-library/react"; +import { render, screen, fireEvent, waitFor, within, act, renderHook } from "@testing-library/react"; // Mock the api() helper so the areas fetch deterministic fixtures. const apiMock = vi.fn(); @@ -16,6 +16,7 @@ import { ToolsArea } from "../ToolsArea"; import { ProductivityArea } from "../ProductivityArea"; import { SignalsArea } from "../SignalsArea"; import { ActivityArea } from "../ActivityArea"; +import { useAnalyticsArea } from "../useAnalyticsArea"; import type { DateRange } from "../DateRangePicker"; const range7d: DateRange = { from: "2026-06-08", to: null, preset: "7d" }; @@ -35,6 +36,28 @@ function tokenFixture() { nTasks: 5, }, cost: { usd: 12.5, unavailable: false, stale: false }, + series: [ + { + bucket: "2026-06-08", + inputTokens: 400, + outputTokens: 200, + cachedTokens: 0, + cacheWriteTokens: 0, + totalTokens: 600, + nTasks: 2, + cost: { usd: 4.5, unavailable: false, stale: false }, + }, + { + bucket: "2026-06-09", + inputTokens: 600, + outputTokens: 300, + cachedTokens: 0, + cacheWriteTokens: 0, + totalTokens: 900, + nTasks: 3, + cost: { usd: 8, unavailable: false, stale: false }, + }, + ], groups: [ { key: "gpt-4o", @@ -101,6 +124,71 @@ afterEach(() => { vi.useRealTimers(); }); +describe("useAnalyticsArea", () => { + it("polls only when pollMs is provided and clears the interval on unmount", async () => { + vi.useFakeTimers(); + apiMock.mockResolvedValue({ ok: true }); + + const { unmount } = renderHook(() => + useAnalyticsArea<{ ok: boolean }>("/command-center/tokens", range7d, { pollMs: 1_000 }), + ); + + await act(async () => { + await Promise.resolve(); + }); + expect(apiMock).toHaveBeenCalledTimes(1); + + await act(async () => { + vi.advanceTimersByTime(1_000); + await Promise.resolve(); + }); + expect(apiMock).toHaveBeenCalledTimes(2); + + unmount(); + await act(async () => { + vi.advanceTimersByTime(1_000); + await Promise.resolve(); + }); + expect(apiMock).toHaveBeenCalledTimes(2); + }); + + it("does not poll by default", async () => { + vi.useFakeTimers(); + apiMock.mockResolvedValue({ ok: true }); + + renderHook(() => useAnalyticsArea<{ ok: boolean }>("/command-center/tools", range7d)); + + await act(async () => { + await Promise.resolve(); + }); + expect(apiMock).toHaveBeenCalledTimes(1); + + await act(async () => { + vi.advanceTimersByTime(5_000); + await Promise.resolve(); + }); + expect(apiMock).toHaveBeenCalledTimes(1); + }); + + it("does not fetch or schedule polling for inverted custom ranges", async () => { + vi.useFakeTimers(); + + renderHook(() => + useAnalyticsArea<{ ok: boolean }>( + "/command-center/tokens", + customRange("2026-06-10", "2026-06-01"), + { pollMs: 1_000 }, + ), + ); + + await act(async () => { + vi.advanceTimersByTime(2_000); + await Promise.resolve(); + }); + expect(apiMock).not.toHaveBeenCalled(); + }); +}); + describe("ActivityArea", () => { it("renders summary stats and the live line chart sections for populated daily activity", async () => { apiMock.mockResolvedValue(activityFixture()); @@ -184,10 +272,47 @@ describe("TokensArea", () => { await screen.findByTestId("cc-area-tokens"); expect(screen.getByTestId("cc-tokens-total").textContent).toContain("1,500"); expect(screen.getByTestId("cc-tokens-cost").textContent).toContain("$12.50"); + expect(screen.getByTestId("cc-token-series-chart")).toBeTruthy(); + expect(screen.getByLabelText("2026-06-09: 900")).toBeTruthy(); expect(screen.getByTestId("cc-tokens-row-gpt-4o")).toBeTruthy(); expect(screen.getByTestId("cc-tokens-row-claude-sonnet")).toBeTruthy(); }); + it("changes the requested endpoint when granularity changes", async () => { + apiMock.mockResolvedValue(tokenFixture()); + render(); + await screen.findByTestId("cc-area-tokens"); + expect(apiMock.mock.calls.at(-1)?.[0]).toContain("granularity=day"); + + fireEvent.click(screen.getByTestId("cc-token-granularity-hour")); + await waitFor(() => expect(apiMock.mock.calls.at(-1)?.[0]).toContain("granularity=hour")); + }); + + it("polls the live token value while preserving rendered content", async () => { + vi.useFakeTimers(); + apiMock + .mockResolvedValueOnce(tokenFixture()) + .mockResolvedValueOnce({ + ...tokenFixture(), + totals: { ...tokenFixture().totals, totalTokens: 1700 }, + }); + + render(); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(screen.getByTestId("cc-tokens-total").textContent).toContain("1,500"); + + await act(async () => { + vi.advanceTimersByTime(15_000); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(screen.getByTestId("cc-tokens-total").textContent).toContain("1,700"); + expect(screen.getByTestId("cc-token-series-chart")).toBeTruthy(); + }); + it("refetches when the date range changes", async () => { apiMock.mockResolvedValue(tokenFixture()); const { rerender } = render(); @@ -208,6 +333,7 @@ describe("TokensArea", () => { totals: { inputTokens: 0, outputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 0, nTasks: 0 }, cost: { usd: null, unavailable: true, stale: false }, groups: [], + series: [], }); render(); await screen.findByTestId("cc-area-tokens-empty"); diff --git a/packages/dashboard/app/components/command-center/areas/areas.css b/packages/dashboard/app/components/command-center/areas/areas.css index 50915cfb64..94f80608af 100644 --- a/packages/dashboard/app/components/command-center/areas/areas.css +++ b/packages/dashboard/app/components/command-center/areas/areas.css @@ -19,6 +19,13 @@ Area headings, table metadata, and empty states use --text-muted so the analytic gap: var(--space-2, 0.5rem); } +.cc-area-section-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); +} + .cc-area-section-title { margin: 0; font-size: var(--font-size-sm, 0.85rem); @@ -40,6 +47,56 @@ Area headings, table metadata, and empty states use --text-muted so the analytic color: var(--text-muted); } +/* +FNXC:CommandCenterTokens 2026-06-18-15:14: +The Tokens area needs a real hour/day/week control and live token-number motion. Keep controls on existing .btn styling, make count-up motion decorative, and collapse the control cleanly on mobile. +*/ +.cc-token-granularity { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: var(--space-1); +} + +.cc-token-granularity .btn.active { + border-color: var(--color-accent); + color: var(--text-primary); + background: color-mix(in srgb, var(--color-accent) 14%, var(--surface-1)); +} + +.cc-token-count-live { + animation: cc-token-count-pop var(--duration-normal) ease-out both; +} + +@keyframes cc-token-count-pop { + from { + transform: translateY(var(--space-1)); + opacity: 0.7; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +@media (prefers-reduced-motion: reduce) { + .cc-token-count-live { + animation: none; + } +} + +@media (max-width: 768px) { + .cc-area-section-header { + align-items: flex-start; + flex-direction: column; + } + + .cc-token-granularity { + justify-content: flex-start; + inline-size: 100%; + } +} + /* ---- Tables ---- */ .cc-table-wrap { overflow-x: auto; diff --git a/packages/dashboard/app/components/command-center/areas/useAnalyticsArea.ts b/packages/dashboard/app/components/command-center/areas/useAnalyticsArea.ts index 31d1a88809..98431b2821 100644 --- a/packages/dashboard/app/components/command-center/areas/useAnalyticsArea.ts +++ b/packages/dashboard/app/components/command-center/areas/useAnalyticsArea.ts @@ -11,6 +11,17 @@ export interface AnalyticsAreaState { reload: () => void; } +export interface AnalyticsAreaOptions { + /** Opt-in bounded polling interval in milliseconds; omitted means no polling. */ + pollMs?: number; +} + +function withRangeQuery(endpoint: string, query: string): string { + if (query === "") return endpoint; + const suffix = query.slice(1); + return `${endpoint}${endpoint.includes("?") ? "&" : "?"}${suffix}`; +} + /** * Fetch one Command Center analytics endpoint for the selected date range. * @@ -20,6 +31,8 @@ export interface AnalyticsAreaState { * - Keeps the previous `data` visible across a refetch so revalidation does not * flash the empty/loading state (and so consumers' derived-keyed effects can * distinguish a real content change from a re-fetch of identical content). + * - Polling is opt-in via `options.pollMs`; invalid ranges never schedule an + * interval, and the interval is cleaned up on unmount/range/endpoint changes. * * NOTE on the SWR-identity trap: this hook intentionally replaces `data` * identity on every successful fetch. Consumers MUST key any selection / sort / @@ -29,6 +42,7 @@ export interface AnalyticsAreaState { export function useAnalyticsArea( endpoint: string, range: DateRange, + options: AnalyticsAreaOptions = {}, ): AnalyticsAreaState { const [data, setData] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -46,7 +60,7 @@ export function useAnalyticsArea( setIsLoading(true); setError(null); try { - const result = await api(`${endpoint}${query}`); + const result = await api(withRangeQuery(endpoint, query)); setData(result); } catch (loadError: unknown) { setError(loadError instanceof Error ? loadError.message : "Failed to load analytics"); @@ -59,6 +73,16 @@ export function useAnalyticsArea( void load(); }, [load]); + useEffect(() => { + if (invalid || options.pollMs === undefined) { + return undefined; + } + const interval = window.setInterval(() => { + void load(); + }, options.pollMs); + return () => window.clearInterval(interval); + }, [invalid, load, options.pollMs]); + const reload = useCallback(() => { void load(); }, [load]); diff --git a/packages/dashboard/app/components/command-center/charts/TokenSeriesChart.tsx b/packages/dashboard/app/components/command-center/charts/TokenSeriesChart.tsx new file mode 100644 index 0000000000..2b62fe0f66 --- /dev/null +++ b/packages/dashboard/app/components/command-center/charts/TokenSeriesChart.tsx @@ -0,0 +1,52 @@ +import type { TokenTimePoint } from "@fusion/core"; +import { formatCount } from "../areas/areaShared"; +import "./charts.css"; + +export interface TokenSeriesChartProps { + points: TokenTimePoint[]; + ariaLabel: string; +} + +function safeHeightPercent(value: number, max: number): number { + if (!Number.isFinite(value) || value <= 0) { + return 0; + } + const denom = Number.isFinite(max) && max > 0 ? max : 1; + return Math.max(0, Math.min(100, (value / denom) * 100)); +} + +/** + * FNXC:CommandCenterCharts 2026-06-18-15:14: + * Token usage over time must render as a reduced-motion-safe, hand-rolled CSS chart that handles empty, sparse, and all-zero buckets without NaN geometry. Bars are positional because adjacent buckets can repeat labels or totals. + */ +export function TokenSeriesChart({ points, ariaLabel }: TokenSeriesChartProps) { + const max = points.reduce((m, p) => (p.totalTokens > m ? p.totalTokens : m), 0); + + return ( +
+
+ {points.length === 0 ? ( + + +
+ ); +} diff --git a/packages/dashboard/app/components/command-center/charts/charts.css b/packages/dashboard/app/components/command-center/charts/charts.css index 26f6e9efa5..273768d5e4 100644 --- a/packages/dashboard/app/components/command-center/charts/charts.css +++ b/packages/dashboard/app/components/command-center/charts/charts.css @@ -118,6 +118,82 @@ Chart labels and legends must use --text-muted so command-center CSS stays align transition: height var(--transition-normal); } +/* ---- TokenSeriesChart ---- */ +/* +FNXC:CommandCenterStyling 2026-06-18-15:14: +The token-over-time chart is live-updated and animated, but the motion is decorative. Use tokenized dimensions/colors and disable height animation for reduced-motion users while keeping empty and all-zero data readable. +*/ +.cc-token-series { + display: flex; + flex-direction: column; + gap: var(--space-2); + min-inline-size: 0; +} + +.cc-token-series-plot { + display: flex; + align-items: flex-end; + gap: var(--space-1); + block-size: clamp(var(--space-16), 24vw, calc(var(--space-20) * 2)); + padding: var(--space-3); + border: var(--border-width) solid var(--border-subtle); + border-radius: var(--radius-md); + background: linear-gradient(180deg, color-mix(in srgb, var(--color-accent) 10%, transparent), transparent), var(--surface-1); + overflow: hidden; +} + +.cc-token-series-bar { + flex: 1 1 0; + min-inline-size: var(--space-1); + border-radius: var(--radius-sm) var(--radius-sm) 0 0; + background: linear-gradient(180deg, var(--color-accent), color-mix(in srgb, var(--color-accent) 45%, var(--surface-2))); + box-shadow: 0 0 var(--space-2) color-mix(in srgb, var(--color-accent) 24%, transparent); + transition: height var(--transition-normal); + animation: cc-token-series-rise var(--duration-normal) ease-out both; +} + +.cc-token-series-empty { + inline-size: 100%; + block-size: var(--border-width-thick); + align-self: flex-end; + border-radius: var(--radius-pill); + background: var(--border-subtle); +} + +.cc-token-series-axis { + display: flex; + justify-content: space-between; + gap: var(--space-2); + color: var(--text-muted); + font-size: var(--font-size-xs); + font-variant-numeric: tabular-nums; +} + +@keyframes cc-token-series-rise { + from { + transform: scaleY(0.82); + opacity: 0.72; + } + to { + transform: scaleY(1); + opacity: 1; + } +} + +@media (prefers-reduced-motion: reduce) { + .cc-token-series-bar { + transition: none; + animation: none; + } +} + +@media (max-width: 768px) { + .cc-token-series-plot { + block-size: clamp(var(--space-14), 38vw, calc(var(--space-20) + var(--space-12))); + padding: var(--space-2); + } +} + /* ---- LineChart ---- */ /* FNXC:CommandCenterStyling 2026-06-18-14:29: diff --git a/packages/dashboard/src/__tests__/register-command-center-routes.test.ts b/packages/dashboard/src/__tests__/register-command-center-routes.test.ts index 5d0fa206e6..20539bf350 100644 --- a/packages/dashboard/src/__tests__/register-command-center-routes.test.ts +++ b/packages/dashboard/src/__tests__/register-command-center-routes.test.ts @@ -16,6 +16,7 @@ import { registerCommandCenterRoutes, resolveRange, resolveGroupBy, + resolveTokenGranularity, DEFAULT_WINDOW_DAYS, } from "../routes/register-command-center-routes.js"; import type { ApiRoutesContext } from "../routes/types.js"; @@ -132,10 +133,37 @@ describe("register-command-center-routes", () => { expect(body).toHaveProperty("totals"); expect(body).toHaveProperty("cost"); expect(body).toHaveProperty("groups"); + expect(body).not.toHaveProperty("series"); expect(body.groupBy).toBe("model"); expect((body.totals as { totalTokens: number }).totalTokens).toBe(200); }); + it("returns token time-series buckets when granularity is requested", async () => { + const res = await request( + app, + "GET", + "/api/command-center/tokens?from=2026-02-01T00:00:00.000Z&to=2026-04-01T00:00:00.000Z&groupBy=model&granularity=day&projectId=proj-a", + ); + + expect(res.status).toBe(200); + const body = res.body as { series?: { bucket: string; totalTokens: number; cost: unknown }[] }; + expect(body.series).toEqual([ + expect.objectContaining({ bucket: "2026-03-01", totalTokens: 200 }), + ]); + expect(body.series?.[0]).toHaveProperty("cost"); + }); + + it("ignores invalid token granularity rather than erroring", async () => { + const res = await request( + app, + "GET", + "/api/command-center/tokens?from=2026-02-01T00:00:00.000Z&to=2026-04-01T00:00:00.000Z&granularity=minute&projectId=proj-a", + ); + + expect(res.status).toBe(200); + expect(res.body as Record).not.toHaveProperty("series"); + }); + it("returns the tools / activity / productivity aggregator shapes", async () => { const range = "from=2026-02-01T00:00:00.000Z&to=2026-04-01T00:00:00.000Z"; const tools = await request(app, "GET", `/api/command-center/tools?${range}&projectId=proj-a`); @@ -317,7 +345,7 @@ describe("register-command-center-routes", () => { }); }); -describe("resolveRange / resolveGroupBy (param parsing)", () => { +describe("resolveRange / resolveGroupBy / resolveTokenGranularity (param parsing)", () => { const NOW = Date.parse("2026-06-15T00:00:00.000Z"); it("uses valid, ordered ISO bounds as-is", () => { @@ -358,6 +386,14 @@ describe("resolveRange / resolveGroupBy (param parsing)", () => { expect(resolveGroupBy({ groupBy: "bogus" })).toBeUndefined(); expect(resolveGroupBy({})).toBeUndefined(); }); + + it("accepts known token granularities and ignores unknown ones", () => { + expect(resolveTokenGranularity({ granularity: "hour" })).toBe("hour"); + expect(resolveTokenGranularity({ granularity: "day" })).toBe("day"); + expect(resolveTokenGranularity({ granularity: "week" })).toBe("week"); + expect(resolveTokenGranularity({ granularity: "minute" })).toBeUndefined(); + expect(resolveTokenGranularity({})).toBeUndefined(); + }); }); describe("vite /api proxy negative-lookahead (proxy verification)", () => { diff --git a/packages/dashboard/src/routes/register-command-center-routes.ts b/packages/dashboard/src/routes/register-command-center-routes.ts index 2fd8b313e5..62ef71b427 100644 --- a/packages/dashboard/src/routes/register-command-center-routes.ts +++ b/packages/dashboard/src/routes/register-command-center-routes.ts @@ -5,6 +5,7 @@ import { aggregateProductivityAnalytics, composeLiveSnapshot, type TokenGroupBy, + type TokenTimeGranularity, } from "@fusion/core"; import type { Request, Response } from "express"; import { ApiError } from "../api-error.js"; @@ -55,6 +56,12 @@ const VALID_GROUP_BY: ReadonlySet = new Set([ "agent", ]); +const VALID_TOKEN_GRANULARITY: ReadonlySet = new Set([ + "hour", + "day", + "week", +]); + /** A resolved, always-valid `[from, to]` ISO range. */ export interface ResolvedRange { from: string; @@ -103,6 +110,12 @@ export function resolveGroupBy(query: Request["query"]): TokenGroupBy | undefine return raw !== undefined && VALID_GROUP_BY.has(raw) ? (raw as TokenGroupBy) : undefined; } +/** Resolve the token-series `granularity` query param, ignoring unknown values. */ +export function resolveTokenGranularity(query: Request["query"]): TokenTimeGranularity | undefined { + const raw = typeof query.granularity === "string" ? query.granularity : undefined; + return raw !== undefined && VALID_TOKEN_GRANULARITY.has(raw) ? (raw as TokenTimeGranularity) : undefined; +} + /** True when the caller asked for CSV via `?format=csv` (case-insensitive). */ export function wantsCsv(query: Request["query"]): boolean { const raw = typeof query.format === "string" ? query.format : undefined; @@ -133,10 +146,12 @@ export const registerCommandCenterRoutes: ApiRouteRegistrar = (ctx) => { const store = await getScopedStore(req); const range = resolveRange(req.query); const groupBy = resolveGroupBy(req.query); + const granularity = resolveTokenGranularity(req.query); const result = aggregateTokenAnalytics(store.getDatabase(), { from: range.from, to: range.to, groupBy, + granularity, now: Date.now(), }); if (wantsCsv(req.query)) {