fix(costs): guard routes, fix DST ranges, sync provider state, wire live updates

- add companyAccess guard to costs route
- fix effectiveProvider/activeProvider desync via sync-back useEffect
- move ROLLING_WINDOWS to module level; replace IIFE with useMemo in ProviderQuotaCard
- add NO_COMPANY sentinel to eliminate non-null assertions before enabled guard
- fix DST-unsafe 7d/30d ranges in useDateRange (use Date constructor)
- remove providerData from providerTabItems memo deps (use byProvider)
- normalize used_percent 0-1 vs 0-100 ambiguity in quota-windows service
- rename secondsToWindowLabel index param to fallback; pass explicit labels
- add 4.33 magic number comment; fix quota window key collision
- remove rounded-md from date inputs (violates --radius: 0 theme)
- wire cost_event invalidation in LiveUpdatesProvider
This commit is contained in:
Sai Shankar
2026-03-08 19:04:27 +05:30
committed by Dotta
parent 56c9d95daa
commit bc991a96b4
6 changed files with 202 additions and 141 deletions

View File

@@ -79,6 +79,8 @@ export function costRoutes(db: Db) {
}); });
router.get("/companies/:companyId/costs/quota-windows", async (req, res) => { router.get("/companies/:companyId/costs/quota-windows", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
assertBoard(req); assertBoard(req);
const results = await fetchAllQuotaWindows(); const results = await fetchAllQuotaWindows();
res.json(results); res.json(results);

View File

@@ -164,12 +164,12 @@ interface WhamUsageResponse {
credits?: WhamCredits | null; credits?: WhamCredits | null;
} }
function secondsToWindowLabel(seconds: number | null | undefined): string { function secondsToWindowLabel(seconds: number | null | undefined, fallback: string): string {
if (seconds == null) return "Window"; if (seconds == null) return fallback;
const hours = seconds / 3600; const hours = seconds / 3600;
if (hours <= 6) return "5h"; if (hours < 6) return "5h";
if (hours <= 30) return "24h"; if (hours <= 24) return "24h";
return "Weekly"; return "7d";
} }
async function fetchCodexQuota(token: string, accountId: string | null): Promise<QuotaWindow[]> { async function fetchCodexQuota(token: string, accountId: string | null): Promise<QuotaWindow[]> {
@@ -186,18 +186,28 @@ async function fetchCodexQuota(token: string, accountId: string | null): Promise
const rateLimit = body.rate_limit; const rateLimit = body.rate_limit;
if (rateLimit?.primary_window != null) { if (rateLimit?.primary_window != null) {
const w = rateLimit.primary_window; 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({ windows.push({
label: secondsToWindowLabel(w.limit_window_seconds), label: secondsToWindowLabel(w.limit_window_seconds, "Primary"),
usedPercent: w.used_percent ?? null, usedPercent,
resetsAt: w.reset_at ?? null, resetsAt: w.reset_at ?? null,
valueLabel: null, valueLabel: null,
}); });
} }
if (rateLimit?.secondary_window != null) { if (rateLimit?.secondary_window != null) {
const w = rateLimit.secondary_window; 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({ windows.push({
label: secondsToWindowLabel(w.limit_window_seconds), label: secondsToWindowLabel(w.limit_window_seconds, "Secondary"),
usedPercent: w.used_percent ?? null, usedPercent,
resetsAt: w.reset_at ?? null, resetsAt: w.reset_at ?? null,
valueLabel: null, valueLabel: null,
}); });
@@ -206,7 +216,7 @@ async function fetchCodexQuota(token: string, accountId: string | null): Promise
const balance = body.credits.balance; const balance = body.credits.balance;
const valueLabel = balance != null const valueLabel = balance != null
? `$${(balance / 100).toFixed(2)} remaining` ? `$${(balance / 100).toFixed(2)} remaining`
: null; : "N/A";
windows.push({ windows.push({
label: "Credits", label: "Credits",
usedPercent: null, usedPercent: null,

View File

@@ -1,8 +1,12 @@
import { useMemo } from "react";
import type { CostByProviderModel, CostWindowSpendRow, QuotaWindow } from "@paperclipai/shared"; import type { CostByProviderModel, CostWindowSpendRow, QuotaWindow } from "@paperclipai/shared";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { QuotaBar } from "./QuotaBar"; import { QuotaBar } from "./QuotaBar";
import { formatCents, formatTokens, providerDisplayName } from "@/lib/utils"; import { formatCents, formatTokens, providerDisplayName } from "@/lib/utils";
// ordered display labels for rolling-window rows
const ROLLING_WINDOWS = ["5h", "24h", "7d"] as const;
interface ProviderQuotaCardProps { interface ProviderQuotaCardProps {
provider: string; provider: string;
rows: CostByProviderModel[]; rows: CostByProviderModel[];
@@ -56,12 +60,23 @@ export function ProviderQuotaCard({
? Math.min(100, (totalCostCents / providerBudgetShare) * 100) ? Math.min(100, (totalCostCents / providerBudgetShare) * 100)
: 0; : 0;
// 4.33 = average weeks per calendar month (52 / 12)
const weeklyBudgetShare = providerBudgetShare > 0 ? providerBudgetShare / 4.33 : 0; const weeklyBudgetShare = providerBudgetShare > 0 ? providerBudgetShare / 4.33 : 0;
const weekPct = const weekPct =
weeklyBudgetShare > 0 ? Math.min(100, (weekSpendCents / weeklyBudgetShare) * 100) : 0; weeklyBudgetShare > 0 ? Math.min(100, (weekSpendCents / weeklyBudgetShare) * 100) : 0;
const hasBudget = budgetMonthlyCents > 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 ( return (
<Card> <Card>
<CardHeader className="px-4 pt-4 pb-0 gap-1"> <CardHeader className="px-4 pt-4 pb-0 gap-1">
@@ -112,46 +127,41 @@ export function ProviderQuotaCard({
)} )}
{/* rolling window consumption — always shown when data is available */} {/* rolling window consumption — always shown when data is available */}
{windowRows.length > 0 && (() => { {windowRows.length > 0 && (
const WINDOWS = ["5h", "24h", "7d"] as const; <>
const windowMap = new Map(windowRows.map((r) => [r.window, r])); <div className="border-t border-border" />
const maxCents = Math.max(...windowRows.map((r) => r.costCents), 1); <div className="space-y-2">
return ( <p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
<> Rolling windows
<div className="border-t border-border" /> </p>
<div className="space-y-2"> <div className="space-y-2.5">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide"> {ROLLING_WINDOWS.map((w) => {
Rolling windows const row = windowMap.get(w);
</p> const cents = row?.costCents ?? 0;
<div className="space-y-2.5"> const tokens = (row?.inputTokens ?? 0) + (row?.outputTokens ?? 0);
{WINDOWS.map((w) => { const barPct = maxWindowCents > 0 ? (cents / maxWindowCents) * 100 : 0;
const row = windowMap.get(w); return (
const cents = row?.costCents ?? 0; <div key={w} className="space-y-1">
const tokens = (row?.inputTokens ?? 0) + (row?.outputTokens ?? 0); <div className="flex items-center justify-between gap-2 text-xs">
const barPct = maxCents > 0 ? (cents / maxCents) * 100 : 0; <span className="font-mono text-muted-foreground w-6 shrink-0">{w}</span>
return ( <span className="text-muted-foreground font-mono flex-1">
<div key={w} className="space-y-1"> {formatTokens(tokens)} tok
<div className="flex items-center justify-between gap-2 text-xs"> </span>
<span className="font-mono text-muted-foreground w-6 shrink-0">{w}</span> <span className="font-medium tabular-nums">{formatCents(cents)}</span>
<span className="text-muted-foreground font-mono flex-1">
{formatTokens(tokens)} tok
</span>
<span className="font-medium tabular-nums">{formatCents(cents)}</span>
</div>
<div className="h-1.5 w-full border border-border overflow-hidden">
<div
className="h-full bg-primary/60 transition-[width] duration-150"
style={{ width: `${barPct}%` }}
/>
</div>
</div> </div>
); <div className="h-1.5 w-full border border-border overflow-hidden">
})} <div
</div> className="h-full bg-primary/60 transition-[width] duration-150"
style={{ width: `${barPct}%` }}
/>
</div>
</div>
);
})}
</div> </div>
</> </div>
); </>
})()} )}
{/* subscription quota windows from provider api — shown when data is available */} {/* subscription quota windows from provider api — shown when data is available */}
{quotaWindows.length > 0 && ( {quotaWindows.length > 0 && (
@@ -172,7 +182,7 @@ export function ProviderQuotaCard({
? "bg-yellow-400" ? "bg-yellow-400"
: "bg-green-400"; : "bg-green-400";
return ( return (
<div key={`${qw.label}-${i}`} className="space-y-1"> <div key={`qw-${i}`} className="space-y-1">
<div className="flex items-center justify-between gap-2 text-xs"> <div className="flex items-center justify-between gap-2 text-xs">
<span className="font-mono text-muted-foreground shrink-0">{qw.label}</span> <span className="font-mono text-muted-foreground shrink-0">{qw.label}</span>
<span className="flex-1" /> <span className="flex-1" />
@@ -218,15 +228,19 @@ export function ProviderQuotaCard({
{" · "} {" · "}
<span className="font-mono text-foreground">{formatTokens(totalSubOutputTokens)}</span> out <span className="font-mono text-foreground">{formatTokens(totalSubOutputTokens)}</span> out
</p> </p>
<div className="h-1.5 w-full border border-border overflow-hidden"> {subSharePct > 0 && (
<div <>
className="h-full bg-primary/60 transition-[width] duration-150" <div className="h-1.5 w-full border border-border overflow-hidden">
style={{ width: `${subSharePct}%` }} <div
/> className="h-full bg-primary/60 transition-[width] duration-150"
</div> style={{ width: `${subSharePct}%` }}
<p className="text-xs text-muted-foreground"> />
{Math.round(subSharePct)}% of token usage via subscription </div>
</p> <p className="text-xs text-muted-foreground">
{Math.round(subSharePct)}% of token usage via subscription
</p>
</>
)}
</div> </div>
</> </>
)} )}

View File

@@ -415,6 +415,8 @@ function invalidateActivityQueries(
queryClient.invalidateQueries({ queryKey: queryKeys.costs(companyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.costs(companyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.usageByProvider(companyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.usageByProvider(companyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.usageWindowSpend(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; return;
} }

View File

@@ -13,20 +13,28 @@ export const PRESET_LABELS: Record<DatePreset, string> = {
export const PRESET_KEYS: DatePreset[] = ["mtd", "7d", "30d", "ytd", "all", "custom"]; 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 } { function computeRange(preset: DatePreset): { from: string; to: string } {
const now = new Date(); 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) { switch (preset) {
case "mtd": { case "mtd": {
const d = new Date(now.getFullYear(), now.getMonth(), 1); const d = new Date(now.getFullYear(), now.getMonth(), 1);
return { from: d.toISOString(), to }; return { from: d.toISOString(), to };
} }
case "7d": { 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 }; return { from: d.toISOString(), to };
} }
case "30d": { 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 }; return { from: d.toISOString(), to };
} }
case "ytd": { case "ytd": {
@@ -59,25 +67,15 @@ export function useDateRange(): UseDateRangeResult {
const [customTo, setCustomTo] = useState(""); const [customTo, setCustomTo] = useState("");
const { from, to } = useMemo(() => { const { from, to } = useMemo(() => {
if (preset === "custom") { if (preset !== "custom") return computeRange(preset);
// treat custom date strings as local-date boundaries so the full day is included // 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. // 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 fromDate = customFrom ? new Date(customFrom + "T00:00:00") : null;
const toDate = customTo ? new Date(customTo + "T23:59:59.999") : null; const toDate = customTo ? new Date(customTo + "T23:59:59.999") : null;
return { return {
from: fromDate ? fromDate.toISOString() : "", from: fromDate ? fromDate.toISOString() : "",
to: toDate ? toDate.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]); }, [preset, customFrom, customTo]);
const customReady = preset !== "custom" || (!!customFrom && !!customTo); const customReady = preset !== "custom" || (!!customFrom && !!customTo);

View File

@@ -18,6 +18,10 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { DollarSign } from "lucide-react"; import { DollarSign } from "lucide-react";
import { useDateRange, PRESET_LABELS, PRESET_KEYS } from "../hooks/useDateRange"; 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 ---------- // ---------- helpers ----------
/** current week mon-sun boundaries as iso strings */ /** 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 day = now.getDay(); // 0 = Sun
const diffToMon = day === 0 ? -6 : 1 - day; const diffToMon = day === 0 ? -6 : 1 - day;
const mon = new Date(now.getFullYear(), now.getMonth(), now.getDate() + diffToMon, 0, 0, 0, 0); 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() }; return { from: mon.toISOString(), to: sun.toISOString() };
} }
@@ -67,19 +71,29 @@ export function Costs() {
setBreadcrumbs([{ label: "Costs" }]); setBreadcrumbs([{ label: "Costs" }]);
}, [setBreadcrumbs]); }, [setBreadcrumbs]);
// key to today's date string so the week range auto-refreshes after midnight on the next render // today as state so a scheduled effect can flip it at midnight, triggering a fresh weekRange
const today = new Date().toDateString(); const [today, setToday] = useState(() => new Date().toDateString());
const weekRange = useMemo(() => currentWeekRange(), [today]); // eslint-disable-line react-hooks/exhaustive-deps 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) ---------- // ---------- 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({ 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 () => { queryFn: async () => {
const [summary, byAgent, byProject] = await Promise.all([ const [summary, byAgent, byProject] = await Promise.all([
costsApi.summary(selectedCompanyId!, from || undefined, to || undefined), costsApi.summary(companyId, from || undefined, to || undefined),
costsApi.byAgent(selectedCompanyId!, from || undefined, to || undefined), costsApi.byAgent(companyId, from || undefined, to || undefined),
costsApi.byProject(selectedCompanyId!, from || undefined, to || undefined), costsApi.byProject(companyId, from || undefined, to || undefined),
]); ]);
return { summary, byAgent, byProject }; return { summary, byAgent, byProject };
}, },
@@ -89,32 +103,32 @@ export function Costs() {
// ---------- providers tab queries (polling — provider quota changes during agent runs) ---------- // ---------- providers tab queries (polling — provider quota changes during agent runs) ----------
const { data: providerData } = useQuery({ const { data: providerData } = useQuery({
queryKey: queryKeys.usageByProvider(selectedCompanyId!, from || undefined, to || undefined), queryKey: queryKeys.usageByProvider(companyId, from || undefined, to || undefined),
queryFn: () => costsApi.byProvider(selectedCompanyId!, from || undefined, to || undefined), queryFn: () => costsApi.byProvider(companyId, from || undefined, to || undefined),
enabled: !!selectedCompanyId && customReady, enabled: !!selectedCompanyId && customReady,
refetchInterval: 30_000, refetchInterval: 30_000,
staleTime: 10_000, staleTime: 10_000,
}); });
const { data: weekData } = useQuery({ const { data: weekData } = useQuery({
queryKey: queryKeys.usageByProvider(selectedCompanyId!, weekRange.from, weekRange.to), queryKey: queryKeys.usageByProvider(companyId, weekRange.from, weekRange.to),
queryFn: () => costsApi.byProvider(selectedCompanyId!, weekRange.from, weekRange.to), queryFn: () => costsApi.byProvider(companyId, weekRange.from, weekRange.to),
enabled: !!selectedCompanyId, enabled: !!selectedCompanyId,
refetchInterval: 30_000, refetchInterval: 30_000,
staleTime: 10_000, staleTime: 10_000,
}); });
const { data: windowData } = useQuery({ const { data: windowData } = useQuery({
queryKey: queryKeys.usageWindowSpend(selectedCompanyId!), queryKey: queryKeys.usageWindowSpend(companyId),
queryFn: () => costsApi.windowSpend(selectedCompanyId!), queryFn: () => costsApi.windowSpend(companyId),
enabled: !!selectedCompanyId, enabled: !!selectedCompanyId,
refetchInterval: 30_000, refetchInterval: 30_000,
staleTime: 10_000, staleTime: 10_000,
}); });
const { data: quotaData } = useQuery({ const { data: quotaData } = useQuery({
queryKey: queryKeys.usageQuotaWindows(selectedCompanyId!), queryKey: queryKeys.usageQuotaWindows(companyId),
queryFn: () => costsApi.quotaWindows(selectedCompanyId!), queryFn: () => costsApi.quotaWindows(companyId),
enabled: !!selectedCompanyId, enabled: !!selectedCompanyId,
// quota windows come from external provider apis; refresh every 5 minutes // quota windows come from external provider apis; refresh every 5 minutes
refetchInterval: 300_000, refetchInterval: 300_000,
@@ -183,45 +197,67 @@ export function Costs() {
return map; return map;
}, [preset, spendData, byProvider]); }, [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: (
<span className="flex items-center gap-1.5">
<span>All providers</span>
{providers.length > 0 && (
<>
<span className="text-xs text-muted-foreground font-mono">
{formatTokens(allTokens)}
</span>
<span className="text-xs text-muted-foreground">
{formatCents(allCents)}
</span>
</>
)}
</span>
),
},
...providers.map((p) => ({
value: p,
label: <ProviderTabLabel provider={p} rows={byProvider.get(p)!} />,
})),
];
}, [providers, byProvider]);
// ---------- guard ----------
if (!selectedCompanyId) { if (!selectedCompanyId) {
return <EmptyState icon={DollarSign} message="Select a company to view costs." />; return <EmptyState icon={DollarSign} message="Select a company to view costs." />;
} }
if (spendLoading) {
return <PageSkeleton variant="costs" />;
}
// ---------- provider tab items ----------
const providerTabItems = [
{
value: "all",
label: (
<span className="flex items-center gap-1.5">
<span>All providers</span>
{providerData && providerData.length > 0 && (
<>
<span className="text-xs text-muted-foreground font-mono">
{formatTokens(providerData.reduce((s, r) => s + r.inputTokens + r.outputTokens, 0))}
</span>
<span className="text-xs text-muted-foreground">
{formatCents(providerData.reduce((s, r) => s + r.costCents, 0))}
</span>
</>
)}
</span>
),
},
...providers.map((p) => ({
value: p,
label: <ProviderTabLabel provider={p} rows={byProvider.get(p)!} />,
})),
];
// ---------- render ---------- // ---------- render ----------
return ( return (
@@ -244,14 +280,14 @@ export function Costs() {
type="date" type="date"
value={customFrom} value={customFrom}
onChange={(e) => setCustomFrom(e.target.value)} 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"
/> />
<span className="text-sm text-muted-foreground">to</span> <span className="text-sm text-muted-foreground">to</span>
<input <input
type="date" type="date"
value={customTo} value={customTo}
onChange={(e) => setCustomTo(e.target.value)} onChange={(e) => 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"
/> />
</div> </div>
)} )}
@@ -266,12 +302,12 @@ export function Costs() {
{/* ── spend tab ─────────────────────────────────────────────── */} {/* ── spend tab ─────────────────────────────────────────────── */}
<TabsContent value="spend" className="mt-4 space-y-4"> <TabsContent value="spend" className="mt-4 space-y-4">
{spendError && ( {spendLoading ? (
<p className="text-sm text-destructive">{(spendError as Error).message}</p> <PageSkeleton variant="costs" />
)} ) : preset === "custom" && !customReady ? (
{preset === "custom" && !customReady ? (
<p className="text-sm text-muted-foreground">Select a start and end date to load data.</p> <p className="text-sm text-muted-foreground">Select a start and end date to load data.</p>
) : spendError ? (
<p className="text-sm text-destructive">{(spendError as Error).message}</p>
) : spendData ? ( ) : spendData ? (
<> <>
{/* summary card */} {/* summary card */}
@@ -359,9 +395,9 @@ export function Costs() {
<p className="text-sm text-muted-foreground">No project-attributed run costs yet.</p> <p className="text-sm text-muted-foreground">No project-attributed run costs yet.</p>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{spendData.byProject.map((row) => ( {spendData.byProject.map((row, i) => (
<div <div
key={row.projectId ?? "na"} key={row.projectId ?? `na-${i}`}
className="flex items-center justify-between text-sm" className="flex items-center justify-between text-sm"
> >
<span className="truncate"> <span className="truncate">
@@ -384,11 +420,10 @@ export function Costs() {
{preset === "custom" && !customReady ? ( {preset === "custom" && !customReady ? (
<p className="text-sm text-muted-foreground">Select a start and end date to load data.</p> <p className="text-sm text-muted-foreground">Select a start and end date to load data.</p>
) : ( ) : (
<Tabs value={activeProvider} onValueChange={setActiveProvider}> <Tabs value={effectiveProvider} onValueChange={setActiveProvider}>
<PageTabBar <PageTabBar
items={providerTabItems} items={providerTabItems}
value={activeProvider} value={effectiveProvider}
onValueChange={setActiveProvider}
/> />
<TabsContent value="all" className="mt-4"> <TabsContent value="all" className="mt-4">