Skip to content
UI Kit
Accent color

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.

4 variantsOne of 4 components in AI setup

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

QuotasNew in 1.2

One card for the account menu or settings. Every quota with what is left, a bar with the warning marks, the reset date and a status once one gets close.

Period chartNew in 1.2

Used, daily average and forecast on top, the meter with its warnings, then one bar per day with the projected rest dashed and the even pace as a line. Arrow keys read every day.

Cost per modelNew in 1.2

Requests, input and output tokens and cost per model, sortable, with the share of cost as a bar and the total. A plain sentence names the model that does the most for the least.

Limit reachedNew in 1.2

The meter at its end, what keeps working and three ways on: wait for the reset at no cost, top up a pack once or change the plan. The button says what happens and what it costs.

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

Props

The AiUsageMeterProps interface from components/ui/ai-usage-meter.tsx, with the comments from the code.

  • variantoptional
    AiUsageMeterVariant

    quotasperiod-chartby-modellimit-reached

  • titleoptional
    string
  • descriptionoptional
    string
  • quotasoptional
    UsageQuota[]

    quotas: several meters in one card

  • usedoptional
    number

    period-chart, limit-reached: the main meter

  • limitoptional
    number
  • unitoptional
    string

    e.g. "credits"

  • warnAtoptional
    number[]

    Warning marks in percent of the limit, e.g. [75, 90]

  • daysoptional
    number[]

    period-chart: use per day so far, oldest first

  • startoptional
    string

    ISO date of the first day of the period

  • endoptional
    string

    ISO date of the last day of the period

  • todayoptional
    string

    ISO date that counts as today

  • modelsoptional
    UsageByModel[]

    by-model

  • currencyoptional
    string

    ISO currency code for costs and prices

  • planoptional
    UsagePlan

    limit-reached and quotas: the plan now and the next one

  • upgradeoptional
    UsagePlan
  • packsoptional
    CreditPack[]

    limit-reached: credit packs to top up

  • fallbackoptional
    string[]

    limit-reached: what keeps working at the limit, e.g. "Answers run on Atlas Mini"

  • onUpgradeoptional
    () => void
  • onBuyoptional
    (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
leftEnglish {v} leftGerman noch {v}
ofEnglish {used} of {limit}German {used} von {limit}
usedEnglish {v} usedGerman {v} verbraucht
resetsEnglish Starts over on {date}German Startet neu am {date}
resetsInEnglish in {n} daysGerman in {n} Tagen
resetsTomorrowEnglish tomorrowGerman morgen
okEnglish Plenty leftGerman Reichlich übrig
warnEnglish Getting closeGerman Wird knapp
highEnglish Almost used upGerman Fast aufgebraucht
fullEnglish Limit reachedGerman Limit erreicht
warnAtEnglish Note at {v}German Hinweis bei {v}
upgradeEnglish Upgrade planGerman Plan erweitern
manageEnglish See usageGerman Verbrauch ansehen
planEnglish Plan {name}German Plan {name}
periodEnglish This periodGerman Dieser Zeitraum
perDayEnglish Per dayGerman Pro Tag
averageEnglish Daily averageGerman Schnitt pro Tag
forecastEnglish Forecast at the endGerman Prognose zum Ende
forecastOverEnglish At this pace you reach the limit on {date}.German In diesem Tempo erreichst du das Limit am {date}.
forecastOkEnglish At this pace you stay below the limit.German In diesem Tempo bleibst du unter dem Limit.
paceEnglish Even pace to the limitGerman Gleichmäßiges Tempo bis zum Limit
projectedEnglish ProjectedGerman Prognose
usedLegendEnglish UsedGerman Verbraucht
chartEnglish Use per dayGerman Verbrauch pro Tag
rangeEnglish RangeGerman Zeitraum
last7English 7 daysGerman 7 Tage
last14English 14 daysGerman 14 Tage
wholeEnglish Whole periodGerman Ganzer Zeitraum
modelEnglish ModelGerman Modell
requestsEnglish RequestsGerman Anfragen
inputEnglish InputGerman Eingabe
outputEnglish OutputGerman Ausgabe
tokensEnglish TokensGerman Tokens
costEnglish CostGerman Kosten
shareEnglish Share of costGerman Anteil an Kosten
totalEnglish TotalGerman Summe
sortByEnglish Sort by {name}German Nach {name} sortieren
insightEnglish {name} handles {req} of requests for {cost} of the cost.German {name} erledigt {req} der Anfragen für {cost} der Kosten.
tokensShortEnglish {v} tokensGerman {v} Tokens
reachedEnglish You used everything in your plan for this period.German Du hast alles aus deinem Plan für diesen Zeitraum genutzt.
reachedSoonEnglish You used {v} of your plan for this period.German Du hast {v} deines Plans für diesen Zeitraum genutzt.
whatNowEnglish What keeps workingGerman Was weiter funktioniert
optionsEnglish How to go onGerman So geht es weiter
waitEnglish Wait for the resetGerman Bis zum Neustart warten
waitTextEnglish Everything is back on {date}, at no cost.German Am {date} ist alles wieder da, ohne Kosten.
buyEnglish Top up creditsGerman Credits nachkaufen
buyTextEnglish Once, no plan change, valid for 12 months.German Einmalig, ohne Planwechsel, 12 Monate gültig.
upgradeTextEnglish {limit} per period instead of {now}.German {limit} pro Zeitraum statt {now}.
confirmBuyEnglish Buy {amount} for {price}German {amount} für {price} kaufen
confirmUpgradeEnglish Switch to {name} for {price} per monthGerman Zu {name} wechseln für {price} im Monat
confirmWaitEnglish Keep the planGerman Plan behalten
doneEnglish Done. Your new credits are ready.German Erledigt. Deine neuen Credits sind da.
upgradedEnglish Done. {name} applies from now on.German Erledigt. {name} gilt ab sofort.
keptEnglish All right. Your plan starts over on {date}.German Alles klar. Dein Plan startet am {date} neu.
confirmEnglish You confirm every purchase. Prices include VAT.German Du bestätigst jeden Kauf. Preise inklusive Mehrwertsteuer.
perMonthEnglish per monthGerman im Monat
currentEnglish CurrentGerman 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
components/ui/ai-usage-meter.tsx
'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.

Often searched

Jump to

Copied to the clipboard