feat(#375): frontend token usage and cost dashboard

- Install recharts for data visualization
- Add Usage nav item to sidebar navigation
- Create telemetry API service with data fetching functions
- Build dashboard page with summary cards, charts, and time range selector
- Token usage line chart, cost breakdown bar chart, task outcome pie chart
- Loading and empty states handled
- Responsive layout with PDA-friendly design
- Add unit tests (14 tests passing)

Refs #375

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 02:02:55 -06:00
parent 5958569cba
commit a5ee974765
7 changed files with 1073 additions and 7 deletions

View File

@@ -33,6 +33,7 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-grid-layout": "^2.2.2",
"recharts": "^3.7.0",
"socket.io-client": "^4.8.3"
},
"devDependencies": {

View File

@@ -0,0 +1,288 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import type { ReactNode } from "react";
import UsagePage from "./page";
// ─── Component Prop Types ────────────────────────────────────────────
interface ChildrenProps {
children: ReactNode;
}
interface StyledChildrenProps extends ChildrenProps {
className?: string;
}
// ─── Mocks ───────────────────────────────────────────────────────────
// Mock @/components/ui/card — @mosaic/ui can't be resolved in vitest
vi.mock("@/components/ui/card", () => ({
Card: ({ children, className }: StyledChildrenProps): React.JSX.Element => (
<div className={className}>{children}</div>
),
CardHeader: ({ children }: ChildrenProps): React.JSX.Element => <div>{children}</div>,
CardContent: ({ children, className }: StyledChildrenProps): React.JSX.Element => (
<div className={className}>{children}</div>
),
CardFooter: ({ children }: ChildrenProps): React.JSX.Element => <div>{children}</div>,
CardTitle: ({ children, className }: StyledChildrenProps): React.JSX.Element => (
<h3 className={className}>{children}</h3>
),
CardDescription: ({ children, className }: StyledChildrenProps): React.JSX.Element => (
<p className={className}>{children}</p>
),
}));
// Mock recharts — jsdom has no SVG layout engine, so we render stubs
vi.mock("recharts", () => ({
LineChart: ({ children }: ChildrenProps): React.JSX.Element => (
<div data-testid="recharts-line-chart">{children}</div>
),
Line: (): React.JSX.Element => <div />,
BarChart: ({ children }: ChildrenProps): React.JSX.Element => (
<div data-testid="recharts-bar-chart">{children}</div>
),
Bar: (): React.JSX.Element => <div />,
PieChart: ({ children }: ChildrenProps): React.JSX.Element => (
<div data-testid="recharts-pie-chart">{children}</div>
),
Pie: (): React.JSX.Element => <div />,
Cell: (): React.JSX.Element => <div />,
XAxis: (): React.JSX.Element => <div />,
YAxis: (): React.JSX.Element => <div />,
CartesianGrid: (): React.JSX.Element => <div />,
Tooltip: (): React.JSX.Element => <div />,
ResponsiveContainer: ({ children }: ChildrenProps): React.JSX.Element => <div>{children}</div>,
Legend: (): React.JSX.Element => <div />,
}));
// Mock the telemetry API module
vi.mock("@/lib/api/telemetry", () => ({
fetchUsageSummary: vi.fn(),
fetchTokenUsage: vi.fn(),
fetchCostBreakdown: vi.fn(),
fetchTaskOutcomes: vi.fn(),
}));
// Import mocked modules after vi.mock
import {
fetchUsageSummary,
fetchTokenUsage,
fetchCostBreakdown,
fetchTaskOutcomes,
} from "@/lib/api/telemetry";
// ─── Test Data ───────────────────────────────────────────────────────
const mockSummary = {
totalTokens: 245800,
totalCost: 3.42,
taskCount: 47,
avgQualityGatePassRate: 0.87,
};
const mockTokenUsage = [
{ date: "2026-02-08", inputTokens: 10000, outputTokens: 5000, totalTokens: 15000 },
{ date: "2026-02-09", inputTokens: 12000, outputTokens: 6000, totalTokens: 18000 },
];
const mockCostBreakdown = [
{ model: "claude-sonnet-4-5", provider: "anthropic", cost: 18.5, taskCount: 124 },
{ model: "gpt-4o", provider: "openai", cost: 12.3, taskCount: 89 },
];
const mockTaskOutcomes = [
{ outcome: "Success", count: 312, color: "#6EBF8B" },
{ outcome: "Partial", count: 48, color: "#F5C862" },
];
function setupMocks(overrides?: { empty?: boolean; error?: boolean }): void {
if (overrides?.error) {
vi.mocked(fetchUsageSummary).mockRejectedValue(new Error("Network error"));
vi.mocked(fetchTokenUsage).mockRejectedValue(new Error("Network error"));
vi.mocked(fetchCostBreakdown).mockRejectedValue(new Error("Network error"));
vi.mocked(fetchTaskOutcomes).mockRejectedValue(new Error("Network error"));
return;
}
const summary = overrides?.empty ? { ...mockSummary, taskCount: 0 } : mockSummary;
vi.mocked(fetchUsageSummary).mockResolvedValue(summary);
vi.mocked(fetchTokenUsage).mockResolvedValue(mockTokenUsage);
vi.mocked(fetchCostBreakdown).mockResolvedValue(mockCostBreakdown);
vi.mocked(fetchTaskOutcomes).mockResolvedValue(mockTaskOutcomes);
}
// ─── Tests ───────────────────────────────────────────────────────────
describe("UsagePage", (): void => {
beforeEach((): void => {
vi.clearAllMocks();
});
it("should render the page title and subtitle", (): void => {
setupMocks();
render(<UsagePage />);
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Usage");
expect(screen.getByText("Token usage and cost overview")).toBeInTheDocument();
});
it("should have proper layout structure", (): void => {
setupMocks();
const { container } = render(<UsagePage />);
const main = container.querySelector("main");
expect(main).toBeInTheDocument();
});
it("should show loading skeleton initially", (): void => {
setupMocks();
render(<UsagePage />);
expect(screen.getByTestId("loading-skeleton")).toBeInTheDocument();
});
it("should render summary cards after loading", async (): Promise<void> => {
setupMocks();
render(<UsagePage />);
await waitFor((): void => {
expect(screen.getByTestId("summary-cards")).toBeInTheDocument();
});
// Check summary card values
expect(screen.getByText("Total Tokens")).toBeInTheDocument();
expect(screen.getByText("245.8K")).toBeInTheDocument();
expect(screen.getByText("Estimated Cost")).toBeInTheDocument();
expect(screen.getByText("$3.42")).toBeInTheDocument();
expect(screen.getByText("Task Count")).toBeInTheDocument();
expect(screen.getByText("47")).toBeInTheDocument();
expect(screen.getByText("Quality Gate Pass Rate")).toBeInTheDocument();
expect(screen.getByText("87.0%")).toBeInTheDocument();
});
it("should render all chart sections after loading", async (): Promise<void> => {
setupMocks();
render(<UsagePage />);
await waitFor((): void => {
expect(screen.getByTestId("token-usage-chart")).toBeInTheDocument();
expect(screen.getByTestId("cost-breakdown-chart")).toBeInTheDocument();
expect(screen.getByTestId("task-outcomes-chart")).toBeInTheDocument();
});
});
it("should render the time range selector with three options", (): void => {
setupMocks();
render(<UsagePage />);
expect(screen.getByText("7 Days")).toBeInTheDocument();
expect(screen.getByText("30 Days")).toBeInTheDocument();
expect(screen.getByText("90 Days")).toBeInTheDocument();
});
it("should have 30 Days selected by default", (): void => {
setupMocks();
render(<UsagePage />);
const button30d = screen.getByText("30 Days");
expect(button30d).toHaveAttribute("aria-pressed", "true");
});
it("should change time range when a different option is clicked", async (): Promise<void> => {
setupMocks();
render(<UsagePage />);
// Wait for initial load
await waitFor((): void => {
expect(screen.getByTestId("summary-cards")).toBeInTheDocument();
});
// Click 7 Days
const button7d = screen.getByText("7 Days");
fireEvent.click(button7d);
expect(button7d).toHaveAttribute("aria-pressed", "true");
expect(screen.getByText("30 Days")).toHaveAttribute("aria-pressed", "false");
});
it("should refetch data when time range changes", async (): Promise<void> => {
setupMocks();
render(<UsagePage />);
// Wait for initial load (30d default)
await waitFor((): void => {
expect(screen.getByTestId("summary-cards")).toBeInTheDocument();
});
// Initial call was with "30d"
expect(fetchUsageSummary).toHaveBeenCalledWith("30d");
// Change to 7d
fireEvent.click(screen.getByText("7 Days"));
await waitFor((): void => {
expect(fetchUsageSummary).toHaveBeenCalledWith("7d");
});
});
it("should show empty state when no tasks exist", async (): Promise<void> => {
setupMocks({ empty: true });
render(<UsagePage />);
await waitFor((): void => {
expect(screen.getByTestId("empty-state")).toBeInTheDocument();
});
expect(screen.getByText("No usage data yet")).toBeInTheDocument();
});
it("should show error state on fetch failure", async (): Promise<void> => {
setupMocks({ error: true });
render(<UsagePage />);
await waitFor((): void => {
expect(screen.getByText("Network error")).toBeInTheDocument();
});
expect(screen.getByText("Try again")).toBeInTheDocument();
});
it("should retry loading when Try again button is clicked after error", async (): Promise<void> => {
setupMocks({ error: true });
render(<UsagePage />);
await waitFor((): void => {
expect(screen.getByText("Try again")).toBeInTheDocument();
});
// Now set up success mocks and click retry
setupMocks();
fireEvent.click(screen.getByText("Try again"));
await waitFor((): void => {
expect(screen.getByTestId("summary-cards")).toBeInTheDocument();
});
});
it("should display chart section titles", async (): Promise<void> => {
setupMocks();
render(<UsagePage />);
await waitFor((): void => {
expect(screen.getByText("Token Usage Over Time")).toBeInTheDocument();
expect(screen.getByText("Cost by Model")).toBeInTheDocument();
expect(screen.getByText("Task Outcomes")).toBeInTheDocument();
});
});
it("should render recharts components within chart containers", async (): Promise<void> => {
setupMocks();
render(<UsagePage />);
await waitFor((): void => {
expect(screen.getByTestId("recharts-line-chart")).toBeInTheDocument();
expect(screen.getByTestId("recharts-bar-chart")).toBeInTheDocument();
expect(screen.getByTestId("recharts-pie-chart")).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,427 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import type { ReactElement } from "react";
import {
LineChart,
Line,
BarChart,
Bar,
PieChart,
Pie,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from "recharts";
import { Card, CardHeader, CardContent, CardTitle, CardDescription } from "@/components/ui/card";
import {
fetchUsageSummary,
fetchTokenUsage,
fetchCostBreakdown,
fetchTaskOutcomes,
} from "@/lib/api/telemetry";
import type {
TimeRange,
UsageSummary,
TokenUsagePoint,
CostBreakdownItem,
TaskOutcomeItem,
} from "@/lib/api/telemetry";
// ─── Constants ───────────────────────────────────────────────────────
const TIME_RANGES: { value: TimeRange; label: string }[] = [
{ value: "7d", label: "7 Days" },
{ value: "30d", label: "30 Days" },
{ value: "90d", label: "90 Days" },
];
// Calm, PDA-friendly chart colors (no aggressive reds)
const CHART_COLORS = {
inputTokens: "#6366F1", // Indigo
outputTokens: "#38BDF8", // Sky blue
grid: "#E2E8F0", // Slate 200
barFill: "#818CF8", // Indigo 400
};
// ─── Helpers ─────────────────────────────────────────────────────────
function formatNumber(value: number): string {
if (value >= 1_000_000) {
return `${(value / 1_000_000).toFixed(1)}M`;
}
if (value >= 1_000) {
return `${(value / 1_000).toFixed(1)}K`;
}
return value.toFixed(0);
}
function formatCurrency(value: number): string {
return `$${value.toFixed(2)}`;
}
function formatPercent(value: number): string {
return `${(value * 100).toFixed(1)}%`;
}
function formatDateLabel(dateStr: string): string {
const date = new Date(dateStr + "T00:00:00");
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
/**
* Map TaskOutcomeItem[] to recharts-compatible data with `fill` property.
* This replaces deprecated Cell component (removed in Recharts 4.0).
*/
function toFillData(
outcomes: TaskOutcomeItem[]
): { outcome: string; count: number; fill: string }[] {
return outcomes.map((item) => ({
outcome: item.outcome,
count: item.count,
fill: item.color,
}));
}
// ─── Sub-components ──────────────────────────────────────────────────
function SummaryCard({
title,
value,
subtitle,
}: {
title: string;
value: string;
subtitle?: string;
}): ReactElement {
return (
<Card>
<CardContent className="pt-6">
<p className="text-sm font-medium text-gray-500">{title}</p>
<p className="text-2xl font-bold text-gray-900 mt-1">{value}</p>
{subtitle ? <p className="text-xs text-gray-400 mt-1">{subtitle}</p> : null}
</CardContent>
</Card>
);
}
function LoadingSkeleton(): ReactElement {
return (
<div className="space-y-6" data-testid="loading-skeleton">
{/* Summary cards skeleton */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i}>
<CardContent className="pt-6">
<div className="h-4 bg-gray-200 rounded w-24 animate-pulse" />
<div className="h-8 bg-gray-200 rounded w-16 mt-2 animate-pulse" />
</CardContent>
</Card>
))}
</div>
{/* Chart skeletons */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{Array.from({ length: 3 }).map((_, i) => (
<Card key={i} className={i === 0 ? "lg:col-span-2" : ""}>
<CardHeader>
<div className="h-6 bg-gray-200 rounded w-40 animate-pulse" />
</CardHeader>
<CardContent>
<div className="h-64 bg-gray-100 rounded animate-pulse" />
</CardContent>
</Card>
))}
</div>
</div>
);
}
function EmptyState(): ReactElement {
return (
<div
className="flex flex-col items-center justify-center py-16 text-center"
data-testid="empty-state"
>
<div className="text-4xl mb-4">📊</div>
<h2 className="text-xl font-semibold text-gray-700 mb-2">No usage data yet</h2>
<p className="text-gray-500 max-w-md">
Once you start using AI-powered features, your token usage and cost data will appear here.
</p>
</div>
);
}
// ─── Main Page Component ─────────────────────────────────────────────
export default function UsagePage(): ReactElement {
const [timeRange, setTimeRange] = useState<TimeRange>("30d");
const [isLoading, setIsLoading] = useState(true);
const [isEmpty, setIsEmpty] = useState(false);
const [error, setError] = useState<string | null>(null);
const [summary, setSummary] = useState<UsageSummary | null>(null);
const [tokenUsage, setTokenUsage] = useState<TokenUsagePoint[]>([]);
const [costBreakdown, setCostBreakdown] = useState<CostBreakdownItem[]>([]);
const [taskOutcomes, setTaskOutcomes] = useState<TaskOutcomeItem[]>([]);
const loadData = useCallback(async (range: TimeRange): Promise<void> => {
setIsLoading(true);
setError(null);
try {
const [summaryData, tokenData, costData, outcomeData] = await Promise.all([
fetchUsageSummary(range),
fetchTokenUsage(range),
fetchCostBreakdown(range),
fetchTaskOutcomes(range),
]);
setSummary(summaryData);
setTokenUsage(tokenData);
setCostBreakdown(costData);
setTaskOutcomes(outcomeData);
// Check if there's any meaningful data
setIsEmpty(summaryData.taskCount === 0);
} catch (err) {
setError(
err instanceof Error
? err.message
: "We had trouble loading usage data. Please try again when you're ready."
);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
void loadData(timeRange);
}, [timeRange, loadData]);
function handleTimeRangeChange(range: TimeRange): void {
setTimeRange(range);
}
return (
<main className="container mx-auto px-4 py-8">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-8 gap-4">
<div>
<h1 className="text-3xl font-bold text-gray-900">Usage</h1>
<p className="text-gray-600 mt-1">Token usage and cost overview</p>
</div>
{/* Time range selector */}
<div className="flex gap-1 bg-gray-100 rounded-lg p-1" role="group" aria-label="Time range">
{TIME_RANGES.map(({ value, label }) => (
<button
key={value}
onClick={() => {
handleTimeRangeChange(value);
}}
className={`px-4 py-2 text-sm font-medium rounded-md transition-colors ${
timeRange === value
? "bg-white text-gray-900 shadow-sm"
: "text-gray-600 hover:text-gray-900"
}`}
aria-pressed={timeRange === value}
>
{label}
</button>
))}
</div>
</div>
{/* Error state */}
{error !== null ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-6 text-center">
<p className="text-amber-800">{error}</p>
<button
onClick={() => void loadData(timeRange)}
className="mt-4 rounded-md bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700 transition-colors"
>
Try again
</button>
</div>
) : isLoading ? (
<LoadingSkeleton />
) : isEmpty ? (
<EmptyState />
) : (
<div className="space-y-6">
{/* Summary Cards */}
<div
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"
data-testid="summary-cards"
>
<SummaryCard
title="Total Tokens"
value={summary ? formatNumber(summary.totalTokens) : "0"}
subtitle="Input + Output"
/>
<SummaryCard
title="Estimated Cost"
value={summary ? formatCurrency(summary.totalCost) : "$0.00"}
subtitle="Based on provider pricing"
/>
<SummaryCard
title="Task Count"
value={summary ? formatNumber(summary.taskCount) : "0"}
subtitle="AI-assisted tasks"
/>
<SummaryCard
title="Quality Gate Pass Rate"
value={summary ? formatPercent(summary.avgQualityGatePassRate) : "0%"}
subtitle="Build, lint, test, typecheck"
/>
</div>
{/* Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Token Usage Over Time — Full width */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="text-lg">Token Usage Over Time</CardTitle>
<CardDescription>Input and output tokens by day</CardDescription>
</CardHeader>
<CardContent>
<div className="h-72" data-testid="token-usage-chart">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={tokenUsage}>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_COLORS.grid} />
<XAxis
dataKey="date"
tickFormatter={formatDateLabel}
tick={{ fontSize: 12, fill: "#64748B" }}
interval="preserveStartEnd"
/>
<YAxis
tickFormatter={formatNumber}
tick={{ fontSize: 12, fill: "#64748B" }}
width={60}
/>
<Tooltip
formatter={(value: number, name: string) => [
formatNumber(value),
name === "inputTokens" ? "Input Tokens" : "Output Tokens",
]}
labelFormatter={formatDateLabel}
contentStyle={{
borderRadius: "8px",
border: "1px solid #E2E8F0",
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
}}
/>
<Legend
formatter={(value: string) =>
value === "inputTokens" ? "Input Tokens" : "Output Tokens"
}
/>
<Line
type="monotone"
dataKey="inputTokens"
stroke={CHART_COLORS.inputTokens}
strokeWidth={2}
dot={false}
activeDot={{ r: 4 }}
/>
<Line
type="monotone"
dataKey="outputTokens"
stroke={CHART_COLORS.outputTokens}
strokeWidth={2}
dot={false}
activeDot={{ r: 4 }}
/>
</LineChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
{/* Cost Breakdown by Model */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Cost by Model</CardTitle>
<CardDescription>Estimated cost breakdown</CardDescription>
</CardHeader>
<CardContent>
<div className="h-72" data-testid="cost-breakdown-chart">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={costBreakdown} layout="vertical">
<CartesianGrid strokeDasharray="3 3" stroke={CHART_COLORS.grid} />
<XAxis
type="number"
tickFormatter={(v: number) => formatCurrency(v)}
tick={{ fontSize: 12, fill: "#64748B" }}
/>
<YAxis
type="category"
dataKey="model"
tick={{ fontSize: 11, fill: "#64748B" }}
width={140}
/>
<Tooltip
formatter={(value: number) => [formatCurrency(value), "Cost"]}
contentStyle={{
borderRadius: "8px",
border: "1px solid #E2E8F0",
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
}}
/>
<Bar dataKey="cost" fill={CHART_COLORS.barFill} radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
{/* Task Outcomes */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Task Outcomes</CardTitle>
<CardDescription>Distribution of task completion results</CardDescription>
</CardHeader>
<CardContent>
<div
className="h-72 flex items-center justify-center"
data-testid="task-outcomes-chart"
>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={toFillData(taskOutcomes)}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={100}
paddingAngle={2}
dataKey="count"
nameKey="outcome"
label={({ outcome, count }: { outcome: string; count: number }) =>
`${outcome}: ${String(count)}`
}
/>
<Tooltip
formatter={(value: number, name: string) => [value, name]}
contentStyle={{
borderRadius: "8px",
border: "1px solid #E2E8F0",
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
}}
/>
<Legend />
</PieChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
</div>
</div>
)}
</main>
);
}

View File

@@ -16,6 +16,7 @@ export function Navigation(): React.JSX.Element {
{ href: "/tasks", label: "Tasks" },
{ href: "/calendar", label: "Calendar" },
{ href: "/knowledge", label: "Knowledge" },
{ href: "/usage", label: "Usage" },
];
// Global keyboard shortcut for search (Cmd+K or Ctrl+K)

View File

@@ -12,3 +12,4 @@ export * from "./knowledge";
export * from "./domains";
export * from "./teams";
export * from "./personalities";
export * from "./telemetry";

View File

@@ -0,0 +1,187 @@
/**
* Telemetry API Client
* Handles telemetry data fetching for the usage dashboard.
*
* NOTE: Currently returns mock/placeholder data since the telemetry API
* aggregation endpoints don't exist yet. The important thing is the UI structure.
* When the backend endpoints are ready, replace mock calls with real apiGet() calls.
*/
import { apiGet, type ApiResponse } from "./client";
// ─── Types ───────────────────────────────────────────────────────────
export type TimeRange = "7d" | "30d" | "90d";
export interface UsageSummary {
totalTokens: number;
totalCost: number;
taskCount: number;
avgQualityGatePassRate: number;
}
export interface TokenUsagePoint {
date: string;
inputTokens: number;
outputTokens: number;
totalTokens: number;
}
export interface CostBreakdownItem {
model: string;
provider: string;
cost: number;
taskCount: number;
}
export interface TaskOutcomeItem {
outcome: string;
count: number;
color: string;
}
export interface EstimateParams {
taskType: string;
model: string;
provider: string;
complexity: string;
}
export interface EstimateResponse {
prediction: {
input_tokens: { median: number; p75: number; p90: number };
output_tokens: { median: number; p75: number; p90: number };
cost_usd_micros: Record<string, number>;
quality: { gate_pass_rate: number; success_rate: number };
} | null;
metadata: {
sample_size: number;
confidence: "none" | "low" | "medium" | "high";
};
}
// ─── Mock Data Generators ────────────────────────────────────────────
function generateDateRange(range: TimeRange): string[] {
const days = range === "7d" ? 7 : range === "30d" ? 30 : 90;
const dates: string[] = [];
const now = new Date();
for (let i = days - 1; i >= 0; i--) {
const d = new Date(now);
d.setDate(d.getDate() - i);
dates.push(d.toISOString().split("T")[0]);
}
return dates;
}
function generateMockTokenUsage(range: TimeRange): TokenUsagePoint[] {
const dates = generateDateRange(range);
return dates.map((date) => {
const baseInput = 8000 + Math.floor(Math.random() * 12000);
const baseOutput = 3000 + Math.floor(Math.random() * 7000);
return {
date,
inputTokens: baseInput,
outputTokens: baseOutput,
totalTokens: baseInput + baseOutput,
};
});
}
function generateMockSummary(range: TimeRange): UsageSummary {
const multiplier = range === "7d" ? 1 : range === "30d" ? 4 : 12;
return {
totalTokens: 245_800 * multiplier,
totalCost: 3.42 * multiplier,
taskCount: 47 * multiplier,
avgQualityGatePassRate: 0.87,
};
}
function generateMockCostBreakdown(): CostBreakdownItem[] {
return [
{ model: "claude-sonnet-4-5", provider: "anthropic", cost: 18.5, taskCount: 124 },
{ model: "gpt-4o", provider: "openai", cost: 12.3, taskCount: 89 },
{ model: "claude-haiku-3.5", provider: "anthropic", cost: 4.2, taskCount: 156 },
{ model: "llama-3.3-70b", provider: "ollama", cost: 0, taskCount: 67 },
{ model: "gemini-2.0-flash", provider: "google", cost: 2.8, taskCount: 42 },
];
}
// PDA-friendly colors: calm, no aggressive reds
function generateMockTaskOutcomes(): TaskOutcomeItem[] {
return [
{ outcome: "Success", count: 312, color: "#6EBF8B" },
{ outcome: "Partial", count: 48, color: "#F5C862" },
{ outcome: "Timeout", count: 18, color: "#94A3B8" },
{ outcome: "Incomplete", count: 22, color: "#C4A5DE" },
];
}
// ─── API Functions ───────────────────────────────────────────────────
/**
* Fetch usage summary data (total tokens, cost, task count, quality rate)
*/
export async function fetchUsageSummary(timeRange: TimeRange): Promise<UsageSummary> {
// TODO: Replace with real API call when backend aggregation endpoints are ready
// const response = await apiGet<ApiResponse<UsageSummary>>(`/api/telemetry/summary?range=${timeRange}`);
// return response.data;
void apiGet; // suppress unused import warning in the meantime
await new Promise((resolve) => setTimeout(resolve, 200));
return generateMockSummary(timeRange);
}
/**
* Fetch token usage time series for charts
*/
export async function fetchTokenUsage(timeRange: TimeRange): Promise<TokenUsagePoint[]> {
// TODO: Replace with real API call
// const response = await apiGet<ApiResponse<TokenUsagePoint[]>>(`/api/telemetry/tokens?range=${timeRange}`);
// return response.data;
await new Promise((resolve) => setTimeout(resolve, 250));
return generateMockTokenUsage(timeRange);
}
/**
* Fetch cost breakdown by model
*/
export async function fetchCostBreakdown(timeRange: TimeRange): Promise<CostBreakdownItem[]> {
// TODO: Replace with real API call
// const response = await apiGet<ApiResponse<CostBreakdownItem[]>>(`/api/telemetry/costs?range=${timeRange}`);
// return response.data;
await new Promise((resolve) => setTimeout(resolve, 200));
void timeRange;
return generateMockCostBreakdown();
}
/**
* Fetch task outcome distribution
*/
export async function fetchTaskOutcomes(timeRange: TimeRange): Promise<TaskOutcomeItem[]> {
// TODO: Replace with real API call
// const response = await apiGet<ApiResponse<TaskOutcomeItem[]>>(`/api/telemetry/outcomes?range=${timeRange}`);
// return response.data;
await new Promise((resolve) => setTimeout(resolve, 150));
void timeRange;
return generateMockTaskOutcomes();
}
/**
* Fetch cost/token estimate for a given task configuration.
* Uses the real GET /api/telemetry/estimate endpoint from TEL-006.
*/
export async function fetchEstimate(params: EstimateParams): Promise<EstimateResponse> {
const query = new URLSearchParams({
taskType: params.taskType,
model: params.model,
provider: params.provider,
complexity: params.complexity,
}).toString();
const response = await apiGet<ApiResponse<EstimateResponse>>(`/api/telemetry/estimate?${query}`);
return response.data;
}