Zum Inhalt springen
UI Kit
Accent

Verbrauch und Limits

Verbrauchs-Screens. Kontingente mit Prognose, Tagesdiagramm mit erwartetem Rest des Zeitraums, Monatsbudget mit Hinweisen, Aufteilung und Prepaid-Guthaben.

Eine von 2 Components in Billing und Usage

5 Variants

Variants

Kontingente

Jedes Kontingent mit Verbrauch und Rest, gestrichelter Prognose für den Zeitraum, Zustand nah am Limit und dem Tag, an dem es in diesem Tempo erreicht ist.

Im Studio öffnen

Tagesverbrauch

Balken je Tag, der Rest des Zeitraums als Umriss, der tägliche Anteil am Limit als Linie und ein Hinweis auf den Tag, an dem das Limit erreicht ist.

Im Studio öffnen

Ausgabenlimit

Ein Halbring mit den Ausgaben gegen das Budget, ein Regler für das Budget, E-Mail-Hinweise bei 50, 80 und 100 % und ein Schalter, um am Budget zu pausieren.

Im Studio öffnen

Aufteilung

Ein gestapelter Balken aller Projekte und eine sortierbare Liste mit Anteilen, der Wechsel der Kennzahl sortiert die Zeilen gleitend neu.

Im Studio öffnen

Guthaben

Das Guthaben mit Reichweite, Pakete zum Kaufen, automatisches Aufladen unter einer Marke und der Verlauf.

Im Studio öffnen

Details

Was Verbrauch und Limits mitbringt, aus Registry und Quellcode gelesen.

Bereich
App
Variants
5
Version
1.0.0 · seit 1.1.0
Datei
components/ui/usage.tsx
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 56 eigene Texte

Props

Die Schnittstelle UsageProps aus components/ui/usage.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    UsageVariant

    quotasdaily-chartspend-limitsbreakdowncredits

  • sidebaroptional
    Omit<SidebarProps, 'children'> | null

    The app frame. Leave it out (or null) for a standalone screen.

  • titlePflicht
    string
  • subtitleoptional
    string
  • currencyoptional
    string
  • todayoptional
    string

    ISO date that counts as today

  • periodoptional
    { start: string; end: string }

    First and last day of the billing period, ISO dates

  • quotasoptional
    Quota[]

    quotas

  • seriesoptional
    UsageSeries[]

    daily-chart

  • spendoptional
    { current: number; cap: number; max?: number; alerts?: number[]; hardStop?: boolean }

    spend-limits

  • metricsoptional
    { id: string; label: string; unit?: string }[]

    breakdown

  • itemsoptional
    UsageItem[]
  • creditsoptional
    { balance: number; perDay?: number; packs: CreditPack[]; history: CreditEntry[]; autoTopUp?: { on: boolean; below: number; pack: string } }

    credits

  • onActionoptional
    (action: string, data: Record<string, unknown>) => void | Promise<void>

    Called on save and purchase. Without it the block simulates the request.

Eingebaute Texte

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

Alle 56 Texte anzeigen
periodEnglisch {from} to {to}Deutsch {from} bis {to}
resetsEnglisch Resets in {n} daysDeutsch Startet neu in {n} Tagen
ofEnglisch of {limit}Deutsch von {limit}
leftEnglisch {n} leftDeutsch noch {n}
overEnglisch {n} over the limitDeutsch {n} über dem Limit
paceEnglisch At this pace you reach the limit on {date}.Deutsch In diesem Tempo erreichst du das Limit am {date}.
paceOkEnglisch At this pace you stay below the limit.Deutsch In diesem Tempo bleibst du unter dem Limit.
forecastEnglisch ForecastDeutsch Prognose
nearLimitEnglisch Close to the limitDeutsch Nah am Limit
atLimitEnglisch Limit reachedDeutsch Limit erreicht
metricEnglisch MetricDeutsch Kennzahl
perDayEnglisch Per dayDeutsch Pro Tag
limitPerDayEnglisch Limit per dayDeutsch Limit pro Tag
spentMarkEnglisch Spent {v}Deutsch Ausgegeben {v}
totalEnglisch So farDeutsch Bisher
projectedEnglisch Projected for the periodDeutsch Erwartet für den Zeitraum
limitEnglisch LimitDeutsch Limit
todayEnglisch TodayDeutsch Heute
chartEnglisch Usage per day, use the arrow keys to read each dayDeutsch Verbrauch pro Tag, mit den Pfeiltasten jeden Tag ablesen
dayValueEnglisch {date}: {v}Deutsch {date}: {v}
projectedDayEnglisch ProjectedDeutsch Erwartet
legendUsedEnglisch UsedDeutsch Verbraucht
legendProjectedEnglisch ProjectedDeutsch Erwartet
budgetEnglisch Monthly budgetDeutsch Monatsbudget
budgetTextEnglisch When usage costs reach the budget, we act the way you choose below.Deutsch Erreichen die Kosten das Budget, handeln wir so, wie du es unten wählst.
spentEnglisch Spent this periodDeutsch Ausgegeben in diesem Zeitraum
capValueEnglisch Budget per monthDeutsch Budget pro Monat
alertsEnglisch Email alertsDeutsch Hinweise per E-Mail
alertsTextEnglisch We email the owners at each mark.Deutsch Wir schreiben den Inhabern an jeder Marke.
alertAtEnglisch At {n}% of the budgetDeutsch Bei {n} % des Budgets
hardStopEnglisch Pause usage at the budgetDeutsch Nutzung am Budget pausieren
hardStopTextEnglisch Requests above the budget fail until the next period or until you raise it.Deutsch Anfragen über dem Budget schlagen fehl, bis der Zeitraum neu startet oder du es erhöhst.
softTextEnglisch Usage keeps running above the budget and is billed at the usual rate.Deutsch Die Nutzung läuft über dem Budget weiter und wird zum üblichen Preis berechnet.
saveEnglisch Save budgetDeutsch Budget speichern
savingEnglisch Saving…Deutsch Wird gespeichert…
savedEnglisch Budget saved.Deutsch Budget gespeichert.
unsavedEnglisch Unsaved changesDeutsch Ungespeicherte Änderungen
discardEnglisch DiscardDeutsch Verwerfen
shareEnglisch ShareDeutsch Anteil
nameEnglisch NameDeutsch Name
totalLabelEnglisch TotalDeutsch Gesamt
balanceEnglisch Credit balanceDeutsch Guthaben
lastsEnglisch Lasts about {n} days at the current paceDeutsch Reicht im jetzigen Tempo etwa {n} Tage
buyEnglisch Buy creditsDeutsch Guthaben kaufen
packEnglisch {n} creditsDeutsch {n} Credits
perCreditEnglisch {price} per 1,000Deutsch {price} pro 1.000
bonusEnglisch BonusDeutsch Bonus
payEnglisch Buy for {price}Deutsch Für {price} kaufen
buyingEnglisch Buying…Deutsch Wird gekauft…
boughtEnglisch {n} credits added.Deutsch {n} Credits gutgeschrieben.
autoEnglisch Top up automaticallyDeutsch Automatisch aufladen
autoTextEnglisch When the balance drops below {n} credits we buy the selected pack.Deutsch Fällt das Guthaben unter {n} Credits, kaufen wir das gewählte Paket.
belowEnglisch BelowDeutsch Unter
historyEnglisch ActivityDeutsch Verlauf
topUpEnglisch Top-upDeutsch Aufladung
usedEnglisch UsedDeutsch Verbraucht

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Verbrauch und Limits reagiert selbst auf die Tasten Enter, Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts und Leertaste.
  • Setzt aria-checked, aria-label, aria-live, aria-pressed, aria-valuemax, aria-valuemin, aria-valuenow und aria-valuetext, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen meter, radio, radiogroup, slider 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.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 2 Stellen im Code schalten Bewegung dann ab.

Quellcode

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

Datei anzeigen, 1.065 Zeilen
components/ui/usage.tsx
'use client';

import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { cn, type Locale, prefersReducedMotion, round, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { NativeSelect } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Sidebar, type SidebarProps } from '@/components/ui/sidebar';

export type UsageVariant = 'quotas' | 'daily-chart' | 'spend-limits' | 'breakdown' | 'credits';

