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 */} +
+ {presetKeys.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) => ( + + + + ))} +
+ )} +
+ ); +}