import type { UsageAllowancePeriod, WorkspaceUsageResponse } from "@opengeni/sdk/usage-allowances"; import type { ReactNode } from "react"; import { useUsage, type UsageClientLike } from "../hooks/use-usage"; import { cn } from "../lib/cn"; import { allowanceLabels, allowanceResetSentence, formatAllowanceInstant, type AllowanceLabels, } from "../usage/allowance-copy"; import { summarizeUsage, usagePercentLabel, type UsageReading, type UsageSummary, } from "../usage/summary"; import { useOpenGeniText, type OpenGeniText } from "../messages/runtime"; export type UsageMeterLabels = AllowanceLabels & { /** Heading for the member's own ceiling. */ title: string; /** Heading when only the shared workspace pool applies. */ workspaceTitle: string; /** "38% left" — `percent` is preformatted ("38", "<1"). */ left: (percent: string) => string; /** "62% used". */ used: (percent: string) => string; limitReached: string; /** Status chip beside a hero meter that is close to its limit. */ nearLimit: string; /** "$13.00 left" for a hero meter with amounts. */ amountLeft: (amount: string) => string; /** No ceiling applies to this member. */ unlimited: string; loading: string; error: string; retry: string; /** "$31.00 of $50.00 used" when the host passes `formatAmount`. */ amounts: (used: string, limit: string) => string; /** The shared pool, under a member's own meter: "Workspace: 9% left". */ workspaceLine: (value: string) => string; }; export const DEFAULT_USAGE_METER_LABELS = (t: OpenGeniText): UsageMeterLabels => { return { ...allowanceLabels(t), title: t("usageMeter.yourUsage"), workspaceTitle: t("usageMeter.workspaceUsage"), left: (percent) => `${percent}% left`, used: (percent) => `${percent}% used`, limitReached: t("usageMeter.limitReached"), nearLimit: t("usageMeter.nearLimit"), amountLeft: (amount) => `${amount} left`, unlimited: t("usageMeter.noUsageLimit"), loading: t("usageMeter.checkingUsage"), error: t("usageMeter.couldnTLoadUsage"), retry: t("usageMeter.tryAgain"), amounts: (used, limit) => `${used} of ${limit} used`, workspaceLine: (value) => t("usageMeter.workspaceValue", { value }), }; }; export type UsageMeterBaseProps = { /** "left" (default) reads like a battery; "used" reads like spend. */ measure?: "left" | "used" | undefined; /** * Opt in to amounts. Receives USD micros; return your own unit ("$31.00", * "3.1 credits", "1.5× plan"). Without it the meter shows only shares. */ formatAmount?: ((micros: number) => string) | undefined; labels?: Partial | undefined; /** * `compact` is one line for menus and headers; `hero` leads a page with the * amount (or share) left in large type. An empty `labels.title` hides the * heading when the page already names it. */ density?: "default" | "compact" | "hero" | undefined; /** Show the shared pool under the member's own meter when it is the tighter one. Default true. */ showWorkspace?: boolean | undefined; className?: string | undefined; }; export type UsageMeterProps = UsageMeterBaseProps & { /** * A usage response you already loaded (for example from `useUsage`). * Omit it to let the meter read `/usage/me` itself. */ usage?: WorkspaceUsageResponse | null | undefined; client?: UsageClientLike | undefined; workspaceId?: string | undefined; period?: UsageAllowancePeriod | undefined; /** Re-read when this changes (see `useUsage`). */ refreshKey?: unknown; }; /** * The signed-in member's own usage: how much of their limit is left and when * it resets. Shares only by default; hosts opt in to amounts. */ export function UsageMeter({ usage, client, workspaceId, period, refreshKey, ...view }: UsageMeterProps) { if (usage !== undefined) { return ( ); } return ( ); } function LoadingUsageMeter({ client, workspaceId, period, refreshKey, ...view }: UsageMeterBaseProps & Pick) { const state = useUsage({ client, workspaceId, period, refreshKey }); return ( void state.refresh()} /> ); } export type UsageMeterViewProps = UsageMeterBaseProps & { summary: UsageSummary | null; loading?: boolean | undefined; error?: Error | null | undefined; onRetry?: (() => void) | undefined; }; const FILL: Record = { ok: "bg-og-accent", warning: "bg-og-status-waiting", exhausted: "bg-og-danger", }; function valueText( reading: UsageReading, measure: "left" | "used", labels: UsageMeterLabels, ): string { if (reading.status === "exhausted") return labels.limitReached; return measure === "left" ? labels.left(usagePercentLabel(Math.max(0, 1 - reading.fraction))) : labels.used(usagePercentLabel(reading.fraction)); } /** Presentational meter for a summary you computed (`summarizeUsage`). */ export function UsageMeterView({ summary, loading = false, error = null, onRetry, measure = "left", formatAmount, labels: overrides, density = "default", showWorkspace = true, className, }: UsageMeterViewProps) { const t = useOpenGeniText(); const labels = { ...DEFAULT_USAGE_METER_LABELS(t), ...overrides }; const compact = density === "compact"; if (loading || error || !summary) { return (
{error ? (

{labels.error} {onRetry ? ( ) : null}

) : ( {labels.loading} )}
); } const primary = summary.member ?? summary.workspace; if (!primary) { return (
{compact ? null : {labels.title} · } {labels.unlimited}
); } const title = summary.member ? labels.title : labels.workspaceTitle; const value = valueText(primary, measure, labels); // The bar always fills with what's used, so "near the limit" reads as a // nearly full bar whichever way the words count. const fill = Math.min(1, Math.max(0, primary.fraction)); const reset = allowanceResetSentence(labels, primary.resetsAt); const pool = showWorkspace && summary.member && summary.workspace && (summary.binding?.scope === "workspace" || summary.workspace.status !== "ok") ? summary.workspace : null; const meterProps = { role: "meter" as const, "aria-label": title || labels.title, "aria-valuemin": 0, "aria-valuemax": 100, "aria-valuenow": Math.round(fill * 100), "aria-valuetext": `${value}. ${reset}${ pool ? ` ${labels.workspaceLine(valueText(pool, measure, labels))}.` : "" }`, }; const bar = ( ); const valueClass = cn( "shrink-0 whitespace-nowrap tabular-nums", primary.status === "ok" ? "text-og-fg-muted" : "font-medium", primary.status === "warning" && "text-og-status-waiting", primary.status === "exhausted" && "text-og-danger", ); if (compact) { return ( {value} {bar} ); } const amounts = formatAmount ? labels.amounts(formatAmount(primary.used), formatAmount(primary.limit)) : null; if (density === "hero") { // Lead with what's left, even at zero; the chip names the state. const headline = formatAmount ? labels.amountLeft(formatAmount(primary.remaining)) : labels.left(usagePercentLabel(Math.max(0, 1 - primary.fraction))); return (
{title ? {title} : null}
{headline} {primary.status !== "ok" ? ( {primary.status === "warning" ? labels.nearLimit : labels.limitReached} ) : null}
{bar}

{[ amounts ?? labels.used(usagePercentLabel(primary.fraction)), amounts ? `${usagePercentLabel(primary.fraction)}%` : null, ] .filter(Boolean) .join(" · ")}{" "} ·{" "} {reset}

{pool ? : null}
); } return (
{title} {value}
{bar}
{reset} {amounts ? {amounts} : null}
{pool ? : null}
); } function PoolLine({ reading, measure, labels, }: { reading: UsageReading; measure: "left" | "used"; labels: UsageMeterLabels; }): ReactNode { return (

{labels.workspaceLine(valueText(reading, measure, labels))}

); }