export interface Quota {
  id: string;
  label: string;
  used: number;
  limit: number;
  /** e.g. GB. Leave out for plain counts. */
  unit?: string;
  icon?: IconName;
  /** Extra line, e.g. what happens at the limit */
  note?: string;
}

export interface UsageSeries {
  id: string;
  label: string;
  unit?: string;
  /** Limit for the whole period */
  limit?: number;
  /** One value per day so far, oldest first */
  days: number[];
}

export interface UsageItem {
  id: string;
  label: string;
  /** Small line under the name, e.g. the owner */
  meta?: string;
  /** Value per metric id */
  values: Record<string, number>;
}

export interface CreditPack {
  id: string;
  credits: number;
  price: number;
  /** e.g. 10 % extra */
  bonus?: string;
}

export interface CreditEntry {
  id: string;
  /** ISO date */
  date: string;
  label: string;
  /** Positive for top-ups, negative for use */
  amount: number;
}

export interface UsageProps {
  variant?: UsageVariant;
  /** The app frame. Leave it out (or null) for a standalone screen. */
  sidebar?: Omit<SidebarProps, 'children'> | null;
  title: string;
  subtitle?: string;
  currency?: string;
  /** ISO date that counts as today */
  today?: string;
  /** First and last day of the billing period, ISO dates */
  period?: { start: string; end: string };
  /** quotas */
  quotas?: Quota[];
  /** daily-chart */
  series?: UsageSeries[];
  /** spend-limits */
  spend?: { current: number; cap: number; max?: number; alerts?: number[]; hardStop?: boolean };
  /** breakdown */
  metrics?: { id: string; label: string; unit?: string }[];
  items?: UsageItem[];
  /** credits */
  credits?: { balance: number; perDay?: number; packs: CreditPack[]; history: CreditEntry[]; autoTopUp?: { on: boolean; below: number; pack: string } };
  /** Called on save and purchase. Without it the block simulates the request. */
  onAction?: (action: string, data: Record<string, unknown>) => void | Promise<void>;
  labels?: Partial<(typeof TEXTS)['en']>;
  className?: string;
}

const TEXTS = {
  en: {
    period: '{from} to {to}',
    resets: 'Resets in {n} days',
    of: 'of {limit}',
    left: '{n} left',
    over: '{n} over the limit',
    pace: 'At this pace you reach the limit on {date}.',
    paceOk: 'At this pace you stay below the limit.',
    forecast: 'Forecast',
    nearLimit: 'Close to the limit',
    atLimit: 'Limit reached',
    metric: 'Metric',
    perDay: 'Per day',
    limitPerDay: 'Limit per day',
    spentMark: 'Spent {v}',
    total: 'So far',
    projected: 'Projected for the period',
    limit: 'Limit',
    today: 'Today',
    chart: 'Usage per day, use the arrow keys to read each day',
    dayValue: '{date}: {v}',
    projectedDay: 'Projected',
    legendUsed: 'Used',
    legendProjected: 'Projected',
    budget: 'Monthly budget',
    budgetText: 'When usage costs reach the budget, we act the way you choose below.',
    spent: 'Spent this period',
    capValue: 'Budget per month',
    alerts: 'Email alerts',
    alertsText: 'We email the owners at each mark.',
    alertAt: 'At {n}% of the budget',
    hardStop: 'Pause usage at the budget',
    hardStopText: 'Requests above the budget fail until the next period or until you raise it.',
    softText: 'Usage keeps running above the budget and is billed at the usual rate.',
    save: 'Save budget',
    saving: 'Saving…',
    saved: 'Budget saved.',
    unsaved: 'Unsaved changes',
    discard: 'Discard',
    share: 'Share',
    name: 'Name',
    totalLabel: 'Total',
    balance: 'Credit balance',
    lasts: 'Lasts about {n} days at the current pace',
    buy: 'Buy credits',
    pack: '{n} credits',
    perCredit: '{price} per 1,000',
    bonus: 'Bonus',
    pay: 'Buy for {price}',
    buying: 'Buying…',
    bought: '{n} credits added.',
    auto: 'Top up automatically',
    autoText: 'When the balance drops below {n} credits we buy the selected pack.',
    below: 'Below',
    history: 'Activity',
    topUp: 'Top-up',
    used: 'Used',
  },
  de: {
    period: '{from} bis {to}',
    resets: 'Startet neu in {n} Tagen',
    of: 'von {limit}',
    left: 'noch {n}',
    over: '{n} über dem Limit',
    pace: 'In diesem Tempo erreichst du das Limit am {date}.',
    paceOk: 'In diesem Tempo bleibst du unter dem Limit.',
    forecast: 'Prognose',
    nearLimit: 'Nah am Limit',
    atLimit: 'Limit erreicht',
    metric: 'Kennzahl',
    perDay: 'Pro Tag',
    limitPerDay: 'Limit pro Tag',
    spentMark: 'Ausgegeben {v}',
    total: 'Bisher',
    projected: 'Erwartet für den Zeitraum',
    limit: 'Limit',
    today: 'Heute',
    chart: 'Verbrauch pro Tag, mit den Pfeiltasten jeden Tag ablesen',
    dayValue: '{date}: {v}',
    projectedDay: 'Erwartet',
    legendUsed: 'Verbraucht',
    legendProjected: 'Erwartet',
    budget: 'Monatsbudget',
    budgetText: 'Erreichen die Kosten das Budget, handeln wir so, wie du es unten wählst.',
    spent: 'Ausgegeben in diesem Zeitraum',
    capValue: 'Budget pro Monat',
    alerts: 'Hinweise per E-Mail',
    alertsText: 'Wir schreiben den Inhabern an jeder Marke.',
    alertAt: 'Bei {n} % des Budgets',
    hardStop: 'Nutzung am Budget pausieren',
    hardStopText: 'Anfragen über dem Budget schlagen fehl, bis der Zeitraum neu startet oder du es erhöhst.',
    softText: 'Die Nutzung läuft über dem Budget weiter und wird zum üblichen Preis berechnet.',
    save: 'Budget speichern',
    saving: 'Wird gespeichert…',
    saved: 'Budget gespeichert.',
    unsaved: 'Ungespeicherte Änderungen',
    discard: 'Verwerfen',
    share: 'Anteil',
    name: 'Name',
    totalLabel: 'Gesamt',
    balance: 'Guthaben',
    lasts: 'Reicht im jetzigen Tempo etwa {n} Tage',
    buy: 'Guthaben kaufen',
    pack: '{n} Credits',
    perCredit: '{price} pro 1.000',
    bonus: 'Bonus',
    pay: 'Für {price} kaufen',
    buying: 'Wird gekauft…',
    bought: '{n} Credits gutgeschrieben.',
    auto: 'Automatisch aufladen',
    autoText: 'Fällt das Guthaben unter {n} Credits, kaufen wir das gewählte Paket.',
    below: 'Unter',
    history: 'Verlauf',
    topUp: 'Aufladung',
    used: 'Verbraucht',
  },
};

type Texts = (typeof TEXTS)['en'];

const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));

/* ------------------------------------------------------------------ */
/* Motion of the app screens                                           */
/* ------------------------------------------------------------------ */
/*
 * The same small set in every app block (auth, dashboard, files, inbox …),
 * kept in the block so it ships on its own:
 *   useEnter   entrance that waits until its area scrolls in. The area gets
 *              data-kitm-scope and data-kitm={state}, children take kitm-wipe
 *              (charts draw from the left), kitm-grow-x, kitm-grow-y (bars),
 *              kitm-up and kitm-pop, with --kitm-d as delay.
 *   useGlide   a marker inside the active item ([data-glide]) glides over
 *              from where the previous one stood: tabs, segments, steps.
 *   kitm-in, kitm-fly, kitm-tick for things that appear after an action.
 * The server renders the end state, without JavaScript nothing waits longer
 * than 2.5 s, with reduced motion nothing moves. Only transform, opacity and
 * clip-path change, so no layout shifts.
 */
