diff --git a/server/src/routes/costs.ts b/server/src/routes/costs.ts index dd1662b7..fdf5d2a4 100644 --- a/server/src/routes/costs.ts +++ b/server/src/routes/costs.ts @@ -79,6 +79,8 @@ export function costRoutes(db: Db) { }); router.get("/companies/:companyId/costs/quota-windows", async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); assertBoard(req); const results = await fetchAllQuotaWindows(); res.json(results); diff --git a/server/src/services/quota-windows.ts b/server/src/services/quota-windows.ts index 07179626..50c7da2e 100644 --- a/server/src/services/quota-windows.ts +++ b/server/src/services/quota-windows.ts @@ -164,12 +164,12 @@ interface WhamUsageResponse { credits?: WhamCredits | null; } -function secondsToWindowLabel(seconds: number | null | undefined): string { - if (seconds == null) return "Window"; +function secondsToWindowLabel(seconds: number | null | undefined, fallback: string): string { + if (seconds == null) return fallback; const hours = seconds / 3600; - if (hours <= 6) return "5h"; - if (hours <= 30) return "24h"; - return "Weekly"; + if (hours < 6) return "5h"; + if (hours <= 24) return "24h"; + return "7d"; } async function fetchCodexQuota(token: string, accountId: string | null): Promise { @@ -186,18 +186,28 @@ async function fetchCodexQuota(token: string, accountId: string | null): Promise const rateLimit = body.rate_limit; if (rateLimit?.primary_window != null) { const w = rateLimit.primary_window; + // wham used_percent is 0-100 (confirmed empirically); guard against 0-1 format just in case + const rawPct = w.used_percent ?? null; + const usedPercent = rawPct != null + ? Math.min(100, Math.round(rawPct <= 1 ? rawPct * 100 : rawPct)) + : null; windows.push({ - label: secondsToWindowLabel(w.limit_window_seconds), - usedPercent: w.used_percent ?? null, + label: secondsToWindowLabel(w.limit_window_seconds, "Primary"), + usedPercent, resetsAt: w.reset_at ?? null, valueLabel: null, }); } if (rateLimit?.secondary_window != null) { const w = rateLimit.secondary_window; + // wham used_percent is 0-100 (confirmed empirically); guard against 0-1 format just in case + const rawPct = w.used_percent ?? null; + const usedPercent = rawPct != null + ? Math.min(100, Math.round(rawPct <= 1 ? rawPct * 100 : rawPct)) + : null; windows.push({ - label: secondsToWindowLabel(w.limit_window_seconds), - usedPercent: w.used_percent ?? null, + label: secondsToWindowLabel(w.limit_window_seconds, "Secondary"), + usedPercent, resetsAt: w.reset_at ?? null, valueLabel: null, }); @@ -206,7 +216,7 @@ async function fetchCodexQuota(token: string, accountId: string | null): Promise const balance = body.credits.balance; const valueLabel = balance != null ? `$${(balance / 100).toFixed(2)} remaining` - : null; + : "N/A"; windows.push({ label: "Credits", usedPercent: null, diff --git a/ui/src/components/ProviderQuotaCard.tsx b/ui/src/components/ProviderQuotaCard.tsx index ca1a2467..0ebe5d93 100644 --- a/ui/src/components/ProviderQuotaCard.tsx +++ b/ui/src/components/ProviderQuotaCard.tsx @@ -1,8 +1,12 @@ +import { useMemo } from "react"; 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"; +// ordered display labels for rolling-window rows +const ROLLING_WINDOWS = ["5h", "24h", "7d"] as const; + interface ProviderQuotaCardProps { provider: string; rows: CostByProviderModel[]; @@ -56,12 +60,23 @@ export function ProviderQuotaCard({ ? Math.min(100, (totalCostCents / providerBudgetShare) * 100) : 0; + // 4.33 = average weeks per calendar month (52 / 12) const weeklyBudgetShare = providerBudgetShare > 0 ? providerBudgetShare / 4.33 : 0; const weekPct = weeklyBudgetShare > 0 ? Math.min(100, (weekSpendCents / weeklyBudgetShare) * 100) : 0; const hasBudget = budgetMonthlyCents > 0; + // memoized so the Map and max are not reconstructed on every parent render tick + const windowMap = useMemo( + () => new Map(windowRows.map((r) => [r.window, r])), + [windowRows], + ); + const maxWindowCents = useMemo( + () => Math.max(...windowRows.map((r) => r.costCents), 0), + [windowRows], + ); + return ( @@ -112,46 +127,41 @@ export function ProviderQuotaCard({ )} {/* 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)} -
-
-
-
+ {windowRows.length > 0 && ( + <> +
+
+

+ Rolling windows +

+
+ {ROLLING_WINDOWS.map((w) => { + const row = windowMap.get(w); + const cents = row?.costCents ?? 0; + const tokens = (row?.inputTokens ?? 0) + (row?.outputTokens ?? 0); + const barPct = maxWindowCents > 0 ? (cents / maxWindowCents) * 100 : 0; + return ( +
+
+ {w} + + {formatTokens(tokens)} tok + + {formatCents(cents)}
- ); - })} -
+
+
+
+
+ ); + })}
- - ); - })()} +
+ + )} {/* subscription quota windows from provider api — shown when data is available */} {quotaWindows.length > 0 && ( @@ -172,7 +182,7 @@ export function ProviderQuotaCard({ ? "bg-yellow-400" : "bg-green-400"; return ( -
+
{qw.label} @@ -218,15 +228,19 @@ export function ProviderQuotaCard({ {" · "} {formatTokens(totalSubOutputTokens)} out

-
-
-
-

- {Math.round(subSharePct)}% of token usage via subscription -

+ {subSharePct > 0 && ( + <> +
+
+
+

+ {Math.round(subSharePct)}% of token usage via subscription +

+ + )}
)} diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index 33f7c90f..5ad06a72 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -415,6 +415,8 @@ function invalidateActivityQueries( queryClient.invalidateQueries({ queryKey: queryKeys.costs(companyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.usageByProvider(companyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.usageWindowSpend(companyId) }); + // usageQuotaWindows is intentionally excluded: quota windows come from external provider + // apis on a 5-minute poll and do not change in response to cost events logged by agents return; } diff --git a/ui/src/hooks/useDateRange.ts b/ui/src/hooks/useDateRange.ts index 53e9c5a8..6d875952 100644 --- a/ui/src/hooks/useDateRange.ts +++ b/ui/src/hooks/useDateRange.ts @@ -13,20 +13,28 @@ export const PRESET_LABELS: Record = { export const PRESET_KEYS: DatePreset[] = ["mtd", "7d", "30d", "ytd", "all", "custom"]; +// note: computeRange calls new Date() at evaluation time. for sliding presets (7d, 30d, etc.) +// the window is computed once at render time and can be up to ~1 minute stale between re-renders. +// this is acceptable for a cost dashboard but means the displayed range may lag wall clock time +// slightly between poll ticks. function computeRange(preset: DatePreset): { from: string; to: string } { const now = new Date(); - const to = now.toISOString(); + // 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) + const toFloored = new Date(now); + toFloored.setSeconds(0, 0); + const to = toFloored.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); + const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7, 0, 0, 0, 0); return { from: d.toISOString(), to }; } case "30d": { - const d = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 30, 0, 0, 0, 0); return { from: d.toISOString(), to }; } case "ytd": { @@ -59,25 +67,15 @@ export function useDateRange(): UseDateRangeResult { 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; + if (preset !== "custom") return computeRange(preset); + // 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() : "", + }; }, [preset, customFrom, customTo]); const customReady = preset !== "custom" || (!!customFrom && !!customTo); diff --git a/ui/src/pages/Costs.tsx b/ui/src/pages/Costs.tsx index 02bebca4..660c2d95 100644 --- a/ui/src/pages/Costs.tsx +++ b/ui/src/pages/Costs.tsx @@ -18,6 +18,10 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { DollarSign } from "lucide-react"; import { useDateRange, PRESET_LABELS, PRESET_KEYS } from "../hooks/useDateRange"; +// sentinel used in query keys when no company is selected, to avoid polluting the cache +// with undefined/null entries before the early-return guard fires +const NO_COMPANY = "__none__"; + // ---------- helpers ---------- /** current week mon-sun boundaries as iso strings */ @@ -26,7 +30,7 @@ function currentWeekRange(): { from: string; to: string } { 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); + const sun = new Date(mon.getFullYear(), mon.getMonth(), mon.getDate() + 6, 23, 59, 59, 999); return { from: mon.toISOString(), to: sun.toISOString() }; } @@ -67,19 +71,29 @@ export function Costs() { setBreadcrumbs([{ label: "Costs" }]); }, [setBreadcrumbs]); - // 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 + // today as state so a scheduled effect can flip it at midnight, triggering a fresh weekRange + const [today, setToday] = useState(() => new Date().toDateString()); + useEffect(() => { + const msUntilMidnight = () => { + const now = new Date(); + return new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1).getTime() - now.getTime(); + }; + const timer = setTimeout(() => setToday(new Date().toDateString()), msUntilMidnight()); + return () => clearTimeout(timer); + }, [today]); + const weekRange = useMemo(() => currentWeekRange(), [today]); // ---------- spend tab queries (no polling — cost data doesn't change in real time) ---------- + const companyId = selectedCompanyId ?? NO_COMPANY; + const { data: spendData, isLoading: spendLoading, error: spendError } = useQuery({ - queryKey: queryKeys.costs(selectedCompanyId!, from || undefined, to || undefined), + queryKey: queryKeys.costs(companyId, from || undefined, to || undefined), queryFn: async () => { const [summary, byAgent, byProject] = await Promise.all([ - costsApi.summary(selectedCompanyId!, from || undefined, to || undefined), - costsApi.byAgent(selectedCompanyId!, from || undefined, to || undefined), - costsApi.byProject(selectedCompanyId!, from || undefined, to || undefined), + costsApi.summary(companyId, from || undefined, to || undefined), + costsApi.byAgent(companyId, from || undefined, to || undefined), + costsApi.byProject(companyId, from || undefined, to || undefined), ]); return { summary, byAgent, byProject }; }, @@ -89,32 +103,32 @@ export function Costs() { // ---------- 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), + queryKey: queryKeys.usageByProvider(companyId, from || undefined, to || undefined), + queryFn: () => costsApi.byProvider(companyId, 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), + queryKey: queryKeys.usageByProvider(companyId, weekRange.from, weekRange.to), + queryFn: () => costsApi.byProvider(companyId, weekRange.from, weekRange.to), enabled: !!selectedCompanyId, refetchInterval: 30_000, staleTime: 10_000, }); const { data: windowData } = useQuery({ - queryKey: queryKeys.usageWindowSpend(selectedCompanyId!), - queryFn: () => costsApi.windowSpend(selectedCompanyId!), + queryKey: queryKeys.usageWindowSpend(companyId), + queryFn: () => costsApi.windowSpend(companyId), enabled: !!selectedCompanyId, refetchInterval: 30_000, staleTime: 10_000, }); const { data: quotaData } = useQuery({ - queryKey: queryKeys.usageQuotaWindows(selectedCompanyId!), - queryFn: () => costsApi.quotaWindows(selectedCompanyId!), + queryKey: queryKeys.usageQuotaWindows(companyId), + queryFn: () => costsApi.quotaWindows(companyId), enabled: !!selectedCompanyId, // quota windows come from external provider apis; refresh every 5 minutes refetchInterval: 300_000, @@ -183,45 +197,67 @@ export function Costs() { return map; }, [preset, spendData, byProvider]); - const providers = Array.from(byProvider.keys()); + const providers = useMemo(() => Array.from(byProvider.keys()), [byProvider]); - // ---------- guards ---------- + // derive effective provider synchronously so the tab body never flashes blank. + // when activeProvider is no longer in the providers list, fall back to "all". + const effectiveProvider = + activeProvider === "all" || providers.includes(activeProvider) + ? activeProvider + : "all"; + + // write the fallback back into state so subsequent renders and user interactions + // start from a consistent baseline — without this, activeProvider stays stale and + // any future setActiveProvider call would re-derive from the wrong base value. + useEffect(() => { + if (effectiveProvider !== activeProvider) setActiveProvider("all"); + }, [effectiveProvider, activeProvider]); + + // ---------- provider tab items (memoized — contains jsx, recreating on every render + // forces PageTabBar to diff the full item tree on every 30s poll tick). + // totals are derived from byProvider (already memoized on providerData) so this memo + // only rebuilds when the underlying data actually changes, not on every query refetch. ---------- + const providerTabItems = useMemo(() => { + const allTokens = providers.reduce( + (s, p) => s + (byProvider.get(p)?.reduce((a, r) => a + r.inputTokens + r.outputTokens, 0) ?? 0), + 0, + ); + const allCents = providers.reduce( + (s, p) => s + (byProvider.get(p)?.reduce((a, r) => a + r.costCents, 0) ?? 0), + 0, + ); + return [ + { + value: "all", + label: ( + + All providers + {providers.length > 0 && ( + <> + + {formatTokens(allTokens)} + + + {formatCents(allCents)} + + + )} + + ), + }, + ...providers.map((p) => ({ + value: p, + label: , + })), + ]; + }, [providers, byProvider]); + + // ---------- guard ---------- if (!selectedCompanyId) { return ; } - if (spendLoading) { - return ; - } - - // ---------- 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 ( @@ -244,14 +280,14 @@ export function Costs() { type="date" value={customFrom} onChange={(e) => setCustomFrom(e.target.value)} - className="h-8 rounded-md border border-input bg-background px-2 text-sm text-foreground" + className="h-8 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" + className="h-8 border border-input bg-background px-2 text-sm text-foreground" />
)} @@ -266,12 +302,12 @@ export function Costs() { {/* ── spend tab ─────────────────────────────────────────────── */} - {spendError && ( -

{(spendError as Error).message}

- )} - - {preset === "custom" && !customReady ? ( + {spendLoading ? ( + + ) : preset === "custom" && !customReady ? (

Select a start and end date to load data.

+ ) : spendError ? ( +

{(spendError as Error).message}

) : spendData ? ( <> {/* summary card */} @@ -359,9 +395,9 @@ export function Costs() {

No project-attributed run costs yet.

) : (
- {spendData.byProject.map((row) => ( + {spendData.byProject.map((row, i) => (
@@ -384,11 +420,10 @@ export function Costs() { {preset === "custom" && !customReady ? (

Select a start and end date to load data.

) : ( - +