import { useEffect, useMemo, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import type { CostByAgentModel, 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 { 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, ChevronDown, ChevronRight } 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 */ function currentWeekRange(): { from: string; to: string } { const now = new Date(); 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.getFullYear(), mon.getMonth(), mon.getDate() + 6, 23, 59, 59, 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 [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]); // today as state so the weekRange memo refreshes after midnight. // stable [] dep + ref avoids the StrictMode double-invoke problem of the // chained [today] dep pattern (which would schedule two concurrent timers). const [today, setToday] = useState(() => new Date().toDateString()); const todayTimerRef = useRef | null>(null); useEffect(() => { const schedule = () => { const now = new Date(); const ms = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1).getTime() - now.getTime(); todayTimerRef.current = setTimeout(() => { setToday(new Date().toDateString()); schedule(); }, ms); }; schedule(); return () => { if (todayTimerRef.current != null) clearTimeout(todayTimerRef.current); }; }, []); 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(companyId, from || undefined, to || undefined), queryFn: async () => { const [summary, byAgent, byProject, byAgentModel] = await Promise.all([ costsApi.summary(companyId, from || undefined, to || undefined), costsApi.byAgent(companyId, from || undefined, to || undefined), costsApi.byProject(companyId, from || undefined, to || undefined), costsApi.byAgentModel(companyId, from || undefined, to || undefined), ]); return { summary, byAgent, byProject, byAgentModel }; }, enabled: !!selectedCompanyId && customReady, }); // tracks which agent rows are expanded in the By Agent card. // reset whenever the date range or company changes so stale open-states // from a previous query window don't bleed into the new result set. const [expandedAgents, setExpandedAgents] = useState>(new Set()); useEffect(() => { setExpandedAgents(new Set()); }, [companyId, from, to]); function toggleAgent(agentId: string) { setExpandedAgents((prev) => { const next = new Set(prev); if (next.has(agentId)) next.delete(agentId); else next.add(agentId); return next; }); } // group byAgentModel rows by agentId for O(1) lookup in the render pass. // sub-rows are sorted by cost descending so the most expensive model is first. const agentModelRows = useMemo(() => { const map = new Map(); for (const row of spendData?.byAgentModel ?? []) { const arr = map.get(row.agentId) ?? []; arr.push(row); map.set(row.agentId, arr); } for (const [id, rows] of map) { map.set(id, rows.slice().sort((a, b) => b.costCents - a.costCents)); } return map; }, [spendData?.byAgentModel]); // ---------- providers tab queries (polling — provider quota changes during agent runs) ---------- const { data: providerData } = useQuery({ queryKey: queryKeys.usageByProvider(companyId, from || undefined, to || undefined), queryFn: () => costsApi.byProvider(companyId, from || undefined, to || undefined), enabled: !!selectedCompanyId && customReady && mainTab === "providers", refetchInterval: 30_000, staleTime: 10_000, }); const { data: weekData } = useQuery({ queryKey: queryKeys.usageByProvider(companyId, weekRange.from, weekRange.to), queryFn: () => costsApi.byProvider(companyId, weekRange.from, weekRange.to), enabled: !!selectedCompanyId && mainTab === "providers", refetchInterval: 30_000, staleTime: 10_000, }); const { data: windowData } = useQuery({ queryKey: queryKeys.usageWindowSpend(companyId), queryFn: () => costsApi.windowSpend(companyId), // only fetch when the providers tab is active — these queries trigger outbound // network calls to provider quota apis; no need to run them on the spend tab. enabled: !!selectedCompanyId && mainTab === "providers", refetchInterval: 30_000, staleTime: 10_000, }); const { data: quotaData } = useQuery({ queryKey: queryKeys.usageQuotaWindows(companyId), queryFn: () => costsApi.quotaWindows(companyId), enabled: !!selectedCompanyId && mainTab === "providers", // 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 = useMemo(() => Array.from(byProvider.keys()), [byProvider]); // 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(() => { // derive provider keys inline so this memo only rebuilds when byProvider changes, // not on the extra tick caused by the derived `providers` memo also changing. const providerKeys = Array.from(byProvider.keys()); const allTokens = providerKeys.reduce( (s, p) => s + (byProvider.get(p)?.reduce((a, r) => a + r.inputTokens + r.outputTokens, 0) ?? 0), 0, ); const allCents = providerKeys.reduce( (s, p) => s + (byProvider.get(p)?.reduce((a, r) => a + r.costCents, 0) ?? 0), 0, ); return [ { value: "all", label: ( All providers {providerKeys.length > 0 && ( <> {formatTokens(allTokens)} {formatCents(allCents)} )} ), }, ...providerKeys.map((p) => ({ value: p, label: , })), ]; }, [byProvider]); // ---------- guard ---------- if (!selectedCompanyId) { return ; } // ---------- render ---------- return (
{/* date range selector */}
{PRESET_KEYS.map((p) => ( ))} {preset === "custom" && (
setCustomFrom(e.target.value)} className="h-8 border border-input bg-background px-2 text-sm text-foreground" /> to setCustomTo(e.target.value)} className="h-8 border border-input bg-background px-2 text-sm text-foreground" />
)}
{/* main spend / providers tab switcher */} setMainTab(v as "spend" | "providers")}> Spend Providers {/* ── spend tab ─────────────────────────────────────────────── */} {spendLoading ? ( ) : preset === "custom" && !customReady ? (

Select a start and end date to load data.

) : spendError ? (

{(spendError as Error).message}

) : spendData ? ( <> {/* summary card */}

{PRESET_LABELS[preset]}

{spendData.summary.budgetCents > 0 && (

{spendData.summary.utilizationPercent}% utilized

)}

{formatCents(spendData.summary.spendCents)}{" "} {spendData.summary.budgetCents > 0 ? `/ ${formatCents(spendData.summary.budgetCents)}` : "Unlimited budget"}

{spendData.summary.budgetCents > 0 && (
90 ? "bg-red-400" : spendData.summary.utilizationPercent > 70 ? "bg-yellow-400" : "bg-green-400" }`} style={{ width: `${Math.min(100, spendData.summary.utilizationPercent)}%` }} />
)} {/* by agent / by project */}

By Agent

{spendData.byAgent.length === 0 ? (

No cost events yet.

) : (
{spendData.byAgent.map((row) => { const modelRows = agentModelRows.get(row.agentId) ?? []; const isExpanded = expandedAgents.has(row.agentId); const hasBreakdown = modelRows.length > 0; return (
hasBreakdown && toggleAgent(row.agentId)} >
{hasBreakdown ? ( isExpanded ? : ) : ( )} {row.agentStatus === "terminated" && ( )}
{formatCents(row.costCents)} in {formatTokens(row.inputTokens)} / out {formatTokens(row.outputTokens)} tok {(row.apiRunCount > 0 || row.subscriptionRunCount > 0) && ( {row.apiRunCount > 0 ? `api runs: ${row.apiRunCount}` : null} {row.apiRunCount > 0 && row.subscriptionRunCount > 0 ? " | " : null} {row.subscriptionRunCount > 0 ? `subscription runs: ${row.subscriptionRunCount} (${formatTokens(row.subscriptionInputTokens)} in / ${formatTokens(row.subscriptionOutputTokens)} out tok)` : null} )}
{isExpanded && modelRows.length > 0 && (
{modelRows.map((m) => { const totalAgentCents = row.costCents; const sharePct = totalAgentCents > 0 ? Math.round((m.costCents / totalAgentCents) * 100) : 0; return (
{providerDisplayName(m.provider)} / {m.model}
{formatCents(m.costCents)} ({sharePct}%) in {formatTokens(m.inputTokens)} / out {formatTokens(m.outputTokens)} tok
); })}
)}
); })}
)}

By Project

{spendData.byProject.length === 0 ? (

No project-attributed run costs yet.

) : (
{spendData.byProject.map((row, i) => (
{row.projectName ?? row.projectId ?? "Unattributed"} {formatCents(row.costCents)}
))}
)}
) : null} {/* ── providers tab ─────────────────────────────────────────── */} {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) => ( ))}
)}
); }