const useIso = typeof window === 'undefined' ? useEffect : useLayoutEffect;
const KITM_EASE = 'cubic-bezier(0.2, 0.7, 0.2, 1)';
const KITM_FX: [string, string, number][] = [
  ['wipe', 'clip-path:inset(0 100% 0 0)', 1100],
  ['grow-x', 'transform:scaleX(0)', 900],
  ['grow-y', 'transform:scaleY(0)', 800],
  ['up', 'opacity:0;transform:translateY(10px)', 600],
  ['pop', 'opacity:0;transform:scale(.92)', 450],
];
const KITM_CSS =
  KITM_FX.map(([k, v]) => `@keyframes kitm-${k}{from{${v}}}@keyframes kitm-${k}-h{from{${v}}}`).join('') +
  '@keyframes kitm-fly{from{opacity:0;transform:translateX(24px)}}@keyframes kitm-sheen{to{transform:translateX(130%)}}.kitm-sheen{transform:translateX(-130%)}@keyframes kitm-blink{50%{opacity:0}}@keyframes kitm-shake{20%,60%{transform:translateX(-5px)}40%,80%{transform:translateX(5px)}}@keyframes kitm-ring{20%{transform:rotate(14deg)}40%{transform:rotate(-11deg)}60%{transform:rotate(7deg)}80%{transform:rotate(-3deg)}}@keyframes kitm-next{from{opacity:0;transform:translateX(18px)}}@keyframes kitm-prev{from{opacity:0;transform:translateX(-18px)}}.kitm-caret{display:inline-block;width:.45em;height:1.1em;margin-left:.15em;vertical-align:-.2em;border-radius:2px;background:color-mix(in oklab,var(--primary) 80%,var(--foreground))}@keyframes kitm-flash{from{background-color:color-mix(in oklab,var(--primary) 14%,transparent)}}' +
  '.kitm-grow-x{transform-origin:left}.kitm-grow-y{transform-origin:bottom}' +
  '@media (prefers-reduced-motion:no-preference){' +
  KITM_FX.map(([k, , d]) => `[data-kitm=play] .kitm-${k}{animation:kitm-${k} var(--kitm-t,${d}ms) var(--ease-kit) var(--kitm-d,0ms) both}[data-kitm=wait] .kitm-${k}{animation:kitm-${k}-h 0s 1s both paused}`).join('') +
  '[data-kitm=play] .kitm-sheen{animation:kitm-sheen 1.6s var(--ease-kit) var(--kitm-d,400ms) both}.kitm-in{animation:kitm-up var(--kitm-t,.5s) var(--ease-kit) var(--kitm-d,0ms) both}.kitm-fly{animation:kitm-fly .55s var(--ease-kit) var(--kitm-d,0ms) both}.kitm-tick{animation:kitm-wipe .36s var(--ease-kit) both}.kitm-caret{animation:kitm-blink 1.05s steps(1) infinite}.kitm-shake{animation:kitm-shake .4s ease-in-out}.kitm-ring{transform-origin:50% 8%;animation:kitm-ring .9s var(--ease-kit) both}.kitm-next{animation:kitm-next .45s var(--ease-kit) both}.kitm-prev{animation:kitm-prev .45s var(--ease-kit) both}.kitm-flash{animation:kitm-flash 1.6s ease-out both}' +
  '}@media (scripting:enabled) and (prefers-reduced-motion:no-preference){' +
  KITM_FX.map(([k]) => `[data-kitm-scope]:not([data-kitm]) .kitm-${k}{animation:kitm-${k}-h 0s 2.5s both}`).join('') +
  '}';

function KitmStyle() {
  // React hoists it once into the head, however many app blocks render
  return (
    <style href="kitm" precedence="default">
      {KITM_CSS}
    </style>
  );
}

/** Entrance state of an area: undefined on the server, then wait (below the fold) or play */
function useEnter<T extends HTMLElement>() {
  const ref = useRef<T>(null);
  const [state, setState] = useState<'wait' | 'play' | undefined>(undefined);
  useEffect(() => {
    const el = ref.current;
    if (!el || prefersReducedMotion() || el.getBoundingClientRect().top < window.innerHeight * 0.92) return setState('play');
    setState('wait');
    const io = new IntersectionObserver(
      ([e]) => {
        if (!e?.isIntersecting) return;
        setState('play');
        io.disconnect();
      },
      { rootMargin: '0px 0px -8% 0px' },
    );
    io.observe(el);
    return () => io.disconnect();
  }, []);
  return [ref, { 'data-kitm-scope': '', 'data-kitm': state }] as const;
}

type Box = { x: number; y: number; w: number; h: number };
const boxOf = (el: Element, root: HTMLElement): Box => {
  const r = el.getBoundingClientRect();
  const b = root.getBoundingClientRect();
  return { x: r.left - b.left + root.scrollLeft, y: r.top - b.top + root.scrollTop, w: r.width, h: r.height };
};

/**
 * The marker ([data-glide]) is rendered inside the active item, so the server
 * and a page without JavaScript show it in place. After a change it starts
 * where the old one stood and glides into the new item.
 */
function useGlide<T extends HTMLElement>(key: unknown) {
  const ref = useRef<T>(null);
  const before = useRef<Box | null>(null);
  // Read during render, while the DOM still shows the old marker
  if (typeof window !== 'undefined' && ref.current) {
    const m = ref.current.querySelector('[data-glide]');
    before.current = m ? boxOf(m, ref.current) : null;
  }
  useIso(() => {
    const root = ref.current;
    const m = root?.querySelector<HTMLElement>('[data-glide]');
    const a = before.current;
    if (!root || !m || !a || prefersReducedMotion()) return;
    const b = boxOf(m, root);
    if (!b.w || !b.h || (Math.abs(a.x - b.x) < 1 && Math.abs(a.y - b.y) < 1 && Math.abs(a.w - b.w) < 1)) return;
    m.animate([{ transformOrigin: '0 0', transform: `translate(${a.x - b.x}px, ${a.y - b.y}px) scale(${a.w / b.w}, ${a.h / b.h})` }, { transformOrigin: '0 0', transform: 'none' }], { duration: 420, easing: KITM_EASE });
  }, [key]);
  return ref;
}

/* ------------------------------------------------------------------ */
/* Dates and numbers                                                   */
/* ------------------------------------------------------------------ */

const DAY = 86400000;
const toDay = (iso: string) => Math.round(Date.parse(iso + 'T00:00:00Z') / DAY);

type Formatter = ReturnType<typeof formatter>;

function formatter(locale: Locale, currency = 'EUR') {
  const tag = locale === 'de' ? 'de-DE' : 'en-US';
  return {
    num: (n: number, digits = 0) => new Intl.NumberFormat(tag, { maximumFractionDigits: digits }).format(n),
    money: (n: number, cents = false) => new Intl.NumberFormat(tag, { style: 'currency', currency, minimumFractionDigits: cents ? 2 : 0, maximumFractionDigits: cents ? 2 : 0 }).format(n),
    pct: (n: number) => new Intl.NumberFormat(tag, { style: 'percent', maximumFractionDigits: 0 }).format(n),
    date: (d: number | string, o: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'short' }) => new Intl.DateTimeFormat(tag, { ...o, timeZone: 'UTC' }).format(new Date((typeof d === 'number' ? d : toDay(d)) * DAY + DAY / 2)),
  };
}

const withUnit = (f: Formatter, n: number, unit?: string, digits = 0) => (unit ? `${f.num(n, digits)} ${unit}` : f.num(n, digits));

/* ------------------------------------------------------------------ */
/* Parts                                                               */
/* ------------------------------------------------------------------ */

type P = Omit<UsageProps, 'variant' | 'sidebar' | 'labels' | 'className'> & { t: Texts; f: Formatter; today: string; period: { start: string; end: string } };

const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function run(onAction: UsageProps['onAction'], action: string, data: Record<string, unknown>) {
  if (onAction) await onAction(action, data);
  else await wait(prefersReducedMotion() ? 0 : 700);
}

function Card({ title, description, action, children, className, bodyClassName }: { title?: React.ReactNode; description?: React.ReactNode; action?: React.ReactNode; children: React.ReactNode; className?: string; bodyClassName?: string }) {
  return (
    <section className={cn('flex min-w-0 flex-col rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]', className)}>
      {(title || action) && (
        <div className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-border px-4 py-3.5 sm:px-5">
          <div className="min-w-0">
            {title && <h2 className="text-[14px] font-semibold tracking-[-0.01em] text-foreground">{title}</h2>}
            {description && <p className="mt-0.5 text-pretty text-[13px] text-muted-foreground">{description}</p>}
          </div>
          {action}
        </div>
      )}
      <div className={cn('flex-1 p-4 sm:p-5', bodyClassName)}>{children}</div>
    </section>
  );
}

