# Status and notify

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

```tsx
"use client"

import * as React from "react"

import { StatusNotify } from "@/components/status-notify"

// Fictional data. Nothing here is saved anywhere.
export function StatusNotifyDemo() {
  const [saved, setSaved] = React.useState({
    automatic: [] as string[],
    onDemand: ["mara@example.org", "jules@example.org"],
  })

  return (
    <div className="w-full max-w-3xl">
      <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={saved}
        onSave={async (channel, emails) => {
          await new Promise((r) => setTimeout(r, 400))
          setSaved((s) => ({ ...s, [channel]: emails }))
        }}
        onSend={async () => {
          await new Promise((r) => setTimeout(r, 600))
          return saved.onDemand.length
        }}
        onError={(error, ctx) => console.error(ctx.action, error)}
        automaticFallback="No one is set to hear as staff finish, so those notices go to the admin team instead."
      />
    </div>
  )
}
```

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

**Command**

```bash
npx 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.

**Manual**

**Step 1.** Copy and paste the following code into your project.

```tsx title="components/status-notify.tsx"
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import {
  applySavedChannel,
  listForChannel,
  peopleFromLists,
  sameList,
} from "@/lib/notify-envelope"
import { ConfirmSend } from "@/components/ui/confirm-send"
import {
  RecipientRoster,
  type RosterChannel,
} from "@/components/ui/recipient-roster"
import { SaveBar } from "@/components/ui/save-bar"
import {
  StatusStrip,
  type StatusSegment,
} from "@/components/ui/status-strip"
import { Card } from "@/components/ui/card"

/** The two channels the block manages. */
export type NotifyChannelName = "automatic" | "onDemand"

/** Column heading, cadence hint, optional limit and per-person switch label. */
export type NotifyChannelConfig = Omit<RosterChannel, "key">

export interface StatusNotifyProps
  extends Omit<React.ComponentProps<typeof Card>, "title" | "onError"> {
  /** Stages of one whole, e.g. completed / in progress / not started. */
  segments: StatusSegment[]
  /** Completes the headline: "11 of 18 <noun>". Defaults to "done". */
  headlineNoun?: string
  /** Emailed as things happen. */
  automatic: NotifyChannelConfig
  /** Sent only when someone presses the button. */
  onDemand: NotifyChannelConfig
  /**
   * The SAVED lists. Your `onSave` must update these (state, refetch, cache) or
   * the bar stays dirty. When a channel's saved list changes, that channel's
   * switches follow it; the other channel's unsaved edits are kept.
   */
  saved: { automatic: string[]; onDemand: string[] }
  /** Persist one channel's list. Called only for lists that changed. Throw to fail. */
  onSave: (channel: NotifyChannelName, emails: string[]) => Promise<void>
  /**
   * Send the on-demand summary to the SAVED list. Return how many were actually
   * sent if your API tells you; otherwise the saved list length is shown.
   */
  onSend: () => Promise<number | void>
  /** Called when a save or send throws, so you can toast or log it. */
  onError?: (
    error: unknown,
    context: { action: "save" | "send"; channel?: NotifyChannelName }
  ) => void
  /** What happens when the automatic list is empty. */
  automaticFallback?: React.ReactNode
  /** Match your backend's rule exactly. Defaults to a close-to-server email check. */
  validateEmail?: (email: string) => boolean
  /** Label for the send button. Defaults to "Email this summary". */
  sendLabel?: string
  /** One recipient, and its plural. Default "person" / "people". */
  recipientNoun?: string
  recipientNounPlural?: string
  /** Heading above the roster. */
  title?: string
  /** One-line help under the heading. */
  description?: string
}

