"use client";
import { useTranslations, useLocale } from "next-intl";
import { useEffect, useState, useCallback } from "react";
import { BudgetEditableCard } from "@/components/dashboard/budget-editable-card";
interface DailyUsage {
date: string;
inputTokens: number;
outputTokens: number;
spend: number;
}
interface UsageData {
month: string;
currentPeriod: {
inputTokens: number;
outputTokens: number;
totalSpend: number;
requestCount: number;
};
budget: {
maxBudget: number | null;
spend: number;
remaining: number | null;
/**
* Feature 7: budget reset cadence as stored on LiteLLM.
* Strings: "30d" / "1mo" / "1y" / null (no reset). UI maps these
* to user-friendly labels.
*/
budgetDuration: string | null;
};
rateLimits: { rpm: number | null; tpm: number | null };
dailyUsage: DailyUsage[];
}
function fmt(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k`;
return n.toString();
}
/**
* Format a numeric amount as CHF.
*
* Note on currency labelling: LiteLLM stores raw cost numbers it
* receives from upstream (OpenAI/Anthropic), which originate as USD.
* The PieCed pricing config (Slice 5) converts those numbers to
* CHF before LiteLLM persists them, so the values flowing through
* here are already CHF amounts. We label them as such in the UI;
* "USD" or "$" anywhere in the customer-facing experience would
* be misleading.
*
* Precision is adaptive:
* - Amounts ≥ 1 CHF: 2 decimals (typical money formatting).
* - Smaller amounts: 4 decimals — per-request inference costs are
* routinely sub-rappen, and rounding to 2dp
* would render CHF 0.0042 as "CHF 0.00",
* which obscures real costs from customers
* looking at the daily breakdown.
*
* This is a customer-facing display helper; for storage and
* comparisons keep using the raw number.
*/
function chf(n: number): string {
const decimals = Math.abs(n) >= 1 ? 2 : 4;
return `CHF ${n.toFixed(decimals)}`;
}
function getCurrentMonth(): string {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
}
function shiftMonth(month: string, delta: number): string {
const [y, m] = month.split("-").map(Number);
const d = new Date(y, m - 1 + delta, 1);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
}
function formatMonth(month: string, locale: string): string {
const [y, m] = month.split("-").map(Number);
return new Date(y, m - 1).toLocaleDateString(locale, { year: "numeric", month: "long" });
}
function UsageChart({ data }: { data: DailyUsage[] }) {
const t = useTranslations("usage");
const locale = useLocale();
// Which day's detail is shown in the readout. Defaults to the most
// recent day; hover (mouse), tap (touch) or focus (keyboard) all
// update it. The previous version put per-day numbers only in SVG
//
hover tooltips, which are unreachable on touch devices and
// invisible to keyboard users — this readout fixes both.
const [selected, setSelected] = useState(null);
if (!data.length) return null;
const maxTokens = Math.max(
...data.map((d) => d.inputTokens + d.outputTokens),
1
);
const barW = Math.max(4, Math.floor(600 / data.length) - 2);
const h = 120;
const activeIndex = selected ?? data.length - 1;
const active = data[activeIndex];
const dayLabel = (iso: string) => {
const [y, m, dd] = iso.split("-").map(Number);
return new Date(y, m - 1, dd).toLocaleDateString(locale, {
month: "short",
day: "numeric",
});
};
const barAria = (d: DailyUsage) =>
`${dayLabel(d.date)}: ${fmt(d.inputTokens)} ${t("inputTokens")}, ${fmt(
d.outputTokens
)} ${t("outputTokens")}, ${chf(d.spend)}`;
return (
{/* Readout — the touch/keyboard-accessible equivalent of the old
hover-only tooltip. Always reflects the active day. */}
);
}
/**
* Usage display widget.
*
* Pass `tenant=` for the canonical path — works for both
* customers and admins, the API resolves team+alias from the tenant
* CR's status. The visibility check on the API ensures users can't
* query tenants they shouldn't see.
*
* `teamId`/`keyAlias` remain available as a platform-admin escape
* hatch for cross-org debugging, but the tenant-detail and dashboard
* paths should always use `tenant`.
*
* Bug 19 fix: previous version omitted both props for customer
* sessions, expecting the API to "figure it out". The API's fallback
* was "first visible tenant", which meant siblings in the same org
* showed identical numbers regardless of which detail page was open.
* Now the page passes the tenant name explicitly; no fallback exists.
*/
export function UsageDisplay({
tenant,
teamId,
keyAlias,
canEditBudget = false,
}: {
tenant?: string | null;
teamId?: string | null;
keyAlias?: string | null;
/**
* Feature 7: when true, the Budget StatCard becomes clickable and
* opens the budget editor. Off by default — owners and platform
* admins get it on; `user` role customers see the budget read-only.
* Server component decides this via canMutate(user).
*/
canEditBudget?: boolean;
}) {
const t = useTranslations("usage");
const locale = useLocale();
const [month, setMonth] = useState(getCurrentMonth);
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const isCurrentMonth = month === getCurrentMonth();
const fetchUsage = useCallback(() => {
setLoading(true);
setError(null);
const params = new URLSearchParams({ month });
if (tenant) {
params.set("tenant", tenant);
} else if (teamId) {
// Admin escape hatch — only honoured by the API when the
// viewer is platform-role.
params.set("teamId", teamId);
if (keyAlias) params.set("keyAlias", keyAlias);
}
fetch(`/api/usage?${params}`)
.then((res) => { if (!res.ok) throw new Error(`${res.status}`); return res.json(); })
.then(setData)
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}, [tenant, teamId, keyAlias, month]);
useEffect(() => { fetchUsage(); }, [fetchUsage]);
return (