Zum Inhalt springen
UI Kit
Accent

KI-Verbrauchsanzeige

Wie viel von einem KI-Plan verbraucht ist. Mehrere Kontingente in einer Karte mit Warnmarken und Neustart, der Zeitraum als Diagramm mit Prognose und gleichmäßigem Tempo, Kosten und Tokens je Modell und der Moment, in dem das Limit erreicht ist, mit drei Wegen weiter. Allgemeine Kontingente und Budgets einer App stehen in usage.

4 VariantsEine von 4 Components in KI-Einrichtung

Verbrauch verstehen, bevor das Limit kommt

Ein KI-Tarif hat Grenzen, und Menschen sollten sie kommen sehen, statt mitten in der Arbeit dagegen zu laufen. Die Verbrauchsanzeige zeigt, wie viel verbraucht ist, wie schnell es geht und was am Ende passiert, mit Zahlen und Daten in der Sprache der Seite.

„Kontingente“ ist eine Karte für Kontomenü oder Einstellungen, jedes Kontingent mit dem Rest, einem Balken mit Warnmarken, dem Neustart und einem Status, sobald eines knapp wird. „Zeitraum als Diagramm“ zeigt oben Verbrauch, Tagesschnitt und Prognose, darunter einen Balken je Tag mit gestrichelter Prognose und dem gleichmäßigen Tempo als Linie, die Pfeiltasten lesen jeden Tag vor. „Kosten je Modell“ führt Anfragen, Eingabe- und Ausgabetokens und Kosten je Modell, sortierbar, mit dem Kostenanteil als Balken und einem klaren Satz, der das Modell nennt, das am meisten für am wenigsten leistet. „Limit erreicht“ sagt, was weiter funktioniert, und bietet drei Wege, kostenlos auf den Neustart warten, einmal ein Paket nachkaufen oder den Plan wechseln.

Warne früh und ruhig, bei rund achtzig Prozent reicht ein kurzer Hinweis. Sag, was beim erreichten Limit weiter funktioniert, das hält Menschen im Produkt. Nenn den Preis auf dem Knopf, dann zahlt niemand überrascht.

Variants

KontingenteNeu in 1.2

Eine Karte für Kontomenü oder Einstellungen. Jedes Kontingent mit dem Rest, einem Balken mit Warnmarken, dem Neustart und einem Status, sobald eines knapp wird.

Zeitraum als DiagrammNeu in 1.2

Oben Verbrauch, Tagesschnitt und Prognose, darunter die Leiste mit Warnungen, dann ein Balken je Tag mit der gestrichelten Prognose und dem gleichmäßigen Tempo als Linie. Die Pfeiltasten lesen jeden Tag vor.

Kosten je ModellNeu in 1.2

Anfragen, Eingabe- und Ausgabetokens und Kosten je Modell, sortierbar, mit dem Anteil an den Kosten als Balken und der Summe. Ein klarer Satz nennt das Modell, das am meisten für am wenigsten leistet.

Limit erreichtNeu in 1.2

Die Leiste am Ende, was weiter funktioniert und drei Wege: kostenlos auf den Neustart warten, einmal ein Paket nachkaufen oder den Plan wechseln. Der Knopf sagt, was passiert und was es kostet.

Details

Was KI-Verbrauch mitbringt, aus Registry und Quellcode gelesen.

Bereich
App
Variants
4
Version
1.0.0 · seit 1.2.0
Datei
components/ui/ai-usage-meter.tsx
Bringt mit
utilskiticonrevealchart
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 57 eigene Texte
Studio
Im Code anpassen
In Sammlungen
KI-Produkt

Props

Die Schnittstelle AiUsageMeterProps aus components/ui/ai-usage-meter.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • 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

Eingebaute Texte

