Skip to content

A status headline, a confirm-then-send action, and one roster of who hears about it.

Build this with your agent

Copy a ready prompt for Claude Code, Cursor or any coding agent.

11 of 18 staff trained

  • 11completed
  • 4signed in, not trained
  • 3not signed in yet

Who hears about it

One list. Each person can get an email as things happen, the overview, both, or neither.

Each finish0 / 5
Digest2 / 5
mara@example.org
jules@example.org

No one is set to hear as staff finish, so those notices go to the admin team instead.

"use client"

import * as React from "react"

A pattern for any admin surface where people depend on a job and need to hear how it is going: training completion, an import run, a sync, a weekly report.

It reads top to bottom: where things stand β†’ who hears about it.

Installation

pnpm dlx shadcn@latest add https://realgood.site/r/status-notify.json

This also adds the five smaller items it is built from, and the shadcn card, button, input and switch they use.

Usage

import { StatusNotify } from "@/components/status-notify"
<StatusNotify
  headlineNoun="staff trained"
  segments={[
    { key: "done", label: "completed", count: 11, tone: "done" },
    {
      key: "active",
      label: "signed in, not trained",
      count: 4,
      tone: "active",
    },
    { key: "pending", label: "not signed in yet", count: 3, tone: "pending" },
  ]}
  automatic={{ label: "Each finish", hint: "as staff complete", limit: 5 }}
  onDemand={{ label: "Digest", hint: "only when sent", limit: 5 }}
  saved={{ automatic: [], onDemand: ["mara@example.org"] }}
  onSave={async (channel, emails) => {
    const saved = await api.saveRecipients(channel, emails) // throw to fail
    setSaved((s) => ({ ...s, [channel]: saved })) // <- required
  }}
  onSend={async () => (await api.sendSummary()).sent} // optional: real count
  onError={(error, { action }) => toast.error(`Could not ${action}`)}
  automaticFallback="No one is set, so these notices go to the admin team instead."
/>

The block never calls your API. You pass the saved lists and two handlers.

Requires Tailwind 3.4 or newer (the parts use size-* and min-h-12).

The pattern

  1. One headline, one bar. "11 of 18 staff trained", then a segmented bar. The segments are stages of one whole and never overlap. The hatched one means "not started or unreachable". The total is the sentence, not another tile.
  2. The status is the message. The send button sits beside the numbers it will send, so no separate preview is needed.
  3. One roster, one switch per channel. People appear once. Each channel is a switch on their row with an n / limit count in its heading. Your storage can stay one list per channel.
  4. Say when it sends. The column hint says "as staff complete" or "only when sent", so nothing automatic looks like something you press.
  5. Manual sends are deliberate. Click, then "Send to N people?", then "Sent to N people". The button is blocked while there are unsaved edits, because the send goes to the saved list, and an open confirmation is dismissed if the list changes underneath it.
  6. Save only when there is something to save. The "Unsaved changes" bar appears when the roster is dirty. Only the lists that changed are saved, and each result is handled on its own, so one failure cannot discard the other.
  7. Tell the truth in the empty state. Say what really happens when nobody is set.

Envelope rule

When you send the summary, choose the shape per audience:

  • A small, named group who are meant to see one another: one message, everyone on To, the people you want copied on Cc and Reply-To. One send reaches everyone or fails as a whole, so a retry never double-mails half the list. Use buildEnvelope from notify-envelope.
  • A list whose members must not learn about each other: one message per recipient.

Parts

Each part is its own item and works alone.

ItemWhat it is
status-stripHeadline number, segmented bar, legend, and a slot for the action
recipient-rosterOne list of people with a switch per channel, limits, paste-to-add, validation
confirm-sendClick, confirm with the count, send, "Sent to N"
save-bar"Unsaved changes" with Discard and Save, hidden when clean
notify-envelopePure helpers: roster from lists, list comparison, one-message To/Cc envelope

What it does not do

  • It never calls your API. You pass the saved lists and two handlers. It does not fetch, save or send.
  • Contacts are email addresses only. There are no phone numbers and no SMS or text. The two channels are fixed as automatic and onDemand.
  • It does not run the automatic channel. Your server sends as things happen. The block only keeps the list of who is on it, and the column hint says when it sends.
  • It is not access control and keeps no audit record. Pair it with audit-timeline and write the events on your server.
  • It makes no compliance claim. It is UI cues, not compliance.

