AI

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  Bell,
  BellOff,
  BellRing,
  Check,
  CircleAlert,
  CircleCheck,
  History,
  LoaderCircle,
  Mail,
  MessageSquare,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/usage-alerts.json

Prompt

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.

On This Page