From 56c9d95daa0797c8fd19f1d4c7b0215142f6ef6a Mon Sep 17 00:00:00 2001 From: Sai Shankar Date: Sun, 8 Mar 2026 17:11:08 +0530 Subject: [PATCH] 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) => ( - ))} - {preset === "custom" && ( -
- setCustomFrom(e.target.value)} - className="h-8 rounded-md border border-input bg-background px-2 text-sm text-foreground" - /> - to - setCustomTo(e.target.value)} - className="h-8 rounded-md border border-input bg-background px-2 text-sm text-foreground" - /> -
- )} -
- - {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) => ( - - - - ))} -
- )} -
- ); -}