import { CircleAlertIcon, RefreshCwIcon } from "lucide-react"; import type { ReactNode } from "react"; import { DisabledReason } from "./disabled-reason"; import { type DateInput, formatAbsoluteTime, formatShortTime, inSentence, toDate, useMinuteNow, } from "./relative-time"; import { cn } from "../lib/cn"; /* ---------------------------------------------------------------------------- UsageMeter (design brief 7.20) - remaining quota at a glance and in detail. Labels are full words ("Weekly", "5-hour"), never "Wk" or "5h". Color is quiet until it matters: a status tone only below 10% left. Reset times are passed in already formatted ("Mon 28 Sep, 09:00"); the relative "Checked just now" line comes from the shared RelativeTime helper. Three looks, same data: - "bar" A. 4px bar, "22% left" and the reset time. In a row the bar drops away and it reads as text: bar in the sheet, text in the row. - "text" B. "Weekly 22% left", no bar. The quietest. - "ring" C. A small ring per window. Compact, harder to compare. Each has two densities: "full" (account sheet) and "compact" (a row). -------------------------------------------------------------------------- */ export type UsageMeterVariant = "bar" | "text" | "ring"; export type UsageMeterDensity = "full" | "compact"; export type UsageLevel = "healthy" | "low" | "exhausted" | "unknown"; /** Below this share left, the meter takes the danger tone. */ export const USAGE_LOW_THRESHOLD = 10; /** * The level for a reading. `percent` is what the meter measures: the share * left (default) or the share used (machines). */ export function usageLevel( percent: number | null | undefined, measure: "left" | "used" = "left", lowAt: number = USAGE_LOW_THRESHOLD, ): UsageLevel { if (percent === null || percent === undefined || Number.isNaN(percent)) return "unknown"; const left = measure === "left" ? percent : 100 - percent; if (left <= 0) return "exhausted"; if (left < lowAt) return "low"; return "healthy"; } /** Clamps to 0-100 and rounds, so "21.6" never shows as "21.6% left". */ export function clampPercent(percent: number): number { return Math.round(Math.min(100, Math.max(0, percent))); } /** The short value text: "22% left", "Limit reached", "Not reported yet". */ export function usageValueText( percent: number | null | undefined, measure: "left" | "used" = "left", lowAt: number = USAGE_LOW_THRESHOLD, ): string { const level = usageLevel(percent, measure, lowAt); if (level === "unknown") return "Not reported yet"; if (level === "exhausted") return "Limit reached"; if (percent! > 0 && percent! < 1) return `<1% ${measure}`; return `${clampPercent(percent as number)}% ${measure}`; } /** * The reset time as it reads after "Resets": "Mon 28 Sep, 09:00" stays, but * "Today, 17:10" becomes "today, 17:10" mid-sentence. */ export function resetPhrase(resetsLabel: string): string { return /^(Today|Tomorrow|Tonight)\b/.test(resetsLabel) ? resetsLabel.charAt(0).toLowerCase() + resetsLabel.slice(1) : resetsLabel; } /** The sentence a screen reader hears for one meter. */ export function usageAccessibleText({ label, percent, measure = "left", valueLabel, resetsLabel, lowAt, }: { label: string; percent: number | null | undefined; measure?: "left" | "used" | undefined; valueLabel?: string | undefined; resetsLabel?: string | undefined; lowAt?: number | undefined; }): string { const value = valueLabel ?? usageValueText(percent, measure, lowAt); const reset = resetsLabel ? `, resets ${resetPhrase(resetsLabel)}` : ""; return `${label}: ${value}${reset}`; } const LEVEL_FILL: Record = { healthy: "bg-brand", low: "bg-danger", exhausted: "bg-danger", unknown: "bg-transparent", }; const LEVEL_STROKE: Record = { healthy: "text-brand", low: "text-danger", exhausted: "text-danger", unknown: "text-transparent", }; const LEVEL_VALUE_TEXT: Record = { healthy: "text-fg-muted", low: "text-danger", exhausted: "text-danger", unknown: "text-fg-subtle", }; export interface UsageMeterProps { /** "Weekly", "5-hour", "Memory". Never abbreviated. */ label: string; /** 0-100, or null when the provider hasn't reported a reading. */ percent: number | null; /** What `percent` measures. Quotas count what's left; machines count what's used. */ measure?: "left" | "used" | undefined; /** Overrides the value text, for example "5.1 of 16 GB". */ valueLabel?: string | undefined; /** Explicit provider rejection, independent of a missing or approximate percentage. */ limitReached?: boolean | undefined; /** Formatted reset time, "Mon 28 Sep, 09:00". */ resetsLabel?: string | undefined; /** A (bar, default), B (text) or C (ring). */ variant?: UsageMeterVariant | undefined; /** "full" in a sheet, "compact" in a row. */ density?: UsageMeterDensity | undefined; /** Share left below which the meter turns red. Default 10. */ lowAt?: number | undefined; loading?: boolean | undefined; className?: string | undefined; } /** One quota window. */ export function UsageMeter({ label, percent, measure = "left", valueLabel, limitReached = false, resetsLabel, variant = "bar", density = "full", lowAt = USAGE_LOW_THRESHOLD, loading = false, className, }: UsageMeterProps) { const level = limitReached ? "exhausted" : usageLevel(percent, measure, lowAt); const value = valueLabel ?? (limitReached ? "Limit reached" : usageValueText(percent, measure, lowAt)); const hasPercent = percent !== null && Number.isFinite(percent); const shown = level === "unknown" ? 0 : clampPercent(percent as number); const fill = level === "exhausted" ? 0 : shown; const meterProps = loading || !hasPercent ? { role: "group" as const, "aria-label": `${label} usage` } : { role: "meter" as const, "aria-label": `${label} usage`, "aria-valuemin": 0, "aria-valuemax": 100, "aria-valuenow": Math.min(100, Math.max(0, percent!)), "aria-valuetext": usageAccessibleText({ label, percent, measure, valueLabel: value, resetsLabel, lowAt, }), }; const percentDisplay = hasPercent && percent! > 0 && percent! < 1 ? "<1" : String(shown); const parts = { label, value, level, fill, resetsLabel, loading, hasPercent, percentDisplay }; const Root = density === "compact" ? "span" : "div"; return ( {variant === "bar" ? ( density === "full" ? ( ) : ( ) ) : variant === "text" ? ( density === "full" ? ( ) : ( ) ) : density === "full" ? ( ) : ( )} ); } /** * The usage readout at the end of an account row: "78% left this week" and a * 64px bar, right-aligned so it sits against the row's chevron, and under it * when the limits reset: "Resets Thu 09:00 · 5-hour 14:20". Without a reading * it says why in words ("No usage yet"). Where the row folds its facts into * the meta line (narrow lists), only the words stay. */ export function UsageReadout({ percent, window: windowLabel, resets = NO_RESETS, loading = false, fallback = "No usage yet", limitReached = false, now: fixedNow, className, }: { /** Share left, 0-100, or null when there is no reading. */ percent: number | null; /** How the window reads after "left": "this week", "this period". */ window: string; /** * When the account's limits reset, the shown window's first (no label), * then any other window by name. Times already past are left out. */ resets?: readonly UsageReset[] | undefined; loading?: boolean | undefined; /** Words for a missing reading: "No usage yet", "Usage unavailable". */ fallback?: string | undefined; limitReached?: boolean | undefined; /** The clock reset times are read against. Defaults to the shared minute clock. */ now?: number | undefined; className?: string | undefined; }) { const level = limitReached ? "exhausted" : usageLevel(percent); const minuteNow = useMinuteNow(); const now = fixedNow ?? minuteNow; if (loading) { return ( ); } if (level === "unknown") { return ( {fallback} ); } const shown = percent === null ? 0 : clampPercent(percent); const text = level === "exhausted" ? "Limit reached" : `${usageValueText(percent)} ${windowLabel}`; const upcoming = resets.filter((reset) => toDate(reset.at).getTime() > now); const resetLong = upcoming.length ? upcoming .map((reset, index) => { const at = inSentence(formatAbsoluteTime(reset.at, { now })); if (!reset.label) return `${index === 0 ? "Resets" : "resets"} ${at}`; return `${reset.label} resets ${at}`; }) .join("; ") : null; const resetShort = upcoming.length ? upcoming .map((reset, index) => { const at = formatShortTime(reset.at, { now }); if (!reset.label) return `Resets ${at}`; return index === 0 ? `${reset.label} resets ${at}` : `${reset.label} ${at}`; }) .join(" · ") : null; const meterProps = percent === null ? { role: "group" as const } : { role: "meter" as const, "aria-valuemin": 0, "aria-valuemax": 100, "aria-valuenow": shown, "aria-valuetext": `${text}${resetLong ? `. ${resetLong}` : ""}`, }; return ( {text} {resetShort ? ( ) : null} ); } /** When one quota window resets. Without a label it is the window the readout shows. */ export interface UsageReset { label?: string | undefined; at: DateInput; } const NO_RESETS: readonly UsageReset[] = []; /** * A readout's resets from an account's windows: the shown window first, then * the others that report a reset, in their given order. A window resetting * in the same minute as an earlier one adds nothing and is left out. */ export function usageResets(readings: readonly UsageWindowReading[], shown: string): UsageReset[] { const first = readings.find((reading) => reading.label === shown); const all: UsageReset[] = [ ...(first?.resetsAt !== undefined ? [{ at: first.resetsAt }] : []), ...readings .filter((reading) => reading !== first && reading.resetsAt !== undefined) .map((reading) => ({ label: reading.label, at: reading.resetsAt! })), ]; const minutes = new Set(); return all.filter((reset) => { const minute = Math.floor(toDate(reset.at).getTime() / 60_000); if (minutes.has(minute)) return false; minutes.add(minute); return true; }); } interface Parts { hasPercent: boolean; percentDisplay: string; label: string; value: string; level: UsageLevel; fill: number; resetsLabel?: string | undefined; loading: boolean; } function ValueSkeleton({ className }: { className?: string }) { return (