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
Variants
Details
What Usage and limits brings along, read from the registry and the source.
- Category
- Billing and usage
- Area
- App
- Variants
- 5
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/usage.tsx
- npm packages
- None
- Languages
- English and German, 56 texts of its own
- Studio
- All 5 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The UsageProps interface from components/ui/usage.tsx, with the comments from the code.
variantoptionalUsageVariantquotasdaily-chartspend-limitsbreakdowncredits
sidebaroptionalOmit<SidebarProps, 'children'> | nullThe app frame. Leave it out (or null) for a standalone screen.
titlerequiredstringsubtitleoptionalstringcurrencyoptionalstringtodayoptionalstringISO 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.
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
| Key | English | German |
|---|---|---|
| period | English {from} to {to} | German {from} bis {to} |
| resets | English Resets in {n} days | German Startet neu in {n} Tagen |
| of | English of {limit} | German von {limit} |
| left | English {n} left | German noch {n} |
| over | English {n} over the limit | German {n} über dem Limit |
| pace | English At this pace you reach the limit on {date}. | German In diesem Tempo erreichst du das Limit am {date}. |
| paceOk | English At this pace you stay below the limit. | German In diesem Tempo bleibst du unter dem Limit. |
| forecast | English Forecast | German Prognose |
| nearLimit | English Close to the limit | German Nah am Limit |
| atLimit | English Limit reached | German Limit erreicht |
| metric | English Metric | German Kennzahl |
| perDay | English Per day | German Pro Tag |
| limitPerDay | English Limit per day | German Limit pro Tag |
| spentMark | English Spent {v} | German Ausgegeben {v} |
| total | English So far | German Bisher |
| projected | English Projected for the period | German Erwartet für den Zeitraum |
| limit | English Limit | German Limit |
| today | English Today | German Heute |
| chart | English Usage per day, use the arrow keys to read each day | German Verbrauch pro Tag, mit den Pfeiltasten jeden Tag ablesen |
| dayValue | English {date}: {v} | German {date}: {v} |
| projectedDay | English Projected | German Erwartet |
| legendUsed | English Used | German Verbraucht |
| legendProjected | English Projected | German Erwartet |
| budget | English Monthly budget | German Monatsbudget |
| budgetText | English 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. |
| spent | English Spent this period | German Ausgegeben in diesem Zeitraum |
| capValue | English Budget per month | German Budget pro Monat |
| alerts | English Email alerts | German Hinweise per E-Mail |
| alertsText | English We email the owners at each mark. | German Wir schreiben den Inhabern an jeder Marke. |
| alertAt | English At {n}% of the budget | German Bei {n} % des Budgets |
| hardStop | English Pause usage at the budget | German Nutzung am Budget pausieren |
| hardStopText | English 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. |
| softText | English 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. |
| save | English Save budget | German Budget speichern |
| saving | English Saving… | German Wird gespeichert… |
| saved | English Budget saved. | German Budget gespeichert. |
| unsaved | English Unsaved changes | German Ungespeicherte Änderungen |
| discard | English Discard | German Verwerfen |
| share | English Share | German Anteil |
| name | English Name | German Name |
| totalLabel | English Total | German Gesamt |
| balance | English Credit balance | German Guthaben |
| lasts | English Lasts about {n} days at the current pace | German Reicht im jetzigen Tempo etwa {n} Tage |
| buy | English Buy credits | German Guthaben kaufen |
| pack | English {n} credits | German {n} Credits |
| perCredit | English {price} per 1,000 | German {price} pro 1.000 |
| bonus | English Bonus | German Bonus |
| pay | English Buy for {price} | German Für {price} kaufen |
| buying | English Buying… | German Wird gekauft… |
| bought | English {n} credits added. | German {n} Credits gutgeschrieben. |
| auto | English Top up automatically | German Automatisch aufladen |
| autoText | English 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. |
| below | English Below | German Unter |
| history | English Activity | German Verlauf |
| topUp | English Top-up | German Aufladung |
| used | English Used | German 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
'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.