# Calibration desk

> One worked example of the whole arc on one screen. Gauges due for calibration, a rule, a two-role approval, a confirm-then-send to owners, and an audit timeline, handing off to one another.

```tsx
"use client"

import { CalibrationDesk } from "@/components/calibration-desk"

// Fictional gauges, people and roles. The server behind it is an in-memory
// stand-in (registry/crisp/lib/calibration-desk-server.ts): nothing is saved
// and nothing is sent. Pass your own `server` to make it real.
export function CalibrationDeskDemo() {
  return <CalibrationDesk className="max-w-4xl" />
}
```

This is not a new pattern. It is the six patterns wired together so you, or your coding agent, can see how they hand off: **See → Decide → Act → Confirm → Record**. The scenario is manufacturing quality. Gauges are due or overdue for calibration, and Quality and Manufacturing must both approve a recall before the owners are emailed. The gauges, people and roles are made up.

Try it in the preview. Select overdue rows, request approval, switch **View as** between the two approvers and approve with a reason, then send. Turn on "Make the next sends fail" to see a failed send recorded and retried. Nothing is saved or sent, and the page forgets everything on reload.

> **This is a UI. The server behind the preview is a stand-in kept in memory.**
> Hiding a button is not access control. Your server must verify who the user is
> and what role they hold, write the audit events, and enforce quiet hours.
> crisp-ui does not make a tool compliant with any regulation, and none of this
> is legal or compliance advice.

## Installation

**Command**

```bash
npx shadcn@latest add https://realgood.site/r/calibration-desk.json
```