function Header({ title, subtitle, children }: { title: string; subtitle?: React.ReactNode; children?: React.ReactNode }) {
  return (
    <div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
      <div className="min-w-0">
        {subtitle && <p className="text-[13px] text-muted-foreground">{subtitle}</p>}
        <h1 className="mt-0.5 text-balance text-[clamp(1.5rem,1.2rem+1vw,1.875rem)] font-semibold tracking-[-0.035em] text-foreground">{title}</h1>
      </div>
      {children && <div className="flex flex-wrap items-center gap-2">{children}</div>}
    </div>
  );
}

function Main({ children, className }: { children: React.ReactNode; className?: string }) {
  return <main className={cn('mx-auto w-full max-w-[80rem] flex-1 p-4 sm:p-6 lg:p-8', className)}>{children}</main>;
}

function Segmented<T extends string>({ options, value, onChange, label, className }: { options: { value: T; label: React.ReactNode }[]; value: T; onChange: (v: T) => void; label: string; className?: string }) {
  const ref = useGlide<HTMLDivElement>(value);
  const onKey = (e: React.KeyboardEvent, i: number) => {
    const dir = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0;
    if (!dir) return;
    e.preventDefault();
    const next = (i + dir + options.length) % options.length;
    onChange(options[next].value);
    ref.current?.querySelectorAll<HTMLButtonElement>('[role=radio]')[next]?.focus();
  };
  return (
    <div ref={ref} role="radiogroup" aria-label={label} className={cn('relative inline-flex shrink-0 rounded-lg bg-muted p-1', className)}>
      {options.map((o, i) => (
        <button
          key={o.value}
          type="button"
          role="radio"
          aria-checked={value === o.value}
          tabIndex={value === o.value ? 0 : -1}
          onClick={() => onChange(o.value)}
          onKeyDown={(e) => onKey(e, i)}
          className={cn(
            'relative inline-flex h-11 min-w-11 sm:h-10 sm:min-w-0 flex-1 shrink-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-md px-3 text-[13px] font-medium outline-none transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-[color-mix(in_oklab,var(--primary)_50%,transparent)] sm:h-8',
            value === o.value ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
          )}
        >
          {value === o.value && <span data-glide aria-hidden className="absolute inset-0 rounded-md bg-background shadow-[var(--shadow-sm)] ring-1 ring-border" />}
          <span className="relative inline-flex items-center gap-1.5">{o.label}</span>
        </button>
      ))}
    </div>
  );
}

function Live({ text }: { text: string }) {
  return (
    <p role="status" aria-live="polite" className="sr-only">
      {text}
    </p>
  );
}

/** A number that rolls in when it changes, keyed on its text */
function Flip({ children, className }: { children: string; className?: string }) {
  return (
    <span className={cn('relative inline-flex overflow-hidden', className)}>
      <span key={children} className="kitm-in [--kitm-t:.4s]">
        {children}
      </span>
    </span>
  );
}

const tone = (share: number) => (share >= 1 ? 'bg-destructive' : share >= 0.8 ? 'bg-warning' : 'bg-primary');
const toneText = (share: number) => (share >= 1 ? 'text-destructive' : share >= 0.8 ? 'text-warning-foreground' : 'text-muted-foreground');

/* ------------------------------------------------------------------ */
/* quotas                                                              */
/* ------------------------------------------------------------------ */

