Skip to content
UI Kit
Accent color

Usage and limits

Usage screens. Quota meters with a forecast, a daily chart with the projected rest of the period, a monthly budget with alerts, a breakdown and prepaid credits.

One of 2 components in Billing and usage

5 variants

Variants

Quota meters

Each quota with used and left, a dashed forecast for the period, a state near the limit and the day the limit is reached at this pace.

Open in Studio

Daily usage

Bars per day with the rest of the period projected in outline, the daily share of the limit as a line and a note on the day the limit is reached.

Open in Studio

Spend limits

A half ring with the spend against the budget, a slider for the budget, email alerts at 50, 80 and 100% and a switch to pause at the budget.

Open in Studio

Usage breakdown

A stacked bar of every project and a sortable list with shares, switching the metric reorders the rows with a glide.

Open in Studio

Prepaid credits

The balance with how long it lasts, credit packs to buy, automatic top-up below a mark and the activity.

Open in Studio

Details

What Usage and limits brings along, read from the registry and the source.

Area
App
Variants
5
Version
1.0.0 · since 1.1.0
File
components/ui/usage.tsx
Brings along
utilskitbuttoniconselectswitchsidebar
npm packages
None
Languages
English and German, 56 texts of its own

Props

The UsageProps interface from components/ui/usage.tsx, with the comments from the code.

  • variantoptional
    UsageVariant

    quotasdaily-chartspend-limitsbreakdowncredits

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

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

  • titlerequired
    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.

Built-in texts

Usage and limits writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 56 texts
periodEnglish {from} to {to}German {from} bis {to}
resetsEnglish Resets in {n} daysGerman Startet neu in {n} Tagen
ofEnglish of {limit}German von {limit}
leftEnglish {n} leftGerman noch {n}
overEnglish {n} over the limitGerman {n} über dem Limit
paceEnglish At this pace you reach the limit on {date}.German In diesem Tempo erreichst du das Limit am {date}.
paceOkEnglish At this pace you stay below the limit.German In diesem Tempo bleibst du unter dem Limit.
forecastEnglish ForecastGerman Prognose
nearLimitEnglish Close to the limitGerman Nah am Limit
atLimitEnglish Limit reachedGerman Limit erreicht
metricEnglish MetricGerman Kennzahl
perDayEnglish Per dayGerman Pro Tag
limitPerDayEnglish Limit per dayGerman Limit pro Tag
spentMarkEnglish Spent {v}German Ausgegeben {v}
totalEnglish So farGerman Bisher
projectedEnglish Projected for the periodGerman Erwartet für den Zeitraum
limitEnglish LimitGerman Limit
todayEnglish TodayGerman Heute
chartEnglish Usage per day, use the arrow keys to read each dayGerman Verbrauch pro Tag, mit den Pfeiltasten jeden Tag ablesen
dayValueEnglish {date}: {v}German {date}: {v}
projectedDayEnglish ProjectedGerman Erwartet
legendUsedEnglish UsedGerman Verbraucht
legendProjectedEnglish ProjectedGerman Erwartet
budgetEnglish Monthly budgetGerman Monatsbudget
budgetTextEnglish When usage costs reach the budget, we act the way you choose below.German Erreichen die Kosten das Budget, handeln wir so, wie du es unten wählst.
spentEnglish Spent this periodGerman Ausgegeben in diesem Zeitraum
capValueEnglish Budget per monthGerman Budget pro Monat
alertsEnglish Email alertsGerman Hinweise per E-Mail
alertsTextEnglish We email the owners at each mark.German Wir schreiben den Inhabern an jeder Marke.
alertAtEnglish At {n}% of the budgetGerman Bei {n} % des Budgets
hardStopEnglish Pause usage at the budgetGerman Nutzung am Budget pausieren
hardStopTextEnglish Requests above the budget fail until the next period or until you raise it.German Anfragen über dem Budget schlagen fehl, bis der Zeitraum neu startet oder du es erhöhst.
softTextEnglish Usage keeps running above the budget and is billed at the usual rate.German Die Nutzung läuft über dem Budget weiter und wird zum üblichen Preis berechnet.
saveEnglish Save budgetGerman Budget speichern
savingEnglish Saving…German Wird gespeichert…
savedEnglish Budget saved.German Budget gespeichert.
unsavedEnglish Unsaved changesGerman Ungespeicherte Änderungen
discardEnglish DiscardGerman Verwerfen
shareEnglish ShareGerman Anteil
nameEnglish NameGerman Name
totalLabelEnglish TotalGerman Gesamt
balanceEnglish Credit balanceGerman Guthaben
lastsEnglish Lasts about {n} days at the current paceGerman Reicht im jetzigen Tempo etwa {n} Tage
buyEnglish Buy creditsGerman Guthaben kaufen
packEnglish {n} creditsGerman {n} Credits
perCreditEnglish {price} per 1,000German {price} pro 1.000
bonusEnglish BonusGerman Bonus
payEnglish Buy for {price}German Für {price} kaufen
buyingEnglish Buying…German Wird gekauft…
boughtEnglish {n} credits added.German {n} Credits gutgeschrieben.
autoEnglish Top up automaticallyGerman Automatisch aufladen
autoTextEnglish When the balance drops below {n} credits we buy the selected pack.German Fällt das Guthaben unter {n} Credits, kaufen wir das gewählte Paket.
belowEnglish BelowGerman Unter
historyEnglish ActivityGerman Verlauf
topUpEnglish Top-upGerman Aufladung
usedEnglish UsedGerman Verbraucht

Accessibility

Read from the source of the component.

  • Usage and limits handles the keys Enter, Arrow up, Arrow down, Arrow left, Arrow right and Space itself.
  • Sets aria-checked, aria-label, aria-live, aria-pressed, aria-valuemax, aria-valuemin, aria-valuenow and aria-valuetext, so screen readers announce purpose and state.
  • Uses the roles meter, radio, radiogroup, slider and status.
  • Announces changes through a live region without moving focus.
  • Every field has a label, visible or for screen readers.
  • Adds text for screen readers where icons and graphics stand alone.
  • Shows a visible focus ring when used with the keyboard.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 2 places in the code switch motion off then.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 1,065 lines
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>
  );
}

Frequent questions

How do I install Usage and limits?

With aiw add @ai-website/usage. The command puts components/ui/usage.tsx into your project, the code is yours from then on. It brings along button, icon, select, switch and sidebar. It needs no further npm packages.

Which variants does Usage and limits have?

5 variants, Quota meters, Daily usage, Spend limits, Usage breakdown and Prepaid credits. You pick one with the variant prop, for example variant="daily-chart".

Can I translate the texts of Usage and limits?

Yes. Usage and limits carries 56 texts of its own in English and German, such as “At this pace you stay below the limit.”, “Forecast” and “Close to the limit”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Usage and limits?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Usage and limits with aiw_components and install it with aiw_add @ai-website/usage. Name the variant in your prompt, for example variant="quotas", and the assistant writes your content straight into the props.

Can I adjust Usage and limits without code?

Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching Usage code to copy.

Often searched

Jump to

Copied to the clipboard