Props

StatusNotify

Also accepts the props of the shadcn card it renders (except title and onError, which are the block's own), including className and ref. The prop types are exported as StatusNotifyProps, and NotifyChannelName is "automatic" | "onDemand", the channel your onSave and onError receive.

PropTypeDescription
segmentsStatusSegment[]Stages of one whole. tone is done, active or pending.
headlineNounstringCompletes the headline: "11 of 18 <noun>".
automatic / onDemand{ label, hint?, limit?, switchLabel? }The two channels.
saved{ automatic: string[]; onDemand: string[] }The saved lists.
onSave(channel, emails) => Promise<void>Called only for lists that changed. Throw to fail. Must lead to saved updating.
onSend() => Promise<number | void>Sends the on-demand summary to the saved list. Return the real sent count if you have it.
onError(error, { action, channel? }) => voidCalled when a save or send throws. Toast or log here.
automaticFallbackReactNodeWhat happens when the automatic list is empty.
validateEmail(email) => booleanMatch your backend's rule. The default is close to common server rules.
sendLabelstringSend button label. Default "Email this summary".
recipientNoun / recipientNounPluralstringDefault "person" / "people".
title / descriptionstringHeading and one-line help above the roster.

Each channel config (automatic, onDemand) also accepts limit and switchLabel: (email) => string for a per-person accessible name.

Agent prompt

Paste this into your coding agent (Claude Code, Cursor, Codex or similar) in your project. Replace the bracketed parts with your own.

Goal: add a status-and-notify block to my [screen]: a headline "N of M [things] [done]" with a segmented bar, a confirm-then-send button, and one roster of who hears about it. Contacts are email addresses only.
 
Install: npx shadcn@latest add https://realgood.site/r/status-notify.json
Read the installed files (components/status-notify.tsx and the parts it adds: status-strip, recipient-roster, confirm-send, save-bar under components/ui, and lib/notify-envelope.ts) before writing any code. Do not guess props.
 
Contract:
<StatusNotify segments={StatusSegment[]} headlineNoun? automatic={{ label, hint?, limit?, switchLabel? }} onDemand={{ same fields }} saved={{ automatic: string[]; onDemand: string[] }} onSave={(channel: "automatic" | "onDemand", emails: string[]) => Promise<void>} onSend={() => Promise<number | void>} onError?={(error, { action: "save" | "send"; channel? }) => void} automaticFallback? validateEmail? sendLabel? recipientNoun? recipientNounPlural? title? description? />
StatusSegment = { key; label; count; tone: "done" | "active" | "pending" }
 
Wiring rule: the block never calls my API. onSave persists one list and throws on failure. It MUST lead to `saved` updating (state, refetch or cache), or the Unsaved changes bar stays and sending stays blocked. onSend sends the on-demand summary to the SAVED list from my server and returns the real sent count. onError shows a toast. Segments come from my data and must not overlap. Email addresses only: do not add phone numbers, SMS or texting.
 
States to handle: clean, dirty, save failed (only that channel stays dirty), sending, sent, an empty automatic list (automaticFallback says what really happens), a list at its limit, an invalid address.
 
Acceptance checks (run them and show me the output):
1. Typecheck and lint pass.
2. Edit one list and Save: the bar goes away only after `saved` updates.
3. Sending is blocked while there are unsaved edits.
4. If one channel's save fails and the other succeeds, only the failed one stays dirty.
5. The confirmation names the recipient count before onSend runs.
 
UI only: it does not send, store or check permissions, and it makes no compliance claim. No new dependencies or abstractions beyond this. If something is unclear, ask me.

Examples by sector

Example data only, to show the wording. These are not claims about any real organisation, and the component proves nothing about a sector's rules. The pattern is the same in each; only the data changes.

  • Education. Staff training completion: "11 of 18 staff trained", an email to the office as each person finishes, and a digest to the principal on demand.
  • Manufacturing. Calibrations in date: "6 of 18 gauges in calibration", an email to the quality team for each overdue gauge, and a digest on demand.
  • Engineering. Change orders reviewed: "7 of 12 ECOs reviewed", with the document controller emailed a digest on demand.
  • Health. Staff licences current: "41 of 46 licences current", with the practice manager emailed a digest on demand. Staff addresses only, and no patient details.

Next

Comes after: none, this is where a screen starts. Leads to: audit-timeline.