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) => (