AI usage meter
How much of an AI plan is used. Several quotas in one card with warning marks and reset dates, the period as a chart with forecast and even pace, cost and tokens per model, and the moment the limit is reached with three ways on. General app quotas and budgets are in usage.
Usage people understand before the limit
An AI plan has limits, and people should see them coming instead of hitting them in the middle of their work. The usage meter shows how much is used, how fast it goes and what happens at the end, with numbers and dates in the language of the page.
Quotas is one card for the account menu or settings, every quota with what is left, a bar with warning marks, the reset date and a status once one gets close. Period chart shows used, daily average and forecast on top, then one bar per day with the projected rest dashed and the even pace to the limit as a line, the arrow keys read every day. Cost per model lists requests, input and output tokens and cost per model, sortable, with the share of cost as a bar and a plain sentence that names the model doing the most for the least. Limit reached says what keeps working and offers three ways on, wait for the reset at no cost, top up a pack once or change the plan.
Warn early and calmly, at around eighty percent a short note is enough. Tell people what still works when the limit is reached, that keeps them in the product. Name the price on the button, then nobody pays by surprise.
Variants
Details
What AI usage meter brings along, read from the registry and the source.
- Category
- AI setup
- Area
- App
- Variants
- 4
- Version
- 1.0.0 · since 1.2.0
- File
- components/ui/ai-usage-meter.tsx
- Brings along
- utilskiticonrevealchart
- npm packages
- None
- Languages
- English and German, 57 texts of its own
- Studio
- Adjust in code
- In collections
- AI product
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The AiUsageMeterProps interface from components/ui/ai-usage-meter.tsx, with the comments from the code.
variantoptionalAiUsageMeterVariantquotasperiod-chartby-modellimit-reached
titleoptionalstringdescriptionoptionalstringquotasoptionalUsageQuota[]quotas: several meters in one card
usedoptionalnumberperiod-chart, limit-reached: the main meter
limitoptionalnumberunitoptionalstringe.g. "credits"
warnAtoptionalnumber[]Warning marks in percent of the limit, e.g. [75, 90]
daysoptionalnumber[]period-chart: use per day so far, oldest first
startoptionalstringISO date of the first day of the period
endoptionalstringISO date of the last day of the period
todayoptionalstringISO date that counts as today
modelsoptionalUsageByModel[]by-model
currencyoptionalstringISO currency code for costs and prices
planoptionalUsagePlanlimit-reached and quotas: the plan now and the next one
upgradeoptionalUsagePlanpacksoptionalCreditPack[]limit-reached: credit packs to top up
fallbackoptionalstring[]limit-reached: what keeps working at the limit, e.g. "Answers run on Atlas Mini"
onUpgradeoptional() => voidonBuyoptional(pack: CreditPack) => void
Built-in texts
AI usage meter writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 57 texts
| Key | English | German |
|---|---|---|
| left | English {v} left | German noch {v} |
| of | English {used} of {limit} | German {used} von {limit} |
| used | English {v} used | German {v} verbraucht |
| resets | English Starts over on {date} | German Startet neu am {date} |
| resetsIn | English in {n} days | German in {n} Tagen |
| resetsTomorrow | English tomorrow | German morgen |
| ok | English Plenty left | German Reichlich übrig |
| warn | English Getting close | German Wird knapp |
| high | English Almost used up | German Fast aufgebraucht |
| full | English Limit reached | German Limit erreicht |
| warnAt | English Note at {v} | German Hinweis bei {v} |
| upgrade | English Upgrade plan | German Plan erweitern |
| manage | English See usage | German Verbrauch ansehen |
| plan | English Plan {name} | German Plan {name} |
| period | English This period | German Dieser Zeitraum |
| perDay | English Per day | German Pro Tag |
| average | English Daily average | German Schnitt pro Tag |
| forecast | English Forecast at the end | German Prognose zum Ende |
| forecastOver | English At this pace you reach the limit on {date}. | German In diesem Tempo erreichst du das Limit am {date}. |
| forecastOk | English At this pace you stay below the limit. | German In diesem Tempo bleibst du unter dem Limit. |
| pace | English Even pace to the limit | German Gleichmäßiges Tempo bis zum Limit |
| projected | English Projected | German Prognose |
| usedLegend | English Used | German Verbraucht |
| chart | English Use per day | German Verbrauch pro Tag |
| range | English Range | German Zeitraum |
| last7 | English 7 days | German 7 Tage |
| last14 | English 14 days | German 14 Tage |
| whole | English Whole period | German Ganzer Zeitraum |
| model | English Model | German Modell |
| requests | English Requests | German Anfragen |
| input | English Input | German Eingabe |
| output | English Output | German Ausgabe |
| tokens | English Tokens | German Tokens |
| cost | English Cost | German Kosten |
| share | English Share of cost | German Anteil an Kosten |
| total | English Total | German Summe |
| sortBy | English Sort by {name} | German Nach {name} sortieren |
| insight | English {name} handles {req} of requests for {cost} of the cost. | German {name} erledigt {req} der Anfragen für {cost} der Kosten. |
| tokensShort | English {v} tokens | German {v} Tokens |
| reached | English You used everything in your plan for this period. | German Du hast alles aus deinem Plan für diesen Zeitraum genutzt. |
| reachedSoon | English You used {v} of your plan for this period. | German Du hast {v} deines Plans für diesen Zeitraum genutzt. |
| whatNow | English What keeps working | German Was weiter funktioniert |
| options | English How to go on | German So geht es weiter |
| wait | English Wait for the reset | German Bis zum Neustart warten |
| waitText | English Everything is back on {date}, at no cost. | German Am {date} ist alles wieder da, ohne Kosten. |
| buy | English Top up credits | German Credits nachkaufen |
| buyText | English Once, no plan change, valid for 12 months. | German Einmalig, ohne Planwechsel, 12 Monate gültig. |
| upgradeText | English {limit} per period instead of {now}. | German {limit} pro Zeitraum statt {now}. |
| confirmBuy | English Buy {amount} for {price} | German {amount} für {price} kaufen |
| confirmUpgrade | English Switch to {name} for {price} per month | German Zu {name} wechseln für {price} im Monat |
| confirmWait | English Keep the plan | German Plan behalten |
| done | English Done. Your new credits are ready. | German Erledigt. Deine neuen Credits sind da. |
| upgraded | English Done. {name} applies from now on. | German Erledigt. {name} gilt ab sofort. |
| kept | English All right. Your plan starts over on {date}. | German Alles klar. Dein Plan startet am {date} neu. |
| confirm | English You confirm every purchase. Prices include VAT. | German Du bestätigst jeden Kauf. Preise inklusive Mehrwertsteuer. |
| perMonth | English per month | German im Monat |
| current | English Current | German Aktuell |
Accessibility
Read from the source of the component.
- AI usage meter handles the keys Arrow up, Arrow down, Arrow left, Arrow right and Space itself.
- Sets aria-checked, aria-label, aria-labelledby, aria-sort, aria-valuemax, aria-valuemin, aria-valuenow and aria-valuetext, so screen readers announce purpose and state.
- Uses the roles meter, radio, radiogroup and status.
- Announces changes through a live region without moving focus.
- Every field has a label, visible or for screen readers.
- Adds text for screen readers where icons and graphics stand alone.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Rises in briefly the first time it comes into view (kit-rise, useInView).
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 874 lines
'use client';
import { useId, useState } from 'react';
import { cn, formatPercent, round, useLocale, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { ChartRoot, ChartSurface, ChartTable, ChartTooltip, barPath, linePath, niceTicks, useActive, useEntrance, useShown, useWidth } from '@/components/ui/chart';
export type AiUsageMeterVariant = 'quotas' | 'period-chart' | 'by-model' | 'limit-reached';
export interface UsageQuota {
id: string;
label: string;
used: number;
limit: number;
/** e.g. "credits", "images" */
unit?: string;
icon?: IconName;
/** ISO date when it starts over */
resets?: string;
}
export interface UsageByModel {
id: string;
/** e.g. "Nova 2" */
name: string;
/** e.g. "Fast" or "Deep reasoning" */
note?: string;
requests: number;
inputTokens: number;
outputTokens: number;
/** Cost in the currency, or credits without one */
cost: number;
}
export interface CreditPack {
id: string;
amount: number;
price: number;
/** e.g. "Best value" */
badge?: string;
}
export interface UsagePlan {
name: string;
/** Included per period */
limit: number;
price?: number;
/** What the plan adds, short lines */
perks?: string[];
}
export interface AiUsageMeterProps {
variant?: AiUsageMeterVariant;
title?: string;
description?: string;
/** quotas: several meters in one card */
quotas?: UsageQuota[];
/** period-chart, limit-reached: the main meter */
used?: number;
limit?: number;
/** e.g. "credits" */
unit?: string;
/** Warning marks in percent of the limit, e.g. [75, 90] */
warnAt?: number[];
/** period-chart: use per day so far, oldest first */
days?: number[];
/** ISO date of the first day of the period */
start?: string;
/** ISO date of the last day of the period */
end?: string;
/** ISO date that counts as today */
today?: string;
/** by-model */
models?: UsageByModel[];
/** ISO currency code for costs and prices */
currency?: string;
/** limit-reached and quotas: the plan now and the next one */
plan?: UsagePlan;
upgrade?: UsagePlan;
/** limit-reached: credit packs to top up */
packs?: CreditPack[];
/** limit-reached: what keeps working at the limit, e.g. "Answers run on Atlas Mini" */
fallback?: string[];
onUpgrade?: () => void;
onBuy?: (pack: CreditPack) => void;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
}
const TEXTS = {
en: {
left: '{v} left',
of: '{used} of {limit}',
used: '{v} used',
resets: 'Starts over on {date}',
resetsIn: 'in {n} days',
resetsTomorrow: 'tomorrow',
ok: 'Plenty left',
warn: 'Getting close',
high: 'Almost used up',
full: 'Limit reached',
warnAt: 'Note at {v}',
upgrade: 'Upgrade plan',
manage: 'See usage',
plan: 'Plan {name}',
period: 'This period',
perDay: 'Per day',
average: 'Daily average',
forecast: 'Forecast at the end',
forecastOver: 'At this pace you reach the limit on {date}.',
forecastOk: 'At this pace you stay below the limit.',
pace: 'Even pace to the limit',
projected: 'Projected',
usedLegend: 'Used',
chart: 'Use per day',
range: 'Range',
last7: '7 days',
last14: '14 days',
whole: 'Whole period',
model: 'Model',
requests: 'Requests',
input: 'Input',
output: 'Output',
tokens: 'Tokens',
cost: 'Cost',
share: 'Share of cost',
total: 'Total',
sortBy: 'Sort by {name}',
insight: '{name} handles {req} of requests for {cost} of the cost.',
tokensShort: '{v} tokens',
reached: 'You used everything in your plan for this period.',
reachedSoon: 'You used {v} of your plan for this period.',
whatNow: 'What keeps working',
options: 'How to go on',
wait: 'Wait for the reset',
waitText: 'Everything is back on {date}, at no cost.',
buy: 'Top up credits',
buyText: 'Once, no plan change, valid for 12 months.',
upgradeText: '{limit} per period instead of {now}.',
confirmBuy: 'Buy {amount} for {price}',
confirmUpgrade: 'Switch to {name} for {price} per month',
confirmWait: 'Keep the plan',
done: 'Done. Your new credits are ready.',
upgraded: 'Done. {name} applies from now on.',
kept: 'All right. Your plan starts over on {date}.',
confirm: 'You confirm every purchase. Prices include VAT.',
perMonth: 'per month',
current: 'Current',
},
de: {
left: 'noch {v}',
of: '{used} von {limit}',
used: '{v} verbraucht',
resets: 'Startet neu am {date}',
resetsIn: 'in {n} Tagen',
resetsTomorrow: 'morgen',
ok: 'Reichlich übrig',
warn: 'Wird knapp',
high: 'Fast aufgebraucht',
full: 'Limit erreicht',
warnAt: 'Hinweis bei {v}',
upgrade: 'Plan erweitern',
manage: 'Verbrauch ansehen',
plan: 'Plan {name}',
period: 'Dieser Zeitraum',
perDay: 'Pro Tag',
average: 'Schnitt pro Tag',
forecast: 'Prognose zum Ende',
forecastOver: 'In diesem Tempo erreichst du das Limit am {date}.',
forecastOk: 'In diesem Tempo bleibst du unter dem Limit.',
pace: 'Gleichmäßiges Tempo bis zum Limit',
projected: 'Prognose',
usedLegend: 'Verbraucht',
chart: 'Verbrauch pro Tag',
range: 'Zeitraum',
last7: '7 Tage',
last14: '14 Tage',
whole: 'Ganzer Zeitraum',
model: 'Modell',
requests: 'Anfragen',
input: 'Eingabe',
output: 'Ausgabe',
tokens: 'Tokens',
cost: 'Kosten',
share: 'Anteil an Kosten',
total: 'Summe',
sortBy: 'Nach {name} sortieren',
insight: '{name} erledigt {req} der Anfragen für {cost} der Kosten.',
tokensShort: '{v} Tokens',
reached: 'Du hast alles aus deinem Plan für diesen Zeitraum genutzt.',
reachedSoon: 'Du hast {v} deines Plans für diesen Zeitraum genutzt.',
whatNow: 'Was weiter funktioniert',
options: 'So geht es weiter',
wait: 'Bis zum Neustart warten',
waitText: 'Am {date} ist alles wieder da, ohne Kosten.',
buy: 'Credits nachkaufen',
buyText: 'Einmalig, ohne Planwechsel, 12 Monate gültig.',
upgradeText: '{limit} pro Zeitraum statt {now}.',
confirmBuy: '{amount} für {price} kaufen',
confirmUpgrade: 'Zu {name} wechseln für {price} im Monat',
confirmWait: 'Plan behalten',
done: 'Erledigt. Deine neuen Credits sind da.',
upgraded: 'Erledigt. {name} gilt ab sofort.',
kept: 'Alles klar. Dein Plan startet am {date} neu.',
confirm: 'Du bestätigst jeden Kauf. Preise inklusive Mehrwertsteuer.',
perMonth: 'im Monat',
current: 'Aktuell',
},
};
type Texts = (typeof TEXTS)['en'];
type P = AiUsageMeterProps & { t: Texts };
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
const CSS =
'@keyframes aum-up{from{opacity:0;transform:translateY(6px)}}@keyframes aum-fill{from{transform:scaleX(0)}}' +
'@media (prefers-reduced-motion:no-preference){.aum-up{animation:aum-up .4s var(--ease-kit) var(--aum-d,0ms) both}.aum-fill{animation:aum-fill 1s var(--ease-kit) var(--aum-d,0ms) both}}';
function Style() {
return (
<style href="kit-ai-usage-meter" precedence="default">
{CSS}
</style>
);
}
/* ------------------------------------------------------------------ */
/* Shared parts */
/* ------------------------------------------------------------------ */
const card = 'rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]';
const btn = 'inline-flex h-11 shrink-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-lg px-3.5 text-[13px] font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 sm:h-9';
const primaryBtn = cn(btn, 'bg-primary text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.18),var(--shadow-sm)] hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))]');
const quietBtn = cn(btn, 'border border-border bg-card text-foreground hover:bg-accent');
const DAY = 86_400_000;
const parse = (iso?: string) => (iso ? Date.parse(`${iso}T00:00:00Z`) : NaN);
function useFmt(currency?: string) {
const locale = useLocale();
const intl = locale === 'de' ? 'de-DE' : 'en-US';
const num = new Intl.NumberFormat(intl);
const compact = new Intl.NumberFormat(intl, { notation: 'compact', maximumFractionDigits: 1 });
const money = currency ? new Intl.NumberFormat(intl, { style: 'currency', currency, minimumFractionDigits: 2, maximumFractionDigits: 2 }) : null;
const date = new Intl.DateTimeFormat(intl, { day: 'numeric', month: 'short', timeZone: 'UTC' });
const dateLong = new Intl.DateTimeFormat(intl, { weekday: 'short', day: 'numeric', month: 'long', timeZone: 'UTC' });
return {
locale,
n: (v: number) => num.format(Math.round(v)),
compact: (v: number) => compact.format(v),
money: (v: number) => (money ? money.format(v) : num.format(v)),
pct: (v: number, d?: number) => formatPercent(v, locale, d),
date: (ms: number) => date.format(ms),
dateLong: (ms: number) => dateLong.format(ms),
};
}
type Tier = 'ok' | 'warn' | 'high' | 'full';
function tierOf(share: number, warnAt: number[]): Tier {
const [w1 = 75, w2 = 90] = [...warnAt].sort((a, b) => a - b);
return share >= 100 ? 'full' : share >= w2 ? 'high' : share >= w1 ? 'warn' : 'ok';
}
const TIER: Record<Tier, { bar: string; text: string; bg: string; icon: IconName }> = {
ok: { bar: 'bg-primary', text: 'text-muted-foreground', bg: '', icon: 'check-circle' },
warn: { bar: 'bg-warning', text: 'text-warning-foreground', bg: 'bg-[color-mix(in_oklab,var(--warning)_12%,var(--card))]', icon: 'alert' },
high: { bar: 'bg-[color-mix(in_oklch,var(--warning),var(--destructive))]', text: 'text-warning-foreground', bg: 'bg-[color-mix(in_oklab,var(--warning)_14%,var(--card))]', icon: 'alert' },
full: { bar: 'bg-destructive', text: 'text-destructive', bg: 'bg-[color-mix(in_oklab,var(--destructive)_10%,var(--card))]', icon: 'alert-circle' },
};
/** A bar with the warning marks on it */
function Meter({ share, warnAt, tier, label, valueText, className, delay = 0 }: { share: number; warnAt: number[]; tier: Tier; label: string; valueText: string; className?: string; delay?: number }) {
return (
<span role="meter" aria-label={label} aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(Math.min(100, share))} aria-valuetext={valueText} className={cn('relative block h-2 rounded-full bg-track', className)}>
<span className="absolute inset-0 overflow-hidden rounded-full">
<span className={cn('aum-fill block h-full origin-left rounded-full', TIER[tier].bar)} style={{ width: `${round(Math.min(100, share), 2)}%`, '--aum-d': `${delay}ms` } as React.CSSProperties} />
</span>
{warnAt.map((w) => (
<span key={w} aria-hidden className="absolute -inset-y-0.5 w-0.5 -translate-x-1/2 rounded-full bg-card" style={{ left: `${w}%` }} />
))}
</span>
);
}
function Stage({ title, description, children, className, width = 'max-w-[68rem]' }: { title?: string; description?: string; children: React.ReactNode; className?: string; width?: string }) {
return (
<section className={cn('kit-glow relative overflow-clip bg-surface py-12 sm:py-16 lg:py-20', className)}>
<Style />
<div className="kit-container">
<div className={cn('mx-auto w-full', width)}>
{(title || description) && (
<Reveal className="mb-6 sm:mb-8">
{title && <h2 className="text-balance text-[22px] font-semibold leading-tight tracking-[-0.02em] text-foreground sm:text-[26px]">{title}</h2>}
{description && <p className="mt-2 max-w-[40rem] text-pretty text-[15px] leading-relaxed text-muted-foreground">{description}</p>}
</Reveal>
)}
{children}
</div>
</div>
</section>
);
}
function resetLine(t: Texts, f: ReturnType<typeof useFmt>, resets?: string, today?: string) {
const r = parse(resets);
if (Number.isNaN(r)) return '';
const now = parse(today);
const days = Number.isNaN(now) ? null : Math.round((r - now) / DAY);
return `${fill(t.resets, { date: f.date(r) })}${days !== null && days >= 0 ? `, ${days === 1 ? t.resetsTomorrow : fill(t.resetsIn, { n: days })}` : ''}`;
}
/* ------------------------------------------------------------------ */
/* quotas */
/* ------------------------------------------------------------------ */
function Quotas({ t, title, description, quotas = [], warnAt = [75, 90], plan, today, onUpgrade, className }: P) {
const f = useFmt();
const worst = quotas.reduce<Tier>((w, q) => {
const tier = tierOf((q.used / q.limit) * 100, warnAt);
const order: Tier[] = ['ok', 'warn', 'high', 'full'];
return order.indexOf(tier) > order.indexOf(w) ? tier : w;
}, 'ok');
return (
<Stage title={title} description={description} className={className} width="max-w-[30rem]">
<Reveal effect="scale" className={cn(card, 'overflow-hidden')}>
{plan && (
<div className="flex items-center justify-between gap-3 border-b border-border px-4 py-3">
<p className="flex items-center gap-2 text-[13px] font-medium text-foreground">
<Icon name="gauge" size={15} className="text-muted-foreground" />
{fill(t.plan, { name: plan.name })}
</p>
{worst !== 'ok' && (
<span className={cn('aum-up inline-flex h-6 items-center gap-1 rounded-md px-1.5 text-[11.5px] font-medium', TIER[worst].bg, TIER[worst].text)}>
<Icon name={TIER[worst].icon} size={12} />
{t[worst]}
</span>
)}
</div>
)}
<ul className="divide-y divide-border">
{quotas.map((q, i) => {
const share = (q.used / q.limit) * 100;
const tier = tierOf(share, warnAt);
const left = Math.max(0, q.limit - q.used);
const unit = q.unit ? ` ${q.unit}` : '';
return (
<li key={q.id} className="px-4 py-4">
<div className="flex items-start gap-3">
<span aria-hidden className="grid size-9 shrink-0 place-items-center rounded-lg border border-border text-muted-foreground">
<Icon name={q.icon ?? 'sparkle'} size={16} />
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline justify-between gap-x-3">
<p className="text-[13.5px] font-medium text-foreground">{q.label}</p>
<p className={cn('text-[12.5px] font-medium tabular-nums', tier === 'ok' ? 'text-foreground' : TIER[tier].text)}>{tier === 'full' ? t.full : fill(t.left, { v: `${f.n(left)}${unit}` })}</p>
</div>
<Meter share={share} warnAt={warnAt} tier={tier} label={q.label} valueText={`${fill(t.of, { used: f.n(q.used), limit: `${f.n(q.limit)}${unit}` })}, ${t[tier]}`} className="mt-2.5" delay={i * 120} />
<p className="mt-2 flex flex-wrap justify-between gap-x-3 text-[12px] tabular-nums text-muted-foreground">
<span>{fill(t.of, { used: f.n(q.used), limit: `${f.n(q.limit)}${unit}` })}</span>
<span>{resetLine(t, f, q.resets, today)}</span>
</p>
</div>
</div>
</li>
);
})}
</ul>
<div className="flex flex-wrap items-center gap-2 border-t border-border bg-surface px-4 py-3">
<p className="min-w-0 flex-1 text-[12px] leading-snug text-muted-foreground">{fill(t.warnAt, { v: warnAt.map((w) => f.pct(w)).join(', ') })}</p>
<button type="button" onClick={onUpgrade} className={worst === 'ok' ? quietBtn : primaryBtn}>
<Icon name="arrow-up-right" size={15} />
{t.upgrade}
</button>
</div>
</Reveal>
</Stage>
);
}
/* ------------------------------------------------------------------ */
/* period-chart */
/* ------------------------------------------------------------------ */
function PeriodChart({ t, title, description, used: _used, limit = 0, unit, warnAt = [75, 90], days = [], start, end, today, className }: P) {
const f = useFmt();
const [range, setRange] = useState<'7' | '14' | 'all'>('all');
const s = parse(start);
const e = parse(end);
const total = Number.isNaN(s) || Number.isNaN(e) ? days.length : Math.round((e - s) / DAY) + 1;
const used = _used ?? days.reduce((a, b) => a + b, 0);
const avg = days.length ? used / days.length : 0;
const recent = days.slice(-7);
const rate = recent.length ? recent.reduce((a, b) => a + b, 0) / recent.length : avg;
const rest = Math.max(0, total - days.length);
const forecast = used + rate * rest;
const overDay = rate > 0 && used < limit ? Math.ceil((limit - used) / rate) : null;
const overDate = overDay !== null && overDay <= rest && !Number.isNaN(s) ? s + (days.length - 1 + overDay) * DAY : null;
const share = limit ? (used / limit) * 100 : 0;
const tier = tierOf(share, warnAt);
const unitS = unit ? ` ${unit}` : '';
// Bars: days so far plus the projected rest of the period, cut to the range
const all = [...days.map((v) => ({ v, projected: false })), ...Array.from({ length: rest }, () => ({ v: rate, projected: true }))];
const from = range === 'all' ? 0 : Math.max(0, days.length - Number(range));
const to = range === 'all' ? all.length : days.length;
const shown = all.slice(from, to).map((x, i) => ({ ...x, i: from + i }));
const dayLimit = limit && total ? limit / total : 0;
const [rootRef, entrance] = useEntrance<HTMLDivElement>();
const [wrapRef, width] = useWidth<HTMLDivElement>(720);
const act = useActive(shown.length);
const { shown: last, open, jump } = useShown(act.active);
const H = 220;
const pad = { l: 44, r: 8, t: 12, b: 26 };
const max = Math.max(dayLimit * 1.15, ...shown.map((x) => x.v), 1);
const scale = niceTicks(0, max, 4);
const ticks = scale.ticks;
const top = scale.max || max;
const iw = Math.max(10, width - pad.l - pad.r);
const ih = H - pad.t - pad.b;
const bw = iw / Math.max(1, shown.length);
const y = (v: number) => pad.t + ih - (v / top) * ih;
const dateOf = (i: number) => (Number.isNaN(s) ? String(i + 1) : f.date(s + i * DAY));
const longOf = (i: number) => (Number.isNaN(s) ? String(i + 1) : f.dateLong(s + i * DAY));
const isToday = (i: number) => !Number.isNaN(parse(today)) && !Number.isNaN(s) && s + i * DAY === parse(today);
const labelEvery = Math.max(1, Math.ceil(shown.length / Math.max(2, Math.floor(iw / 56))));
const todayK = shown.findIndex((x) => isToday(x.i));
const cur = last !== null ? shown[last] : null;
const ranges: { id: '7' | '14' | 'all'; label: string }[] = [
{ id: '7', label: t.last7 },
{ id: '14', label: t.last14 },
{ id: 'all', label: t.whole },
];
const kpis = [
{ label: t.period, value: `${f.n(used)}${unitS}`, sub: limit ? fill(t.of, { used: f.pct(round(share), 0), limit: `${f.n(limit)}${unitS}` }) : '' },
{ label: t.average, value: `${f.n(avg)}${unitS}`, sub: dayLimit ? `${t.pace}: ${f.n(dayLimit)}` : '' },
{ label: t.forecast, value: `${f.n(forecast)}${unitS}`, sub: limit ? f.pct(round((forecast / limit) * 100), 0) : '', warn: limit > 0 && forecast > limit },
];
return (
<Stage title={title} description={description} className={className}>
<Reveal className={cn(card, 'overflow-hidden')}>
<dl className="grid grid-cols-1 gap-px bg-border sm:grid-cols-3">
{kpis.map((k) => (
<div key={k.label} className="bg-card px-4 py-3.5 sm:px-5 sm:py-4">
<dt className="text-[12.5px] text-muted-foreground">{k.label}</dt>
<dd className={cn('mt-1 text-[22px] font-semibold tabular-nums tracking-[-0.02em]', k.warn ? 'text-warning-foreground' : 'text-foreground')}>{k.value}</dd>
{k.sub && <dd className="text-[12px] tabular-nums text-muted-foreground">{k.sub}</dd>}
</div>
))}
</dl>
<div className="border-t border-border px-4 pb-4 pt-4 sm:px-5">
{limit > 0 && <Meter share={share} warnAt={warnAt} tier={tier} label={t.period} valueText={`${fill(t.of, { used: f.n(used), limit: f.n(limit) })}, ${t[tier]}`} />}
<p className={cn('mt-2.5 flex items-start gap-1.5 text-[13px] leading-snug', overDate ? 'text-warning-foreground' : 'text-muted-foreground')}>
<Icon name={overDate ? 'alert' : 'check-circle'} size={14} className="mt-0.5 shrink-0" />
{overDate ? fill(t.forecastOver, { date: f.dateLong(overDate) }) : t.forecastOk}
</p>
<div className="mt-5 flex flex-wrap items-center justify-between gap-3">
<p className="text-[13px] font-medium text-foreground">{t.chart}</p>
<div role="radiogroup" aria-label={t.range} className="flex gap-1 rounded-lg bg-[color-mix(in_oklab,var(--foreground)_6%,transparent)] p-1">
{ranges.map((r) => (
<button key={r.id} type="button" role="radio" data-r={r.id} aria-checked={range === r.id} tabIndex={range === r.id ? 0 : -1} onClick={() => (setRange(r.id), act.setActive(null))} onKeyDown={(ev) => {
const d = ev.key === 'ArrowRight' ? 1 : ev.key === 'ArrowLeft' ? -1 : 0;
if (!d) return;
ev.preventDefault();
const i = (ranges.findIndex((x) => x.id === r.id) + d + ranges.length) % ranges.length;
setRange(ranges[i].id);
(ev.currentTarget.parentElement?.querySelector(`[data-r="${ranges[i].id}"]`) as HTMLElement | null)?.focus();
}} className={cn('inline-flex h-11 items-center rounded-md px-3 text-[12.5px] font-medium transition-colors sm:h-8', range === r.id ? 'bg-card text-foreground shadow-[var(--highlight),var(--shadow-sm)]' : 'text-muted-foreground hover:text-foreground')}>
{r.label}
</button>
))}
</div>
</div>
<ChartRoot anim={entrance} rootRef={rootRef} className="mt-3">
<div ref={wrapRef} className="relative">
<ChartSurface
label={t.chart}
live={cur ? `${longOf(cur.i)}: ${f.n(cur.v)}${unitS}${cur.projected ? ` (${t.projected})` : ''}` : ''}
table={<ChartTable caption={t.chart} head={[t.chart, t.usedLegend]} rows={shown.map((x) => [longOf(x.i), `${f.n(x.v)}${unitS}${x.projected ? ` (${t.projected})` : ''}`])} />}
onKeyDown={act.onKeyDown}
onFocus={act.onFocus}
onBlur={act.onBlur}
onPointerMove={(ev) => {
const r = ev.currentTarget.getBoundingClientRect();
const i = Math.floor((ev.clientX - r.left - pad.l) / bw);
act.setActive(i >= 0 && i < shown.length ? i : null);
}}
onPointerLeave={() => act.setActive(null)}
>
<svg width={width} height={H} viewBox={`0 0 ${width} ${H}`} className="block overflow-visible" aria-hidden>
{ticks.map((v) => (
<g key={v}>
<line x1={pad.l} x2={width - pad.r} y1={round(y(v))} y2={round(y(v))} stroke="var(--kitch-grid)" strokeDasharray={v ? '2 4' : undefined} />
<text x={pad.l - 8} y={round(y(v))} dy="0.32em" textAnchor="end" className="fill-[var(--muted-foreground)] text-[11px] tabular-nums">
{f.compact(v)}
</text>
</g>
))}
{shown.map((x, k) => {
const h = Math.max(0, ih - (y(x.v) - pad.t));
const bx = pad.l + k * bw + Math.min(3, bw * 0.15);
const w = Math.max(1, bw - Math.min(6, bw * 0.3));
return (
<path
key={x.i}
d={barPath(bx, y(x.v), w, h, Math.min(4, w / 2))}
className="kitch-grow-y"
style={{ '--kitch-d': `${Math.min(k, 30) * 18}ms`, opacity: act.active !== null && act.active !== k ? 0.45 : 1 } as React.CSSProperties}
fill={x.projected ? 'color-mix(in oklab, var(--primary) 18%, transparent)' : 'var(--kitch-1)'}
stroke={x.projected ? 'color-mix(in oklab, var(--primary) 55%, transparent)' : 'none'}
strokeDasharray={x.projected ? '3 3' : undefined}
/>
);
})}
{dayLimit > 0 && (
<g className="kitch-fade" style={{ '--kitch-d': '500ms' } as React.CSSProperties}>
<path d={linePath([[pad.l, y(dayLimit)], [width - pad.r, y(dayLimit)]], false)} stroke="var(--foreground)" strokeOpacity="0.55" strokeWidth="1.25" strokeDasharray="6 4" />
</g>
)}
{shown.map((x, k) =>
(k % labelEvery === 0 && (todayK < 0 || Math.abs(k - todayK) >= Math.max(2, labelEvery * 0.8))) || isToday(x.i) ? (
<text key={`l${x.i}`} x={round(pad.l + k * bw + bw / 2)} y={H - 6} textAnchor="middle" className={cn('text-[11px] tabular-nums', isToday(x.i) ? 'fill-[var(--foreground)] font-semibold' : 'fill-[var(--muted-foreground)]')}>
{dateOf(x.i)}
</text>
) : null,
)}
</svg>
{cur && (
<ChartTooltip
open={open}
jump={jump}
x={round(pad.l + (last ?? 0) * bw + bw / 2)}
y={round(Math.max(pad.t, y(cur.v) - 8))}
width={width}
title={longOf(cur.i)}
rows={[{ key: 'v', label: cur.projected ? t.projected : t.usedLegend, value: `${f.n(cur.v)}${unitS}`, color: cur.projected ? 'color-mix(in oklab, var(--primary) 40%, transparent)' : 'var(--primary)' }]}
footer={dayLimit ? `${t.pace}: ${f.n(dayLimit)}${unitS}` : undefined}
/>
)}
</ChartSurface>
</div>
</ChartRoot>
<ul aria-hidden className="mt-3 flex flex-wrap gap-x-4 gap-y-1.5 text-[12px] text-muted-foreground">
<li className="inline-flex items-center gap-1.5">
<span className="size-2.5 rounded-[3px] bg-primary" />
{t.usedLegend}
</li>
<li className="inline-flex items-center gap-1.5">
<span className="size-2.5 rounded-[3px] border border-dashed border-[color-mix(in_oklab,var(--primary)_55%,transparent)] bg-[color-mix(in_oklab,var(--primary)_18%,transparent)]" />
{t.projected}
</li>
<li className="inline-flex items-center gap-1.5">
<span className="w-4 border-t-[1.5px] border-dashed border-[color-mix(in_oklab,var(--foreground)_55%,transparent)]" />
{t.pace}
</li>
</ul>
</div>
</Reveal>
</Stage>
);
}
/* ------------------------------------------------------------------ */
/* by-model */
/* ------------------------------------------------------------------ */
type ModelKey = 'name' | 'requests' | 'tokens' | 'cost';
function ByModel({ t, title, description, models = [], currency, className }: P) {
const f = useFmt(currency);
const [sort, setSort] = useState<{ key: ModelKey; dir: 1 | -1 }>({ key: 'cost', dir: -1 });
const totalCost = models.reduce((a, m) => a + m.cost, 0);
const totalReq = models.reduce((a, m) => a + m.requests, 0);
const totalIn = models.reduce((a, m) => a + m.inputTokens, 0);
const totalOut = models.reduce((a, m) => a + m.outputTokens, 0);
const val = (m: UsageByModel, k: ModelKey) => (k === 'tokens' ? m.inputTokens + m.outputTokens : k === 'name' ? 0 : m[k]);
const rows = [...models].sort((a, b) => (sort.key === 'name' ? a.name.localeCompare(b.name, f.locale) : val(a, sort.key) - val(b, sort.key)) * sort.dir);
// The model with the best ratio of requests to cost
const best = models.length > 1 ? [...models].sort((a, b) => b.requests / totalReq - b.cost / totalCost - (a.requests / totalReq - a.cost / totalCost))[0] : null;
const head = (key: ModelKey, label: string, cls?: string) => {
const on = sort.key === key;
return (
<th scope="col" aria-sort={on ? (sort.dir === 1 ? 'ascending' : 'descending') : undefined} className={cn('px-3 py-2.5 text-[12px] font-medium text-muted-foreground first:pl-5 last:pr-5', cls)}>
<button type="button" aria-label={fill(t.sortBy, { name: label })} onClick={() => setSort((x) => ({ key, dir: x.key === key ? ((x.dir * -1) as 1 | -1) : key === 'name' ? 1 : -1 }))} className={cn('-mx-1.5 inline-flex h-8 items-center gap-1 rounded-md px-1.5 hover:bg-accent hover:text-foreground', cls?.includes('text-right') && 'flex-row-reverse')}>
{label}
<Icon name={on ? (sort.dir === 1 ? 'chevron-up' : 'chevron-down') : 'chevrons-up-down'} size={13} className={on ? 'text-foreground' : 'opacity-60'} />
</button>
</th>
);
};
return (
<Stage title={title} description={description} className={className}>
{best && (
<Reveal className="mb-4 flex items-start gap-2.5 rounded-xl border border-border bg-[color-mix(in_oklab,var(--primary)_6%,var(--card))] px-4 py-3">
<Icon name="lightbulb" size={16} className="mt-0.5 shrink-0 text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]" />
<p className="text-[13.5px] leading-snug text-foreground">{fill(t.insight, { name: best.name, req: f.pct(Math.round((best.requests / totalReq) * 100)), cost: f.pct(Math.round((best.cost / totalCost) * 100)) })}</p>
</Reveal>
)}
<Reveal delay={80} className={cn(card, 'hidden overflow-hidden md:block')}>
<table className="w-full border-collapse text-[13px]">
<thead className="border-b border-border bg-surface">
<tr>
{head('name', t.model, 'text-left')}
{head('requests', t.requests, 'text-right')}
{head('tokens', t.tokens, 'text-right')}
{head('cost', t.cost, 'text-right')}
<th scope="col" className="w-[24%] px-3 py-2.5 pr-5 text-left text-[12px] font-medium text-muted-foreground max-lg:hidden">
{t.share}
</th>
</tr>
</thead>
<tbody>
{rows.map((m, i) => {
const share = totalCost ? (m.cost / totalCost) * 100 : 0;
return (
<tr key={m.id} className="aum-up border-b border-border last:border-b-0" style={{ '--aum-d': `${i * 50}ms` } as React.CSSProperties}>
<th scope="row" className="py-3 pl-5 pr-3 text-left font-normal">
<span className="block font-medium text-foreground">{m.name}</span>
{m.note && <span className="block text-[12px] text-muted-foreground">{m.note}</span>}
</th>
<td className="px-3 py-3 text-right tabular-nums text-foreground">{f.n(m.requests)}</td>
<td className="px-3 py-3 text-right tabular-nums">
<span className="block text-foreground">{f.compact(m.inputTokens + m.outputTokens)}</span>
<span className="block text-[11.5px] text-muted-foreground">
{f.compact(m.inputTokens)} / {f.compact(m.outputTokens)}
</span>
</td>
<td className="px-3 py-3 text-right font-medium tabular-nums text-foreground">{f.money(m.cost)}</td>
<td className="py-3 pl-3 pr-5 max-lg:hidden">
<span className="flex items-center gap-2.5">
<span className="block h-1.5 flex-1 overflow-hidden rounded-full bg-track">
<span className="aum-fill block h-full origin-left rounded-full bg-primary" style={{ width: `${round(share, 2)}%`, '--aum-d': `${i * 80}ms` } as React.CSSProperties} />
</span>
<span className="w-11 text-right text-[12px] tabular-nums text-muted-foreground">{f.pct(Math.round(share))}</span>
</span>
</td>
</tr>
);
})}
</tbody>
<tfoot className="border-t border-border bg-surface">
<tr>
<th scope="row" className="py-3 pl-5 pr-3 text-left text-[12.5px] font-medium text-muted-foreground">
{t.total}
</th>
<td className="px-3 py-3 text-right font-medium tabular-nums text-foreground">{f.n(totalReq)}</td>
<td className="px-3 py-3 text-right tabular-nums">
<span className="block font-medium text-foreground">{f.compact(totalIn + totalOut)}</span>
<span className="block text-[11.5px] text-muted-foreground">
{t.input} {f.compact(totalIn)} · {t.output} {f.compact(totalOut)}
</span>
</td>
<td className="px-3 py-3 text-right font-semibold tabular-nums text-foreground">{f.money(totalCost)}</td>
<td className="max-lg:hidden" />
</tr>
</tfoot>
</table>
</Reveal>
<ul className="space-y-2 md:hidden">
{rows.map((m, i) => {
const share = totalCost ? (m.cost / totalCost) * 100 : 0;
return (
<li key={m.id} className={cn(card, 'aum-up p-4')} style={{ '--aum-d': `${i * 50}ms` } as React.CSSProperties}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-[14px] font-medium text-foreground">{m.name}</p>
{m.note && <p className="text-[12px] text-muted-foreground">{m.note}</p>}
</div>
<p className="text-[15px] font-semibold tabular-nums text-foreground">{f.money(m.cost)}</p>
</div>
<span className="mt-3 block h-1.5 overflow-hidden rounded-full bg-track">
<span className="aum-fill block h-full origin-left rounded-full bg-primary" style={{ width: `${round(share, 2)}%` }} />
</span>
<dl className="mt-3 grid grid-cols-3 gap-2 text-[12px]">
<div>
<dt className="text-muted-foreground">{t.requests}</dt>
<dd className="font-medium tabular-nums text-foreground">{f.n(m.requests)}</dd>
</div>
<div>
<dt className="text-muted-foreground">{t.tokens}</dt>
<dd className="font-medium tabular-nums text-foreground">{f.compact(m.inputTokens + m.outputTokens)}</dd>
</div>
<div>
<dt className="text-muted-foreground">{t.share}</dt>
<dd className="font-medium tabular-nums text-foreground">{f.pct(Math.round(share))}</dd>
</div>
</dl>
</li>
);
})}
<li className="flex items-center justify-between px-1 pt-1 text-[13px]">
<span className="text-muted-foreground">{t.total}</span>
<span className="font-semibold tabular-nums text-foreground">{f.money(totalCost)}</span>
</li>
</ul>
</Stage>
);
}
/* ------------------------------------------------------------------ */
/* limit-reached */
/* ------------------------------------------------------------------ */
function LimitReached({ t, title, description, used = 0, limit = 0, unit, warnAt = [75, 90], end, plan, upgrade, packs = [], fallback = [], currency, onUpgrade, onBuy, className }: P) {
const f = useFmt(currency);
const uid = useId();
const [choice, setChoice] = useState<'wait' | 'buy' | 'upgrade'>(packs.length ? 'buy' : 'wait');
const [pack, setPack] = useState(packs.find((p) => p.badge)?.id ?? packs[0]?.id ?? '');
const [done, setDone] = useState<string | null>(null);
const share = limit ? (used / limit) * 100 : 0;
const tier = tierOf(share, warnAt);
const unitS = unit ? ` ${unit}` : '';
const resetDate = Number.isNaN(parse(end)) ? '' : f.dateLong(parse(end) + DAY);
const p = packs.find((x) => x.id === pack);
const confirm = () => {
if (choice === 'buy' && p) (onBuy?.(p), setDone(t.done));
else if (choice === 'upgrade' && upgrade) (onUpgrade?.(), setDone(fill(t.upgraded, { name: upgrade.name })));
else setDone(fill(t.kept, { date: resetDate }));
};
const options: { id: 'wait' | 'buy' | 'upgrade'; icon: IconName; title: string; text: string }[] = [
{ id: 'wait', icon: 'calendar', title: t.wait, text: fill(t.waitText, { date: resetDate }) },
...(packs.length ? [{ id: 'buy' as const, icon: 'coins' as IconName, title: t.buy, text: t.buyText }] : []),
...(upgrade && plan ? [{ id: 'upgrade' as const, icon: 'arrow-up-right' as IconName, title: fill(t.plan, { name: upgrade.name }), text: fill(t.upgradeText, { limit: `${f.n(upgrade.limit)}${unitS}`, now: f.n(plan.limit) }) }] : []),
];
const label = choice === 'buy' && p ? fill(t.confirmBuy, { amount: `${f.n(p.amount)}${unitS}`, price: f.money(p.price) }) : choice === 'upgrade' && upgrade ? fill(t.confirmUpgrade, { name: upgrade.name, price: f.money(upgrade.price ?? 0) }) : t.confirmWait;
return (
<Stage title={title} description={description} className={className} width="max-w-[60rem]">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] lg:items-start lg:gap-5">
<Reveal className={cn(card, 'p-5')}>
<p className={cn('inline-flex h-6 items-center gap-1 rounded-md px-1.5 text-[11.5px] font-medium', TIER[tier].bg || 'bg-surface-2', TIER[tier].text)}>
<Icon name={TIER[tier].icon} size={12} />
{t[tier]}
</p>
<p className="mt-3 text-[30px] font-semibold tabular-nums leading-none tracking-[-0.03em] text-foreground">
{f.n(used)}
<span className="text-[15px] font-medium tracking-normal text-muted-foreground"> / {f.n(limit)}{unitS}</span>
</p>
<Meter share={share} warnAt={warnAt} tier={tier} label={plan ? fill(t.plan, { name: plan.name }) : t.period} valueText={`${fill(t.of, { used: f.n(used), limit: f.n(limit) })}, ${t[tier]}`} className="mt-4" />
<p className="mt-3 text-pretty text-[13.5px] leading-relaxed text-foreground">{share >= 100 ? t.reached : fill(t.reachedSoon, { v: f.pct(Math.round(share)) })}</p>
{fallback.length > 0 && (
<div className="mt-4 border-t border-border pt-4">
<p className="text-[12.5px] font-medium text-muted-foreground">{t.whatNow}</p>
<ul className="mt-2 space-y-1.5">
{fallback.map((x) => (
<li key={x} className="flex items-start gap-2 text-[13.5px] leading-snug text-foreground">
<Icon name="check" size={14} className="mt-0.5 shrink-0 text-success-foreground" />
{x}
</li>
))}
</ul>
</div>
)}
</Reveal>
<Reveal delay={100} className={cn(card, 'p-5')}>
<p id={`${uid}-o`} className="text-[13px] font-medium text-foreground">
{t.options}
</p>
<div role="radiogroup" aria-labelledby={`${uid}-o`} className="mt-3 space-y-2">
{options.map((o) => {
const on = choice === o.id;
return (
<div key={o.id} className={cn('rounded-xl border transition-colors', on ? 'border-primary bg-[color-mix(in_oklab,var(--primary)_6%,var(--card))]' : 'border-border hover:border-border-strong')}>
<button
type="button"
role="radio"
data-o={o.id}
aria-checked={on}
tabIndex={on ? 0 : -1}
onClick={() => (setChoice(o.id), setDone(null))}
onKeyDown={(ev) => {
const d = ['ArrowDown', 'ArrowRight'].includes(ev.key) ? 1 : ['ArrowUp', 'ArrowLeft'].includes(ev.key) ? -1 : 0;
if (!d) return;
ev.preventDefault();
const i = (options.findIndex((x) => x.id === o.id) + d + options.length) % options.length;
setChoice(options[i].id);
setDone(null);
(ev.currentTarget.closest('[role=radiogroup]')?.querySelector(`[data-o="${options[i].id}"]`) as HTMLElement | null)?.focus();
}}
className="flex w-full items-start gap-3 rounded-xl p-3.5 text-left"
>
<span aria-hidden className={cn('mt-0.5 grid size-[18px] shrink-0 place-items-center rounded-full border-[1.5px]', on ? 'border-primary' : 'border-border-strong')}>
{on && <span className="size-2 rounded-full bg-primary" />}
</span>
<span className="min-w-0 flex-1">
<span className="flex items-center gap-1.5 text-[14px] font-medium text-foreground">
<Icon name={o.icon} size={15} className="text-muted-foreground" />
{o.title}
</span>
<span className="mt-0.5 block text-[12.5px] leading-snug text-muted-foreground">{o.text}</span>
</span>
{o.id === 'upgrade' && upgrade?.price !== undefined && (
<span className="shrink-0 text-right text-[13px] font-semibold tabular-nums text-foreground">
{f.money(upgrade.price)}
<span className="block text-[11px] font-normal text-muted-foreground">{t.perMonth}</span>
</span>
)}
</button>
{on && o.id === 'buy' && (
<div className="aum-up grid grid-cols-1 gap-2 px-3.5 pb-3.5 sm:grid-cols-3">
{packs.map((x) => (
<label key={x.id} className={cn('relative flex cursor-pointer flex-col rounded-lg border px-3 py-2.5 transition-colors has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-solid has-[:focus-visible]:outline-[var(--ring)]', pack === x.id ? 'border-primary bg-card' : 'border-border bg-card hover:border-border-strong')}>
<input type="radio" name={`${uid}-pack`} value={x.id} checked={pack === x.id} onChange={() => (setPack(x.id), setDone(null))} className="sr-only" />
<span className="text-[14px] font-semibold tabular-nums text-foreground">
{f.n(x.amount)}
<span className="text-[12px] font-normal text-muted-foreground">{unitS}</span>
</span>
<span className="text-[12.5px] tabular-nums text-muted-foreground">{f.money(x.price)}</span>
{x.badge && <span className="mt-1.5 w-fit rounded-[5px] bg-[color-mix(in_oklab,var(--primary)_14%,transparent)] px-1.5 text-[11px] font-medium text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">{x.badge}</span>}
</label>
))}
</div>
)}
{on && o.id === 'upgrade' && upgrade?.perks?.length ? (
<ul className="aum-up space-y-1 px-3.5 pb-3.5 pl-[2.6rem]">
{upgrade.perks.map((x) => (
<li key={x} className="flex items-start gap-2 text-[12.5px] leading-snug text-foreground">
<Icon name="plus" size={13} className="mt-0.5 shrink-0 text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]" />
{x}
</li>
))}
</ul>
) : null}
</div>
);
})}
</div>
<button type="button" onClick={confirm} className={cn(choice === 'wait' ? quietBtn : primaryBtn, 'mt-4 h-11 w-full whitespace-normal text-center leading-tight sm:h-11')}>
{label}
</button>
<p className="mt-2.5 text-center text-[12px] text-muted-foreground">{t.confirm}</p>
<p role="status" className="text-[13px] text-success-foreground empty:hidden">
{done && (
<span className="aum-up mt-3 flex items-start gap-1.5">
<Icon name="check-circle" size={15} className="mt-px shrink-0" />
{done}
</span>
)}
</p>
</Reveal>
</div>
</Stage>
);
}
/* ------------------------------------------------------------------ */
/**
* How much of an AI plan is used: several quotas in one card with warning
* marks and reset dates, the period as a chart with the projected rest and the
* even pace to the limit, cost and tokens per model, and the moment the limit
* is reached with what keeps working and three ways on. Numbers and dates in
* the language of the page. General app quotas and spend limits are usage.
*/
export function AiUsageMeter({ variant = 'quotas', labels, ...props }: AiUsageMeterProps) {
const t = useTexts(TEXTS, labels);
const p: P = { ...props, t };
switch (variant) {
case 'period-chart':
return <PeriodChart {...p} />;
case 'by-model':
return <ByModel {...p} />;
case 'limit-reached':
return <LimitReached {...p} />;
default:
return <Quotas {...p} />;
}
}Frequent questions
How do I install AI usage meter?
With aiw add @ai-website/ai-usage-meter. The command puts components/ui/ai-usage-meter.tsx into your project, the code is yours from then on. It brings along icon, reveal and chart. It needs no further npm packages.
Which variants does AI usage meter have?
4 variants, Quotas, Period chart, Cost per model and Limit reached. You pick one with the variant prop, for example variant="period-chart".
Can I translate the texts of AI usage meter?
Yes. AI usage meter carries 57 texts of its own in English and German, such as “tomorrow”, “Plenty left” and “Getting close”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with AI usage meter?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find AI usage meter with aiw_components and install it with aiw_add @ai-website/ai-usage-meter. Name the variant in your prompt, for example variant="quotas", and the assistant writes your content straight into the props.