Usage Alerts
A budget alert-rule manager — threshold tripwires on a scale that runs past 100%, per-rule channels, a one-shot test send with a real in-flight state, and a fired-alert history waiting to be acknowledged, in four contract-driven states.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/usage-alerts.jsonPrompt
Build a React + TypeScript + Tailwind "UsageAlerts" component with zod and
lucide-react. Nothing else: the slider, the switch and the channel chips are
hand-rolled, because the two extra marks on the track — the budget line and the
live "you are here" tick — are the entire reason the control exists.
Contract
- A zod schema in a sibling contract file is the single source of truth. One
rule is { id, thresholdPct, channels: ("email"|"slack"|"webhook")[], enabled,
lastFiredAt?: string|number|Date, label? }.
- `thresholdPct` is a percent of a budget the component never learns the value
of, which is exactly why one panel serves a dollar cap, a token quota and a
request quota. The scale runs 5..200 by default: "page me at 120%" is a real
rule and a 0..100 slider cannot express it.
- `channels` is a SET. The empty set is representable ON PURPOSE — an armed rule
with nowhere to send is the state the panel has to warn about, not a state it
refuses to store.
- `enabled` is armed/muted and is NOT deletion: a muted rule keeps its threshold
and its history for the next cycle. They are separate actions with separate
callbacks.
- A fired alert is { id, ruleId, firedAt, atPct, channels, failedChannels?,
acknowledgedAt?, note? }. `atPct` is the usage when it fired, which is NOT the
rule's threshold — a burst carries a run from 78% to 91% between two samples,
and the history has to be able to say so. `ruleId` may point at a rule that
has since been deleted.
- Budget context is { label?, usedPct, amountLabel?, resetsAt? } and is CONTEXT,
not a meter: one caption line plus a tick on every track. `amountLabel` is
pre-formatted by the caller — the panel does no money math and knows no
currency.
- Envelope status "loading" | "empty" | "error" | "ready" is the panel's own
render state, independent of any rule's `enabled`: `error` means the rules
failed to load, which is a different failure from an alert failing to deliver.
`status` is authoritative — "empty" renders the empty body even if rules
arrive with it, and "ready" with an empty array falls back to the same body.
- Props: status; rules; events?; budget?; now?; title?; label?; channels? (the
destinations this product can actually deliver on); minThresholdPct (5);
maxThresholdPct (200); thresholdStep (5); largeThresholdStep (step * 5);
maxRules (8); maxEvents (4); defaultChannels (["email"]);
preferredThresholdPct (80); disabled?; errorMessage?; emptyState?;
formatInstant?; onThresholdChange?(id, pct); onThresholdCommit?(id, pct);
onChannelsChange?(id, channels); onEnabledChange?(id, enabled);
onAddRule?({thresholdPct, channels}); onRemoveRule?(id); onTestAlert?(id) =>
void | Promise<unknown>; onAcknowledge?(eventId); onRetry?; className plus the
rest of the element props, with the ref forwarded to the <section>.
- EVERY affordance is gated on its handler. No onEnabledChange, no switch. No
onChannelsChange and the chips collapse into plain tags showing only the
channels that are ON — the read-only reading is "this goes to email", not
"email yes, slack no". No onThresholdChange and the slider becomes
aria-readonly with a static badge. A control that changes nothing is worse
than no control at all.
Behavior
- ONE clamp for every edit path. Drag, arrow key, PageUp/PageDown, Home/End and
a typed percent all funnel through the same function: snap onto the step grid
FIRST, then clamp into [min, max], so the exact max stays reachable even when
it is off-grid. A step of 0 falls back to 1, because a NaN threshold would
silently disable the rule it belongs to.
- Change and commit are two different events. onThresholdChange fires on every
drag frame (the UI must track the pointer); onThresholdCommit fires once the
gesture settles — pointer release, key press, committed edit. Persist in the
commit handler and you write one row per gesture instead of sixty.
- THE ORDER FREEZES DURING A DRAG. Rules render ascending by threshold, so
dragging one past its neighbour would re-sort the list and yank the row out
from under the pointer mid-gesture. The id order is captured on pointer-down
and released on settle; the list re-sorts once, after the user let go.
- The typed badge is a real input: Enter or blur commits, Escape reverts, an
empty or unparseable value reverts rather than coercing to 0 — a 0% threshold
fires on the first request of every cycle.
- Threshold geometry is drawn against the SAME axis as usage: a translucent
band from 0 to `usedPct`, the threshold fill over it, a diamond tick at
current usage, and a hairline at 100% only when the scale actually runs past
it. A rule the meter has already passed is filled solid; one still ahead is
filled at lower opacity, and aria-valuetext spells out the difference ("80% of
budget, 12% above current usage" / "already reached").
- TEST SENDS ARE ONE-SHOT PER RULE. The lock lives in a ref that is read and
written synchronously inside the click handler: several clicks dispatched in a
single task all read the same stale state, so a state-only guard would let the
extras through and fire four test notifications. The row's in-flight state is
driven by the promise the handler returns — "sent" when it fulfils, "failed"
when it rejects, and a handler that throws before returning anything still
releases the lock, or that row could never be tested again. A synchronous
handler settles in a microtask, so the spinner is never seen, which is
correct: nothing was in flight. A mounted ref stops the settle from writing
state into an unmounted panel.
- Add suggests a slot instead of a duplicate: the free grid position furthest
from every existing threshold, biased toward `preferredThresholdPct` when
several tie. When the grid is full the suggestion is null and the button is
aria-disabled with the reason spelled out; at `maxRules` it says "8 of 8 rules
used — delete one to add another". Never a silently dead button.
- Three per-rule notices, none of which block an edit: an armed rule with no
channel is called out destructively ("armed but can't reach anyone"), two
rules on the same percent get a duplicate notice (legitimate — they may
notify different teams — but both will send), and a rule the meter has already
passed explains that it fires on the next cycle. The notices are wired to the
switch and the slider through aria-describedby.
- Toggling a channel re-projects the result through the declared channel ORDER,
so ["slack","email"] and ["email","slack"] never read as two different values
to a consumer diffing the array.
- The history is newest first, capped at `maxEvents` with an explicit "Show all
7 alerts" toggle — a silent cut is worse than no cut. Each unacknowledged
event carries an Acknowledge button; acknowledged ones stay, dimmed and still
readable, because the list is also the audit trail. Failed channels are named
in destructive text next to the delivered ones.
- THE COMPONENT OWNS NO CLOCK. "2 hours ago" is derived from an injected `now`;
omit it and every time renders as an absolute string with a FIXED locale and a
FIXED UTC zone, because a visitor-zoned date is a hydration mismatch on every
row. Nothing calls Date.now().
- Locking uses aria-disabled, never the native `disabled` attribute, which blurs
the control the instant the panel locks under the cursor and drops it out of
the tab order while it is still worth reading.
Rendering & styling
- Semantic tokens only: bg-card / border / text-muted-foreground for chrome,
bg-primary (+ bg-primary/45 for a threshold not yet reached, bg-primary/10 for
an active chip, border-primary/40 for a crossed rule), text-primary-foreground
on the Add button, bg-muted for tracks and skeletons, foreground/15 for the
usage band, text-destructive for an undeliverable rule and a failed delivery.
No hard-coded colours anywhere.
- Three activity states are three ICONS plus a word — a ringing bell (crossed),
a bell (armed), a struck bell (muted) — so the distinction survives a
monochrome theme and a colour-blind reader.
- Accessibility: role="slider" with aria-valuemin/max/now/valuetext and full
keyboard control; role="switch" with aria-checked for arming; aria-pressed
toggle buttons for channels; one persistent sr-only role="status" summarising
the panel ("4 alert rules, 2 already crossed, 2 alerts to acknowledge") plus a
per-row status region that is ALWAYS mounted, because a live region that
appears together with its first message is a message nobody hears.
- Every transition and the skeleton pulse are motion-reduce guarded; the
in-flight spinner stops under prefers-reduced-motion and the row still reads
as sending. During a drag the thumb transition is switched off entirely so it
tracks the pointer exactly, and switched back on for keyboard steps.
- Pointer capture on the track, not window listeners: the gesture survives the
pointer leaving the element and there is nothing to unsubscribe on unmount.
- Long labels use wrap-anywhere + min-w-0, so a 60-character rule name wraps
inside the card instead of widening it.
- cn() merges the consumer's className into the root <section>, which spreads
the remaining element props and forwards its ref.
Customization levers
- Scale: minThresholdPct / maxThresholdPct / thresholdStep / largeThresholdStep.
Cap at 100 for a hard quota that cannot be exceeded, drop the step to 1 for
precise thresholds, widen largeThresholdStep for a fast keyboard sweep.
- Destinations: pass `channels` to narrow the set to what your product can
actually deliver on; add a member (sms, pagerduty) by extending the enum in
the contract and adding one { label, Icon } entry to the icon map.
- Budgets: `defaultChannels` and `preferredThresholdPct` decide what a
brand-new rule looks like; `maxRules` decides when Add turns itself off.
- Density: omit `events` and the history block disappears; omit `budget` and the
tracks lose their usage tick and the header its caption line — the rows keep
working. Lower `maxEvents` for a settings side panel, raise it for a full page.
- Time: `formatInstant` swaps the wording (a locale-aware library, "on 14 Apr",
a duration). `now` is the clock you tick — one interval for the whole page.
- Chrome: `title`, `label`, `emptyState`, `errorMessage` + `onRetry`, and
`disabled` for a viewer without permission to edit (numbers stay visible).Concepts
- A threshold is a tripwire, not a reading — the rule says when to interrupt a human, so it is drawn on the same axis as current usage: a translucent band up to where the meter stands, the threshold over it, and a tick you can read against. "80%" alone is an abstract number; "80%, twelve points ahead of you" is a decision.
- The scale runs past 100% — the alerts that actually cost money live above the budget line ("page finance at 120%"), and a 0..100 slider cannot express them. The hairline at 100 is drawn only when the range really extends past it, so it never masquerades as an end stop.
- Change versus commit — dragging emits every frame because the UI has to track the pointer; the settle is a separate event. Persisting on commit writes one row per gesture instead of sixty, and gives you the natural place to hang an optimistic update.
- Freezing the order during a drag — a list sorted by threshold re-sorts the moment you drag one rule past another, pulling the row out from under the pointer. Capturing the id order on pointer-down and releasing it on settle keeps the gesture stable and lets the list re-sort exactly once, after the user let go.
- One-shot test send — a test alert is a real notification to real people. The lock is a ref read and written synchronously in the click handler, because several clicks dispatched in one task all observe the same stale state; the in-flight state then comes from the promise the handler returns, so "sent" means sent and "failed" means failed.
- Undeliverable is a warning, not a refusal — an armed rule with no channel, and two rules on the same percent, are both states a real account gets into. The panel names them where they happen instead of blocking the edit that created them, because refusing an edit you cannot explain is how a settings page becomes unusable.
- Firing is only half the loop — an alert that went out and that nobody claimed is still asking for a human, so the history is interactive: acknowledge per event, the acknowledged ones stay for the audit trail, and a cut list always states how many it withheld.
Upgrade Prompt
An in-feature paywall — usage note, benefit bullets, a two-tier mini compare, an async upgrade CTA and a "remind me tomorrow" snooze that round-trips through the host, as a banner or as an overlay that blurs only its own box.
Request History
A playground's run rail — every request with the model and knobs it was sent with, pinned favourites kept above the churn, one click to load a run back into the editor, and a confirmation when that would overwrite your draft.