From 94018e0239b4db7c7e4af1122bf0c30a7aefebba Mon Sep 17 00:00:00 2001
From: Sai Shankar
Date: Sun, 8 Mar 2026 03:18:37 +0530
Subject: [PATCH] feat(ui): add resource and usage dashboard (/usage route)
adds a new /usage page that lets board operators see how much each ai
provider is consuming across any date window, with per-model breakdowns,
rolling 5h/24h/7d burn windows, weekly budget bars, and a deficit notch
when projected spend is on track to exceed the monthly budget.
- new GET /companies/:id/costs/by-provider endpoint aggregates cost events
by provider + model with pro-rated billing type splits from heartbeat runs
- new GET /companies/:id/costs/window-spend endpoint returns rolling window
spend (5h, 24h, 7d) per provider with no schema changes
- QuotaBar: reusable boxed-border progress bar with green/yellow/red
threshold fill colors and optional deficit notch
- ProviderQuotaCard: per-provider card showing budget allocation bars,
rolling windows, subscription usage, and model breakdown with token/cost
share overlays
- Usage page: date preset toggles (mtd, 7d, 30d, ytd, all, custom),
provider tabs, 30s polling plus ws invalidation on cost_event
- custom date range blocks queries until both dates are selected and
treats boundaries as local-time (not utc midnight) so full days are
included regardless of timezone
- query key to timestamp is floored to the nearest minute to prevent
cache churn on every 30s refetch tick
---
packages/shared/src/index.ts | 2 +
packages/shared/src/types/cost.ts | 24 ++
packages/shared/src/types/index.ts | 2 +-
server/src/routes/costs.ts | 15 ++
server/src/services/costs.ts | 115 +++++++++
ui/src/App.tsx | 2 +
ui/src/api/costs.ts | 6 +-
ui/src/components/ProviderQuotaCard.tsx | 238 +++++++++++++++++
ui/src/components/QuotaBar.tsx | 65 +++++
ui/src/components/Sidebar.tsx | 2 +
ui/src/context/LiveUpdatesProvider.tsx | 2 +
ui/src/lib/company-routes.ts | 1 +
ui/src/lib/queryKeys.ts | 4 +
ui/src/pages/Costs.tsx | 4 +-
ui/src/pages/Usage.tsx | 325 ++++++++++++++++++++++++
15 files changed, 803 insertions(+), 4 deletions(-)
create mode 100644 ui/src/components/ProviderQuotaCard.tsx
create mode 100644 ui/src/components/QuotaBar.tsx
create mode 100644 ui/src/pages/Usage.tsx
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 4ebf0543..40ddbc0f 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -132,6 +132,8 @@ export type {
CostEvent,
CostSummary,
CostByAgent,
+ CostByProviderModel,
+ CostWindowSpendRow,
HeartbeatRun,
HeartbeatRunEvent,
AgentRuntimeState,
diff --git a/packages/shared/src/types/cost.ts b/packages/shared/src/types/cost.ts
index c5b2bb2e..7480c03b 100644
--- a/packages/shared/src/types/cost.ts
+++ b/packages/shared/src/types/cost.ts
@@ -34,3 +34,27 @@ export interface CostByAgent {
subscriptionInputTokens: number;
subscriptionOutputTokens: number;
}
+
+export interface CostByProviderModel {
+ provider: string;
+ model: string;
+ costCents: number;
+ inputTokens: number;
+ outputTokens: number;
+ apiRunCount: number;
+ subscriptionRunCount: number;
+ subscriptionInputTokens: number;
+ subscriptionOutputTokens: number;
+}
+
+/** spend per provider for a fixed rolling time window */
+export interface CostWindowSpendRow {
+ provider: string;
+ /** duration label, e.g. "5h", "24h", "7d" */
+ window: string;
+ /** rolling window duration in hours */
+ windowHours: number;
+ costCents: number;
+ inputTokens: number;
+ outputTokens: number;
+}
diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts
index 06782f68..7eae528b 100644
--- a/packages/shared/src/types/index.ts
+++ b/packages/shared/src/types/index.ts
@@ -46,7 +46,7 @@ export type {
CompanySecret,
SecretProviderDescriptor,
} from "./secrets.js";
-export type { CostEvent, CostSummary, CostByAgent } from "./cost.js";
+export type { CostEvent, CostSummary, CostByAgent, CostByProviderModel, CostWindowSpendRow } from "./cost.js";
export type {
HeartbeatRun,
HeartbeatRunEvent,
diff --git a/server/src/routes/costs.ts b/server/src/routes/costs.ts
index e4527bff..e6bb6785 100644
--- a/server/src/routes/costs.ts
+++ b/server/src/routes/costs.ts
@@ -62,6 +62,21 @@ export function costRoutes(db: Db) {
res.json(rows);
});
+ router.get("/companies/:companyId/costs/by-provider", async (req, res) => {
+ const companyId = req.params.companyId as string;
+ assertCompanyAccess(req, companyId);
+ const range = parseDateRange(req.query);
+ const rows = await costs.byProvider(companyId, range);
+ res.json(rows);
+ });
+
+ router.get("/companies/:companyId/costs/window-spend", async (req, res) => {
+ const companyId = req.params.companyId as string;
+ assertCompanyAccess(req, companyId);
+ const rows = await costs.windowSpend(companyId);
+ res.json(rows);
+ });
+
router.get("/companies/:companyId/costs/by-project", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
diff --git a/server/src/services/costs.ts b/server/src/services/costs.ts
index 2d430aa9..d1954b2d 100644
--- a/server/src/services/costs.ts
+++ b/server/src/services/costs.ts
@@ -153,6 +153,121 @@ export function costService(db: Db) {
});
},
+ byProvider: async (companyId: string, range?: CostDateRange) => {
+ const conditions: ReturnType[] = [eq(costEvents.companyId, companyId)];
+ if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from));
+ if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to));
+
+ const costRows = await db
+ .select({
+ provider: costEvents.provider,
+ model: costEvents.model,
+ costCents: sql`coalesce(sum(${costEvents.costCents}), 0)::int`,
+ inputTokens: sql`coalesce(sum(${costEvents.inputTokens}), 0)::int`,
+ outputTokens: sql`coalesce(sum(${costEvents.outputTokens}), 0)::int`,
+ })
+ .from(costEvents)
+ .where(and(...conditions))
+ .groupBy(costEvents.provider, costEvents.model)
+ .orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)::int`));
+
+ const runConditions: ReturnType[] = [eq(heartbeatRuns.companyId, companyId)];
+ if (range?.from) runConditions.push(gte(heartbeatRuns.finishedAt, range.from));
+ if (range?.to) runConditions.push(lte(heartbeatRuns.finishedAt, range.to));
+
+ const runRows = await db
+ .select({
+ agentId: heartbeatRuns.agentId,
+ apiRunCount:
+ sql`coalesce(sum(case when coalesce((${heartbeatRuns.usageJson} ->> 'billingType'), 'unknown') = 'api' then 1 else 0 end), 0)::int`,
+ subscriptionRunCount:
+ sql`coalesce(sum(case when coalesce((${heartbeatRuns.usageJson} ->> 'billingType'), 'unknown') = 'subscription' then 1 else 0 end), 0)::int`,
+ subscriptionInputTokens:
+ sql`coalesce(sum(case when coalesce((${heartbeatRuns.usageJson} ->> 'billingType'), 'unknown') = 'subscription' then coalesce((${heartbeatRuns.usageJson} ->> 'inputTokens')::int, 0) else 0 end), 0)::int`,
+ subscriptionOutputTokens:
+ sql`coalesce(sum(case when coalesce((${heartbeatRuns.usageJson} ->> 'billingType'), 'unknown') = 'subscription' then coalesce((${heartbeatRuns.usageJson} ->> 'outputTokens')::int, 0) else 0 end), 0)::int`,
+ })
+ .from(heartbeatRuns)
+ .where(and(...runConditions))
+ .groupBy(heartbeatRuns.agentId);
+
+ // aggregate run billing splits across all agents (runs don't carry model info so we can't go per-model)
+ const totals = runRows.reduce(
+ (acc, r) => ({
+ apiRunCount: acc.apiRunCount + r.apiRunCount,
+ subscriptionRunCount: acc.subscriptionRunCount + r.subscriptionRunCount,
+ subscriptionInputTokens: acc.subscriptionInputTokens + r.subscriptionInputTokens,
+ subscriptionOutputTokens: acc.subscriptionOutputTokens + r.subscriptionOutputTokens,
+ }),
+ { apiRunCount: 0, subscriptionRunCount: 0, subscriptionInputTokens: 0, subscriptionOutputTokens: 0 },
+ );
+
+ // pro-rate billing split across models by token share
+ const totalTokens = costRows.reduce((s, r) => s + r.inputTokens + r.outputTokens, 0);
+
+ return costRows.map((row) => {
+ const rowTokens = row.inputTokens + row.outputTokens;
+ const share = totalTokens > 0 ? rowTokens / totalTokens : 0;
+ return {
+ provider: row.provider,
+ model: row.model,
+ costCents: row.costCents,
+ inputTokens: row.inputTokens,
+ outputTokens: row.outputTokens,
+ apiRunCount: Math.round(totals.apiRunCount * share),
+ subscriptionRunCount: Math.round(totals.subscriptionRunCount * share),
+ subscriptionInputTokens: Math.round(totals.subscriptionInputTokens * share),
+ subscriptionOutputTokens: Math.round(totals.subscriptionOutputTokens * share),
+ };
+ });
+ },
+
+ /**
+ * aggregates cost_events by provider for each of three rolling windows:
+ * last 5 hours, last 24 hours, last 7 days.
+ * purely internal consumption data, no external rate-limit sources.
+ */
+ windowSpend: async (companyId: string) => {
+ const windows = [
+ { label: "5h", hours: 5 },
+ { label: "24h", hours: 24 },
+ { label: "7d", hours: 168 },
+ ] as const;
+
+ const results = await Promise.all(
+ windows.map(async ({ label, hours }) => {
+ const since = new Date(Date.now() - hours * 60 * 60 * 1000);
+ const rows = await db
+ .select({
+ provider: costEvents.provider,
+ costCents: sql`coalesce(sum(${costEvents.costCents}), 0)::int`,
+ inputTokens: sql`coalesce(sum(${costEvents.inputTokens}), 0)::int`,
+ outputTokens: sql`coalesce(sum(${costEvents.outputTokens}), 0)::int`,
+ })
+ .from(costEvents)
+ .where(
+ and(
+ eq(costEvents.companyId, companyId),
+ gte(costEvents.occurredAt, since),
+ ),
+ )
+ .groupBy(costEvents.provider)
+ .orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)::int`));
+
+ return rows.map((row) => ({
+ provider: row.provider,
+ window: label as string,
+ windowHours: hours,
+ costCents: row.costCents,
+ inputTokens: row.inputTokens,
+ outputTokens: row.outputTokens,
+ }));
+ }),
+ );
+
+ return results.flat();
+ },
+
byProject: async (companyId: string, range?: CostDateRange) => {
const issueIdAsText = sql`${issues.id}::text`;
const runProjectLinks = db
diff --git a/ui/src/App.tsx b/ui/src/App.tsx
index b8d77f44..b1016a88 100644
--- a/ui/src/App.tsx
+++ b/ui/src/App.tsx
@@ -19,6 +19,7 @@ import { GoalDetail } from "./pages/GoalDetail";
import { Approvals } from "./pages/Approvals";
import { ApprovalDetail } from "./pages/ApprovalDetail";
import { Costs } from "./pages/Costs";
+import { Usage } from "./pages/Usage";
import { Activity } from "./pages/Activity";
import { Inbox } from "./pages/Inbox";
import { CompanySettings } from "./pages/CompanySettings";
@@ -147,6 +148,7 @@ function boardRoutes() {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/ui/src/api/costs.ts b/ui/src/api/costs.ts
index 2bfa2ecb..ca08c31a 100644
--- a/ui/src/api/costs.ts
+++ b/ui/src/api/costs.ts
@@ -1,4 +1,4 @@
-import type { CostSummary, CostByAgent } from "@paperclipai/shared";
+import type { CostSummary, CostByAgent, CostByProviderModel, CostWindowSpendRow } from "@paperclipai/shared";
import { api } from "./client";
export interface CostByProject {
@@ -24,4 +24,8 @@ export const costsApi = {
api.get(`/companies/${companyId}/costs/by-agent${dateParams(from, to)}`),
byProject: (companyId: string, from?: string, to?: string) =>
api.get(`/companies/${companyId}/costs/by-project${dateParams(from, to)}`),
+ byProvider: (companyId: string, from?: string, to?: string) =>
+ api.get(`/companies/${companyId}/costs/by-provider${dateParams(from, to)}`),
+ windowSpend: (companyId: string) =>
+ api.get(`/companies/${companyId}/costs/window-spend`),
};
diff --git a/ui/src/components/ProviderQuotaCard.tsx b/ui/src/components/ProviderQuotaCard.tsx
new file mode 100644
index 00000000..ecb7c7cd
--- /dev/null
+++ b/ui/src/components/ProviderQuotaCard.tsx
@@ -0,0 +1,238 @@
+import type { CostByProviderModel, CostWindowSpendRow } from "@paperclipai/shared";
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
+import { QuotaBar } from "./QuotaBar";
+import { formatCents, formatTokens } from "@/lib/utils";
+
+interface ProviderQuotaCardProps {
+ provider: string;
+ rows: CostByProviderModel[];
+ /** company monthly budget in cents (0 means unlimited) */
+ budgetMonthlyCents: number;
+ /** total company spend in this period in cents, all providers */
+ totalCompanySpendCents: number;
+ /** spend in the current calendar week in cents, this provider only */
+ weekSpendCents: number;
+ /** rolling window rows for this provider: 5h, 24h, 7d */
+ windowRows: CostWindowSpendRow[];
+ showDeficitNotch: boolean;
+}
+
+function providerLabel(provider: string): string {
+ const map: Record = {
+ anthropic: "Anthropic",
+ openai: "OpenAI",
+ google: "Google",
+ cursor: "Cursor",
+ jetbrains: "JetBrains AI",
+ };
+ return map[provider.toLowerCase()] ?? provider;
+}
+
+export function ProviderQuotaCard({
+ provider,
+ rows,
+ budgetMonthlyCents,
+ totalCompanySpendCents,
+ weekSpendCents,
+ windowRows,
+ showDeficitNotch,
+}: ProviderQuotaCardProps) {
+ const totalInputTokens = rows.reduce((s, r) => s + r.inputTokens, 0);
+ const totalOutputTokens = rows.reduce((s, r) => s + r.outputTokens, 0);
+ const totalTokens = totalInputTokens + totalOutputTokens;
+ const totalCostCents = rows.reduce((s, r) => s + r.costCents, 0);
+ const totalApiRuns = rows.reduce((s, r) => s + r.apiRunCount, 0);
+ const totalSubRuns = rows.reduce((s, r) => s + r.subscriptionRunCount, 0);
+ const totalSubInputTokens = rows.reduce((s, r) => s + r.subscriptionInputTokens, 0);
+ const totalSubOutputTokens = rows.reduce((s, r) => s + r.subscriptionOutputTokens, 0);
+ const totalSubTokens = totalSubInputTokens + totalSubOutputTokens;
+
+ // sub share = sub tokens / (api tokens + sub tokens)
+ const allTokens = totalTokens + totalSubTokens;
+ const subSharePct = allTokens > 0 ? (totalSubTokens / allTokens) * 100 : 0;
+
+ // budget bars: use this provider's own spend vs its pro-rata share of budget
+ // pro-rata: if a provider is 40% of total spend, it gets 40% of the budget allocated.
+ // falls back to raw provider spend vs total budget when totalCompanySpend is 0.
+ const providerBudgetShare =
+ budgetMonthlyCents > 0 && totalCompanySpendCents > 0
+ ? (totalCostCents / totalCompanySpendCents) * budgetMonthlyCents
+ : budgetMonthlyCents;
+
+ const budgetPct =
+ providerBudgetShare > 0
+ ? Math.min(100, (totalCostCents / providerBudgetShare) * 100)
+ : 0;
+
+ const weeklyBudgetShare = providerBudgetShare > 0 ? providerBudgetShare / 4.33 : 0;
+ const weekPct =
+ weeklyBudgetShare > 0 ? Math.min(100, (weekSpendCents / weeklyBudgetShare) * 100) : 0;
+
+ const hasBudget = budgetMonthlyCents > 0;
+
+ return (
+
+
+
+
+
+ {providerLabel(provider)}
+
+
+ {formatTokens(totalInputTokens)} in
+ {" · "}
+ {formatTokens(totalOutputTokens)} out
+ {(totalApiRuns > 0 || totalSubRuns > 0) && (
+
+ ·{" "}
+ {totalApiRuns > 0 && `~${totalApiRuns} api`}
+ {totalApiRuns > 0 && totalSubRuns > 0 && " / "}
+ {totalSubRuns > 0 && `~${totalSubRuns} sub`}
+ {" runs"}
+
+ )}
+
+
+
+ {formatCents(totalCostCents)}
+
+
+
+
+
+ {hasBudget && (
+
+
+ = 100}
+ />
+
+ )}
+
+ {/* rolling window consumption — always shown when data is available */}
+ {windowRows.length > 0 && (() => {
+ const WINDOWS = ["5h", "24h", "7d"] as const;
+ const windowMap = new Map(windowRows.map((r) => [r.window, r]));
+ const maxCents = Math.max(...windowRows.map((r) => r.costCents), 1);
+ return (
+ <>
+
+
+
+ Rolling windows
+
+
+ {WINDOWS.map((w) => {
+ const row = windowMap.get(w);
+ const cents = row?.costCents ?? 0;
+ const tokens = (row?.inputTokens ?? 0) + (row?.outputTokens ?? 0);
+ const barPct = maxCents > 0 ? (cents / maxCents) * 100 : 0;
+ return (
+
+
+ {w}
+
+ {formatTokens(tokens)} tok
+
+ {formatCents(cents)}
+
+
+
+ );
+ })}
+
+
+ >
+ );
+ })()}
+
+ {/* subscription usage — shown when any subscription-billed runs exist */}
+ {totalSubRuns > 0 && (
+ <>
+
+
+
+ Subscription
+
+
+ {totalSubRuns} runs
+ {" · "}
+ {formatTokens(totalSubInputTokens)} in
+ {" · "}
+ {formatTokens(totalSubOutputTokens)} out
+
+
+
+ {Math.round(subSharePct)}% of token usage via subscription
+
+
+ >
+ )}
+
+ {/* model breakdown — always shown, with token-share bars */}
+ {rows.length > 0 && (
+ <>
+
+
+ {rows.map((row) => {
+ const rowTokens = row.inputTokens + row.outputTokens;
+ const tokenPct = totalTokens > 0 ? (rowTokens / totalTokens) * 100 : 0;
+ const costPct = totalCostCents > 0 ? (row.costCents / totalCostCents) * 100 : 0;
+ return (
+
+ {/* model name and cost */}
+
+
+ {row.model}
+
+
+
+ {formatTokens(rowTokens)} tok
+
+ {formatCents(row.costCents)}
+
+
+ {/* token share bar */}
+
+
+ {/* cost share overlay — narrower, opaque, shows relative cost weight */}
+
+
+
+ );
+ })}
+
+ >
+ )}
+
+
+ );
+}
diff --git a/ui/src/components/QuotaBar.tsx b/ui/src/components/QuotaBar.tsx
new file mode 100644
index 00000000..89c25c6e
--- /dev/null
+++ b/ui/src/components/QuotaBar.tsx
@@ -0,0 +1,65 @@
+import { cn } from "@/lib/utils";
+
+interface QuotaBarProps {
+ label: string;
+ // value between 0 and 100
+ percentUsed: number;
+ leftLabel: string;
+ rightLabel?: string;
+ // shows a 2px destructive notch at the fill tip when true
+ showDeficitNotch?: boolean;
+ className?: string;
+}
+
+function fillColor(pct: number): string {
+ if (pct > 90) return "bg-red-400";
+ if (pct > 70) return "bg-yellow-400";
+ return "bg-green-400";
+}
+
+export function QuotaBar({
+ label,
+ percentUsed,
+ leftLabel,
+ rightLabel,
+ showDeficitNotch = false,
+ className,
+}: QuotaBarProps) {
+ const clampedPct = Math.min(100, Math.max(0, percentUsed));
+ // keep the notch visible even near the edges
+ const notchLeft = Math.min(clampedPct, 97);
+
+ return (
+
+ {/* row header */}
+
+
{label}
+
+ {leftLabel}
+ {rightLabel && (
+ {rightLabel}
+ )}
+
+
+
+ {/* track — boxed border, square corners to match the theme */}
+
+ {/* fill */}
+
+ {/* deficit notch — 2px wide, sits at the fill tip */}
+ {showDeficitNotch && clampedPct > 0 && (
+
+ )}
+
+
+ );
+}
diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx
index b8742dee..a1ea894e 100644
--- a/ui/src/components/Sidebar.tsx
+++ b/ui/src/components/Sidebar.tsx
@@ -4,6 +4,7 @@ import {
Target,
LayoutDashboard,
DollarSign,
+ Gauge,
History,
Search,
SquarePen,
@@ -107,6 +108,7 @@ export function Sidebar() {
+
diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx
index 96e3f654..33f7c90f 100644
--- a/ui/src/context/LiveUpdatesProvider.tsx
+++ b/ui/src/context/LiveUpdatesProvider.tsx
@@ -413,6 +413,8 @@ function invalidateActivityQueries(
if (entityType === "cost_event") {
queryClient.invalidateQueries({ queryKey: queryKeys.costs(companyId) });
+ queryClient.invalidateQueries({ queryKey: queryKeys.usageByProvider(companyId) });
+ queryClient.invalidateQueries({ queryKey: queryKeys.usageWindowSpend(companyId) });
return;
}
diff --git a/ui/src/lib/company-routes.ts b/ui/src/lib/company-routes.ts
index 736e3897..8f141a9e 100644
--- a/ui/src/lib/company-routes.ts
+++ b/ui/src/lib/company-routes.ts
@@ -9,6 +9,7 @@ const BOARD_ROUTE_ROOTS = new Set([
"goals",
"approvals",
"costs",
+ "usage",
"activity",
"inbox",
"design-guide",
diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts
index f53ac464..75285e0c 100644
--- a/ui/src/lib/queryKeys.ts
+++ b/ui/src/lib/queryKeys.ts
@@ -71,6 +71,10 @@ export const queryKeys = {
activity: (companyId: string) => ["activity", companyId] as const,
costs: (companyId: string, from?: string, to?: string) =>
["costs", companyId, from, to] as const,
+ usageByProvider: (companyId: string, from?: string, to?: string) =>
+ ["usage-by-provider", companyId, from, to] as const,
+ usageWindowSpend: (companyId: string) =>
+ ["usage-window-spend", companyId] as const,
heartbeats: (companyId: string, agentId?: string) =>
["heartbeats", companyId, agentId] as const,
runDetail: (runId: string) => ["heartbeat-run", runId] as const,
diff --git a/ui/src/pages/Costs.tsx b/ui/src/pages/Costs.tsx
index 6b977928..c4717ddd 100644
--- a/ui/src/pages/Costs.tsx
+++ b/ui/src/pages/Costs.tsx
@@ -153,9 +153,9 @@ export function Costs() {
{data.summary.budgetCents > 0 && (
-
+
90
? "bg-red-400"
: data.summary.utilizationPercent > 70
diff --git a/ui/src/pages/Usage.tsx b/ui/src/pages/Usage.tsx
new file mode 100644
index 00000000..61a43aba
--- /dev/null
+++ b/ui/src/pages/Usage.tsx
@@ -0,0 +1,325 @@
+import { useEffect, useMemo, useState } from "react";
+import { useQuery } from "@tanstack/react-query";
+import type { CostByProviderModel, CostWindowSpendRow } from "@paperclipai/shared";
+import { costsApi } from "../api/costs";
+import { useCompany } from "../context/CompanyContext";
+import { useBreadcrumbs } from "../context/BreadcrumbContext";
+import { queryKeys } from "../lib/queryKeys";
+import { EmptyState } from "../components/EmptyState";
+import { PageSkeleton } from "../components/PageSkeleton";
+import { ProviderQuotaCard } from "../components/ProviderQuotaCard";
+import { PageTabBar } from "../components/PageTabBar";
+import { formatCents, formatTokens } from "../lib/utils";
+import { Button } from "@/components/ui/button";
+import { Tabs, TabsContent } from "@/components/ui/tabs";
+import { Gauge } from "lucide-react";
+
+type DatePreset = "mtd" | "7d" | "30d" | "ytd" | "all" | "custom";
+
+const PRESET_LABELS: Record
= {
+ mtd: "Month to Date",
+ "7d": "Last 7 Days",
+ "30d": "Last 30 Days",
+ ytd: "Year to Date",
+ all: "All Time",
+ custom: "Custom",
+};
+
+function computeRange(preset: DatePreset): { from: string; to: string } {
+ const now = new Date();
+ const to = now.toISOString();
+ switch (preset) {
+ case "mtd": {
+ const d = new Date(now.getFullYear(), now.getMonth(), 1);
+ return { from: d.toISOString(), to };
+ }
+ case "7d": {
+ const d = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
+ return { from: d.toISOString(), to };
+ }
+ case "30d": {
+ const d = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
+ return { from: d.toISOString(), to };
+ }
+ case "ytd": {
+ const d = new Date(now.getFullYear(), 0, 1);
+ return { from: d.toISOString(), to };
+ }
+ case "all":
+ return { from: "", to: "" };
+ case "custom":
+ return { from: "", to: "" };
+ }
+}
+
+function providerDisplayName(provider: string): string {
+ const map: Record = {
+ anthropic: "Anthropic",
+ openai: "OpenAI",
+ google: "Google",
+ cursor: "Cursor",
+ jetbrains: "JetBrains AI",
+ };
+ return map[provider.toLowerCase()] ?? provider;
+}
+
+/** current week mon-sun boundaries as iso strings */
+function currentWeekRange(): { from: string; to: string } {
+ const now = new Date();
+ const day = now.getDay(); // 0 = Sun, 1 = Mon, …
+ const diffToMon = (day === 0 ? -6 : 1 - day);
+ const mon = new Date(now.getFullYear(), now.getMonth(), now.getDate() + diffToMon, 0, 0, 0, 0);
+ const sun = new Date(mon.getTime() + 6 * 24 * 60 * 60 * 1000 + 23 * 3600 * 1000 + 3599 * 1000 + 999);
+ return { from: mon.toISOString(), to: sun.toISOString() };
+}
+
+function ProviderTabLabel({ provider, rows }: { provider: string; rows: CostByProviderModel[] }) {
+ const totalTokens = rows.reduce((s, r) => s + r.inputTokens + r.outputTokens, 0);
+ const totalCost = rows.reduce((s, r) => s + r.costCents, 0);
+ return (
+
+ {providerDisplayName(provider)}
+ {formatTokens(totalTokens)}
+ {formatCents(totalCost)}
+
+ );
+}
+
+export function Usage() {
+ const { selectedCompanyId } = useCompany();
+ const { setBreadcrumbs } = useBreadcrumbs();
+
+ const [preset, setPreset] = useState("mtd");
+ const [customFrom, setCustomFrom] = useState("");
+ const [customTo, setCustomTo] = useState("");
+ const [activeProvider, setActiveProvider] = useState("all");
+
+ useEffect(() => {
+ setBreadcrumbs([{ label: "Usage" }]);
+ }, [setBreadcrumbs]);
+
+ const { from, to } = useMemo(() => {
+ if (preset === "custom") {
+ // treat custom date strings as local-date boundaries so the full day is included
+ // regardless of the user's timezone. "from" starts at local midnight (00:00:00),
+ // "to" ends at local 23:59:59.999 (converted to utc via Date constructor).
+ const fromDate = customFrom ? new Date(customFrom + "T00:00:00") : null;
+ const toDate = customTo ? new Date(customTo + "T23:59:59.999") : null;
+ return {
+ from: fromDate ? fromDate.toISOString() : "",
+ to: toDate ? toDate.toISOString() : "",
+ };
+ }
+ const range = computeRange(preset);
+ // floor `to` to the nearest minute so the query key is stable across 30s refetch ticks
+ // (prevents a new cache entry being created on every poll cycle)
+ if (range.to) {
+ const d = new Date(range.to);
+ d.setSeconds(0, 0);
+ range.to = d.toISOString();
+ }
+ return range;
+ }, [preset, customFrom, customTo]);
+
+ const weekRange = useMemo(() => currentWeekRange(), []);
+
+ // for custom preset, only fetch once both dates are selected
+ const customReady = preset !== "custom" || (!!customFrom && !!customTo);
+
+ const { data, isLoading, error } = useQuery({
+ queryKey: queryKeys.usageByProvider(selectedCompanyId!, from || undefined, to || undefined),
+ queryFn: () => costsApi.byProvider(selectedCompanyId!, from || undefined, to || undefined),
+ enabled: !!selectedCompanyId && customReady,
+ refetchInterval: 30_000,
+ staleTime: 10_000,
+ });
+
+ const { data: summary } = useQuery({
+ queryKey: queryKeys.costs(selectedCompanyId!, from || undefined, to || undefined),
+ queryFn: () =>
+ costsApi.summary(selectedCompanyId!, from || undefined, to || undefined),
+ enabled: !!selectedCompanyId && customReady,
+ refetchInterval: 30_000,
+ staleTime: 10_000,
+ });
+
+ const { data: weekData } = useQuery({
+ queryKey: queryKeys.usageByProvider(selectedCompanyId!, weekRange.from, weekRange.to),
+ queryFn: () => costsApi.byProvider(selectedCompanyId!, weekRange.from, weekRange.to),
+ enabled: !!selectedCompanyId,
+ refetchInterval: 30_000,
+ staleTime: 10_000,
+ });
+
+ const { data: windowData } = useQuery({
+ queryKey: queryKeys.usageWindowSpend(selectedCompanyId!),
+ queryFn: () => costsApi.windowSpend(selectedCompanyId!),
+ enabled: !!selectedCompanyId,
+ refetchInterval: 30_000,
+ staleTime: 10_000,
+ });
+
+ // rows grouped by provider
+ const byProvider = useMemo(() => {
+ const map = new Map();
+ for (const row of data ?? []) {
+ const arr = map.get(row.provider) ?? [];
+ arr.push(row);
+ map.set(row.provider, arr);
+ }
+ return map;
+ }, [data]);
+
+ // week spend per provider
+ const weekSpendByProvider = useMemo(() => {
+ const map = new Map();
+ for (const row of weekData ?? []) {
+ map.set(row.provider, (map.get(row.provider) ?? 0) + row.costCents);
+ }
+ return map;
+ }, [weekData]);
+
+ // window spend rows per provider, keyed by provider with the 3-window array
+ const windowSpendByProvider = useMemo(() => {
+ const map = new Map();
+ for (const row of windowData ?? []) {
+ const arr = map.get(row.provider) ?? [];
+ arr.push(row);
+ map.set(row.provider, arr);
+ }
+ return map;
+ }, [windowData]);
+
+ // deficit notch: projected spend exceeds remaining budget — only meaningful for mtd preset
+ // (other presets use a different date range than the monthly budget, so the projection is nonsensical)
+ const showDeficitNotch = useMemo(() => {
+ if (preset !== "mtd") return false;
+ const budget = summary?.budgetCents ?? 0;
+ if (budget <= 0) return false;
+ const spend = summary?.spendCents ?? 0;
+ const today = new Date();
+ const daysElapsed = today.getDate();
+ const daysInMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0).getDate();
+ const daysRemaining = daysInMonth - daysElapsed;
+ const burnRatePerDay = spend / Math.max(daysElapsed, 1);
+ const projected = spend + burnRatePerDay * daysRemaining;
+ return projected > budget;
+ }, [summary, preset]);
+
+ const providers = Array.from(byProvider.keys());
+
+ if (!selectedCompanyId) {
+ return ;
+ }
+
+ if (isLoading) {
+ return ;
+ }
+
+ const presetKeys: DatePreset[] = ["mtd", "7d", "30d", "ytd", "all", "custom"];
+
+ const tabItems = [
+ {
+ value: "all",
+ label: (
+
+ All providers
+ {data && data.length > 0 && (
+ <>
+
+ {formatTokens(data.reduce((s, r) => s + r.inputTokens + r.outputTokens, 0))}
+
+
+ {formatCents(data.reduce((s, r) => s + r.costCents, 0))}
+
+ >
+ )}
+
+ ),
+ },
+ ...providers.map((p) => ({
+ value: p,
+ label: ,
+ })),
+ ];
+
+ return (
+
+ {/* date range selector */}
+
+
+ {error &&
{(error as Error).message}
}
+
+ {preset === "custom" && !customReady ? (
+
Select a start and end date to load data.
+ ) : (
+
+
+
+
+ {providers.length === 0 ? (
+ No cost events in this period.
+ ) : (
+
+ {providers.map((p) => (
+
+ ))}
+
+ )}
+
+
+ {providers.map((p) => (
+
+
+
+ ))}
+
+ )}
+
+ );
+}