Diese Texte schreibt KI-Verbrauch selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 57 Texte anzeigen
leftEnglisch {v} leftDeutsch noch {v}
ofEnglisch {used} of {limit}Deutsch {used} von {limit}
usedEnglisch {v} usedDeutsch {v} verbraucht
resetsEnglisch Starts over on {date}Deutsch Startet neu am {date}
resetsInEnglisch in {n} daysDeutsch in {n} Tagen
resetsTomorrowEnglisch tomorrowDeutsch morgen
okEnglisch Plenty leftDeutsch Reichlich übrig
warnEnglisch Getting closeDeutsch Wird knapp
highEnglisch Almost used upDeutsch Fast aufgebraucht
fullEnglisch Limit reachedDeutsch Limit erreicht
warnAtEnglisch Note at {v}Deutsch Hinweis bei {v}
upgradeEnglisch Upgrade planDeutsch Plan erweitern
manageEnglisch See usageDeutsch Verbrauch ansehen
planEnglisch Plan {name}Deutsch Plan {name}
periodEnglisch This periodDeutsch Dieser Zeitraum
perDayEnglisch Per dayDeutsch Pro Tag
averageEnglisch Daily averageDeutsch Schnitt pro Tag
forecastEnglisch Forecast at the endDeutsch Prognose zum Ende
forecastOverEnglisch At this pace you reach the limit on {date}.Deutsch In diesem Tempo erreichst du das Limit am {date}.
forecastOkEnglisch At this pace you stay below the limit.Deutsch In diesem Tempo bleibst du unter dem Limit.
paceEnglisch Even pace to the limitDeutsch Gleichmäßiges Tempo bis zum Limit
projectedEnglisch ProjectedDeutsch Prognose
usedLegendEnglisch UsedDeutsch Verbraucht
chartEnglisch Use per dayDeutsch Verbrauch pro Tag
rangeEnglisch RangeDeutsch Zeitraum
last7Englisch 7 daysDeutsch 7 Tage
last14Englisch 14 daysDeutsch 14 Tage
wholeEnglisch Whole periodDeutsch Ganzer Zeitraum
modelEnglisch ModelDeutsch Modell
requestsEnglisch RequestsDeutsch Anfragen
inputEnglisch InputDeutsch Eingabe
outputEnglisch OutputDeutsch Ausgabe
tokensEnglisch TokensDeutsch Tokens
costEnglisch CostDeutsch Kosten
shareEnglisch Share of costDeutsch Anteil an Kosten
totalEnglisch TotalDeutsch Summe
sortByEnglisch Sort by {name}Deutsch Nach {name} sortieren
insightEnglisch {name} handles {req} of requests for {cost} of the cost.Deutsch {name} erledigt {req} der Anfragen für {cost} der Kosten.
tokensShortEnglisch {v} tokensDeutsch {v} Tokens
reachedEnglisch You used everything in your plan for this period.Deutsch Du hast alles aus deinem Plan für diesen Zeitraum genutzt.
reachedSoonEnglisch You used {v} of your plan for this period.Deutsch Du hast {v} deines Plans für diesen Zeitraum genutzt.
whatNowEnglisch What keeps workingDeutsch Was weiter funktioniert
optionsEnglisch How to go onDeutsch So geht es weiter
waitEnglisch Wait for the resetDeutsch Bis zum Neustart warten
waitTextEnglisch Everything is back on {date}, at no cost.Deutsch Am {date} ist alles wieder da, ohne Kosten.
buyEnglisch Top up creditsDeutsch Credits nachkaufen
buyTextEnglisch Once, no plan change, valid for 12 months.Deutsch Einmalig, ohne Planwechsel, 12 Monate gültig.
upgradeTextEnglisch {limit} per period instead of {now}.Deutsch {limit} pro Zeitraum statt {now}.
confirmBuyEnglisch Buy {amount} for {price}Deutsch {amount} für {price} kaufen
confirmUpgradeEnglisch Switch to {name} for {price} per monthDeutsch Zu {name} wechseln für {price} im Monat
confirmWaitEnglisch Keep the planDeutsch Plan behalten
doneEnglisch Done. Your new credits are ready.Deutsch Erledigt. Deine neuen Credits sind da.
upgradedEnglisch Done. {name} applies from now on.Deutsch Erledigt. {name} gilt ab sofort.
keptEnglisch All right. Your plan starts over on {date}.Deutsch Alles klar. Dein Plan startet am {date} neu.
confirmEnglisch You confirm every purchase. Prices include VAT.Deutsch Du bestätigst jeden Kauf. Preise inklusive Mehrwertsteuer.
perMonthEnglisch per monthDeutsch im Monat
currentEnglisch CurrentDeutsch Aktuell

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • KI-Verbrauch reagiert selbst auf die Tasten Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts und Leertaste.
  • Setzt aria-checked, aria-label, aria-labelledby, aria-sort, aria-valuemax, aria-valuemin, aria-valuenow und aria-valuetext, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen meter, radio, radiogroup und status.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Jedes Feld hat ein Label, sichtbar oder für Screenreader.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 874 Zeilen
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} />;
  }
}

Häufige Fragen

Wie installiere ich KI-Verbrauch?

Mit aiw add @ai-website/ai-usage-meter. Der Befehl legt die Datei components/ui/ai-usage-meter.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon, reveal und chart. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat KI-Verbrauch?

4 Varianten, Kontingente, Zeitraum als Diagramm, Kosten je Modell und Limit erreicht. Du wählst sie mit der Prop variant, zum Beispiel variant="period-chart".

Kann ich die Texte von KI-Verbrauch übersetzen?

Ja. KI-Verbrauch bringt 57 eigene Texte auf Deutsch und Englisch mit, etwa „morgen“, „Reichlich übrig“ und „Wird knapp“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent KI-Verbrauch einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini KI-Verbrauch mit aiw_components und installieren sie mit aiw_add @ai-website/ai-usage-meter. Nenn im Prompt die Variante, etwa variant="quotas", dann schreibt der Assistent deine Inhalte direkt in die Props.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert