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
Variants
Details
Was Verbrauch und Limits mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Billing und Usage
- 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
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle UsageProps aus components/ui/usage.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalUsageVariantquotasdaily-chartspend-limitsbreakdowncredits
sidebaroptionalOmit<SidebarProps, 'children'> | nullThe app frame. Leave it out (or null) for a standalone screen.
titlePflichtstringsubtitleoptionalstringcurrencyoptionalstringtodayoptionalstringISO date that counts as today
periodoptional{ start: string; end: string }First and last day of the billing period, ISO dates
quotasoptionalQuota[]quotas
seriesoptionalUsageSeries[]daily-chart
spendoptional{ current: number; cap: number; max?: number; alerts?: number[]; hardStop?: boolean }spend-limits
metricsoptional{ id: string; label: string; unit?: string }[]breakdown
itemsoptionalUsageItem[]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
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| period | Englisch {from} to {to} | Deutsch {from} bis {to} |
| resets | Englisch Resets in {n} days | Deutsch Startet neu in {n} Tagen |
| of | Englisch of {limit} | Deutsch von {limit} |
| left | Englisch {n} left | Deutsch noch {n} |
| over | Englisch {n} over the limit | Deutsch {n} über dem Limit |
| pace | Englisch At this pace you reach the limit on {date}. | Deutsch In diesem Tempo erreichst du das Limit am {date}. |
| paceOk | Englisch At this pace you stay below the limit. | Deutsch In diesem Tempo bleibst du unter dem Limit. |
| forecast | Englisch Forecast | Deutsch Prognose |
| nearLimit | Englisch Close to the limit | Deutsch Nah am Limit |
| atLimit | Englisch Limit reached | Deutsch Limit erreicht |
| metric | Englisch Metric | Deutsch Kennzahl |
| perDay | Englisch Per day | Deutsch Pro Tag |
| limitPerDay | Englisch Limit per day | Deutsch Limit pro Tag |
| spentMark | Englisch Spent {v} | Deutsch Ausgegeben {v} |
| total | Englisch So far | Deutsch Bisher |
| projected | Englisch Projected for the period | Deutsch Erwartet für den Zeitraum |
| limit | Englisch Limit | Deutsch Limit |
| today | Englisch Today | Deutsch Heute |
| chart | Englisch Usage per day, use the arrow keys to read each day | Deutsch Verbrauch pro Tag, mit den Pfeiltasten jeden Tag ablesen |
| dayValue | Englisch {date}: {v} | Deutsch {date}: {v} |
| projectedDay | Englisch Projected | Deutsch Erwartet |
| legendUsed | Englisch Used | Deutsch Verbraucht |
| legendProjected | Englisch Projected | Deutsch Erwartet |
| budget | Englisch Monthly budget | Deutsch Monatsbudget |
| budgetText | Englisch 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. |
| spent | Englisch Spent this period | Deutsch Ausgegeben in diesem Zeitraum |
| capValue | Englisch Budget per month | Deutsch Budget pro Monat |
| alerts | Englisch Email alerts | Deutsch Hinweise per E-Mail |
| alertsText | Englisch We email the owners at each mark. | Deutsch Wir schreiben den Inhabern an jeder Marke. |
| alertAt | Englisch At {n}% of the budget | Deutsch Bei {n} % des Budgets |
| hardStop | Englisch Pause usage at the budget | Deutsch Nutzung am Budget pausieren |
| hardStopText | Englisch 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. |
| softText | Englisch 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. |
| save | Englisch Save budget | Deutsch Budget speichern |
| saving | Englisch Saving… | Deutsch Wird gespeichert… |
| saved | Englisch Budget saved. | Deutsch Budget gespeichert. |
| unsaved | Englisch Unsaved changes | Deutsch Ungespeicherte Änderungen |
| discard | Englisch Discard | Deutsch Verwerfen |
| share | Englisch Share | Deutsch Anteil |
| name | Englisch Name | Deutsch Name |
| totalLabel | Englisch Total | Deutsch Gesamt |
| balance | Englisch Credit balance | Deutsch Guthaben |
| lasts | Englisch Lasts about {n} days at the current pace | Deutsch Reicht im jetzigen Tempo etwa {n} Tage |
| buy | Englisch Buy credits | Deutsch Guthaben kaufen |
| pack | Englisch {n} credits | Deutsch {n} Credits |
| perCredit | Englisch {price} per 1,000 | Deutsch {price} pro 1.000 |
| bonus | Englisch Bonus | Deutsch Bonus |
| pay | Englisch Buy for {price} | Deutsch Für {price} kaufen |
| buying | Englisch Buying… | Deutsch Wird gekauft… |
| bought | Englisch {n} credits added. | Deutsch {n} Credits gutgeschrieben. |
| auto | Englisch Top up automatically | Deutsch Automatisch aufladen |
| autoText | Englisch 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. |
| below | Englisch Below | Deutsch Unter |
| history | Englisch Activity | Deutsch Verlauf |
| topUp | Englisch Top-up | Deutsch Aufladung |
| used | Englisch Used | Deutsch 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
'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.