reads local claude and codex auth files server-side, calls provider quota apis (anthropic oauth usage, chatgpt wham/usage), and surfaces live usedPercent per window in ProviderQuotaCard with threshold fill colors
340 lines
13 KiB
TypeScript
340 lines
13 KiB
TypeScript
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 { ProviderQuotaCard } from "../components/ProviderQuotaCard";
|
|
import { PageTabBar } from "../components/PageTabBar";
|
|
import { formatCents, formatTokens, providerDisplayName } 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<DatePreset, string> = {
|
|
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: "" };
|
|
}
|
|
}
|
|
|
|
/** 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 (
|
|
<span className="flex items-center gap-1.5">
|
|
<span>{providerDisplayName(provider)}</span>
|
|
<span className="text-xs text-muted-foreground font-mono">{formatTokens(totalTokens)}</span>
|
|
<span className="text-xs text-muted-foreground">{formatCents(totalCost)}</span>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export function Usage() {
|
|
const { selectedCompanyId } = useCompany();
|
|
const { setBreadcrumbs } = useBreadcrumbs();
|
|
|
|
const [preset, setPreset] = useState<DatePreset>("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]);
|
|
|
|
// key to today's date string so the range auto-refreshes after midnight on the next 30s refetch
|
|
const today = new Date().toDateString();
|
|
const weekRange = useMemo(() => currentWeekRange(), [today]);
|
|
|
|
// 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,
|
|
});
|
|
|
|
const { data: quotaData } = useQuery({
|
|
queryKey: queryKeys.usageQuotaWindows(selectedCompanyId!),
|
|
queryFn: () => costsApi.quotaWindows(selectedCompanyId!),
|
|
enabled: !!selectedCompanyId,
|
|
// quota windows change infrequently; refresh every 5 minutes
|
|
refetchInterval: 300_000,
|
|
staleTime: 60_000,
|
|
});
|
|
|
|
// rows grouped by provider
|
|
const byProvider = useMemo(() => {
|
|
const map = new Map<string, CostByProviderModel[]>();
|
|
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<string, number>();
|
|
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<string, CostWindowSpendRow[]>();
|
|
for (const row of windowData ?? []) {
|
|
const arr = map.get(row.provider) ?? [];
|
|
arr.push(row);
|
|
map.set(row.provider, arr);
|
|
}
|
|
return map;
|
|
}, [windowData]);
|
|
|
|
// quota windows from the provider's own api, keyed by provider
|
|
const quotaWindowsByProvider = useMemo(() => {
|
|
const map = new Map<string, QuotaWindow[]>();
|
|
for (const result of quotaData ?? []) {
|
|
if (result.ok && result.windows.length > 0) {
|
|
map.set(result.provider, result.windows);
|
|
}
|
|
}
|
|
return map;
|
|
}, [quotaData]);
|
|
|
|
// compute deficit notch per provider: only meaningful for mtd — projects spend to month end
|
|
// and flags when that projection exceeds the provider's pro-rata budget share.
|
|
function providerDeficitNotch(providerKey: string): boolean {
|
|
if (preset !== "mtd") return false;
|
|
const budget = summary?.budgetCents ?? 0;
|
|
if (budget <= 0) return false;
|
|
const totalSpend = summary?.spendCents ?? 0;
|
|
const providerCostCents = (byProvider.get(providerKey) ?? []).reduce((s, r) => s + r.costCents, 0);
|
|
const providerShare = totalSpend > 0 ? providerCostCents / totalSpend : 0;
|
|
const providerBudget = budget * providerShare;
|
|
if (providerBudget <= 0) return false;
|
|
const now = new Date();
|
|
const daysElapsed = now.getDate();
|
|
const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
|
|
const burnRate = providerCostCents / Math.max(daysElapsed, 1);
|
|
return providerCostCents + burnRate * (daysInMonth - daysElapsed) > providerBudget;
|
|
}
|
|
|
|
const providers = Array.from(byProvider.keys());
|
|
|
|
if (!selectedCompanyId) {
|
|
return <EmptyState icon={Gauge} message="Select a company to view usage." />;
|
|
}
|
|
|
|
if (isLoading) {
|
|
return <PageSkeleton variant="costs" />;
|
|
}
|
|
|
|
const presetKeys: DatePreset[] = ["mtd", "7d", "30d", "ytd", "all", "custom"];
|
|
|
|
const tabItems = [
|
|
{
|
|
value: "all",
|
|
label: (
|
|
<span className="flex items-center gap-1.5">
|
|
<span>All providers</span>
|
|
{data && data.length > 0 && (
|
|
<>
|
|
<span className="text-xs text-muted-foreground font-mono">
|
|
{formatTokens(data.reduce((s, r) => s + r.inputTokens + r.outputTokens, 0))}
|
|
</span>
|
|
<span className="text-xs text-muted-foreground">
|
|
{formatCents(data.reduce((s, r) => s + r.costCents, 0))}
|
|
</span>
|
|
</>
|
|
)}
|
|
</span>
|
|
),
|
|
},
|
|
...providers.map((p) => ({
|
|
value: p,
|
|
label: <ProviderTabLabel provider={p} rows={byProvider.get(p)!} />,
|
|
})),
|
|
];
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* date range selector */}
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
{presetKeys.map((p) => (
|
|
<Button
|
|
key={p}
|
|
variant={preset === p ? "secondary" : "ghost"}
|
|
size="sm"
|
|
onClick={() => setPreset(p)}
|
|
>
|
|
{PRESET_LABELS[p]}
|
|
</Button>
|
|
))}
|
|
{preset === "custom" && (
|
|
<div className="flex items-center gap-2 ml-2">
|
|
<input
|
|
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"
|
|
/>
|
|
<span className="text-sm text-muted-foreground">to</span>
|
|
<input
|
|
type="date"
|
|
value={customTo}
|
|
onChange={(e) => setCustomTo(e.target.value)}
|
|
className="h-8 rounded-md border border-input bg-background px-2 text-sm text-foreground"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{error && <p className="text-sm text-destructive">{(error as Error).message}</p>}
|
|
|
|
{preset === "custom" && !customReady ? (
|
|
<p className="text-sm text-muted-foreground">Select a start and end date to load data.</p>
|
|
) : (
|
|
<Tabs value={activeProvider} onValueChange={setActiveProvider}>
|
|
<PageTabBar items={tabItems} value={activeProvider} onValueChange={setActiveProvider} />
|
|
|
|
<TabsContent value="all" className="mt-4">
|
|
{providers.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">No cost events in this period.</p>
|
|
) : (
|
|
<div className="grid md:grid-cols-2 gap-4">
|
|
{providers.map((p) => (
|
|
<ProviderQuotaCard
|
|
key={p}
|
|
provider={p}
|
|
rows={byProvider.get(p)!}
|
|
budgetMonthlyCents={summary?.budgetCents ?? 0}
|
|
totalCompanySpendCents={summary?.spendCents ?? 0}
|
|
weekSpendCents={weekSpendByProvider.get(p) ?? 0}
|
|
windowRows={windowSpendByProvider.get(p) ?? []}
|
|
showDeficitNotch={providerDeficitNotch(p)}
|
|
quotaWindows={quotaWindowsByProvider.get(p) ?? []}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
|
|
{providers.map((p) => (
|
|
<TabsContent key={p} value={p} className="mt-4">
|
|
<ProviderQuotaCard
|
|
provider={p}
|
|
rows={byProvider.get(p)!}
|
|
budgetMonthlyCents={summary?.budgetCents ?? 0}
|
|
totalCompanySpendCents={summary?.spendCents ?? 0}
|
|
weekSpendCents={weekSpendByProvider.get(p) ?? 0}
|
|
windowRows={windowSpendByProvider.get(p) ?? []}
|
|
showDeficitNotch={providerDeficitNotch(p)}
|
|
quotaWindows={quotaWindowsByProvider.get(p) ?? []}
|
|
/>
|
|
</TabsContent>
|
|
))}
|
|
</Tabs>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|