function Quotas({ t, f, title, subtitle, quotas = [], today, period }: P) {
  const [scope, enter] = useEnter<HTMLDivElement>();
  const start = toDay(period.start);
  const end = toDay(period.end);
  const total = Math.max(1, end - start + 1);
  const passed = Math.max(1, Math.min(total, toDay(today) - start + 1));
  const daysLeft = Math.max(0, end - toDay(today));
  return (
    <Main>
      <Header title={title} subtitle={subtitle}>
        <span className="inline-flex h-8 items-center gap-2 rounded-lg bg-muted px-3 text-[12.5px] text-muted-foreground">
          <Icon name="calendar" size={14} />
          {fill(t.period, { from: f.date(period.start), to: f.date(period.end) })}
          <span aria-hidden>·</span>
          <span className="font-medium text-foreground">{fill(t.resets, { n: daysLeft })}</span>
        </span>
      </Header>
      {/* The time bar: how far into the period we are, so every meter below reads against it */}
      <div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-4 md:grid-cols-2 xl:grid-cols-3">
        {quotas.map((q, i) => {
          const share = q.limit ? q.used / q.limit : 0;
          const forecast = (q.used / passed) * total;
          const fshare = q.limit ? forecast / q.limit : 0;
          const hitDay = forecast > q.limit && q.used < q.limit ? start + Math.ceil(q.limit / (q.used / passed)) - 1 : null;
          const state = share >= 1 ? t.atLimit : share >= 0.8 ? t.nearLimit : '';
          return (
            <article key={q.id} className="kitm-up flex flex-col rounded-xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] sm:p-5" style={{ '--kitm-d': `${i * 70}ms` } as React.CSSProperties}>
              <div className="flex items-start justify-between gap-3">
                <p className="flex items-center gap-2 text-[13.5px] font-medium text-foreground">
                  {q.icon && (
                    <span className="grid size-8 place-items-center rounded-lg bg-surface-2 text-muted-foreground">
                      <Icon name={q.icon} size={16} />
                    </span>
                  )}
                  {q.label}
                </p>
                {state && <span className={cn('rounded-md px-1.5 py-0.5 text-[11.5px] font-semibold', share >= 1 ? 'bg-[color-mix(in_oklab,var(--destructive)_12%,transparent)] text-destructive' : 'bg-[color-mix(in_oklab,var(--warning)_20%,transparent)] text-warning-foreground')}>{state}</span>}
              </div>
              <p className="mt-4 flex flex-wrap items-baseline gap-x-2">
                <span className="text-[26px] font-semibold tracking-[-0.02em] tabular-nums text-foreground">{withUnit(f, q.used, q.unit, q.unit ? 1 : 0)}</span>
                <span className="text-[13px] tabular-nums text-muted-foreground">{fill(t.of, { limit: withUnit(f, q.limit, q.unit) })}</span>
              </p>
              {/* The bar: what is used, a dashed part for the forecast, a mark at the limit */}
              <div className="relative mt-3 h-2.5 rounded-full bg-track" role="meter" aria-label={q.label} aria-valuemin={0} aria-valuemax={q.limit} aria-valuenow={q.used} aria-valuetext={`${withUnit(f, q.used, q.unit, 1)} ${fill(t.of, { limit: withUnit(f, q.limit, q.unit) })}`}>
                <span aria-hidden className="absolute inset-y-0 left-0 rounded-full border border-dashed border-[color-mix(in_oklab,var(--foreground)_30%,transparent)] transition-[width] duration-700" style={{ width: `${round(Math.min(1, fshare) * 100)}%` }} />
                <span aria-hidden className={cn('kitm-grow-x absolute inset-y-0 left-0 rounded-full', tone(share))} style={{ width: `${round(Math.min(1, share) * 100)}%`, '--kitm-d': `${200 + i * 70}ms` } as React.CSSProperties} />
              </div>
              <p className={cn('mt-2 flex justify-between gap-3 text-[12.5px] tabular-nums', toneText(share))}>
                <span>{share >= 1 ? fill(t.over, { n: withUnit(f, q.used - q.limit, q.unit, 1) }) : fill(t.left, { n: withUnit(f, q.limit - q.used, q.unit, 1) })}</span>
                <span className="text-subtle-foreground">{f.pct(share)}</span>
              </p>
              <span aria-hidden className="min-h-4 flex-1" />
              <p className="flex items-start gap-2 border-t border-border pt-3 text-pretty text-[12.5px] leading-snug text-muted-foreground">
                <Icon name={hitDay ? 'trending-up' : share >= 1 ? 'info' : 'check-circle'} size={14} className={cn('mt-px shrink-0', hitDay ? 'text-warning-foreground' : share >= 1 ? 'text-muted-foreground' : 'text-success-foreground')} />
                <span>
                  {hitDay ? fill(t.pace, { date: f.date(hitDay, { day: 'numeric', month: 'long' }) }) : share >= 1 ? q.note ?? t.atLimit : t.paceOk}
                </span>
              </p>
            </article>
          );
        })}
      </div>
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* daily-chart                                                         */
/* ------------------------------------------------------------------ */

function DailyChart({ t, f, title, subtitle, series = [], today, period }: P) {
  const [metric, setMetric] = useState(series[0]?.id ?? '');
  const s = series.find((x) => x.id === metric) ?? series[0];
  const start = toDay(period.start);
  const total = Math.max(1, toDay(period.end) - start + 1);
  const days = s?.days ?? [];
  const avg = days.length ? days.reduce((a, b) => a + b, 0) / days.length : 0;
  const all = Array.from({ length: total }, (_, i) => (i < days.length ? days[i] : avg));
  const max = Math.max(1, ...all, s?.limit ? s.limit / total : 0) * 1.15;
  const sum = days.reduce((a, b) => a + b, 0);
  const projected = sum + avg * (total - days.length);
  const [sel, setSel] = useState(Math.max(0, days.length - 1));
  const [hover, setHover] = useState<number | null>(null);
  const [scope, enter] = useEnter<HTMLDivElement>();
  useEffect(() => setSel(Math.max(0, (series.find((x) => x.id === metric)?.days.length ?? 1) - 1)), [metric, series]);
  const active = hover ?? sel;
  const cum = (i: number) => all.slice(0, i + 1).reduce((a, b) => a + b, 0);
  const limitDay = s?.limit ? all.findIndex((_, i) => cum(i) > s.limit!) : -1;
  // A daily average line reads easier than a running sum: the limit per day is the limit spread evenly
  const perDayLimit = s?.limit ? s.limit / total : 0;
  const unit = s?.unit;
  const kpis = [
    { label: t.total, value: withUnit(f, sum, unit) },
    { label: t.projected, value: withUnit(f, projected, unit), tone: s?.limit && projected > s.limit ? 'text-warning-foreground' : undefined },
    { label: t.limit, value: s?.limit ? withUnit(f, s.limit, unit) : '·' },
  ];
  return (
    <Main>
      <Header title={title} subtitle={subtitle}>
        <Segmented label={t.metric} value={metric} onChange={setMetric} className="w-full sm:w-auto" options={series.map((x) => ({ value: x.id, label: x.label }))} />
      </Header>
      <div ref={scope} {...enter} className="mt-6 grid gap-6">
        <dl className="grid grid-cols-3 gap-px overflow-hidden rounded-xl border border-border bg-border shadow-[var(--shadow-sm)]">
          {kpis.map((k) => (
            <div key={k.label} className="bg-card p-3.5 sm:p-5">
              <dt className="text-[12px] text-muted-foreground sm:text-[12.5px]">{k.label}</dt>
              <dd className={cn('mt-1 text-[16px] font-semibold tracking-[-0.01em] tabular-nums text-foreground sm:text-[22px]', k.tone)}>
                <Flip>{k.value}</Flip>
              </dd>
            </div>
          ))}
        </dl>
        <Card
          className="kitm-up [--kitm-d:100ms]"
          title={s?.label}
          description={fill(t.period, { from: f.date(period.start), to: f.date(period.end) })}
          action={
            <span className="hidden items-center gap-3 text-[12px] text-muted-foreground sm:flex">
              <span className="flex items-center gap-1.5">
                <span aria-hidden className="size-2 rounded-sm bg-primary" />
                {t.legendUsed}
              </span>
              <span className="flex items-center gap-1.5">
                <span aria-hidden className="size-2 rounded-sm border border-dashed border-[color-mix(in_oklab,var(--foreground)_40%,transparent)]" />
                {t.legendProjected}
              </span>
              {s?.limit && (
                <span className="flex items-center gap-1.5">
                  <span aria-hidden className="h-px w-3 bg-warning" />
                  {t.limitPerDay}
                </span>
              )}
            </span>
          }
        >
          <p className="flex items-baseline justify-between gap-3 text-[13px]">
            <span key={`d${active}`} className="kitm-in font-medium text-foreground">
              {f.date(start + active, { weekday: 'short', day: 'numeric', month: 'short' })}
              {active >= days.length && <span className="ms-2 font-normal text-subtle-foreground">{t.projectedDay}</span>}
            </span>
            <span key={`v${active}${metric}`} className="kitm-in tabular-nums text-muted-foreground">
              {withUnit(f, all[active] ?? 0, unit)}
            </span>
          </p>
          <div
            key={metric}
            role="slider"
            tabIndex={0}
            aria-label={t.chart}
            aria-valuemin={0}
            aria-valuemax={total - 1}
            aria-valuenow={sel}
            aria-valuetext={fill(t.dayValue, { date: f.date(start + sel), v: withUnit(f, all[sel] ?? 0, unit) })}
            onKeyDown={(e) => {
              if (e.key === 'ArrowLeft') setSel((x) => Math.max(0, x - 1));
              else if (e.key === 'ArrowRight') setSel((x) => Math.min(total - 1, x + 1));
              else return;
              e.preventDefault();
            }}
            onPointerLeave={() => setHover(null)}
            className="relative mt-4 flex h-48 items-end gap-[3px] rounded-md outline-none focus-visible:ring-2 focus-visible:ring-[color-mix(in_oklab,var(--primary)_50%,transparent)] focus-visible:ring-offset-4 focus-visible:ring-offset-card sm:h-56 sm:gap-1"
          >
            {perDayLimit > 0 && (
              <span aria-hidden className="pointer-events-none absolute inset-x-0 z-[1] border-t border-dashed border-warning" style={{ bottom: `${round((perDayLimit / max) * 100)}%` }}>
                <span className="absolute -top-5 right-0 rounded bg-card px-1 text-[11px] tabular-nums text-warning-foreground">{withUnit(f, perDayLimit, unit)}</span>
              </span>
            )}
            {all.map((v, i) => {
              const future = i >= days.length;
              const over = limitDay >= 0 && i >= limitDay;
              return (
                <button key={i} type="button" tabIndex={-1} aria-hidden onPointerEnter={(e) => e.pointerType === 'mouse' && setHover(i)} onClick={() => setSel(i)} className="group relative flex h-full min-w-0 flex-1 items-end">
                  <span
                    className={cn(
                      'kitm-grow-y w-full rounded-t-[3px] transition-opacity duration-200',
                      future ? cn('border border-b-0 border-dashed bg-transparent', active === i ? 'border-primary bg-[color-mix(in_oklab,var(--primary)_14%,transparent)]' : 'border-[color-mix(in_oklab,var(--foreground)_32%,transparent)]') : over ? 'bg-warning' : 'bg-primary',
                      active === i ? 'opacity-100' : future ? 'opacity-80' : 'opacity-60 group-hover:opacity-85',
                    )}
                    style={{ height: `${round((v / max) * 100)}%`, '--kitm-d': `${Math.min(i, 30) * 18}ms` } as React.CSSProperties}
                  />
                  {i === days.length - 1 && <span aria-hidden className="absolute -bottom-1 left-1/2 size-1.5 -translate-x-1/2 rounded-full bg-foreground" />}
                </button>
              );
            })}
          </div>
          <div aria-hidden className="mt-3 flex justify-between text-[11.5px] tabular-nums text-subtle-foreground">
            <span>{f.date(start)}</span>
            <span className="text-foreground">{t.today}</span>
            <span>{f.date(start + total - 1)}</span>
          </div>
          {s?.limit && limitDay >= 0 && (
            <p className="mt-4 flex items-start gap-2 rounded-lg bg-[color-mix(in_oklab,var(--warning)_12%,var(--card))] p-3 text-[13px] text-foreground">
              <Icon name="trending-up" size={15} className="mt-px shrink-0 text-warning-foreground" />
              {fill(t.pace, { date: f.date(start + limitDay, { day: 'numeric', month: 'long' }) })}
            </p>
          )}
        </Card>
      </div>
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* spend-limits                                                        */
/* ------------------------------------------------------------------ */

function SpendLimits({ t, f, title, subtitle, spend, onAction }: P) {
  const id = useId();
  const sp = spend ?? { current: 0, cap: 100 };
  const max = sp.max ?? Math.max(1000, sp.cap * 2);
  const start = { cap: sp.cap, alerts: sp.alerts ?? [50, 80, 100], hardStop: sp.hardStop ?? false };
  const [saved, setSaved] = useState(start);
  const [cap, setCap] = useState(start.cap);
  const [alerts, setAlerts] = useState<number[]>(start.alerts);
  const [hard, setHard] = useState(start.hardStop);
  const [state, setState] = useState<'idle' | 'busy' | 'saved'>('idle');
  const [scope, enter] = useEnter<HTMLDivElement>();
  const dirty = cap !== saved.cap || hard !== saved.hardStop || alerts.join() !== saved.alerts.join();
  const share = cap ? sp.current / cap : 0;
  const marks = [50, 80, 100];
  const step = max >= 2000 ? 50 : 10;
  const save = async () => {
    setState('busy');
    await run(onAction, 'save-budget', { cap, alerts, hardStop: hard });
    setSaved({ cap, alerts, hardStop: hard });
    setState('saved');
  };
  // The gauge: a half ring with the spend, ticks for each alert mark
  const r = 80;
  const len = Math.PI * r;
  const arc = Math.min(1, share);
  return (
    <Main className="pb-28">
      <Header title={title} subtitle={subtitle} />
      <div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[22rem_minmax(0,1fr)] lg:items-start">
        <Card className="kitm-up lg:sticky lg:top-6">
          <div className="flex items-center gap-5 sm:flex-col sm:text-center">
            <div className="relative w-36 shrink-0 sm:w-52">
              <svg viewBox="0 0 200 112" className="w-full" aria-hidden>
                <path d={`M 20 100 A ${r} ${r} 0 0 1 180 100`} fill="none" stroke="var(--track)" strokeWidth="14" strokeLinecap="round" />
                <path d={`M 20 100 A ${r} ${r} 0 0 1 180 100`} fill="none" stroke={share >= 1 ? 'var(--destructive)' : share >= 0.8 ? 'var(--warning)' : 'var(--primary)'} strokeWidth="14" strokeLinecap="round" strokeDasharray={`${round(len * arc)} ${round(len)}`} className="transition-[stroke-dasharray,stroke] duration-700 ease-[var(--ease-kit)]" />
                {alerts.map((a) => {
                  const ang = Math.PI * (1 - Math.min(1, a / 100));
                  const x1 = 100 + Math.cos(ang) * (r - 12);
                  const y1 = 100 - Math.sin(ang) * (r - 12);
                  const x2 = 100 + Math.cos(ang) * (r + 12);
                  const y2 = 100 - Math.sin(ang) * (r + 12);
                  return <line key={a} x1={round(x1)} y1={round(y1)} x2={round(x2)} y2={round(y2)} stroke="var(--foreground)" strokeOpacity="0.5" strokeWidth="2" strokeLinecap="round" />;
                })}
              </svg>
              <span className="absolute inset-x-0 bottom-0 text-center text-[22px] font-semibold tracking-[-0.02em] tabular-nums text-foreground sm:text-[28px]">
                <Flip>{f.pct(share)}</Flip>
              </span>
            </div>
            <div className="min-w-0">
              <p className="text-[12.5px] text-muted-foreground">{t.spent}</p>
              <p className="mt-0.5 text-[20px] font-semibold tabular-nums text-foreground">
                {f.money(sp.current, true)} <span className="text-[14px] font-normal text-muted-foreground">/ <Flip>{f.money(cap)}</Flip></span>
              </p>
            </div>
          </div>
        </Card>
        <div className="grid min-w-0 gap-6">
          <Card title={t.budget} description={t.budgetText} className="kitm-up [--kitm-d:80ms]">
            <div className="flex items-end justify-between gap-4">
              <label htmlFor={`${id}-cap`} className="text-sm font-medium text-foreground">
                {t.capValue}
              </label>
              <span className="text-[24px] font-semibold tracking-[-0.02em] tabular-nums text-foreground">
                <Flip>{f.money(cap)}</Flip>
              </span>
            </div>
            <input
              id={`${id}-cap`}
              type="range"
              min={step}
              max={max}
              step={step}
              value={cap}
              aria-valuetext={f.money(cap)}
              onChange={(e) => {
                setCap(Number(e.target.value));
                setState('idle');
              }}
              className="mt-4 h-11 w-full cursor-pointer accent-[var(--primary)]"
            />
            {/* Range ends on one line, the spent mark on its own line below, so they never collide */}
            <div aria-hidden className="flex justify-between text-[11.5px] leading-5 tabular-nums text-subtle-foreground">
              <span>{f.money(step)}</span>
              <span>{f.money(max)}</span>
            </div>
            <div aria-hidden className="relative mt-1 h-5 text-[11.5px] leading-5 tabular-nums">
              <span className="absolute top-0 -translate-x-1/2 whitespace-nowrap rounded bg-muted px-1.5 font-medium text-foreground" style={{ left: `${round(Math.min(80, Math.max(20, (sp.current / max) * 100)))}%` }}>
                {fill(t.spentMark, { v: f.money(sp.current) })}
              </span>
            </div>
          </Card>
          <Card title={t.alerts} description={t.alertsText} className="kitm-up [--kitm-d:140ms]" bodyClassName="p-0 sm:p-0">
            <ul className="divide-y divide-border">
              {marks.map((m) => (
                <li key={m} className="px-4 py-1 sm:px-5">
                  <Switch
                    checked={alerts.includes(m)}
                    onCheckedChange={(v) => {
                      setAlerts((a) => (v ? [...a, m].sort((x, y) => x - y) : a.filter((x) => x !== m)));
                      setState('idle');
                    }}
                    label={fill(t.alertAt, { n: m })}
                    description={f.money((cap * m) / 100)}
                    className="py-2.5"
                  />
                </li>
              ))}
            </ul>
          </Card>
          <Card className="kitm-up [--kitm-d:200ms]">
            <Switch
              checked={hard}
              onCheckedChange={(v) => {
                setHard(v);
                setState('idle');
              }}
              label={t.hardStop}
              description={<span key={String(hard)} className="kitm-in block">{hard ? t.hardStopText : t.softText}</span>}
            />
          </Card>
        </div>
      </div>
      <div className={cn('fixed inset-x-0 bottom-0 z-30 px-3 pb-[calc(env(safe-area-inset-bottom)+12px)] transition-[translate,opacity] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none sm:px-6', dirty || state === 'saved' ? 'translate-y-0 opacity-100' : 'pointer-events-none translate-y-6 opacity-0')} inert={!(dirty || state === 'saved')}>
        <div className="mx-auto flex max-w-[44rem] items-center gap-3 rounded-xl border border-border bg-[color-mix(in_oklab,var(--popover)_92%,transparent)] p-2.5 ps-4 shadow-[var(--highlight),var(--shadow-lg)] backdrop-blur-xl">
          <p className="min-w-0 flex-1 truncate text-[13.5px] text-foreground">
            {state === 'saved' && !dirty ? (
              <span className="kitm-in flex items-center gap-2 text-success-foreground">
                <Icon name="check-circle" size={16} />
                {t.saved}
              </span>
            ) : (
              <span className="flex items-center gap-2">
                <span aria-hidden className="size-1.5 rounded-full bg-warning" />
                {t.unsaved}
              </span>
            )}
          </p>
          {dirty && (
            <>
              <Button
                variant="ghost"
                size="sm"
                onClick={() => {
                  setCap(saved.cap);
                  setAlerts(saved.alerts);
                  setHard(saved.hardStop);
                }}
              >
                {t.discard}
              </Button>
              <Button size="sm" onClick={save} disabled={state === 'busy'} iconLeft={state === 'busy' ? 'loader' : undefined}>
                {state === 'busy' ? t.saving : t.save}
              </Button>
            </>
          )}
        </div>
      </div>
      <Live text={state === 'saved' && !dirty ? t.saved : ''} />
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* breakdown                                                           */
/* ------------------------------------------------------------------ */

const SHADES = [100, 78, 58, 42, 30, 20];

function Breakdown({ t, f, title, subtitle, metrics = [], items = [] }: P) {
  const [metric, setMetric] = useState(metrics[0]?.id ?? '');
  const [sort, setSort] = useState<'value' | 'name'>('value');
  const [scope, enter] = useEnter<HTMLDivElement>();
  const listRef = useRef<HTMLOListElement>(null);
  const m = metrics.find((x) => x.id === metric);
  const sum = items.reduce((a, x) => a + (x.values[metric] ?? 0), 0) || 1;
  const rows = useMemo(() => [...items].sort((a, b) => (sort === 'name' ? a.label.localeCompare(b.label) : (b.values[metric] ?? 0) - (a.values[metric] ?? 0))), [items, metric, sort]);
  const ranked = [...items].sort((a, b) => (b.values[metric] ?? 0) - (a.values[metric] ?? 0));
  const shade = (itemId: string) => SHADES[Math.min(SHADES.length - 1, ranked.findIndex((x) => x.id === itemId))];
  // Rows glide to their new place when the metric or the sort changes
  const before = useRef<Map<string, number>>(new Map());
  if (typeof window !== 'undefined' && listRef.current) before.current = new Map(Array.from(listRef.current.children, (el) => [(el as HTMLElement).dataset.id!, el.getBoundingClientRect().top]));
  useIso(() => {
    if (!listRef.current || prefersReducedMotion()) return;
    Array.from(listRef.current.children).forEach((el) => {
      const a = before.current.get((el as HTMLElement).dataset.id!);
      const b = el.getBoundingClientRect().top;
      if (a !== undefined && Math.abs(a - b) > 1) el.animate([{ transform: `translateY(${a - b}px)` }, { transform: 'none' }], { duration: 460, easing: KITM_EASE });
    });
  }, [metric, sort]);
  return (
    <Main>
      <Header title={title} subtitle={subtitle}>
        <Segmented label={t.metric} value={metric} onChange={setMetric} className="w-full sm:w-auto" options={metrics.map((x) => ({ value: x.id, label: x.label }))} />
      </Header>
      <div ref={scope} {...enter} className="mt-6 grid gap-6">
        <section className="kitm-up rounded-xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] sm:p-5">
          <p className="flex items-baseline justify-between gap-3">
            <span className="text-[12.5px] text-muted-foreground">{t.totalLabel}</span>
            <span className="text-[22px] font-semibold tracking-[-0.02em] tabular-nums text-foreground">
              <Flip>{withUnit(f, sum, m?.unit)}</Flip>
            </span>
          </p>
          {/* One stacked bar: the share of each item, largest first */}
          <div className="mt-3 flex h-3 gap-[2px] overflow-hidden rounded-full" aria-hidden>
            {ranked.map((x) => (
              <span key={x.id} className="h-full transition-[flex-grow] duration-700 ease-[var(--ease-kit)] first:rounded-s-full last:rounded-e-full" style={{ flexGrow: x.values[metric] ?? 0, flexBasis: 0, background: `color-mix(in oklab, var(--primary) ${shade(x.id)}%, var(--muted))` }} />
            ))}
          </div>
        </section>
        <section className="kitm-up [--kitm-d:100ms] overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]">
          <div className="flex items-center justify-between gap-3 border-b border-border bg-surface px-4 py-2.5 text-[12.5px] text-muted-foreground sm:px-5">
            <button type="button" onClick={() => setSort('name')} aria-pressed={sort === 'name'} className={cn('-mx-1.5 inline-flex h-11 min-w-11 items-center gap-1 rounded-md px-1.5 font-medium sm:h-8 sm:min-w-0', sort === 'name' ? 'text-foreground' : 'hover:text-foreground')}>
              {t.name}
              {sort === 'name' && <Icon name="arrow-down" size={13} />}
            </button>
            <button type="button" onClick={() => setSort('value')} aria-pressed={sort === 'value'} className={cn('inline-flex h-11 items-center gap-1 rounded-md font-medium sm:h-8', sort === 'value' ? 'text-foreground' : 'hover:text-foreground')}>
              {m?.label}
              {sort === 'value' && <Icon name="arrow-down" size={13} />}
            </button>
          </div>
          <ol ref={listRef} className="divide-y divide-border">
            {rows.map((x, i) => {
              const v = x.values[metric] ?? 0;
              return (
                <li key={x.id} data-id={x.id} className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-3 gap-y-2 bg-card px-4 py-3.5 sm:grid-cols-[auto_minmax(0,14rem)_minmax(0,1fr)_7rem_3.5rem] sm:px-5">
                  <span aria-hidden className="size-2.5 shrink-0 rounded-[3px]" style={{ background: `color-mix(in oklab, var(--primary) ${shade(x.id)}%, var(--muted))` }} />
                  <span className="min-w-0">
                    <span className="block truncate text-[14px] font-medium text-foreground">{x.label}</span>
                    {x.meta && <span className="block truncate text-[12px] text-muted-foreground">{x.meta}</span>}
                  </span>
                  <span className="col-span-3 col-start-1 row-start-2 h-1.5 overflow-hidden rounded-full bg-track sm:col-span-1 sm:col-start-3 sm:row-start-1">
                    <span className="kitm-grow-x block h-full rounded-full bg-[color-mix(in_oklab,var(--primary)_75%,var(--muted))] transition-[width] duration-700 ease-[var(--ease-kit)]" style={{ width: `${round((v / sum) * 100)}%`, '--kitm-d': `${150 + i * 50}ms` } as React.CSSProperties} />
                  </span>
                  <span className="text-right text-[13.5px] tabular-nums text-foreground">
                    <Flip>{withUnit(f, v, m?.unit)}</Flip>
                  </span>
                  <span className="hidden text-right text-[12.5px] tabular-nums text-subtle-foreground sm:block">{f.pct(v / sum)}</span>
                </li>
              );
            })}
          </ol>
        </section>
      </div>
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* credits                                                             */
/* ------------------------------------------------------------------ */

function Credits({ t, f, title, subtitle, credits, today, onAction }: P) {
  const c = credits ?? { balance: 0, packs: [], history: [] };
  const [balance, setBalance] = useState(c.balance);
  const [pack, setPack] = useState(c.packs[1]?.id ?? c.packs[0]?.id ?? '');
  const [busy, setBusy] = useState(false);
  const [log, setLog] = useState(c.history);
  const [auto, setAuto] = useState(c.autoTopUp ?? { on: false, below: 1000, pack: c.packs[0]?.id ?? '' });
  const [msg, setMsg] = useState('');
  const [scope, enter] = useEnter<HTMLDivElement>();
  const groupRef = useGlide<HTMLDivElement>(pack);
  const p = c.packs.find((x) => x.id === pack);
  const lasts = c.perDay ? Math.floor(balance / c.perDay) : 0;
  // The balance counts toward its new value after a purchase
  const [shown, setShown] = useState(balance);
  useEffect(() => {
    if (prefersReducedMotion()) return setShown(balance);
    const from = shown;
    const t0 = performance.now();
    let raf = 0;
    const tick = (now: number) => {
      const k = Math.min(1, (now - t0) / 900);
      setShown(Math.round(from + (balance - from) * (1 - Math.pow(1 - k, 3))));
      if (k < 1) raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [balance]); // eslint-disable-line react-hooks/exhaustive-deps
  const buy = async () => {
    if (!p) return;
    setBusy(true);
    await run(onAction, 'buy-credits', { pack: p.id });
    setBalance((b) => b + p.credits);
    setLog((l) => [{ id: `c${Date.now()}`, date: today, label: t.topUp, amount: p.credits }, ...l]);
    setBusy(false);
    setMsg(fill(t.bought, { n: f.num(p.credits) }));
  };
  const onKey = (e: React.KeyboardEvent, i: number) => {
    const dir = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0;
    if (!dir) return;
    e.preventDefault();
    const next = (i + dir + c.packs.length) % c.packs.length;
    setPack(c.packs[next].id);
    groupRef.current?.querySelectorAll<HTMLElement>('[role=radio]')[next]?.focus();
  };
  return (
    <Main>
      <Header title={title} subtitle={subtitle} />
      <div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,1fr)_22rem] lg:items-start">
        <div className="grid min-w-0 gap-6">
          <section className="kitm-up relative overflow-hidden rounded-xl border border-border bg-card p-5 shadow-[var(--highlight),var(--shadow-sm)] sm:p-6">
            <span aria-hidden className="pointer-events-none absolute inset-0 bg-[radial-gradient(120%_80%_at_100%_0%,color-mix(in_oklab,var(--primary)_14%,transparent),transparent_60%)]" />
            <p className="relative text-[12.5px] font-medium text-muted-foreground">{t.balance}</p>
            <p className="relative mt-1 text-[clamp(2rem,1.6rem+1.6vw,2.75rem)] font-semibold leading-none tracking-[-0.03em] tabular-nums text-foreground">{f.num(shown)}</p>
            {lasts > 0 && <p className="relative mt-2 text-[13px] text-muted-foreground">{fill(t.lasts, { n: lasts })}</p>}
          </section>
          <Card title={t.buy} className="kitm-up [--kitm-d:80ms]">
            <div ref={groupRef} role="radiogroup" aria-label={t.buy} className="grid grid-cols-[minmax(0,1fr)] gap-2.5 sm:grid-cols-3">
              {c.packs.map((x, i) => {
                const on = x.id === pack;
                return (
                  <div
                    key={x.id}
                    role="radio"
                    aria-checked={on}
                    tabIndex={on ? 0 : -1}
                    onClick={() => setPack(x.id)}
                    onKeyDown={(e) => {
                      if (e.key === ' ' || e.key === 'Enter') {
                        e.preventDefault();
                        setPack(x.id);
                      } else onKey(e, i);
                    }}
                    className={cn('relative flex cursor-pointer items-center justify-between gap-3 rounded-xl border p-4 outline-none transition-[border-color,background-color] duration-300 focus-visible:ring-2 focus-visible:ring-[color-mix(in_oklab,var(--primary)_50%,transparent)] sm:flex-col sm:items-start', on ? 'border-[color-mix(in_oklab,var(--primary)_60%,var(--border))]' : 'border-border hover:border-border-strong')}
                  >
                    {on && <span data-glide aria-hidden className="absolute inset-0 rounded-[11px] bg-[color-mix(in_oklab,var(--primary)_7%,transparent)]" />}
                    <span className="relative">
                      <span className="block text-[16px] font-semibold tabular-nums text-foreground">{fill(t.pack, { n: f.num(x.credits) })}</span>
                      <span className="block text-[12px] tabular-nums text-muted-foreground">{fill(t.perCredit, { price: f.money((x.price / x.credits) * 1000, true) })}</span>
                    </span>
                    <span className="relative flex items-center gap-2 sm:w-full sm:justify-between">
                      <span className="text-[15px] font-medium tabular-nums text-foreground">{f.money(x.price)}</span>
                      {x.bonus && <span className="rounded bg-[color-mix(in_oklab,var(--success)_15%,transparent)] px-1.5 py-px text-[11px] font-semibold text-success-foreground">{x.bonus}</span>}
                    </span>
                  </div>
                );
              })}
            </div>
            <Button className="mt-5 w-full sm:w-auto" onClick={buy} disabled={busy || !p} iconLeft={busy ? 'loader' : 'plus'}>
              {busy ? t.buying : p ? fill(t.pay, { price: f.money(p.price) }) : t.buy}
            </Button>
          </Card>
          <Card className="kitm-up [--kitm-d:140ms]">
            <Switch checked={auto.on} onCheckedChange={(v) => setAuto((a) => ({ ...a, on: v }))} label={t.auto} description={fill(t.autoText, { n: f.num(auto.below) })} />
            <div className={cn('grid transition-[grid-template-rows] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', auto.on ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]')} inert={!auto.on}>
              <div className="min-h-0 overflow-hidden">
                <div className="mt-4 grid gap-3 border-t border-border pt-4 sm:grid-cols-2">
                  <NativeSelect label={t.below} value={String(auto.below)} onChange={(e) => setAuto((a) => ({ ...a, below: Number(e.target.value) }))} options={[500, 1000, 2500, 5000].map((n) => ({ value: String(n), label: fill(t.pack, { n: f.num(n) }) }))} />
                  <NativeSelect label={t.buy} value={auto.pack} onChange={(e) => setAuto((a) => ({ ...a, pack: e.target.value }))} options={c.packs.map((x) => ({ value: x.id, label: `${fill(t.pack, { n: f.num(x.credits) })} · ${f.money(x.price)}` }))} />
                </div>
              </div>
            </div>
          </Card>
        </div>
        <Card title={t.history} className="kitm-up [--kitm-d:200ms] lg:sticky lg:top-6" bodyClassName="p-0 sm:p-0">
          <ul className="divide-y divide-border">
            {log.slice(0, 8).map((e, i) => (
              <li key={e.id} className={cn('flex items-center gap-3 px-4 py-3 sm:px-5', i === 0 && log.length > c.history.length && 'kitm-fly')}>
                <span className={cn('grid size-8 shrink-0 place-items-center rounded-lg', e.amount > 0 ? 'bg-[color-mix(in_oklab,var(--success)_14%,transparent)] text-success-foreground' : 'bg-surface-2 text-muted-foreground')}>
                  <Icon name={e.amount > 0 ? 'plus' : 'activity'} size={15} />
                </span>
                <span className="min-w-0 flex-1">
                  <span className="block truncate text-[13.5px] text-foreground">{e.label}</span>
                  <span className="block text-[12px] text-subtle-foreground">{f.date(e.date, { day: 'numeric', month: 'short', year: 'numeric' })}</span>
                </span>
                <span className={cn('shrink-0 text-[13.5px] font-medium tabular-nums', e.amount > 0 ? 'text-success-foreground' : 'text-foreground')}>
                  {e.amount > 0 ? '+' : '−'}
                  {f.num(Math.abs(e.amount))}
                </span>
              </li>
            ))}
          </ul>
        </Card>
      </div>
      <Live text={msg} />
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* Usage                                                               */
/* ------------------------------------------------------------------ */

/**
 * Usage screens for apps: quota meters with a forecast, a daily chart with
 * the projected rest of the period, a monthly budget with alerts, a
 * breakdown by project or person and prepaid credits.
 *   <Usage variant="quotas" title="Usage" quotas={[{ id, label, used, limit, unit }]} period={{ start, end }} />
 */
export function Usage({ variant = 'quotas', sidebar, labels, className, today = '2026-10-19', period = { start: '2026-10-01', end: '2026-10-31' }, ...props }: UsageProps) {
  const t = useTexts(TEXTS, labels);
  const locale = useLocale();
  const f = useMemo(() => formatter(locale, props.currency), [locale, props.currency]);
  const p: P = { ...props, t, f, today, period };
  const body = (() => {
    switch (variant) {
      case 'daily-chart':
        return <DailyChart {...p} />;
      case 'spend-limits':
        return <SpendLimits {...p} />;
      case 'breakdown':
        return <Breakdown {...p} />;
      case 'credits':
        return <Credits {...p} />;
      default:
        return <Quotas {...p} />;
    }
  })();
  if (!sidebar)
    return (
      <div className={cn('min-h-dvh bg-background', className)}>
        <KitmStyle />
        {body}
      </div>
    );
  return (
    <Sidebar mobile="drawer" {...sidebar} className={className}>
      <KitmStyle />
      {body}
    </Sidebar>
  );
}

Häufige Fragen

Wie installiere ich Verbrauch und Limits?

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

Welche Varianten hat Verbrauch und Limits?

5 Varianten, Kontingente, Tagesverbrauch, Ausgabenlimit, Aufteilung und Guthaben. Du wählst sie mit der Prop variant, zum Beispiel variant="daily-chart".

Kann ich die Texte von Verbrauch und Limits übersetzen?

Ja. Verbrauch und Limits bringt 56 eigene Texte auf Deutsch und Englisch mit, etwa „In diesem Tempo bleibst du unter dem Limit.“, „Prognose“ und „Nah am Limit“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Verbrauch und Limits einbauen?

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

Kann ich Verbrauch und Limits ohne Code anpassen?

Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden Usage-Code zum Kopieren.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert