Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions dashboard/src/v2/pages/stats/stats-page-view-model.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
54 changes: 12 additions & 42 deletions dashboard/src/v2/pages/stats/use-stats-page-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<ProjectStatsWindow, "custom">) => {
setActiveQuery({ window });
Expand Down Expand Up @@ -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,
Expand All @@ -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,
};
}
2 changes: 1 addition & 1 deletion docs/dashboard/design-system-stats.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
224 changes: 224 additions & 0 deletions tests/dashboard/v2/stats-page-view-model.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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> = {},
): 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");
});
});
15 changes: 15 additions & 0 deletions tests/dashboard/v2/use-stats-page-data.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
});
});