From 94018e0239b4db7c7e4af1122bf0c30a7aefebba Mon Sep 17 00:00:00 2001
From: Sai Shankar
Date: Sun, 8 Mar 2026 03:18:37 +0530
Subject: [PATCH 01/17] 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) => (
+
+
+
+ ))}
+
+ )}
+
+ );
+}
From 82bc00a3ae30148096af8cd23d471c287477170f Mon Sep 17 00:00:00 2001
From: Sai Shankar
Date: Sun, 8 Mar 2026 03:35:23 +0530
Subject: [PATCH 02/17] address greptile review: per-provider deficit notch,
startedAt filter, weekRange refresh, deduplicate providerDisplayName
---
server/src/services/costs.ts | 4 +--
ui/src/components/ProviderQuotaCard.tsx | 15 ++------
ui/src/lib/utils.ts | 12 +++++++
ui/src/pages/Usage.tsx | 47 +++++++++++--------------
4 files changed, 36 insertions(+), 42 deletions(-)
diff --git a/server/src/services/costs.ts b/server/src/services/costs.ts
index d1954b2d..1dd46fe0 100644
--- a/server/src/services/costs.ts
+++ b/server/src/services/costs.ts
@@ -172,8 +172,8 @@ export function costService(db: Db) {
.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));
+ if (range?.from) runConditions.push(gte(heartbeatRuns.startedAt, range.from));
+ if (range?.to) runConditions.push(lte(heartbeatRuns.startedAt, range.to));
const runRows = await db
.select({
diff --git a/ui/src/components/ProviderQuotaCard.tsx b/ui/src/components/ProviderQuotaCard.tsx
index ecb7c7cd..b17b54b7 100644
--- a/ui/src/components/ProviderQuotaCard.tsx
+++ b/ui/src/components/ProviderQuotaCard.tsx
@@ -1,7 +1,7 @@
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";
+import { formatCents, formatTokens, providerDisplayName } from "@/lib/utils";
interface ProviderQuotaCardProps {
provider: string;
@@ -17,17 +17,6 @@ interface ProviderQuotaCardProps {
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,
@@ -76,7 +65,7 @@ export function ProviderQuotaCard({
- {providerLabel(provider)}
+ {providerDisplayName(provider)}
{formatTokens(totalInputTokens)} in
diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts
index b9c3a020..f25b46f8 100644
--- a/ui/src/lib/utils.ts
+++ b/ui/src/lib/utils.ts
@@ -48,6 +48,18 @@ export function formatTokens(n: number): string {
return String(n);
}
+/** Map a raw provider slug to a display-friendly name. */
+export function providerDisplayName(provider: string): string {
+ const map: Record = {
+ anthropic: "Anthropic",
+ openai: "OpenAI",
+ google: "Google",
+ cursor: "Cursor",
+ jetbrains: "JetBrains AI",
+ };
+ return map[provider.toLowerCase()] ?? provider;
+}
+
/** Build an issue URL using the human-readable identifier when available. */
export function issueUrl(issue: { id: string; identifier?: string | null }): string {
return `/issues/${issue.identifier ?? issue.id}`;
diff --git a/ui/src/pages/Usage.tsx b/ui/src/pages/Usage.tsx
index 61a43aba..c102ea03 100644
--- a/ui/src/pages/Usage.tsx
+++ b/ui/src/pages/Usage.tsx
@@ -9,7 +9,7 @@ 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 { formatCents, formatTokens, providerDisplayName } from "../lib/utils";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent } from "@/components/ui/tabs";
import { Gauge } from "lucide-react";
@@ -52,17 +52,6 @@ function computeRange(preset: DatePreset): { from: string; to: string } {
}
}
-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();
@@ -121,7 +110,9 @@ export function Usage() {
return range;
}, [preset, customFrom, customTo]);
- const weekRange = useMemo(() => currentWeekRange(), []);
+ // key to today's date string so the range auto-refreshes after midnight on the next 30s refetch
+ const today = new Date().toDateString();
+ const weekRange = useMemo(() => currentWeekRange(), [today]);
// for custom preset, only fetch once both dates are selected
const customReady = preset !== "custom" || (!!customFrom && !!customTo);
@@ -190,21 +181,23 @@ export function Usage() {
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(() => {
+ // compute deficit notch per provider: only meaningful for mtd — projects spend to month end
+ // and flags when that projection exceeds the provider's pro-rata budget share.
+ function providerDeficitNotch(providerKey: string): boolean {
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 totalSpend = summary?.spendCents ?? 0;
+ const providerCostCents = (byProvider.get(providerKey) ?? []).reduce((s, r) => s + r.costCents, 0);
+ const providerShare = totalSpend > 0 ? providerCostCents / totalSpend : 0;
+ const providerBudget = budget * providerShare;
+ if (providerBudget <= 0) return false;
+ const now = new Date();
+ const daysElapsed = now.getDate();
+ const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
+ const burnRate = providerCostCents / Math.max(daysElapsed, 1);
+ return providerCostCents + burnRate * (daysInMonth - daysElapsed) > providerBudget;
+ }
const providers = Array.from(byProvider.keys());
@@ -298,7 +291,7 @@ export function Usage() {
totalCompanySpendCents={summary?.spendCents ?? 0}
weekSpendCents={weekSpendByProvider.get(p) ?? 0}
windowRows={windowSpendByProvider.get(p) ?? []}
- showDeficitNotch={showDeficitNotch}
+ showDeficitNotch={providerDeficitNotch(p)}
/>
))}
@@ -314,7 +307,7 @@ export function Usage() {
totalCompanySpendCents={summary?.spendCents ?? 0}
weekSpendCents={weekSpendByProvider.get(p) ?? 0}
windowRows={windowSpendByProvider.get(p) ?? []}
- showDeficitNotch={showDeficitNotch}
+ showDeficitNotch={providerDeficitNotch(p)}
/>
))}
From f14b6e449ffd82a3aec876de750042662db7d2b2 Mon Sep 17 00:00:00 2001
From: Sai Shankar
Date: Sun, 8 Mar 2026 16:35:14 +0530
Subject: [PATCH 03/17] feat(usage): add subscription quota windows per
provider on /usage page
reads local claude and codex auth files server-side, calls provider
quota apis (anthropic oauth usage, chatgpt wham/usage), and surfaces
live usedPercent per window in ProviderQuotaCard with threshold fill colors
---
packages/shared/src/index.ts | 2 +
packages/shared/src/types/index.ts | 1 +
packages/shared/src/types/quota.ts | 22 +++
server/src/routes/costs.ts | 7 +
server/src/services/quota-windows.ts | 242 ++++++++++++++++++++++++
ui/src/api/costs.ts | 4 +-
ui/src/components/ProviderQuotaCard.tsx | 54 +++++-
ui/src/context/LiveUpdatesProvider.tsx | 1 +
ui/src/lib/queryKeys.ts | 2 +
ui/src/pages/Usage.tsx | 24 ++-
10 files changed, 356 insertions(+), 3 deletions(-)
create mode 100644 packages/shared/src/types/quota.ts
create mode 100644 server/src/services/quota-windows.ts
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 40ddbc0f..a8df3802 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -189,6 +189,8 @@ export type {
PluginJobRecord,
PluginJobRunRecord,
PluginWebhookDeliveryRecord,
+ QuotaWindow,
+ ProviderQuotaResult,
} from "./types/index.js";
export {
diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts
index 7eae528b..1564614c 100644
--- a/packages/shared/src/types/index.ts
+++ b/packages/shared/src/types/index.ts
@@ -66,6 +66,7 @@ export type {
JoinRequest,
InstanceUserRoleGrant,
} from "./access.js";
+export type { QuotaWindow, ProviderQuotaResult } from "./quota.js";
export type {
CompanyPortabilityInclude,
CompanyPortabilitySecretRequirement,
diff --git a/packages/shared/src/types/quota.ts b/packages/shared/src/types/quota.ts
new file mode 100644
index 00000000..f5e5a391
--- /dev/null
+++ b/packages/shared/src/types/quota.ts
@@ -0,0 +1,22 @@
+/** a single rate-limit or usage window returned by a provider quota API */
+export interface QuotaWindow {
+ /** human label, e.g. "5h", "7d", "Sonnet 7d", "Credits" */
+ label: string;
+ /** percent of the window already consumed (0-100), null when not reported */
+ usedPercent: number | null;
+ /** iso timestamp when this window resets, null when not reported */
+ resetsAt: string | null;
+ /** free-form value label for credit-style windows, e.g. "$4.20 remaining" */
+ valueLabel: string | null;
+}
+
+/** result for one provider from the quota-windows endpoint */
+export interface ProviderQuotaResult {
+ /** provider slug, e.g. "anthropic", "openai" */
+ provider: string;
+ /** true when the fetch succeeded and windows is populated */
+ ok: boolean;
+ /** error message when ok is false */
+ error?: string;
+ windows: QuotaWindow[];
+}
diff --git a/server/src/routes/costs.ts b/server/src/routes/costs.ts
index e6bb6785..dd1662b7 100644
--- a/server/src/routes/costs.ts
+++ b/server/src/routes/costs.ts
@@ -4,6 +4,7 @@ import { createCostEventSchema, updateBudgetSchema } from "@paperclipai/shared";
import { validate } from "../middleware/validate.js";
import { costService, companyService, agentService, logActivity } from "../services/index.js";
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
+import { fetchAllQuotaWindows } from "../services/quota-windows.js";
export function costRoutes(db: Db) {
const router = Router();
@@ -77,6 +78,12 @@ export function costRoutes(db: Db) {
res.json(rows);
});
+ router.get("/companies/:companyId/costs/quota-windows", async (req, res) => {
+ assertBoard(req);
+ const results = await fetchAllQuotaWindows();
+ res.json(results);
+ });
+
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/quota-windows.ts b/server/src/services/quota-windows.ts
new file mode 100644
index 00000000..0394a0ff
--- /dev/null
+++ b/server/src/services/quota-windows.ts
@@ -0,0 +1,242 @@
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import type { ProviderQuotaResult, QuotaWindow } from "@paperclipai/shared";
+
+// ---------- claude ----------
+
+function claudeConfigDir(): string {
+ const fromEnv = process.env.CLAUDE_CONFIG_DIR;
+ if (typeof fromEnv === "string" && fromEnv.trim().length > 0) return fromEnv.trim();
+ return path.join(os.homedir(), ".claude");
+}
+
+async function readClaudeToken(): Promise {
+ const credPath = path.join(claudeConfigDir(), "credentials.json");
+ let raw: string;
+ try {
+ raw = await fs.readFile(credPath, "utf8");
+ } catch {
+ return null;
+ }
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(raw);
+ } catch {
+ return null;
+ }
+ if (typeof parsed !== "object" || parsed === null) return null;
+ const obj = parsed as Record;
+ const oauth = obj["claudeAiOauth"];
+ if (typeof oauth !== "object" || oauth === null) return null;
+ const token = (oauth as Record)["accessToken"];
+ return typeof token === "string" && token.length > 0 ? token : null;
+}
+
+interface AnthropicUsageWindow {
+ utilization?: number | null;
+ resets_at?: string | null;
+}
+
+interface AnthropicUsageResponse {
+ five_hour?: AnthropicUsageWindow | null;
+ seven_day?: AnthropicUsageWindow | null;
+ seven_day_sonnet?: AnthropicUsageWindow | null;
+ seven_day_opus?: AnthropicUsageWindow | null;
+}
+
+function toPercent(utilization: number | null | undefined): number | null {
+ if (utilization == null) return null;
+ // utilization is 0-1 fraction
+ return Math.round(utilization * 100);
+}
+
+async function fetchClaudeQuota(token: string): Promise {
+ const resp = await fetch("https://api.anthropic.com/api/oauth/usage", {
+ headers: {
+ "Authorization": `Bearer ${token}`,
+ "anthropic-beta": "oauth-2025-04-20",
+ },
+ });
+ if (!resp.ok) throw new Error(`anthropic usage api returned ${resp.status}`);
+ const body = (await resp.json()) as AnthropicUsageResponse;
+ const windows: QuotaWindow[] = [];
+
+ if (body.five_hour != null) {
+ windows.push({
+ label: "5h",
+ usedPercent: toPercent(body.five_hour.utilization),
+ resetsAt: body.five_hour.resets_at ?? null,
+ valueLabel: null,
+ });
+ }
+ if (body.seven_day != null) {
+ windows.push({
+ label: "7d",
+ usedPercent: toPercent(body.seven_day.utilization),
+ resetsAt: body.seven_day.resets_at ?? null,
+ valueLabel: null,
+ });
+ }
+ if (body.seven_day_sonnet != null) {
+ windows.push({
+ label: "Sonnet 7d",
+ usedPercent: toPercent(body.seven_day_sonnet.utilization),
+ resetsAt: body.seven_day_sonnet.resets_at ?? null,
+ valueLabel: null,
+ });
+ }
+ if (body.seven_day_opus != null) {
+ windows.push({
+ label: "Opus 7d",
+ usedPercent: toPercent(body.seven_day_opus.utilization),
+ resetsAt: body.seven_day_opus.resets_at ?? null,
+ valueLabel: null,
+ });
+ }
+ return windows;
+}
+
+// ---------- codex / openai ----------
+
+function codexHomeDir(): string {
+ const fromEnv = process.env.CODEX_HOME;
+ if (typeof fromEnv === "string" && fromEnv.trim().length > 0) return fromEnv.trim();
+ return path.join(os.homedir(), ".codex");
+}
+
+interface CodexAuthFile {
+ accessToken?: string | null;
+ accountId?: string | null;
+}
+
+async function readCodexToken(): Promise<{ token: string; accountId: string | null } | null> {
+ const authPath = path.join(codexHomeDir(), "auth.json");
+ let raw: string;
+ try {
+ raw = await fs.readFile(authPath, "utf8");
+ } catch {
+ return null;
+ }
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(raw);
+ } catch {
+ return null;
+ }
+ if (typeof parsed !== "object" || parsed === null) return null;
+ const obj = parsed as CodexAuthFile;
+ const token = obj.accessToken;
+ if (typeof token !== "string" || token.length === 0) return null;
+ const accountId = typeof obj.accountId === "string" && obj.accountId.length > 0
+ ? obj.accountId
+ : null;
+ return { token, accountId };
+}
+
+interface WhamWindow {
+ used_percent?: number | null;
+ limit_window_seconds?: number | null;
+ reset_at?: string | null;
+}
+
+interface WhamCredits {
+ balance?: number | null;
+ unlimited?: boolean | null;
+}
+
+interface WhamUsageResponse {
+ rate_limit?: {
+ primary_window?: WhamWindow | null;
+ secondary_window?: WhamWindow | null;
+ } | null;
+ credits?: WhamCredits | null;
+}
+
+function secondsToWindowLabel(seconds: number | null | undefined): string {
+ if (seconds == null) return "Window";
+ const hours = seconds / 3600;
+ if (hours <= 6) return "5h";
+ if (hours <= 30) return "24h";
+ return "Weekly";
+}
+
+async function fetchCodexQuota(token: string, accountId: string | null): Promise {
+ const headers: Record = {
+ "Authorization": `Bearer ${token}`,
+ };
+ if (accountId) headers["ChatGPT-Account-Id"] = accountId;
+
+ const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", { headers });
+ if (!resp.ok) throw new Error(`chatgpt wham api returned ${resp.status}`);
+ const body = (await resp.json()) as WhamUsageResponse;
+ const windows: QuotaWindow[] = [];
+
+ const rateLimit = body.rate_limit;
+ if (rateLimit?.primary_window != null) {
+ const w = rateLimit.primary_window;
+ windows.push({
+ label: secondsToWindowLabel(w.limit_window_seconds),
+ usedPercent: w.used_percent ?? null,
+ resetsAt: w.reset_at ?? null,
+ valueLabel: null,
+ });
+ }
+ if (rateLimit?.secondary_window != null) {
+ const w = rateLimit.secondary_window;
+ windows.push({
+ label: "Weekly",
+ usedPercent: w.used_percent ?? null,
+ resetsAt: w.reset_at ?? null,
+ valueLabel: null,
+ });
+ }
+ if (body.credits != null && body.credits.unlimited !== true) {
+ const balance = body.credits.balance;
+ const valueLabel = balance != null
+ ? `$${(balance / 100).toFixed(2)} remaining`
+ : null;
+ windows.push({
+ label: "Credits",
+ usedPercent: null,
+ resetsAt: null,
+ valueLabel,
+ });
+ }
+ return windows;
+}
+
+// ---------- aggregate ----------
+
+export async function fetchAllQuotaWindows(): Promise {
+ const results: ProviderQuotaResult[] = [];
+
+ const [claudeResult, codexResult] = await Promise.allSettled([
+ (async (): Promise => {
+ const token = await readClaudeToken();
+ if (!token) return { provider: "anthropic", ok: false, error: "no local claude auth token", windows: [] };
+ const windows = await fetchClaudeQuota(token);
+ return { provider: "anthropic", ok: true, windows };
+ })(),
+ (async (): Promise => {
+ const auth = await readCodexToken();
+ if (!auth) return { provider: "openai", ok: false, error: "no local codex auth token", windows: [] };
+ const windows = await fetchCodexQuota(auth.token, auth.accountId);
+ return { provider: "openai", ok: true, windows };
+ })(),
+ ]);
+
+ if (claudeResult.status === "fulfilled") {
+ results.push(claudeResult.value);
+ } else {
+ results.push({ provider: "anthropic", ok: false, error: String(claudeResult.reason), windows: [] });
+ }
+
+ if (codexResult.status === "fulfilled") {
+ results.push(codexResult.value);
+ } else {
+ results.push({ provider: "openai", ok: false, error: String(codexResult.reason), windows: [] });
+ }
+
+ return results;
+}
diff --git a/ui/src/api/costs.ts b/ui/src/api/costs.ts
index ca08c31a..ba515934 100644
--- a/ui/src/api/costs.ts
+++ b/ui/src/api/costs.ts
@@ -1,4 +1,4 @@
-import type { CostSummary, CostByAgent, CostByProviderModel, CostWindowSpendRow } from "@paperclipai/shared";
+import type { CostSummary, CostByAgent, CostByProviderModel, CostWindowSpendRow, ProviderQuotaResult } from "@paperclipai/shared";
import { api } from "./client";
export interface CostByProject {
@@ -28,4 +28,6 @@ export const costsApi = {
api.get(`/companies/${companyId}/costs/by-provider${dateParams(from, to)}`),
windowSpend: (companyId: string) =>
api.get(`/companies/${companyId}/costs/window-spend`),
+ quotaWindows: (companyId: string) =>
+ api.get(`/companies/${companyId}/costs/quota-windows`),
};
diff --git a/ui/src/components/ProviderQuotaCard.tsx b/ui/src/components/ProviderQuotaCard.tsx
index b17b54b7..b46baa56 100644
--- a/ui/src/components/ProviderQuotaCard.tsx
+++ b/ui/src/components/ProviderQuotaCard.tsx
@@ -1,4 +1,4 @@
-import type { CostByProviderModel, CostWindowSpendRow } from "@paperclipai/shared";
+import type { CostByProviderModel, CostWindowSpendRow, QuotaWindow } from "@paperclipai/shared";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { QuotaBar } from "./QuotaBar";
import { formatCents, formatTokens, providerDisplayName } from "@/lib/utils";
@@ -15,6 +15,8 @@ interface ProviderQuotaCardProps {
/** rolling window rows for this provider: 5h, 24h, 7d */
windowRows: CostWindowSpendRow[];
showDeficitNotch: boolean;
+ /** live subscription quota windows from the provider's own api */
+ quotaWindows?: QuotaWindow[];
}
export function ProviderQuotaCard({
@@ -25,6 +27,7 @@ export function ProviderQuotaCard({
weekSpendCents,
windowRows,
showDeficitNotch,
+ quotaWindows = [],
}: ProviderQuotaCardProps) {
const totalInputTokens = rows.reduce((s, r) => s + r.inputTokens, 0);
const totalOutputTokens = rows.reduce((s, r) => s + r.outputTokens, 0);
@@ -150,6 +153,55 @@ export function ProviderQuotaCard({
);
})()}
+ {/* subscription quota windows from provider api — shown when data is available */}
+ {quotaWindows.length > 0 && (
+ <>
+
+
+
+ Subscription quota
+
+
+ {quotaWindows.map((qw) => {
+ const pct = qw.usedPercent ?? 0;
+ const fillColor =
+ pct >= 90
+ ? "bg-red-400"
+ : pct >= 70
+ ? "bg-yellow-400"
+ : "bg-green-400";
+ return (
+
+
+ {qw.label}
+
+ {qw.valueLabel != null ? (
+ {qw.valueLabel}
+ ) : qw.usedPercent != null ? (
+ {qw.usedPercent}% used
+ ) : null}
+
+ {qw.usedPercent != null && (
+
+ )}
+ {qw.resetsAt && (
+
+ resets {new Date(qw.resetsAt).toLocaleDateString(undefined, { month: "short", day: "numeric" })}
+
+ )}
+
+ );
+ })}
+
+
+ >
+ )}
+
{/* subscription usage — shown when any subscription-billed runs exist */}
{totalSubRuns > 0 && (
<>
diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx
index 33f7c90f..22a2dc49 100644
--- a/ui/src/context/LiveUpdatesProvider.tsx
+++ b/ui/src/context/LiveUpdatesProvider.tsx
@@ -415,6 +415,7 @@ function invalidateActivityQueries(
queryClient.invalidateQueries({ queryKey: queryKeys.costs(companyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.usageByProvider(companyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.usageWindowSpend(companyId) });
+ queryClient.invalidateQueries({ queryKey: queryKeys.usageQuotaWindows(companyId) });
return;
}
diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts
index 75285e0c..3ae44c70 100644
--- a/ui/src/lib/queryKeys.ts
+++ b/ui/src/lib/queryKeys.ts
@@ -75,6 +75,8 @@ export const queryKeys = {
["usage-by-provider", companyId, from, to] as const,
usageWindowSpend: (companyId: string) =>
["usage-window-spend", companyId] as const,
+ usageQuotaWindows: (companyId: string) =>
+ ["usage-quota-windows", 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/Usage.tsx b/ui/src/pages/Usage.tsx
index c102ea03..ee5bf2aa 100644
--- a/ui/src/pages/Usage.tsx
+++ b/ui/src/pages/Usage.tsx
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
-import type { CostByProviderModel, CostWindowSpendRow } from "@paperclipai/shared";
+import type { CostByProviderModel, CostWindowSpendRow, QuotaWindow } from "@paperclipai/shared";
import { costsApi } from "../api/costs";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
@@ -150,6 +150,15 @@ export function Usage() {
staleTime: 10_000,
});
+ const { data: quotaData } = useQuery({
+ queryKey: queryKeys.usageQuotaWindows(selectedCompanyId!),
+ queryFn: () => costsApi.quotaWindows(selectedCompanyId!),
+ enabled: !!selectedCompanyId,
+ // quota windows change infrequently; refresh every 5 minutes
+ refetchInterval: 300_000,
+ staleTime: 60_000,
+ });
+
// rows grouped by provider
const byProvider = useMemo(() => {
const map = new Map();
@@ -181,6 +190,17 @@ export function Usage() {
return map;
}, [windowData]);
+ // quota windows from the provider's own api, keyed by provider
+ const quotaWindowsByProvider = useMemo(() => {
+ const map = new Map();
+ for (const result of quotaData ?? []) {
+ if (result.ok && result.windows.length > 0) {
+ map.set(result.provider, result.windows);
+ }
+ }
+ return map;
+ }, [quotaData]);
+
// compute deficit notch per provider: only meaningful for mtd — projects spend to month end
// and flags when that projection exceeds the provider's pro-rata budget share.
function providerDeficitNotch(providerKey: string): boolean {
@@ -292,6 +312,7 @@ export function Usage() {
weekSpendCents={weekSpendByProvider.get(p) ?? 0}
windowRows={windowSpendByProvider.get(p) ?? []}
showDeficitNotch={providerDeficitNotch(p)}
+ quotaWindows={quotaWindowsByProvider.get(p) ?? []}
/>
))}
@@ -308,6 +329,7 @@ export function Usage() {
weekSpendCents={weekSpendByProvider.get(p) ?? 0}
windowRows={windowSpendByProvider.get(p) ?? []}
showDeficitNotch={providerDeficitNotch(p)}
+ quotaWindows={quotaWindowsByProvider.get(p) ?? []}
/>
))}
From 56c9d95daa0797c8fd19f1d4c7b0215142f6ef6a Mon Sep 17 00:00:00 2001
From: Sai Shankar
Date: Sun, 8 Mar 2026 17:11:08 +0530
Subject: [PATCH 04/17] feat(costs): consolidate /usage into /costs with Spend
+ Providers tabs
merge Usage page into Costs as two tabs ('Spend' and 'Providers'),
extract shared date-range logic to useDateRange() hook, delete /usage
route and sidebar entry, fix quota-windows bugs from prior review
---
server/src/services/quota-windows.ts | 21 +-
ui/src/App.tsx | 2 -
ui/src/components/ProviderQuotaCard.tsx | 21 +-
ui/src/components/Sidebar.tsx | 2 -
ui/src/context/LiveUpdatesProvider.tsx | 1 -
ui/src/hooks/useDateRange.ts | 96 +++++
ui/src/pages/Costs.tsx | 498 ++++++++++++++++--------
ui/src/pages/Usage.tsx | 340 ----------------
8 files changed, 468 insertions(+), 513 deletions(-)
create mode 100644 ui/src/hooks/useDateRange.ts
delete mode 100644 ui/src/pages/Usage.tsx
diff --git a/server/src/services/quota-windows.ts b/server/src/services/quota-windows.ts
index 0394a0ff..07179626 100644
--- a/server/src/services/quota-windows.ts
+++ b/server/src/services/quota-windows.ts
@@ -47,12 +47,23 @@ interface AnthropicUsageResponse {
function toPercent(utilization: number | null | undefined): number | null {
if (utilization == null) return null;
- // utilization is 0-1 fraction
- return Math.round(utilization * 100);
+ // utilization is 0-1 fraction; clamp to 100 in case of floating-point overshoot
+ return Math.min(100, Math.round(utilization * 100));
+}
+
+// fetch with an abort-based timeout so a hanging provider api doesn't block the response indefinitely
+async function fetchWithTimeout(url: string, init: RequestInit, ms = 8000): Promise {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), ms);
+ try {
+ return await fetch(url, { ...init, signal: controller.signal });
+ } finally {
+ clearTimeout(timer);
+ }
}
async function fetchClaudeQuota(token: string): Promise {
- const resp = await fetch("https://api.anthropic.com/api/oauth/usage", {
+ const resp = await fetchWithTimeout("https://api.anthropic.com/api/oauth/usage", {
headers: {
"Authorization": `Bearer ${token}`,
"anthropic-beta": "oauth-2025-04-20",
@@ -167,7 +178,7 @@ async function fetchCodexQuota(token: string, accountId: string | null): Promise
};
if (accountId) headers["ChatGPT-Account-Id"] = accountId;
- const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", { headers });
+ const resp = await fetchWithTimeout("https://chatgpt.com/backend-api/wham/usage", { headers });
if (!resp.ok) throw new Error(`chatgpt wham api returned ${resp.status}`);
const body = (await resp.json()) as WhamUsageResponse;
const windows: QuotaWindow[] = [];
@@ -185,7 +196,7 @@ async function fetchCodexQuota(token: string, accountId: string | null): Promise
if (rateLimit?.secondary_window != null) {
const w = rateLimit.secondary_window;
windows.push({
- label: "Weekly",
+ label: secondsToWindowLabel(w.limit_window_seconds),
usedPercent: w.used_percent ?? null,
resetsAt: w.reset_at ?? null,
valueLabel: null,
diff --git a/ui/src/App.tsx b/ui/src/App.tsx
index b1016a88..b8d77f44 100644
--- a/ui/src/App.tsx
+++ b/ui/src/App.tsx
@@ -19,7 +19,6 @@ 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";
@@ -148,7 +147,6 @@ function boardRoutes() {
} />
} />
} />
- } />
} />
} />
} />
diff --git a/ui/src/components/ProviderQuotaCard.tsx b/ui/src/components/ProviderQuotaCard.tsx
index b46baa56..ca1a2467 100644
--- a/ui/src/components/ProviderQuotaCard.tsx
+++ b/ui/src/components/ProviderQuotaCard.tsx
@@ -162,16 +162,17 @@ export function ProviderQuotaCard({
Subscription quota
- {quotaWindows.map((qw) => {
- const pct = qw.usedPercent ?? 0;
+ {quotaWindows.map((qw, i) => {
const fillColor =
- pct >= 90
- ? "bg-red-400"
- : pct >= 70
- ? "bg-yellow-400"
- : "bg-green-400";
+ qw.usedPercent == null
+ ? null
+ : qw.usedPercent >= 90
+ ? "bg-red-400"
+ : qw.usedPercent >= 70
+ ? "bg-yellow-400"
+ : "bg-green-400";
return (
-
+
{qw.label}
@@ -181,11 +182,11 @@ export function ProviderQuotaCard({
{qw.usedPercent}% used
) : null}
- {qw.usedPercent != null && (
+ {qw.usedPercent != null && fillColor != null && (
)}
diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx
index a1ea894e..b8742dee 100644
--- a/ui/src/components/Sidebar.tsx
+++ b/ui/src/components/Sidebar.tsx
@@ -4,7 +4,6 @@ import {
Target,
LayoutDashboard,
DollarSign,
- Gauge,
History,
Search,
SquarePen,
@@ -108,7 +107,6 @@ export function Sidebar() {
-
diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx
index 22a2dc49..33f7c90f 100644
--- a/ui/src/context/LiveUpdatesProvider.tsx
+++ b/ui/src/context/LiveUpdatesProvider.tsx
@@ -415,7 +415,6 @@ function invalidateActivityQueries(
queryClient.invalidateQueries({ queryKey: queryKeys.costs(companyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.usageByProvider(companyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.usageWindowSpend(companyId) });
- queryClient.invalidateQueries({ queryKey: queryKeys.usageQuotaWindows(companyId) });
return;
}
diff --git a/ui/src/hooks/useDateRange.ts b/ui/src/hooks/useDateRange.ts
new file mode 100644
index 00000000..53e9c5a8
--- /dev/null
+++ b/ui/src/hooks/useDateRange.ts
@@ -0,0 +1,96 @@
+import { useMemo, useState } from "react";
+
+export type DatePreset = "mtd" | "7d" | "30d" | "ytd" | "all" | "custom";
+
+export 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",
+};
+
+export const PRESET_KEYS: DatePreset[] = ["mtd", "7d", "30d", "ytd", "all", "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":
+ case "custom":
+ return { from: "", to: "" };
+ }
+}
+
+export interface UseDateRangeResult {
+ preset: DatePreset;
+ setPreset: (p: DatePreset) => void;
+ customFrom: string;
+ setCustomFrom: (v: string) => void;
+ customTo: string;
+ setCustomTo: (v: string) => void;
+ /** resolved iso strings ready to pass to api calls; empty string means unbounded */
+ from: string;
+ to: string;
+ /** false when preset=custom but both dates are not yet selected */
+ customReady: boolean;
+}
+
+export function useDateRange(): UseDateRangeResult {
+ const [preset, setPreset] = useState("mtd");
+ const [customFrom, setCustomFrom] = useState("");
+ const [customTo, setCustomTo] = useState("");
+
+ 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, "to" at 23:59:59.999.
+ 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 customReady = preset !== "custom" || (!!customFrom && !!customTo);
+
+ return {
+ preset,
+ setPreset,
+ customFrom,
+ setCustomFrom,
+ customTo,
+ setCustomTo,
+ from,
+ to,
+ customReady,
+ };
+}
diff --git a/ui/src/pages/Costs.tsx b/ui/src/pages/Costs.tsx
index c4717ddd..02bebca4 100644
--- a/ui/src/pages/Costs.tsx
+++ b/ui/src/pages/Costs.tsx
@@ -1,79 +1,79 @@
import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
+import type { CostByProviderModel, CostWindowSpendRow, QuotaWindow } 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 { formatCents, formatTokens } from "../lib/utils";
+import { ProviderQuotaCard } from "../components/ProviderQuotaCard";
+import { PageTabBar } from "../components/PageTabBar";
+import { formatCents, formatTokens, providerDisplayName } from "../lib/utils";
import { Identity } from "../components/Identity";
import { StatusBadge } from "../components/StatusBadge";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { DollarSign } from "lucide-react";
+import { useDateRange, PRESET_LABELS, PRESET_KEYS } from "../hooks/useDateRange";
-type DatePreset = "mtd" | "7d" | "30d" | "ytd" | "all" | "custom";
+// ---------- helpers ----------
-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 } {
+/** current week mon-sun boundaries as iso strings */
+function currentWeekRange(): { 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: "" };
- }
+ const day = now.getDay(); // 0 = Sun
+ 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)}
+
+ );
+}
+
+// ---------- page ----------
+
export function Costs() {
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
- const [preset, setPreset] = useState("mtd");
- const [customFrom, setCustomFrom] = useState("");
- const [customTo, setCustomTo] = useState("");
+ const [mainTab, setMainTab] = useState<"spend" | "providers">("spend");
+ const [activeProvider, setActiveProvider] = useState("all");
+
+ const {
+ preset,
+ setPreset,
+ customFrom,
+ setCustomFrom,
+ customTo,
+ setCustomTo,
+ from,
+ to,
+ customReady,
+ } = useDateRange();
useEffect(() => {
setBreadcrumbs([{ label: "Costs" }]);
}, [setBreadcrumbs]);
- const { from, to } = useMemo(() => {
- if (preset === "custom") {
- return {
- from: customFrom ? new Date(customFrom).toISOString() : "",
- to: customTo ? new Date(customTo + "T23:59:59.999Z").toISOString() : "",
- };
- }
- return computeRange(preset);
- }, [preset, customFrom, customTo]);
+ // key to today's date string so the week range auto-refreshes after midnight on the next render
+ const today = new Date().toDateString();
+ const weekRange = useMemo(() => currentWeekRange(), [today]); // eslint-disable-line react-hooks/exhaustive-deps
- const { data, isLoading, error } = useQuery({
+ // ---------- spend tab queries (no polling — cost data doesn't change in real time) ----------
+
+ const { data: spendData, isLoading: spendLoading, error: spendError } = useQuery({
queryKey: queryKeys.costs(selectedCompanyId!, from || undefined, to || undefined),
queryFn: async () => {
const [summary, byAgent, byProject] = await Promise.all([
@@ -83,24 +83,152 @@ export function Costs() {
]);
return { summary, byAgent, byProject };
},
- enabled: !!selectedCompanyId,
+ enabled: !!selectedCompanyId && customReady,
});
+ // ---------- providers tab queries (polling — provider quota changes during agent runs) ----------
+
+ const { data: providerData } = 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: 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,
+ });
+
+ const { data: quotaData } = useQuery({
+ queryKey: queryKeys.usageQuotaWindows(selectedCompanyId!),
+ queryFn: () => costsApi.quotaWindows(selectedCompanyId!),
+ enabled: !!selectedCompanyId,
+ // quota windows come from external provider apis; refresh every 5 minutes
+ refetchInterval: 300_000,
+ staleTime: 60_000,
+ });
+
+ // ---------- providers tab derived maps ----------
+
+ const byProvider = useMemo(() => {
+ const map = new Map();
+ for (const row of providerData ?? []) {
+ const arr = map.get(row.provider) ?? [];
+ arr.push(row);
+ map.set(row.provider, arr);
+ }
+ return map;
+ }, [providerData]);
+
+ 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]);
+
+ 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]);
+
+ const quotaWindowsByProvider = useMemo(() => {
+ const map = new Map();
+ for (const result of quotaData ?? []) {
+ if (result.ok && result.windows.length > 0) {
+ map.set(result.provider, result.windows);
+ }
+ }
+ return map;
+ }, [quotaData]);
+
+ // deficit notch: projected month-end spend vs pro-rata budget share (mtd only)
+ // memoized to avoid stale closure reads when summary and byProvider arrive separately
+ const deficitNotchByProvider = useMemo(() => {
+ const map = new Map();
+ if (preset !== "mtd") return map;
+ const budget = spendData?.summary.budgetCents ?? 0;
+ if (budget <= 0) return map;
+ const totalSpend = spendData?.summary.spendCents ?? 0;
+ const now = new Date();
+ const daysElapsed = now.getDate();
+ const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
+ for (const [providerKey, rows] of byProvider) {
+ const providerCostCents = rows.reduce((s, r) => s + r.costCents, 0);
+ const providerShare = totalSpend > 0 ? providerCostCents / totalSpend : 0;
+ const providerBudget = budget * providerShare;
+ if (providerBudget <= 0) { map.set(providerKey, false); continue; }
+ const burnRate = providerCostCents / Math.max(daysElapsed, 1);
+ map.set(providerKey, providerCostCents + burnRate * (daysInMonth - daysElapsed) > providerBudget);
+ }
+ return map;
+ }, [preset, spendData, byProvider]);
+
+ const providers = Array.from(byProvider.keys());
+
+ // ---------- guards ----------
+
if (!selectedCompanyId) {
return ;
}
- if (isLoading) {
+ if (spendLoading) {
return ;
}
- const presetKeys: DatePreset[] = ["mtd", "7d", "30d", "ytd", "all", "custom"];
+ // ---------- provider tab items ----------
+
+ const providerTabItems = [
+ {
+ value: "all",
+ label: (
+
+ All providers
+ {providerData && providerData.length > 0 && (
+ <>
+
+ {formatTokens(providerData.reduce((s, r) => s + r.inputTokens + r.outputTokens, 0))}
+
+
+ {formatCents(providerData.reduce((s, r) => s + r.costCents, 0))}
+
+ >
+ )}
+
+ ),
+ },
+ ...providers.map((p) => ({
+ value: p,
+ label: ,
+ })),
+ ];
+
+ // ---------- render ----------
return (
- {/* Date range selector */}
+ {/* date range selector */}
- {presetKeys.map((p) => (
+ {PRESET_KEYS.map((p) => (