Or, with the [`@crisp` namespace](https://realgood.site/docs/installation.md) set up:

```bash
npx shadcn@latest add @crisp/calibration-desk
```

This also adds the six patterns it is built from ([`status-strip`](https://realgood.site/docs/components/status-strip.md), [`data-table`](https://realgood.site/docs/components/data-table.md), [`alert-rules`](https://realgood.site/docs/components/alert-rules.md), [`approval-step`](https://realgood.site/docs/components/approval-step.md), [`confirm-send`](https://realgood.site/docs/components/confirm-send.md) and [`audit-timeline`](https://realgood.site/docs/components/audit-timeline.md)), their helpers, `lucide-react`, and the shadcn `button`, `checkbox`, `table` and `switch`. Read the installed files before you change anything.

**Manual**

**Step 1.** Copy and paste the block into your project.

```tsx title="components/calibration-desk.tsx"
"use client"

/**
 * Calibration desk: the whole story on one screen.
 *
 *   See      status-strip + data-table      what is due, and which rows to act on
 *   Decide   alert-rules (summary text)     what would go out, to whom, on what rule
 *   Act      approval-step                  Quality and Manufacturing sign the recall
 *   Confirm  confirm-send                   "Send to N owners?", then the send
 *   Record   audit-timeline                 every decision, send and failure
 *
 * Each step hands its result to the next: the selected rows set the owner count
 * on the send button, an approval enables the send, a send adds an event to the
 * record and moves the gauges to "recalled", and the strip counts follow.
 *
 * THE SERVER IS A STAND-IN. With no `server` prop this uses
 * `createMemoryServer()` from `calibration-desk-server.ts`: gauges, request and
 * audit log live in memory and nothing is sent. To go real, change one place:
 * pass `server={yourServer}`, an object with the four methods of
 * `CalibrationDeskServer` that call your API, and delete the demo file. Your
 * server must verify the user and role, write the audit events and enforce
 * quiet hours; this screen only shows and asks. Hiding a button is not access
 * control.
 *
 * Example data only. See /docs/components/calibration-desk.
 */
import * as React from "react"
import {
  CircleAlert,
  CircleCheck,
  Clock,
  TriangleAlert,
  Undo2,
} from "lucide-react"

import { cn } from "@/lib/utils"
import { summarizeRule } from "@/lib/alert-rules-lib"
import { summarizeApproval } from "@/lib/approval"
import {
  calibrationStatus,
  checkRecallSend,
  countStatuses,
  daysUntilDue,
  describeSkipped,
  gaugeCount,
  ownerCount,
  planRecall,
  RECALL_MEANING,
  RECALL_POLICY,
  RECALL_REJECT_MEANING,
  recallTitle,
  summarizeIds,
  todayOf,
  type CalibrationDeskServer,
  type CalibrationStatus,
  type DeskState,
  type DeskUser,
  type Gauge,
} from "@/lib/calibration-desk-lib"
import {
  createMemoryServer,
  DEMO_USERS,
} from "@/lib/calibration-desk-server"
import { selectedRows } from "@/lib/table-view"
import { ApprovalStep } from "@/components/ui/approval-step"
import { AuditTimeline } from "@/components/ui/audit-timeline"
import { ConfirmSend } from "@/components/ui/confirm-send"
import {
  DataTable,
  type DataTableColumn,
  type DataTableView,
} from "@/components/ui/data-table"
import { StatusStrip } from "@/components/ui/status-strip"
import { Button } from "@/components/ui/button"
import { Switch } from "@/components/ui/switch"

export interface CalibrationDeskProps extends React.ComponentProps<"div"> {
  /**
   * Your server. Omit it to use the in-memory demo server, which shows the
   * demo controls (view as, simulate a failure).
   */
  server?: CalibrationDeskServer
  /** The signed-in person, from your session. Omit it in the demo to pick one. */
  currentUser?: DeskUser
  /** The first state, if you already fetched it. Otherwise the screen loads it. */
  initialState?: DeskState
  /** IANA zone the record shows times in. Default "UTC". */
  timeZone?: string
}

const message = (error: unknown) =>
  error instanceof Error && error.message
    ? error.message.replace(/[.\s]+$/, "")
    : "Something went wrong"

const formatDate = (iso: string) =>
  new Date(`${iso}T00:00:00Z`).toLocaleDateString("en-GB", {
    day: "numeric",
    month: "short",
    year: "numeric",
    timeZone: "UTC",
  })

const plural = (n: number, unit: string) => `${n} ${unit}${n === 1 ? "" : "s"}`

const RANK: Record<CalibrationStatus, number> = {
  overdue: 0,
  "due-soon": 1,
  recalled: 2,
  "in-date": 3,
}

// The status is an icon and words, so it still reads without colour.
function StatusCell({ gauge, today }: { gauge: Gauge; today: string }) {
  const status = calibrationStatus(gauge, today)
  const days = daysUntilDue(gauge.due, today)
  if (status === "overdue") {
    return (
      <span className="inline-flex items-center gap-1.5 font-medium text-destructive">
        <TriangleAlert className="size-4" aria-hidden="true" />
        {Number.isNaN(days)
          ? "Date unknown"
          : `Overdue by ${plural(-days, "day")}`}
      </span>
    )
  }
  if (status === "due-soon") {
    return (
      <span className="inline-flex items-center gap-1.5">
        <Clock className="size-4" aria-hidden="true" />
        {days === 0 ? "Due today" : `Due in ${plural(days, "day")}`}
      </span>
    )
  }
  if (status === "recalled") {
    return (
      <span className="inline-flex items-center gap-1.5 font-medium">
        <Undo2 className="size-4" aria-hidden="true" />
        Recalled
      </span>
    )
  }
  return (
    <span className="inline-flex items-center gap-1.5 text-muted-foreground">
      <CircleCheck className="size-4" aria-hidden="true" />
      In date
    </span>
  )
}

/** One stage of the story: a heading, what it shows, and what it hands on. */
function Stage({
  stage,
  title,
  handsOn,
  children,
}: {
  stage: string
  title: string
  /** What this stage passes to the next one. */
  handsOn: string
  children: React.ReactNode
}) {
  const id = React.useId()
  return (
    <section aria-labelledby={id} className="flex flex-col gap-3">
      <div>
        <h3 id={id} className="font-semibold">
          <span className="text-muted-foreground">{stage}.</span> {title}
        </h3>
        <p className="text-sm text-muted-foreground">{handsOn}</p>
      </div>
      {children}
    </section>
  )
}

/** The whole See, Decide, Act, Confirm, Record screen for recalling gauges. */
function CalibrationDesk({
  server,
  currentUser,
  initialState,
  timeZone = "UTC",
  className,
  ...props
}: CalibrationDeskProps) {
  // The demo server is always created (it is cheap) and used only when no
  // `server` is passed. Your own server replaces it here.
  const [memory] = React.useState(() => createMemoryServer())
  const api: CalibrationDeskServer = server ?? memory
  const [state, setState] = React.useState<DeskState | null>(
    () => initialState ?? (server ? null : memory.snapshot())
  )
  const [loadError, setLoadError] = React.useState<string | null>(null)
  const [viewerId, setViewerId] = React.useState(DEMO_USERS[0].id)
  const [failSend, setFailSend] = React.useState(false)

  // With your own server the person must come from your session; the demo
  // people are only for the in-memory server.
  const user: DeskUser | undefined =
    currentUser ??
    (server ? undefined : DEMO_USERS.find((u) => u.id === viewerId))

  const refresh = React.useCallback(async () => {
    try {
      setState(await api.load())
      setLoadError(null)
    } catch (error) {
      setLoadError(message(error))
    }
  }, [api])

  React.useEffect(() => {
    if (!state) void refresh()
    // Load once on mount when no first state was given.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [])

  const demo = !server && !currentUser

  return (
    <div
      data-slot="calibration-desk"
      className={cn("flex w-full flex-col gap-8", className)}
      {...props}
    >
      {!server && (
        <div
          role="group"
          aria-label="Demo controls"
          className="flex flex-col gap-3 rounded-lg border border-dashed p-3 text-sm"
        >
          <p className="text-muted-foreground">
            Demo controls. They are not part of the pattern, and the data is
            made up.
          </p>
          <div className="flex flex-wrap items-center gap-x-6 gap-y-3">
            {demo && (
              <div
                role="group"
                aria-label="View as"
                className="flex flex-wrap items-center gap-2"
              >
                <span className="text-muted-foreground">View as</span>
                {DEMO_USERS.map((u) => (
                  <Button
                    key={u.id}
                    size="sm"
                    variant={viewerId === u.id ? "default" : "outline"}
                    aria-pressed={viewerId === u.id}
                    onClick={() => setViewerId(u.id)}
                  >
                    {u.name}, {u.role}
                  </Button>
                ))}
              </div>
            )}
            <label className="flex items-center gap-2 text-muted-foreground">
              <Switch
                checked={failSend}
                onCheckedChange={(next) => {
                  memory.failSend = next
                  setFailSend(next)
                }}
              />
              Make the next sends fail
            </label>
          </div>
        </div>
      )}

      {loadError && (
        <p
          role="alert"
          className="flex flex-wrap items-center gap-x-3 gap-y-2 rounded-md border border-destructive/50 px-3 py-2 text-sm"
        >
          <CircleAlert
            className="size-4 shrink-0 text-destructive"
            aria-hidden="true"
          />
          <span>
            {state
              ? `Could not refresh: ${loadError}. What you see may be out of date.`
              : `Could not load the calibration list: ${loadError}.`}
          </span>
          <Button variant="outline" size="sm" onClick={() => void refresh()}>
            Try again
          </Button>
        </p>
      )}

      {state && user ? (
        <Desk
          state={state}
          api={api}
          user={user}
          refresh={refresh}
          timeZone={timeZone}
          refreshFailed={loadError !== null}
        />
      ) : state ? (
        <p role="alert" className="text-sm text-destructive">
          Pass currentUser (id, name, role) from your session to use your own
          server.
        </p>
      ) : !loadError ? (
        <p role="status" className="text-sm text-muted-foreground">
          Loading the calibration list…
        </p>
      ) : null}
    </div>
  )
}

interface DeskProps {
  state: DeskState
  api: CalibrationDeskServer
  user: DeskUser
  refresh: () => Promise<void>
  timeZone: string
  refreshFailed: boolean
}

function Desk({
  state,
  api,
  user,
  refresh,
  timeZone,
  refreshFailed,
}: DeskProps) {
  const { gauges, request, rule, events } = state
  const today = todayOf(new Date(state.asOf))

  const [selectedIds, setSelectedIds] = React.useState<string[]>([])
  const [requesting, setRequesting] = React.useState(false)
  const [requestError, setRequestError] = React.useState<string | null>(null)
  const [sending, setSending] = React.useState(false)
  const [sendError, setSendError] = React.useState<string | null>(null)
  // Always mounted, so each result is announced when its text changes.
  const [done, setDone] = React.useState("")
  const requestWhyId = React.useId()
  const approvalRef = React.useRef<HTMLElement>(null)
  const doneRef = React.useRef<HTMLParagraphElement>(null)
  // Set when a new request was just made, so focus can follow it to Act.
  const focusApproval = React.useRef(false)

  // See -> Decide: the selected rows decide what a recall would cover.
  const selected = selectedRows(gauges, selectedIds, (g) => g.id)
  const plan = planRecall(selected, today)
  const skippedText = describeSkipped(plan.skipped)

  // Decide -> Act: a request covers exactly the gauges it was made for.
  const covers =
    request !== null &&
    plan.gaugeIds.length === request.gaugeIds.length &&
    plan.gaugeIds.every((id) => request.gaugeIds.includes(id))
  const requestOpen =
    covers &&
    !request?.sentAt &&
    summarizeApproval(request?.approvers ?? [], RECALL_POLICY).status !==
      "rejected"

  // Act -> Confirm: approval for exactly this selection opens the send.
  const gate = checkRecallSend(plan.gaugeIds, request, RECALL_POLICY)

  const counts = countStatuses(gauges, today)

  const views = React.useMemo<DataTableView<Gauge>[]>(
    () => [
      { key: "all", label: "All", filter: () => true },
      {
        key: "overdue",
        label: "Overdue",
        filter: (g) => calibrationStatus(g, today) === "overdue",
      },
      {
        key: "due-soon",
        label: "Due in 30 days",
        filter: (g) => calibrationStatus(g, today) === "due-soon",
      },
    ],
    [today]
  )

  const columns = React.useMemo<DataTableColumn<Gauge>[]>(
    () => [
      {
        key: "gauge",
        header: "Gauge",
        rowHeader: true,
        sortValue: (g) => g.id,
        cell: (g) => (
          <span className="flex flex-col">
            <span>{g.id}</span>
            <span className="text-xs font-normal text-muted-foreground">
              {g.name}
            </span>
          </span>
        ),
      },
      {
        key: "owner",
        header: "Owner",
        sortValue: (g) => g.owner,
        cell: (g) => g.owner,
      },
      {
        key: "due",
        header: "Due date",
        sortValue: (g) => g.due,
        cell: (g) => formatDate(g.due),
      },
      {
        key: "status",
        header: "Status",
        sortValue: (g) => RANK[calibrationStatus(g, today)],
        cell: (g) => <StatusCell gauge={g} today={today} />,
      },
    ],
    [today]
  )

  const requestId = request?.id
  React.useEffect(() => {
    if (focusApproval.current && requestId) {
      focusApproval.current = false
      approvalRef.current?.focus()
    }
  }, [requestId])

  // The send button goes quiet once the gauges are recalled, so move focus to
  // the result rather than leaving it on nothing. Never steals focus the person
  // has already moved elsewhere.
  React.useEffect(() => {
    if (done && document.activeElement === document.body) {
      doneRef.current?.focus()
    }
  }, [done])

  async function requestRecall() {
    setRequesting(true)
    setRequestError(null)
    setSendError(null)
    setDone("")
    try {
      await api.requestRecall({ userId: user.id, gaugeIds: plan.gaugeIds })
      focusApproval.current = true
    } catch (error) {
      setRequestError(message(error))
    } finally {
      // Refetch either way: a refusal is in the record too.
      await refresh()
      setRequesting(false)
    }
  }

  async function sendRecall() {
    if (!request) return
    setSending(true)
    setSendError(null)
    setDone("")
    try {
      const { sent } = await api.sendRecall({
        userId: user.id,
        requestId: request.id,
        gaugeIds: plan.gaugeIds,
      })
      setSelectedIds([])
      setDone(
        `${request.id} sent to ${ownerCount(sent)}. ${gaugeCount(plan.gaugeIds.length)} recalled.`
      )
    } catch (error) {
      // The server has recorded the failure. Nothing was delivered, and the
      // same button tries again.
      setSendError(message(error))
    } finally {
      await refresh()
      setSending(false)
    }
  }

  const requestBlocked = requesting
    ? "Requesting…"
    : plan.gaugeIds.length === 0
      ? "Select at least one gauge that is overdue or due in 30 days"
      : requestOpen
        ? `${request?.id} already covers these gauges. Sign it below`
        : undefined

  return (
    <>
      <Stage
        stage="See"
        title="What is due"
        handsOn="Selecting rows hands them to Decide."
      >
        <StatusStrip
          headlineNoun="gauges in date or recalled"
          segments={[
            {
              key: "in-date",
              label: "in date",
              count: counts["in-date"],
              tone: "done",
            },
            {
              key: "recalled",
              label: "recalled",
              count: counts.recalled,
              tone: "done",
            },
            {
              key: "due-soon",
              label: "due in 30 days",
              count: counts["due-soon"],
              tone: "active",
            },
            {
              key: "overdue",
              label: "overdue",
              count: counts.overdue,
              tone: "pending",
            },
          ]}
        />
        <DataTable
          caption="Gauges and when each is next due for calibration"
          noun="gauge"
          rows={gauges}
          columns={columns}
          views={views}
          defaultActiveView="overdue"
          getRowId={(g) => g.id}
          rowLabel={(g) => `${g.id}, ${g.name}`}
          defaultSort={{ key: "due", direction: "asc" }}
          selectedIds={selectedIds}
          onSelectionChange={setSelectedIds}
          asOf={state.asOf}
          onRefresh={() => void refresh()}
        />
      </Stage>

      <Stage
        stage="Decide"
        title="What would go out"
        handsOn="The recallable gauges go to Act as a request for sign-off."
      >
        <div className="flex flex-col gap-2 text-sm">
          {plan.gaugeIds.length === 0 ? (
            <p>No gauges chosen for recall yet. Select rows above.</p>
          ) : (
            <p>
              <span className="font-medium">
                {gaugeCount(plan.gaugeIds.length)}
              </span>{" "}
              would be recalled and{" "}
              <span className="font-medium">
                {ownerCount(plan.owners.length)}
              </span>{" "}
              told ({summarizeIds(plan.owners, 4)}).
            </p>
          )}
          {skippedText && (
            <p className="text-muted-foreground">{skippedText}</p>
          )}
          <p>
            <span className="font-medium">{rule.label} rule.</span>{" "}
            {summarizeRule(rule, { includeTimeZone: true })}
          </p>
          <p className="text-muted-foreground">
            The rule is shown here, not applied. The server that sends must
            check quiet hours itself.
          </p>
        </div>
        <div className="flex flex-col items-start gap-2">
          <Button
            disabled={Boolean(requestBlocked)}
            aria-describedby={requestBlocked ? requestWhyId : undefined}
            onClick={() => void requestRecall()}
          >
            {plan.gaugeIds.length === 0
              ? "Request approval to recall"
              : `Request approval to recall ${gaugeCount(plan.gaugeIds.length)}`}
          </Button>
          {requestBlocked && (
            <p id={requestWhyId} className="text-sm text-muted-foreground">
              {requestBlocked}
            </p>
          )}
          {requestError && (
            <p role="alert" className="text-sm text-destructive">
              {requestError}.
            </p>
          )}
        </div>
      </Stage>

      <Stage
        stage="Act"
        title="Sign off the recall"
        handsOn="Once Quality and Manufacturing both approve, Confirm unlocks."
      >
        {request ? (
          <ApprovalStep
            key={request.id}
            ref={approvalRef}
            tabIndex={-1}
            className="outline-none"
            title={recallTitle(request.id, request.gaugeIds)}
            approvers={request.approvers}
            policy={RECALL_POLICY}
            currentUserId={user.id}
            meaning={RECALL_MEANING}
            rejectMeaning={RECALL_REJECT_MEANING}
            requireReason
            onDecide={async ({ outcome, reason }) => {
              try {
                await api.decide({
                  userId: user.id,
                  requestId: request.id,
                  outcome,
                  reason,
                })
              } finally {
                // On success the new decision comes back in `approvers`; on a
                // refusal the "blocked" event does. Refetch either way.
                await refresh()
              }
            }}
          />
        ) : (
          <p className="rounded-md border border-dashed px-4 py-6 text-center text-sm text-muted-foreground">
            No recall request yet. Choose gauges, then request approval.
          </p>
        )}
      </Stage>

      <Stage
        stage="Confirm"
        title="Tell the owners"
        handsOn="Every send, and every failure, goes to Record."
      >
        <div className="flex flex-wrap items-center gap-3">
          <ConfirmSend
            count={plan.owners.length}
            noun="owner"
            label="Send recall notice"
            blockedReason={gate.ok ? undefined : gate.reason}
            sending={sending}
            onSend={sendRecall}
          />
        </div>
        {sendError && (
          <p
            role="alert"
            className="flex items-start gap-1.5 text-sm text-destructive"
          >
            <CircleAlert
              className="mt-0.5 size-4 shrink-0"
              aria-hidden="true"
            />
            <span>
              The send failed: {sendError}. Nothing was delivered, and the
              failure is in the record below. Press Send again to retry.
            </span>
          </p>
        )}
        <p
          ref={doneRef}
          role="status"
          tabIndex={-1}
          className="flex items-center gap-1.5 text-sm font-medium outline-none"
        >
          {done && <CircleCheck className="size-4" aria-hidden="true" />}
          {done}
        </p>
      </Stage>

      <Stage
        stage="Record"
        title="Who did what"
        handsOn="The loop closes: the counts above already reflect it."
      >
        <AuditTimeline
          events={events}
          timeZone={timeZone}
          initialCount={6}
          headingLevel={4}
          emptyMessage="No recall activity yet. Each request, decision, send and failure will appear here."
          stale={
            refreshFailed
              ? "The last refresh failed, so newer activity may not be shown."
              : false
          }
        />
      </Stage>
    </>
  )
}

export { CalibrationDesk }
```

**Step 2.** The pure logic: gauge status, what a selection would recall, and whether a
send is allowed.

```ts title="lib/calibration-desk-lib.ts"
/**
 * Pure logic for the calibration-desk block: which gauges are due, which of a
 * selection can be recalled and who hears about it, and whether a send is
 * allowed yet. No React, no network and no clock (every date is passed in), so
 * it is safe to unit test and to run on a server.
 *
 * The same `checkRecallSend` is meant to run twice: in the browser, to explain
 * why the Send button is off, and on your server, which must decide for itself
 * from its own data. A rule that only the browser checks is not a rule.
 *
 * Example data only. The gauges, people and roles in the demo are made up.
 */
import {
  isEmailAddress,
  type AlertRule,
} from "@/lib/alert-rules-lib"
import {
  summarizeApproval,
  type ApprovalPolicy,
  type Approver,
} from "@/lib/approval"
import type { AuditEvent } from "@/lib/audit-event"
import { sameSelection } from "@/lib/table-view"

// ---------------------------------------------------------------------------
// Shared types
// ---------------------------------------------------------------------------

/** One gauge. Dates are calendar dates, "YYYY-MM-DD". */
export interface Gauge {
  id: string
  name: string
  owner: string
  /** Where the recall notice goes. Email only: there is no phone field. */
  ownerEmail: string
  /** When calibration is next due. */
  due: string
  /** ISO 8601 date-time the recall notice was sent. Set by the server. */
  recalledAt?: string
}

export type CalibrationStatus = "overdue" | "due-soon" | "in-date" | "recalled"

/** The signed-in person. A real server reads this from the session. */
export interface DeskUser {
  id: string
  name: string
  /** "Quality", "Manufacturing", ... Shown in the record. */
  role: string
}

/** One request to recall a set of gauges, and who has signed it. */
export interface RecallRequest {
  /** "RR-1". */
  id: string
  /** The gauges this approval covers. Nothing else may be sent under it. */
  gaugeIds: string[]
  requestedBy: string
  approvers: Approver[]
  /** ISO 8601 date-time the notice went out. Set once, by the server. */
  sentAt?: string
  /** How many owners it went to. */
  sentTo?: number
}

/** Everything the screen shows, as the server last said it. */
export interface DeskState {
  /** When the server read this, ISO 8601. */
  asOf: string
  gauges: Gauge[]
  /** The latest request, or null before anyone has made one. */
  request: RecallRequest | null
  /** The reminder rule, read-only here. */
  rule: AlertRule
  /** Every decision, send and failure, as the server recorded it. */
  events: AuditEvent[]
}

/**
 * The four things the screen asks of a server. This is the only seam: the demo
 * implements it in memory (`calibration-desk-server.ts`); yours implements it
 * with `fetch`. Each method is an authenticated request, and the server takes
 * the user from the session, never from the request body (the demo passes a
 * `userId` only because it has no session).
 *
 * Every method must throw (reject) with a readable message when the server
 * refuses or fails, and must write an audit event for the refusal or failure.
 */
export interface CalibrationDeskServer {
  load(): Promise<DeskState>
  requestRecall(input: { userId: string; gaugeIds: string[] }): Promise<void>
  decide(input: {
    userId: string
    requestId: string
    outcome: "approved" | "rejected"
    reason?: string
  }): Promise<void>
  sendRecall(input: {
    userId: string
    requestId: string
    gaugeIds: string[]
  }): Promise<{ sent: number }>
}

/** Quality and Manufacturing must both sign. */
export const RECALL_POLICY: ApprovalPolicy = "all"
/** What each signature means. The server stamps these; the browser only displays them. */
export const RECALL_MEANING = "Approved for recall"
export const RECALL_REJECT_MEANING = "Recall not approved"

/** Gauges due within this many days count as "due soon". */
export const DUE_SOON_DAYS = 30

// ---------------------------------------------------------------------------
// Dates and status
// ---------------------------------------------------------------------------

const DAY_MS = 86_400_000

/** Days since the epoch for a "YYYY-MM-DD" date, or NaN when it is not a real date. */
function dayNumber(date: string): number {
  if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return Number.NaN
  const ms = Date.parse(`${date}T00:00:00Z`)
  if (Number.isNaN(ms)) return Number.NaN
  // Rejects "2026-02-30", which some engines roll over into March.
  return new Date(ms).toISOString().slice(0, 10) === date ? ms / DAY_MS : NaN
}

/** The calendar date of an instant in UTC, "YYYY-MM-DD". */
export function todayOf(now: Date): string {
  return now.toISOString().slice(0, 10)
}

/** `date` moved by `days` (negative goes back). Throws on a date that is not real. */
export function addDays(date: string, days: number): string {
  const day = dayNumber(date)
  if (Number.isNaN(day)) throw new RangeError(`Not a date: "${date}"`)
  return new Date((day + days) * DAY_MS).toISOString().slice(0, 10)
}

/** Whole days from `today` to `due`. Negative when overdue. NaN if either is not a date. */
export function daysUntilDue(due: string, today: string): number {
  return dayNumber(due) - dayNumber(today)
}

/**
 * Where a gauge stands today.
 *
 * - `recalled`: the notice has gone out (it wins over the date).
 * - `overdue`: due before today. A due date that cannot be read counts as
 *   overdue, because nobody can vouch for a gauge whose date is unknown.
 * - `due-soon`: due today or within `DUE_SOON_DAYS` days.
 * - `in-date`: anything later.
 */
export function calibrationStatus(
  gauge: Gauge,
  today: string
): CalibrationStatus {
  if (gauge.recalledAt) return "recalled"
  const days = daysUntilDue(gauge.due, today)
  if (Number.isNaN(days) || days < 0) return "overdue"
  return days <= DUE_SOON_DAYS ? "due-soon" : "in-date"
}

/** How many gauges are in each status. Every status is present, even at 0. */
export function countStatuses(
  gauges: readonly Gauge[],
  today: string
): Record<CalibrationStatus, number> {
  const counts: Record<CalibrationStatus, number> = {
    overdue: 0,
    "due-soon": 0,
    "in-date": 0,
    recalled: 0,
  }
  for (const gauge of gauges) counts[calibrationStatus(gauge, today)] += 1
  return counts
}

// ---------------------------------------------------------------------------
// Decide: what a selection means
// ---------------------------------------------------------------------------

export type SkipReason = "in-date" | "already-recalled" | "no-owner-email"

export interface RecallPlan {
  /** The selected gauges that can be recalled, in the order given, once each. */
  gaugeIds: string[]
  /** Who is told: each owner's email once, trimmed and lower-cased. */
  owners: string[]
  /** Selected gauges that are left out, and why. */
  skipped: { id: string; reason: SkipReason }[]
}

const normalizeEmail = (email: string) => email.trim().toLowerCase()

/**
 * Turn selected rows into what would be recalled and who would hear.
 *
 * A gauge can be recalled when it is overdue or due within 30 days, has not
 * already been recalled, and its owner has a valid email address (one that
 * cannot be told is not recalled: a recall nobody hears is worse than none).
 * Everything else is listed in `skipped`, so the screen can say what it left
 * out instead of silently dropping it. A repeated id counts once.
 */
export function planRecall(
  selected: readonly Gauge[],
  today: string
): RecallPlan {
  const gaugeIds: string[] = []
  const owners: string[] = []
  const skipped: RecallPlan["skipped"] = []
  const seen = new Set<string>()
  for (const gauge of selected) {
    if (seen.has(gauge.id)) continue
    seen.add(gauge.id)
    const status = calibrationStatus(gauge, today)
    if (status === "recalled") {
      skipped.push({ id: gauge.id, reason: "already-recalled" })
    } else if (status === "in-date") {
      skipped.push({ id: gauge.id, reason: "in-date" })
    } else if (!isEmailAddress(normalizeEmail(gauge.ownerEmail))) {
      skipped.push({ id: gauge.id, reason: "no-owner-email" })
    } else {
      gaugeIds.push(gauge.id)
      const email = normalizeEmail(gauge.ownerEmail)
      if (!owners.includes(email)) owners.push(email)
    }
  }
  return { gaugeIds, owners, skipped }
}

const SKIP_LABEL: Record<SkipReason, string> = {
  "in-date": "in date",
  "already-recalled": "already recalled",
  "no-owner-email": "no valid owner email",
}

/**
 * One sentence about what a selection left out, or null when nothing was left
 * out: "2 selected gauges are left out: 1 in date, 1 already recalled."
 */
export function describeSkipped(skipped: RecallPlan["skipped"]): string | null {
  if (skipped.length === 0) return null
  const parts = (Object.keys(SKIP_LABEL) as SkipReason[])
    .map((reason) => ({
      reason,
      count: skipped.filter((s) => s.reason === reason).length,
    }))
    .filter((p) => p.count > 0)
    .map((p) => `${p.count} ${SKIP_LABEL[p.reason]}`)
  const many = skipped.length !== 1
  return `${skipped.length} selected ${many ? "gauges are" : "gauge is"} left out: ${parts.join(", ")}.`
}

/** "G-101, G-104, G-107, and 2 more": a short list for a title or a log line. */
export function summarizeIds(ids: readonly string[], max = 4): string {
  if (ids.length <= max) return ids.join(", ")
  return `${ids.slice(0, max).join(", ")}, and ${ids.length - max} more`
}

const plural = (n: number, noun: string) => `${n} ${noun}${n === 1 ? "" : "s"}`

/** "3 gauges", "1 owner". */
export const gaugeCount = (n: number) => plural(n, "gauge")
export const ownerCount = (n: number) => plural(n, "owner")

/** The line that names a request, for the approval step and the record. */
export function recallTitle(id: string, gaugeIds: readonly string[]): string {
  return `Recall request ${id}: ${gaugeCount(gaugeIds.length)} (${summarizeIds(gaugeIds)})`
}

// ---------------------------------------------------------------------------
// Act to Confirm: may the notice go out?
// ---------------------------------------------------------------------------

export type SendBlock =
  | "nothing-to-send"
  | "no-request"
  | "selection-changed"
  | "already-sent"
  | "rejected"
  | "waiting"

export type SendCheck =
  | { ok: true }
  | { ok: false; code: SendBlock; reason: string }

/**
 * May the recall notice go out for exactly these gauges under this request?
 * Checked in this order, so the first thing that is wrong is the one named:
 *
 * 1. nothing to send (no recallable gauge);
 * 2. no request, or the selection is not the one the request covers
 *    (approval is for a set of gauges, so a different set needs its own);
 * 3. already sent (a request can only be sent once);
 * 4. rejected, or still waiting for signatures.
 *
 * Run it in the browser for the message beside the button, and on the server
 * against the server's own copy of the request: the server's answer is the one
 * that counts.
 */
export function checkRecallSend(
  gaugeIds: readonly string[],
  request: RecallRequest | null | undefined,
  policy: ApprovalPolicy = RECALL_POLICY
): SendCheck {
  if (gaugeIds.length === 0) {
    return {
      ok: false,
      code: "nothing-to-send",
      reason: "Select at least one gauge that is overdue or due in 30 days",
    }
  }
  if (!request) {
    return {
      ok: false,
      code: "no-request",
      reason:
        "Request a recall first. It needs approval before anything is sent",
    }
  }
  if (!sameSelection(gaugeIds, request.gaugeIds)) {
    return {
      ok: false,
      code: "selection-changed",
      reason: `These gauges are not the ones in ${request.id}. Request a recall for them`,
    }
  }
  if (request.sentAt) {
    return {
      ok: false,
      code: "already-sent",
      reason: `${request.id} was already sent`,
    }
  }
  const summary = summarizeApproval(request.approvers, policy)
  if (summary.status === "rejected") {
    return {
      ok: false,
      code: "rejected",
      reason: `${request.id} was rejected. Request a new recall to try again`,
    }
  }
  if (summary.status === "pending") {
    return {
      ok: false,
      code: "waiting",
      reason: `Waiting for approval: ${summary.approved} of ${summary.needed} signed`,
    }
  }
  return { ok: true }
}
```

**Step 3.** The in-memory server. This is the one file you replace.

```ts title="lib/calibration-desk-server.ts"
/**
 * DEMO STAND-IN. REPLACE THIS FILE'S `createMemoryServer()` WITH REAL CALLS.
 *
 * `CalibrationDesk` talks to a `CalibrationDeskServer` (see
 * `calibration-desk-lib.ts`) and to nothing else. This file is the fake one: it
 * keeps the gauges, the recall request and the audit log in memory, answers
 * after a short delay, and can be told to fail a send. Nothing here is saved,
 * and nothing is ever sent anywhere.
 *
 * To make it real, change ONE place: pass your own object to
 * `<CalibrationDesk server={...} />`, with the same four methods, each a
 * request to your backend. Then delete this file. The rules this fake follows
 * are the rules your server must follow:
 *
 * - It takes the user from the session. Here a `userId` is passed in only
 *   because there is no session, and it looks the person up in its own table.
 * - It re-checks everything itself (`checkRecallSend`, who may decide, once
 *   only, a reason on every approval) and does not trust what the browser says
 *   it checked.
 * - It stamps times and ids with its own clock, and stamps what each signature
 *   means. The browser never makes those up.
 * - It writes an audit event for every decision, every send, every refusal and
 *   every failure, in the same step that does the thing, so the record exists
 *   even if the page is closed.
 * - It would enforce quiet hours before sending. This fake does not, so the
 *   demo works at any hour. Yours must (see `isQuietTime` in `alert-rules`).
 *
 * Example data only.
 */
import type { AlertRule } from "@/lib/alert-rules-lib"
import {
  summarizeApproval,
  validateDecision,
} from "@/lib/approval"
import {
  buildAuditEvent,
  type AuditEvent,
  type AuditEventInput,
} from "@/lib/audit-event"
import {
  addDays,
  checkRecallSend,
  gaugeCount,
  ownerCount,
  planRecall,
  RECALL_MEANING,
  RECALL_POLICY,
  RECALL_REJECT_MEANING,
  summarizeIds,
  todayOf,
  type CalibrationDeskServer,
  type DeskState,
  type DeskUser,
  type Gauge,
  type RecallRequest,
} from "@/lib/calibration-desk-lib"

/** The people the demo can be viewed as. Dana and Marcus are the two approvers. */
export const DEMO_USERS: DeskUser[] = [
  { id: "dana", name: "Dana Whitfield", role: "Quality" },
  { id: "marcus", name: "Marcus Webb", role: "Manufacturing" },
  { id: "sam", name: "Sam Reyes", role: "Metrology technician" },
]

/** Who must sign a recall. Role names are what "Only Quality and Manufacturing can sign off" reads. */
const APPROVER_IDS = ["dana", "marcus"]

/** Example rule, shown read-only on the screen. */
export const DEMO_RULE: AlertRule = {
  id: "calibration-due",
  label: "Calibration due",
  enabled: true,
  cadenceDays: [30, 14, 7, 1],
  escalateAfterDays: 3,
  escalateTo: "quality@example.org",
  quietHours: { start: "21:00", end: "08:00", timeZone: "America/Chicago" },
}

const OWNERS = {
  mara: { owner: "Mara Okafor", ownerEmail: "mara@example.org" },
  jules: { owner: "Jules Bernard", ownerEmail: "jules@example.org" },
  ines: { owner: "Ines Varga", ownerEmail: "ines@example.org" },
  tomas: { owner: "Tomas Lindqvist", ownerEmail: "tomas@example.org" },
}

// Days from today until calibration is due. Relative to the clock, so the demo
// always has the same 7 overdue, 5 due soon and 6 in date, whenever it is opened.
const SEED: [
  id: string,
  name: string,
  owner: keyof typeof OWNERS,
  due: number,
][] = [
  ["G-101", "Micrometer 0-25 mm", "mara", -29],
  ["G-104", "Dial caliper 150 mm", "mara", -13],
  ["G-107", "Torque wrench 20-100 Nm", "jules", -41],
  ["G-112", "Pressure gauge 0-10 bar", "tomas", -4],
  ["G-115", "Height gauge 300 mm", "ines", -63],
  ["G-118", "Thread plug M8", "jules", -22],
  ["G-121", "Bore gauge 18-35 mm", "ines", -2],
  ["G-124", "Surface plate 400 mm", "tomas", 8],
  ["G-127", "Dial indicator 0.01 mm", "mara", 13],
  ["G-130", "Torque screwdriver 1-6 Nm", "jules", 21],
  ["G-133", "Digital caliper 200 mm", "ines", 26],
  ["G-136", "Feeler gauge set", "tomas", 29],
  ["G-139", "Micrometer 25-50 mm", "mara", 64],
  ["G-142", "Pin gauge set 1-10 mm", "jules", 78],
  ["G-145", "Gauge block set", "ines", 103],
  ["G-148", "Pressure gauge 0-6 bar", "tomas", 125],
  ["G-151", "Thermometer probe", "mara", 147],
  ["G-154", "Torque wrench 5-25 Nm", "jules", 166],
]

/** The demo's gauges, due dates counted from `today` ("YYYY-MM-DD"). */
export function seedGauges(today: string): Gauge[] {
  return SEED.map(([id, name, owner, due]) => ({
    id,
    name,
    ...OWNERS[owner],
    due: addDays(today, due),
  }))
}

export interface MemoryServerOptions {
  /** The server's clock. Defaults to the real one. Inject a fixed one in tests. */
  now?: () => Date
  /** How long each call takes, in milliseconds. Default 350. Use 0 in tests. */
  delayMs?: number
}

/** The in-memory server, plus the switch the demo uses to make a send fail. */
export interface MemoryServer extends CalibrationDeskServer {
  /** While true, `sendRecall` fails after recording a "failed" event. */
  failSend: boolean
  /** The same answer as `load()`, without the delay. Used for the first paint. */
  snapshot(): DeskState
}

export function createMemoryServer(
  options: MemoryServerOptions = {}
): MemoryServer {
  const now = options.now ?? (() => new Date())
  const delayMs = options.delayMs ?? 350
  const wait = () =>
    delayMs > 0
      ? new Promise<void>((resolve) => setTimeout(resolve, delayMs))
      : Promise.resolve()

  const gauges = seedGauges(todayOf(now()))
  const events: AuditEvent[] = []
  let request: RecallRequest | null = null
  let eventCount = 0
  let requestCount = 0

  function record(input: AuditEventInput, requireReason = false) {
    events.push(
      buildAuditEvent(input, {
        now,
        newId: () => `evt_${++eventCount}`,
        requireReason,
      })
    )
  }

  const actorOf = (user: DeskUser) => ({ name: user.name, role: user.role })

  function userOf(userId: string): DeskUser {
    const user = DEMO_USERS.find((u) => u.id === userId)
    if (!user) throw new Error("Unknown user. Sign in again.")
    return user
  }

  /** Writes a "blocked" event, then throws: a refusal is part of the record. */
  function refuse(
    user: DeskUser,
    action: string,
    target: string,
    why: string
  ): never {
    record({
      actor: actorOf(user),
      action,
      target,
      reason: why,
      outcome: "blocked",
      detail: "Nothing was changed.",
    })
    throw new Error(why)
  }

  function snapshot(): DeskState {
    return {
      asOf: now().toISOString(),
      gauges: gauges.map((g) => ({ ...g })),
      request: request && {
        ...request,
        gaugeIds: [...request.gaugeIds],
        approvers: request.approvers.map((a) => ({
          ...a,
          decision: a.decision && { ...a.decision },
        })),
      },
      rule: DEMO_RULE,
      events: [...events],
    }
  }

  const server: MemoryServer = {
    failSend: false,
    snapshot,

    async load() {
      await wait()
      return snapshot()
    },

    async requestRecall({ userId, gaugeIds }) {
      await wait()
      const user = userOf(userId)
      // The server works out what is recallable from its own gauges.
      const plan = planRecall(
        gauges.filter((g) => gaugeIds.includes(g.id)),
        todayOf(now())
      )
      if (plan.gaugeIds.length === 0) {
        refuse(
          user,
          "tried to request approval to recall",
          gaugeCount(0),
          "None of those gauges can be recalled."
        )
      }
      const previous = request
      requestCount += 1
      const id = `RR-${requestCount}`
      if (previous && !previous.sentAt) {
        const status = summarizeApproval(previous.approvers, RECALL_POLICY)
        if (status.status !== "rejected") {
          record({
            actor: actorOf(user),
            action: "withdrew",
            target: `recall request ${previous.id}`,
            outcome: "succeeded",
            detail: `Replaced by ${id}.`,
          })
        }
      }
      request = {
        id,
        gaugeIds: plan.gaugeIds,
        requestedBy: user.name,
        approvers: APPROVER_IDS.map((approverId) => {
          const approver = DEMO_USERS.find((u) => u.id === approverId)!
          return { id: approver.id, name: approver.name, role: approver.role }
        }),
      }
      record({
        actor: actorOf(user),
        action: "requested approval to recall",
        target: gaugeCount(plan.gaugeIds.length),
        outcome: "succeeded",
        detail: `${id}: ${summarizeIds(plan.gaugeIds, 6)}. Needs Quality and Manufacturing.`,
      })
    },

    async decide({ userId, requestId, outcome, reason }) {
      await wait()
      const user = userOf(userId)
      const verb = outcome === "approved" ? "approve" : "reject"
      const refusedAction = `tried to ${verb}`
      const target = `recall request ${requestId}`
      if (!request || request.id !== requestId) {
        refuse(user, refusedAction, target, "That request is no longer open.")
      }
      const before = summarizeApproval(request.approvers, RECALL_POLICY)
      if (request.sentAt || before.status !== "pending") {
        refuse(user, refusedAction, target, "That request is already settled.")
      }
      const approver = request.approvers.find((a) => a.id === user.id)
      if (!approver) {
        refuse(
          user,
          refusedAction,
          target,
          "Only Quality and Manufacturing can sign off."
        )
      }
      if (approver.decision) {
        refuse(
          user,
          refusedAction,
          target,
          "You already decided on this request."
        )
      }
      const check = validateDecision(
        { outcome, reason },
        { requireReasonOnApprove: true }
      )
      if (!check.valid) refuse(user, refusedAction, target, check.error)

      const meaning =
        outcome === "approved" ? RECALL_MEANING : RECALL_REJECT_MEANING
      approver.decision = {
        outcome,
        at: now().toISOString(),
        reason: check.reason,
        meaning,
      }
      const after = summarizeApproval(request.approvers, RECALL_POLICY)
      const settled =
        after.status === "pending" ? "" : ` The request is now ${after.status}.`
      record(
        {
          actor: actorOf(user),
          action: outcome,
          target,
          reason: check.reason,
          outcome: "succeeded",
          detail: `Signature meaning: ${meaning}.${settled}`,
        },
        true
      )
    },

    async sendRecall({ userId, requestId, gaugeIds }) {
      await wait()
      const user = userOf(userId)
      const at = now()
      const plan = planRecall(
        gauges.filter((g) => gaugeIds.includes(g.id)),
        todayOf(at)
      )
      const target = ownerCount(plan.owners.length)
      const action = "tried to send the recall notice to"
      const check = checkRecallSend(
        plan.gaugeIds,
        request?.id === requestId ? request : null
      )
      if (!check.ok) refuse(user, action, target, check.reason)
      // `request` is the one the check just passed, so it exists.
      const approved = request as RecallRequest

      if (server.failSend) {
        record({
          actor: actorOf(user),
          action,
          target,
          outcome: "failed",
          detail:
            "Nothing was delivered: the mail service did not answer. It is safe to try again.",
        })
        throw new Error("The mail service did not answer")
      }

      for (const gauge of gauges) {
        if (plan.gaugeIds.includes(gauge.id))
          gauge.recalledAt = at.toISOString()
      }
      approved.sentAt = at.toISOString()
      approved.sentTo = plan.owners.length
      record({
        actor: actorOf(user),
        action: "sent the recall notice to",
        target,
        reason: `${approved.id} was approved by Quality and Manufacturing`,
        outcome: "succeeded",
        detail: `${gaugeCount(plan.gaugeIds.length)} recalled: ${summarizeIds(plan.gaugeIds, 6)}.`,
      })
      return { sent: plan.owners.length }
    },
  }
  return server
}
```

**Step 4.** Add the six patterns above, the shadcn `button` and `switch`, and
`lucide-react`. Update the import paths to match your project setup.

## Usage

```tsx
import { CalibrationDesk } from "@/components/calibration-desk"
```

With no props it runs on the in-memory server, as in the preview. For your own data, pass your own server and the signed-in user:

```tsx
<CalibrationDesk
  server={myServer} // four methods that call your API
  currentUser={session.user} // { id, name, role } from your session
  timeZone="America/Chicago"
/>
```

| Prop           | Type                    | Description                                                                         |
| -------------- | ----------------------- | ----------------------------------------------------------------------------------- |
| `server`       | `CalibrationDeskServer` | Your four calls. Omit it to use the in-memory demo server and its demo controls.    |
| `currentUser`  | `DeskUser`              | `{ id, name, role }` from your session. Omit it in the demo to pick who to view as. |
| `initialState` | `DeskState`             | The first state, if you already fetched it. Otherwise the screen calls `load()`.    |
| `timeZone`     | `string`                | IANA zone for the record. Default `"UTC"`.                                          |

It also takes the props of a `div`, including `className`.

### How each stage hands to the next

| Stage   | Pattern                                                                                      | It takes                              | It hands on                                                                         |
| ------- | -------------------------------------------------------------------------------------------- | ------------------------------------- | ----------------------------------------------------------------------------------- |
| See     | [`status-strip`](https://realgood.site/docs/components/status-strip.md), [`data-table`](https://realgood.site/docs/components/data-table.md) | The gauges from `load()`              | The selected rows. The strip counts come from the same gauges.                      |
| Decide  | [`alert-rules`](https://realgood.site/docs/components/alert-rules.md) (`summarizeRule`) and `planRecall`             | The selected rows                     | A plan: the gauges that can be recalled, each owner's email once, what was left out |
| Act     | [`approval-step`](https://realgood.site/docs/components/approval-step.md)                                            | A request for exactly those gauges    | A request that is approved, rejected or waiting, with each decision's reason        |
| Confirm | [`confirm-send`](https://realgood.site/docs/components/confirm-send.md)                                              | The owner count and `checkRecallSend` | One send that succeeds or fails, and can be retried                                 |
| Record  | [`audit-timeline`](https://realgood.site/docs/components/audit-timeline.md)                                          | The events your server wrote          | Nothing, except that the counts above now include what was just recorded            |

Four rules make the hand-offs hold:

- **Selecting rows changes the send count.** The button says "Send to N owners", where N is the distinct owner emails of the gauges that can be recalled. Gauges that are in date, already recalled or without a valid owner email are left out, and the screen says so.
- **Approval is for a set of gauges.** If the selection changes after approval, the send is blocked until a new request is made. The server checks the same rule, because the browser's check is only a convenience.
- **Approval enables the send.** Both roles must approve, each with a reason. One rejection settles it, and a rejected request cannot be sent.
- **Everything is recorded by the server.** Each request, decision, send, refusal and failure is one `AuditEvent`. A failed send adds a `failed` event, changes nothing else, and the same button retries it. When a send succeeds the gauges become "recalled" and the status strip moves.

### Replace the in-memory server

The screen talks to a `CalibrationDeskServer` and to nothing else. Change one place: pass your own object with the same four methods, then delete `calibration-desk-server.ts`.

```ts
const myServer: CalibrationDeskServer = {
  load: () => fetch("/api/calibration").then(read), // gauges, request, rule, events
  requestRecall: ({ gaugeIds }) => post("/api/recalls", { gaugeIds }),
  decide: ({ requestId, outcome, reason }) =>
    post(`/api/recalls/${requestId}/decisions`, { outcome, reason }),
  sendRecall: ({ requestId, gaugeIds }) =>
    post(`/api/recalls/${requestId}/send`, { gaugeIds }), // -> { sent: number }
}
```

`read` and `post` are yours: they throw an `Error` with a readable message on a non-2xx answer, which is what the screen shows. The `userId` argument the demo passes exists only because it has no session; a real server takes the user from the session and ignores any id in the body. The screen refetches with `load()` after every call, so what you see is what the server holds.

## What it does not do

- **It is a UI only.** It shows state and asks for decisions. It sends nothing, stores nothing and checks no one.
- **Hiding a button is not access control.** Your server must verify the user and the role, that it is their turn, and that they have not already decided, then refuse otherwise.
- **The server writes the audit events.** The in-memory server does it in the same step as each action, so a record exists even if the page is closed. A browser that records its own actions can be closed or edited. The timeline only displays.
- **The server enforces quiet hours.** The rule is shown as a sentence, not applied. The server that sends must call `isQuietTime` or `nextAllowedSendTime` from `alert-rules` itself.
- **The in-memory server is a stand-in.** It forgets everything on reload, has no email, and the "View as" switch is not a login.
- **One notice per owner.** Owners must not see each other, so do not put them on one To line. The send is all or nothing: there is no per-recipient delivery log and no partial failure.
- **One request at a time, and only this scenario.** The nouns (gauge, recall) and the rules in `planRecall` are this example's. Rename them and change the rules for your own, and keep the hand-offs.
- **It is not compliance.** crisp-ui does not make a tool compliant with ISO 9001, FDA, FERPA, HIPAA or anything else.
- **Not tested with a screen reader.** This composition has not been checked with one. Run your own keyboard and screen reader check before relying on it.

## Agent prompt

Paste this into your coding agent. Replace the bracketed part with your own scenario.

```text
Goal: build a "recall these items" screen for my internal tool, on my own data and my own API. [Describe: what is due, who owns each item, who must approve, who is told.] It runs See, Decide, Act, Confirm, Record on one screen.

Install: npx shadcn@latest add https://realgood.site/r/calibration-desk.json
Read the installed files (components/calibration-desk.tsx, lib/calibration-desk-lib.ts, lib/calibration-desk-server.ts, then the files of the six patterns it uses) before writing any code. Do not guess props. Do not rewrite them.

Contract: CalibrationDesk takes server (CalibrationDeskServer: load, requestRecall, decide, sendRecall), currentUser ({ id, name, role }), optional initialState and timeZone. Contacts are email addresses only; never phone.

Wiring rule: change one place. Write my own server object with those four methods calling my API, pass it as server, then delete calibration-desk-server.ts and the demo controls. Each method rejects with a readable message on refusal or failure. The UI only shows and asks; hiding a button is not access control. My server must: take the user from the session; check role and turn; refuse a second decision; require a reason on every approval; use its own clock and ids; refuse a send unless the request is approved for exactly those gauges (reuse checkRecallSend); enforce quiet hours; and write one audit event for every decision, send, refusal and failure, in the same step as the action.

States to cover: loading; load failed with retry; empty list; nothing selected; waiting for approval; rejected; approved; sending; send failed and retryable; sent; viewer cannot decide.

Acceptance checks, run them and show me the output:
1. typecheck and lint pass.
2. Selecting rows changes "Send to N owners"; rows that are not recallable are left out and said so.
3. Approving needs a reason; Send stays off until both roles approve.
4. One send adds one audit event; a failed send adds a failed event and can be retried.
5. The server, not the UI, refuses a send that is not approved for those exact items.
6. The status counts change after a completed recall.
7. Keyboard only: reach the table, Approve and Send; Cancel returns focus.

Limits: UI only, not compliance. No new dependencies or abstractions beyond this. If something is unclear, ask me.
```

## Examples by sector

Example data only. Each is the same arc with the nouns changed: rename `Gauge` and "recall", change the rules in `planRecall`, keep the hand-offs.

- **Education: attendance follow-up.** Rows are students with three or more unexcused absences in ten days. Decide: the reminder rule, with quiet hours from 9 pm to 8 am. Act: the attendance officer approves the batch (policy `"any"`). Confirm: "Send to N guardians?", first names only, no reasons for absence in the text. Record: each send.
- **Manufacturing: this one.** Gauges overdue or due in 30 days. Quality and Manufacturing approve a recall; the gauge owners are emailed; the strip moves from overdue to recalled.
- **Engineering: ECO release.** Rows are engineering change orders waiting for release. Quality, Manufacturing and the engineering manager approve (`"all"`). Confirm: tell the people who build from the drawing. Record: who approved which revision, and why.
- **Health: licence expiry.** Rows are staff licences expiring within 60 days. The compliance lead approves a reminder batch. Confirm: "Your licence needs renewing", with no licence numbers or details in the message. Record: each send.

## Next

Comes after: [Status strip](https://realgood.site/docs/components/status-strip.md). Leads to: nothing; it closes the loop.
