diff --git a/dashboard/src/v2/pages/stats/stats-page-view-model.ts b/dashboard/src/v2/pages/stats/stats-page-view-model.ts new file mode 100644 index 0000000000..f55cbfbe59 --- /dev/null +++ b/dashboard/src/v2/pages/stats/stats-page-view-model.ts @@ -0,0 +1,52 @@ +import type { + ExecutionStatsEntitySummary, + ExecutionUsageTotals, + ProjectExecutionStatsSnapshot, + SegmentDefinition, +} from "../../types.js"; +import { createStatsSegments, createSeries, EMPTY_USAGE } from "./stats-utils.js"; + +export interface StatsPageViewModel { + usage: ExecutionUsageTotals; + tokenSeries: number[]; + activeTimeSeries: number[]; + wallTimeSeries: number[]; + planningUsage: ExecutionStatsEntitySummary | null; + providerSegments: SegmentDefinition[]; + sourceSegments: SegmentDefinition[]; + tokenSegments: SegmentDefinition[]; + completionConfidence: string; +} + +export function deriveStatsPageViewModel(stats: ProjectExecutionStatsSnapshot | null): StatsPageViewModel { + const usage = stats?.usage || EMPTY_USAGE; + const buckets = stats?.buckets || []; + const tokenSeries = createSeries(buckets, (bucket) => bucket.usage.totalTokens); + const activeTimeSeries = createSeries(buckets, (bucket) => bucket.usage.activeTimeMs / 1000); + const wallTimeSeries = createSeries(buckets, (bucket) => bucket.usage.wallTimeMs / 1000); + const planningUsage = stats?.purposes.find((purpose) => purpose.id === "planning") || null; + const { providerSegments, sourceSegments, tokenSegments } = createStatsSegments(stats, usage); + + let completionConfidence = "Unavailable"; + if (!stats) { + completionConfidence = "No telemetry"; + } else if (usage.reportedInvocationCount > 0 && usage.estimatedInvocationCount === 0) { + completionConfidence = "Provider reported"; + } else if (usage.reportedInvocationCount > 0 && usage.estimatedInvocationCount > 0) { + completionConfidence = "Mixed reported + fallback"; + } else if (usage.estimatedInvocationCount > 0) { + completionConfidence = "Estimated fallback"; + } + + return { + usage, + tokenSeries, + activeTimeSeries, + wallTimeSeries, + planningUsage, + providerSegments, + sourceSegments, + tokenSegments, + completionConfidence, + }; +} diff --git a/dashboard/src/v2/pages/stats/use-stats-page-data.ts b/dashboard/src/v2/pages/stats/use-stats-page-data.ts index 0b35bb9a92..af71155586 100644 --- a/dashboard/src/v2/pages/stats/use-stats-page-data.ts +++ b/dashboard/src/v2/pages/stats/use-stats-page-data.ts @@ -7,7 +7,8 @@ import type { ProjectStatsWindow, SegmentDefinition, } from "../../types.js"; -import { createStatsSegments, createSeries, EMPTY_USAGE, isValidCustomRange } from "./stats-utils.js"; +import { isValidCustomRange } from "./stats-utils.js"; +import { deriveStatsPageViewModel } from "./stats-page-view-model.js"; import { useUsageChartState } from "./use-usage-chart-state.js"; export interface StatsPageData { @@ -51,39 +52,8 @@ export function useStatsPageData(projectId: string | null): StatsPageData { const { stats, loading, error, refresh } = useProjectStats(projectId, activeQuery); const chartState = useUsageChartState(projectId, stats || null); - - const usage = stats?.usage || EMPTY_USAGE; - const derivations = useMemo(() => { - const tokenSeries = createSeries(stats?.buckets || [], (bucket) => bucket.usage.totalTokens); - const activeTimeSeries = createSeries(stats?.buckets || [], (bucket) => bucket.usage.activeTimeMs / 1000); - const wallTimeSeries = createSeries(stats?.buckets || [], (bucket) => bucket.usage.wallTimeMs / 1000); - const planningUsage = stats?.purposes.find((purpose) => purpose.id === "planning") || null; - - const { providerSegments, sourceSegments, tokenSegments } = createStatsSegments(stats, usage); - - let completionConfidence = "Unavailable"; - if (!stats) { - completionConfidence = "No telemetry"; - } else if (usage.reportedInvocationCount > 0 && usage.estimatedInvocationCount === 0) { - completionConfidence = "Provider reported"; - } else if (usage.reportedInvocationCount > 0 && usage.estimatedInvocationCount > 0) { - completionConfidence = "Mixed reported + fallback"; - } else if (usage.estimatedInvocationCount > 0) { - completionConfidence = "Estimated fallback"; - } - - return { - tokenSeries, - activeTimeSeries, - wallTimeSeries, - planningUsage, - providerSegments, - sourceSegments, - tokenSegments, - completionConfidence, - }; - }, [stats, usage]); + const viewModel = useMemo(() => deriveStatsPageViewModel(stats), [stats]); const applyPresetWindow = (window: Exclude) => { setActiveQuery({ window }); @@ -113,11 +83,11 @@ export function useStatsPageData(projectId: string | null): StatsPageData { loading, error, refresh, - usage, - tokenSeries: derivations.tokenSeries, - activeTimeSeries: derivations.activeTimeSeries, - wallTimeSeries: derivations.wallTimeSeries, - planningUsage: derivations.planningUsage, + usage: viewModel.usage, + tokenSeries: viewModel.tokenSeries, + activeTimeSeries: viewModel.activeTimeSeries, + wallTimeSeries: viewModel.wallTimeSeries, + planningUsage: viewModel.planningUsage, activeQuery, customFrom, setCustomFrom, @@ -127,11 +97,11 @@ export function useStatsPageData(projectId: string | null): StatsPageData { visualMode: chartState.visualMode, setVisualMode: chartState.setVisualMode, chartState, - providerSegments: derivations.providerSegments, - sourceSegments: derivations.sourceSegments, - tokenSegments: derivations.tokenSegments, + providerSegments: viewModel.providerSegments, + sourceSegments: viewModel.sourceSegments, + tokenSegments: viewModel.tokenSegments, applyPresetWindow, applyCustomRange, - completionConfidence: derivations.completionConfidence, + completionConfidence: viewModel.completionConfidence, }; } diff --git a/docs/dashboard/design-system-stats.md b/docs/dashboard/design-system-stats.md index 27a694934c..46451ef7b8 100644 --- a/docs/dashboard/design-system-stats.md +++ b/docs/dashboard/design-system-stats.md @@ -157,7 +157,7 @@ Use page-scoped Stats primitives instead of one-off analytics chrome. The post-r - `stats-theme.css` defines Stats-specific aliases for panel surfaces, subpanels, chips, inputs, focus rings, status fills, borders, shadows, and motion. - `PANEL_CLASS`, `SUBPANEL_CLASS`, `CHIP_CLASS`, `INPUT_CLASS`, `LEDGER_ROW_CLASS`, `LEDGER_ROW_MODERN_CLASS`, `STATUS_TONE_CLASS`, `TAB_ACTIVE_CLASS`, `TAB_IDLE_CLASS`, `DASHED_EMPTY_CLASS`, and `TRACK_CLASS` provide the shell vocabulary. - `StatsCard`, `StudioHeader`, `SignalMetricCard`, `DonutCard`, `PurposeRibbon`, `TokenChip`, `TokenFlowBar`, `ChurnFlowBar`, `SortButton`, `ViewToggle`, and `SeriesLegendButton` cover repeated Stats patterns. -- Typed view-model helpers should own reusable derivations for trend, chart, model, provider, and ledger projections. Avoid recalculating meaningful bucket or efficiency summaries directly in JSX. +- Typed view-model helpers should own reusable derivations for trend, chart, model, provider, and ledger projections. `stats-page-view-model.ts` owns page-level snapshot derivations such as usage defaults, token/active/wall time series, planning lookup, provider/source/token segments, and completion-confidence copy so `useStatsPageData` can stay focused on fetch state, date controls, and chart state. Avoid recalculating meaningful bucket or efficiency summaries directly in JSX or stateful hooks. - New or touched Stats surfaces should use semantic Stats variables for backgrounds, borders, text, status tones, focus rings, chart tracks, selection fills, and scrims instead of raw slate/white/black light-dark utility pairs. - Signal fills, chart strokes, metric sparklines, selected controls, and Stats card accents must resolve through `--stats-accent-signal`, `--stats-accent-signal-fill`, or `--signal-rgb`. Light mode uses the dashboard blue signal; dark mode keeps the existing dark signal without per-component overrides. - Stats typography follows the dashboard heading scale directly in Tailwind or component tokens: the page title is the only hero-scale heading, section and studio headings use `text-xl`/`text-2xl font-semibold`, row and card titles use `text-base`/`text-lg font-semibold`, and metric-card values use the restrained `--stats-card-value-size` tokens. diff --git a/tests/dashboard/v2/stats-page-view-model.test.ts b/tests/dashboard/v2/stats-page-view-model.test.ts new file mode 100644 index 0000000000..d9ac811e3d --- /dev/null +++ b/tests/dashboard/v2/stats-page-view-model.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, it } from "vitest"; +import type { + ExecutionStatsEntitySummary, + ExecutionUsageBucketSummary, + ExecutionUsageTotals, + ProjectExecutionStatsSnapshot, +} from "../../../dashboard/src/v2/types.js"; +import { deriveStatsPageViewModel } from "../../../dashboard/src/v2/pages/stats/stats-page-view-model.js"; + +const usage = (overrides: Partial = {}): ExecutionUsageTotals => ({ + invocationCount: 0, + activeTimeMs: 0, + wallTimeMs: 0, + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 0, + inputCostUsd: 0, + outputCostUsd: 0, + cachedInputCostUsd: 0, + totalCostUsd: 0, + reportedInvocationCount: 0, + estimatedInvocationCount: 0, + unavailableInvocationCount: 0, + unsupportedInvocationCount: 0, + ...overrides, +}); + +const entity = ( + id: string, + label: string, + entityUsage: ExecutionUsageTotals, +): ExecutionStatsEntitySummary => ({ + id, + label, + secondaryLabel: null, + status: null, + purpose: null, + provider: null, + usage: entityUsage, + lastActivityAt: null, +}); + +const bucket = ( + label: string, + bucketUsage: ExecutionUsageTotals, +): ExecutionUsageBucketSummary => ({ + bucketStart: label, + bucketEnd: label, + label, + usage: bucketUsage, +}); + +const statsSnapshot = ( + overrides: Partial = {}, +): ProjectExecutionStatsSnapshot => { + const totals = usage({ + invocationCount: 3, + activeTimeMs: 4000, + wallTimeMs: 6000, + inputTokens: 60, + cachedInputTokens: 10, + outputTokens: 40, + reasoningOutputTokens: 5, + totalTokens: 115, + reportedInvocationCount: 3, + }); + + return { + projectId: "project-1", + projectName: "Project", + window: "7d", + query: { window: "7d" }, + range: { + window: "7d", + label: "Last 7 Days", + resolution: "day", + resolutionLabel: "daily", + from: "2026-01-01T00:00:00.000Z", + to: "2026-01-07T23:59:59.999Z", + bucketCount: 7, + isCustom: false, + }, + generatedAt: "2026-01-07T23:59:59.999Z", + usage: totals, + git: { + totals: { + insertions: 0, + deletions: 0, + filesChanged: 0, + prCount: 0, + mergedCount: 0, + mergeConflictCount: 0, + }, + buckets: [], + tasks: [], + sprints: [], + }, + activeSprint: null, + buckets: [ + bucket("B1", usage({ totalTokens: 25, activeTimeMs: 1000, wallTimeMs: 2000 })), + bucket("B2", usage({ totalTokens: 90, activeTimeMs: 3000, wallTimeMs: 4000 })), + ], + sprints: [], + tasks: [], + providers: [ + entity("provider-a", "Provider A", usage({ totalTokens: 80 })), + entity("provider-b", "Provider B", usage({ totalTokens: 35 })), + ], + purposes: [entity("planning", "Planning", usage({ totalTokens: 20 }))], + models: [], + statusCounts: { + completed: 0, + failed: 0, + cancelled: 0, + running: 0, + paused: 0, + }, + duration: { + sampleCount: 0, + avgMs: 0, + p50Ms: 0, + p95Ms: 0, + maxMs: 0, + }, + tokenSources: [{ source: "reported", count: 3 }], + chartSeries: [], + ...overrides, + }; +}; + +describe("deriveStatsPageViewModel", () => { + it("returns empty telemetry defaults for null stats", () => { + const viewModel = deriveStatsPageViewModel(null); + + expect(viewModel.usage.invocationCount).toBe(0); + expect(viewModel.tokenSeries).toEqual([0, 0, 0, 0, 0, 0, 0]); + expect(viewModel.activeTimeSeries).toEqual([0, 0, 0, 0, 0, 0, 0]); + expect(viewModel.wallTimeSeries).toEqual([0, 0, 0, 0, 0, 0, 0]); + expect(viewModel.planningUsage).toBeNull(); + expect(viewModel.providerSegments).toEqual([]); + expect(viewModel.sourceSegments).toEqual([]); + expect(viewModel.tokenSegments).toEqual([]); + expect(viewModel.completionConfidence).toBe("No telemetry"); + }); + + it("derives reported-only usage view data", () => { + const viewModel = deriveStatsPageViewModel(statsSnapshot()); + + expect(viewModel.usage.totalTokens).toBe(115); + expect(viewModel.tokenSeries).toEqual([25, 90]); + expect(viewModel.activeTimeSeries).toEqual([1, 3]); + expect(viewModel.wallTimeSeries).toEqual([2, 4]); + expect(viewModel.planningUsage?.id).toBe("planning"); + expect(viewModel.providerSegments.map((segment) => [segment.label, segment.value])).toEqual([ + ["Provider A", 80], + ["Provider B", 35], + ]); + expect(viewModel.sourceSegments.map((segment) => [segment.label, segment.value])).toEqual([["reported", 3]]); + expect(viewModel.tokenSegments.map((segment) => [segment.label, segment.value])).toEqual([ + ["Input", 60], + ["Cached", 10], + ["Output", 40], + ["Reasoning", 5], + ]); + expect(viewModel.completionConfidence).toBe("Provider reported"); + }); + + it("derives mixed reported plus estimated usage confidence", () => { + const stats = statsSnapshot({ + usage: usage({ + invocationCount: 5, + reportedInvocationCount: 3, + estimatedInvocationCount: 2, + totalTokens: 40, + }), + tokenSources: [ + { source: "reported", count: 3 }, + { source: "estimated", count: 2 }, + ], + }); + + const viewModel = deriveStatsPageViewModel(stats); + + expect(viewModel.completionConfidence).toBe("Mixed reported + fallback"); + expect(viewModel.sourceSegments.map((segment) => [segment.label, segment.value])).toEqual([ + ["reported", 3], + ["estimated", 2], + ]); + }); + + it("derives estimated-only usage confidence", () => { + const viewModel = deriveStatsPageViewModel(statsSnapshot({ + usage: usage({ + invocationCount: 4, + estimatedInvocationCount: 4, + totalTokens: 40, + }), + tokenSources: [{ source: "estimated", count: 4 }], + })); + + expect(viewModel.completionConfidence).toBe("Estimated fallback"); + expect(viewModel.sourceSegments.map((segment) => [segment.label, segment.value])).toEqual([["estimated", 4]]); + }); + + it("pads empty bucket series while preserving empty segments", () => { + const viewModel = deriveStatsPageViewModel(statsSnapshot({ + buckets: [], + providers: [], + purposes: [], + tokenSources: [], + usage: usage({ invocationCount: 2, unsupportedInvocationCount: 2 }), + })); + + expect(viewModel.tokenSeries).toEqual([0, 0, 0, 0, 0, 0, 0]); + expect(viewModel.activeTimeSeries).toEqual([0, 0, 0, 0, 0, 0, 0]); + expect(viewModel.wallTimeSeries).toEqual([0, 0, 0, 0, 0, 0, 0]); + expect(viewModel.providerSegments).toEqual([]); + expect(viewModel.sourceSegments).toEqual([]); + expect(viewModel.tokenSegments).toEqual([]); + expect(viewModel.completionConfidence).toBe("Unavailable"); + }); +}); diff --git a/tests/dashboard/v2/use-stats-page-data.test.tsx b/tests/dashboard/v2/use-stats-page-data.test.tsx index a498f9d099..65e4ddc8ab 100644 --- a/tests/dashboard/v2/use-stats-page-data.test.tsx +++ b/tests/dashboard/v2/use-stats-page-data.test.tsx @@ -105,4 +105,19 @@ describe("useStatsPageData", () => { expect(result.current.activeQuery).toEqual({ window: "custom", from: "2023-10-01", to: "2023-10-15" }); }); + + it("keeps activeQuery unchanged when an invalid custom range is applied", async () => { + const { result } = renderHook(() => useStatsPageData("proj-1")); + + await act(async () => { + result.current.setCustomFrom("2023-10-15"); + result.current.setCustomTo("2023-10-01"); + }); + + await act(async () => { + result.current.applyCustomRange(); + }); + + expect(result.current.activeQuery).toEqual({ window: "7d" }); + }); });