/** Status headline → action → who hears about it. See /docs/components/status-notify. */
function StatusNotify({
  segments,
  headlineNoun,
  automatic,
  onDemand,
  saved,
  onSave,
  onSend,
  onError,
  automaticFallback,
  validateEmail,
  sendLabel = "Email this summary",
  recipientNoun,
  recipientNounPlural,
  title = "Who hears about it",
  description = "One list. Each person can get an email as things happen, the overview, both, or neither.",
  className,
  ...props
}: StatusNotifyProps) {
  const channelKeys = React.useMemo(() => ["automatic", "onDemand"], [])
  const [people, setPeople] = React.useState(() =>
    peopleFromLists(saved, channelKeys)
  )
  const [saving, setSaving] = React.useState(false)
  const [sending, setSending] = React.useState(false)
  const [sentCount, setSentCount] = React.useState<number | null>(null)

  // Follow a channel's SAVED list when it changes underneath us (a save landed,
  // the school changed, a refetch normalised it). Per channel, so a partial
  // failure keeps the failed channel's edits.
  const savedAuto = saved.automatic.join("\n")
  const savedDemand = saved.onDemand.join("\n")
  const firstRun = React.useRef(true)
  React.useEffect(() => {
    if (firstRun.current) {
      firstRun.current = false
      return
    }
    setPeople((current) =>
      applySavedChannel(current, "automatic", saved.automatic, channelKeys)
    )
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [savedAuto])
  const firstRunDemand = React.useRef(true)
  React.useEffect(() => {
    if (firstRunDemand.current) {
      firstRunDemand.current = false
      return
    }
    setPeople((current) =>
      applySavedChannel(current, "onDemand", saved.onDemand, channelKeys)
    )
    setSentCount(null)
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [savedDemand])

  const autoList = listForChannel(people, "automatic")
  const demandList = listForChannel(people, "onDemand")
  const autoChanged = !sameList(autoList, saved.automatic)
  const demandChanged = !sameList(demandList, saved.onDemand)

  const channels: RosterChannel[] = [
    { key: "automatic", ...automatic },
    { key: "onDemand", ...onDemand },
  ]

  async function save() {
    setSaving(true)
    // Independent full-list writes: one failing must not discard the other.
    const jobs: Array<{ channel: NotifyChannelName; run: Promise<void> }> = []
    if (autoChanged)
      jobs.push({ channel: "automatic", run: onSave("automatic", autoList) })
    if (demandChanged)
      jobs.push({ channel: "onDemand", run: onSave("onDemand", demandList) })
    const results = await Promise.allSettled(jobs.map((job) => job.run))
    results.forEach((result, i) => {
      if (result.status === "rejected") {
        onError?.(result.reason, { action: "save", channel: jobs[i].channel })
      }
    })
    if (results.every((r) => r.status === "fulfilled")) {
      // Someone with both switches off has no saved trace, so stop listing them.
      setPeople((current) =>
        current.filter((p) => p.channels.automatic || p.channels.onDemand)
      )
    }
    setSaving(false)
  }

  async function send() {
    setSending(true)
    setSentCount(null)
    try {
      const sent = await onSend()
      setSentCount(typeof sent === "number" ? sent : saved.onDemand.length)
    } catch (error) {
      onError?.(error, { action: "send" })
    } finally {
      setSending(false)
    }
  }

  return (
    <Card className={cn("gap-0 overflow-hidden py-0", className)} {...props}>
      <div className="space-y-4 p-4 sm:p-6">
        <StatusStrip
          segments={segments}
          headlineNoun={headlineNoun}
          action={
            <ConfirmSend
              count={saved.onDemand.length}
              label={sendLabel}
              noun={recipientNoun}
              nounPlural={recipientNounPlural}
              sending={sending}
              sentCount={sentCount}
              blockedReason={
                demandChanged
                  ? "Save your changes first — the summary goes to the saved list"
                  : undefined
              }
              emptyReason={`Turn on ${onDemand.label} for at least one person first`}
              onSend={send}
            />
          }
        />
      </div>
      <div className="border-t p-4 sm:p-6">
        <h3 className="font-semibold">{title}</h3>
        <p className="mt-0.5 mb-4 max-w-xl text-sm text-muted-foreground">
          {description}
        </p>
        <RecipientRoster
          people={people}
          channels={channels}
          disabled={saving}
          validate={validateEmail}
          onChange={(next) => {
            setPeople(next)
            setSentCount(null)
          }}
          footnote={
            saved.automatic.length === 0 ? automaticFallback : undefined
          }
        />
      </div>
      <SaveBar
        dirty={autoChanged || demandChanged}
        saving={saving}
        onSave={save}
        onDiscard={() => setPeople(peopleFromLists(saved, channelKeys))}
      />
    </Card>
  )
}

export { StatusNotify }
```

**Step 2.** Add the parts it imports: [`status-strip`](https://realgood.site/docs/components/status-strip.md),
[`recipient-roster`](https://realgood.site/docs/components/recipient-roster.md),
[`confirm-send`](https://realgood.site/docs/components/confirm-send.md),
[`save-bar`](https://realgood.site/docs/components/save-bar.md) and
[`notify-envelope`](https://realgood.site/docs/components/notify-envelope.md). Update the import paths
to match your project setup.

## Usage

```tsx
import { StatusNotify } from "@/components/status-notify"
```

```tsx
<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.

> Your `onSave` must update `saved` (state, refetch or cache) once the write
> succeeds. Until it does, the "Unsaved changes" bar stays and sending stays
> blocked, because the summary goes to the saved list. If one channel's save
> fails and the other succeeds, only the failed channel stays dirty.

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.

| Item                                                    | What it is                                                                     |
| ------------------------------------------------------- | ------------------------------------------------------------------------------ |
| [`status-strip`](https://realgood.site/docs/components/status-strip.md)         | Headline number, segmented bar, legend, and a slot for the action              |
| [`recipient-roster`](https://realgood.site/docs/components/recipient-roster.md) | One list of people with a switch per channel, limits, paste-to-add, validation |
| [`confirm-send`](https://realgood.site/docs/components/confirm-send.md)         | Click, confirm with the count, send, "Sent to N"                               |
| [`save-bar`](https://realgood.site/docs/components/save-bar.md)                 | "Unsaved changes" with Discard and Save, hidden when clean                     |
| [`notify-envelope`](https://realgood.site/docs/components/notify-envelope.md)   | Pure 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.

| Prop                                    | Type                                          | Description                                                                               |
| --------------------------------------- | --------------------------------------------- | ----------------------------------------------------------------------------------------- |
| `segments`                              | `StatusSegment[]`                             | Stages of one whole. `tone` is `done`, `active` or `pending`.                             |
| `headlineNoun`                          | `string`                                      | Completes 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? }) => void`       | Called when a save or send throws. Toast or log here.                                     |
| `automaticFallback`                     | `ReactNode`                                   | What happens when the automatic list is empty.                                            |
| `validateEmail`                         | `(email) => boolean`                          | Match your backend's rule. The default is close to common server rules.                   |
| `sendLabel`                             | `string`                                      | Send button label. Default "Email this summary".                                          |
| `recipientNoun` / `recipientNounPlural` | `string`                                      | Default "person" / "people".                                                              |
| `title` / `description`                 | `string`                                      | Heading 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.

```text
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`](https://realgood.site/docs/components/audit-timeline.md).
