# Alert rules

> An editor for reminder cadence, escalation and quiet hours, with a plain-language summary under each rule. It edits the saved rule; it does not send or schedule anything.

```tsx
"use client"

import * as React from "react"

import {
  isRuleValid,
  normalizeRule,
  sameRules,
  validateRule,
  type AlertRule,
} from "@/lib/alert-rules-lib"
import { AlertRules } from "@/components/ui/alert-rules"
import { SaveBar } from "@/components/ui/save-bar"

const INITIAL: AlertRule[] = [
  {
    id: "calibration-due",
    label: "Gauge calibration due",
    enabled: true,
    cadenceDays: [30, 14, 7, 1],
    escalateAfterDays: 3,
    escalateTo: "supervisor@example.org",
    quietHours: { start: "21:00", end: "08:00", timeZone: "America/Chicago" },
  },
  {
    id: "out-of-tolerance",
    label: "Out-of-tolerance review",
    enabled: true,
    cadenceDays: [1],
  },
]

// Fictional data. Nothing here is saved anywhere.
export function AlertRulesDemo() {
  const [saved, setSaved] = React.useState(INITIAL)
  const [rules, setRules] = React.useState(INITIAL)
  const [saving, setSaving] = React.useState(false)
  const [problem, setProblem] = React.useState("")

  return (
    <div className="w-full max-w-3xl overflow-hidden rounded-xl border">
      <div className="p-4 sm:p-6">
        <AlertRules
          rules={rules}
          timeZone="America/Chicago"
          disabled={saving}
          onChange={(next) => {
            setProblem("")
            setRules(next)
          }}
        />
        <p className="mt-5 text-sm text-muted-foreground">
          Saving stores the rule only. The server that sends the emails must
          apply quiet hours itself.
        </p>
      </div>
      {problem && (
        <p role="alert" className="px-4 pb-3 text-sm text-destructive sm:px-6">
          {problem}
        </p>
      )}
      <SaveBar
        dirty={!sameRules(rules, saved)}
        saving={saving}
        onDiscard={() => {
          setProblem("")
          setRules(saved)
        }}
        onSave={async () => {
          // Check again here, and on your server: the editor only shows errors.
          if (!rules.every((rule) => isRuleValid(validateRule(rule)))) {
            setProblem(
              "Fix the fields marked in the rules above before saving."
            )
            return
          }
          setSaving(true)
          await new Promise((resolve) => setTimeout(resolve, 400))
          const next = rules.map(normalizeRule)
          setSaved(next)
          setRules(next)
          setSaving(false)
        }}
      />
    </div>
  )
}
```

Use it when something has a due date and people need reminding: a gauge calibration, a training certificate, a licence, a review. It lets a person read and change three things for each kind of reminder: **which days before the due date** it goes, **who hears about it if it is still open afterwards**, and **the hours when nothing is sent**. Under every rule it writes the result as a sentence, so the rule can be checked at a glance instead of by reading switches.

It is the **Decide** stage of See, Decide, Act, Confirm, Record: it settles _when_ and _to whom_ a notice goes, before [`confirm-send`](https://realgood.site/docs/components/confirm-send.md) acts. What is due is what [`status-strip`](https://realgood.site/docs/components/status-strip.md) and [`data-table`](https://realgood.site/docs/components/data-table.md) show. Who the people are is the job of [`recipient-roster`](https://realgood.site/docs/components/recipient-roster.md).

> A setting in this editor is not enforcement. It edits a saved rule and nothing
> more. The server that sends the messages must read the saved rule and apply
> quiet hours and escalation itself, with the helpers below or its own code. If
> only the screen knows about quiet hours, a script, a retry or a second tool
> will send at 3 am. crisp-ui does not make anything compliant: it gives you a
> clear way to set and review the rule, and the rest is your backend and your
> own legal advice.

## Installation

**Command**

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

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

```bash
npx shadcn@latest add @crisp/alert-rules
```

This also adds `alert-rules-lib` (the helpers), the shadcn `button`, `input` and `switch`, and `lucide-react`.

To get only the helpers, for example in server code with no screen:

```bash
npx shadcn@latest add https://realgood.site/r/alert-rules-lib.json
```

**Manual**

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

```tsx title="components/ui/alert-rules.tsx"
"use client"

import * as React from "react"
import { Plus, X } from "lucide-react"

import { cn } from "@/lib/utils"
import {
  MAX_CADENCE_DAYS,
  summarizeRule,
  validateRule,
  type AlertRule,
  type RuleError,
} from "@/lib/alert-rules-lib"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Switch } from "@/components/ui/switch"

export interface AlertRulesProps
  extends Omit<React.ComponentProps<"div">, "onChange"> {
  /** The current rules, including unsaved edits. One row per rule. */
  rules: AlertRule[]
  /** Called with the full next list after any edit. */
  onChange: (rules: AlertRule[]) => void
  /** Return true if the address is acceptable. Defaults to a simple shape check. */
  validateEmail?: (email: string) => boolean
  /** Zone given to quiet hours when someone turns them on. Defaults to the browser's. */
  timeZone?: string
  /** Disables every control, e.g. while saving. */
  disabled?: boolean
}

const dayWord = (n: number) => (n === 1 ? "day" : "days")

function browserTimeZone(): string {
  try {
    return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"
  } catch {
    return "UTC"
  }
}

function FieldError({ id, error }: { id: string; error?: RuleError | string }) {
  if (!error) return null
  return (
    <p id={id} className="text-sm text-destructive">
      {typeof error === "string" ? error : error.message}
    </p>
  )
}

/** Space-separated ids of the elements that exist, or undefined. */
const describedBy = (...ids: Array<string | false | undefined>) =>
  ids.filter(Boolean).join(" ") || undefined

interface RuleRowProps {
  rule: AlertRule
  onChange: (rule: AlertRule) => void
  validateEmail?: (email: string) => boolean
  timeZone?: string
  zoneListId: string
  disabled: boolean
}

function RuleRow({
  rule,
  onChange,
  validateEmail,
  timeZone,
  zoneListId,
  disabled,
}: RuleRowProps) {
  const uid = React.useId()
  const id = (name: string) => `${uid}-${name}`
  const errors = validateRule(rule, { validateEmail })
  const [draft, setDraft] = React.useState("")
  const [addError, setAddError] = React.useState("")
  // Polite announcement for changes that are otherwise only visible.
  const [status, setStatus] = React.useState("")
  const rootRef = React.useRef<HTMLDivElement>(null)
  const addRef = React.useRef<HTMLInputElement>(null)
  // Index of a chip just removed, so focus is not lost with it.
  const removedAt = React.useRef<number | null>(null)

  React.useEffect(() => {
    const index = removedAt.current
    if (index === null) return
    removedAt.current = null
    const buttons =
      rootRef.current?.querySelectorAll<HTMLButtonElement>(
        "[data-chip-remove]:not(:disabled)"
      ) ?? []
    const next = buttons[Math.min(index, buttons.length - 1)]
    ;(next ?? addRef.current)?.focus()
  }, [rule.cadenceDays])

  const set = (patch: Partial<AlertRule>) => onChange({ ...rule, ...patch })
  const quiet = rule.quietHours ?? null
  const setQuiet = (patch: Partial<NonNullable<AlertRule["quietHours"]>>) =>
    quiet && set({ quietHours: { ...quiet, ...patch } })

  function addDay() {
    const text = draft.trim()
    if (!text) return
    const days = Number(text)
    if (!Number.isInteger(days) || days < 1) {
      setAddError("Enter a whole number of days, 1 or more.")
      return
    }
    if (days > MAX_CADENCE_DAYS) {
      setAddError(`Enter ${MAX_CADENCE_DAYS} days or fewer.`)
      return
    }
    if (rule.cadenceDays.includes(days)) {
      setAddError(`${days} ${dayWord(days)} before is already in the list.`)
      return
    }
    setAddError("")
    setStatus(`Added ${days} ${dayWord(days)} before.`)
    set({ cadenceDays: [...rule.cadenceDays, days].sort((a, b) => b - a) })
    setDraft("")
  }

  const cadenceError = errors.cadenceDays
  const afterDays = rule.escalateAfterDays
  const quietZoneError = errors["quietHours.timeZone"]

  return (
    <div
      ref={rootRef}
      role="group"
      aria-labelledby={id("title")}
      className="grid gap-4"
    >
      <div className="flex items-center justify-between gap-3">
        <h3 id={id("title")} className="text-base font-medium">
          {rule.label}
        </h3>
        <span className="flex items-center gap-2 text-sm text-muted-foreground">
          <span aria-hidden="true" className="w-6 text-right">
            {rule.enabled ? "On" : "Off"}
          </span>
          <Switch
            aria-label="Rule enabled"
            aria-describedby={id("summary")}
            className="relative after:absolute after:-inset-x-1 after:-inset-y-2.5 after:content-['']"
            checked={rule.enabled}
            disabled={disabled}
            onCheckedChange={(enabled) => set({ enabled })}
          />
        </span>
      </div>

      {/* Reminder days */}
      <div className="grid gap-2">
        <span id={id("cadence")} className="text-sm font-medium">
          Remind this many days before it is due
        </span>
        {rule.cadenceDays.length === 0 ? (
          <p className="text-sm text-muted-foreground">No reminder days yet.</p>
        ) : (
          <ul aria-labelledby={id("cadence")} className="flex flex-wrap gap-2">
            {rule.cadenceDays.map((days, index) => (
              <li
                key={`${days}-${index}`}
                className="inline-flex items-center gap-0.5 rounded-full border bg-muted/50 py-0.5 pr-0.5 pl-3 text-sm tabular-nums"
              >
                <span>
                  {days} {dayWord(days)}
                </span>
                <Button
                  type="button"
                  variant="ghost"
                  size="icon"
                  data-chip-remove=""
                  aria-label={`Remove ${days} ${dayWord(days)} before`}
                  disabled={disabled}
                  className="size-7 rounded-full text-muted-foreground hover:text-foreground"
                  onClick={() => {
                    removedAt.current = index
                    setStatus(`Removed ${days} ${dayWord(days)} before.`)
                    set({
                      cadenceDays: rule.cadenceDays.filter(
                        (_, i) => i !== index
                      ),
                    })
                  }}
                >
                  <X className="size-3.5" aria-hidden="true" />
                </Button>
              </li>
            ))}
          </ul>
        )}
        <div className="flex items-center gap-2">
          <Input
            ref={addRef}
            type="text"
            inputMode="numeric"
            autoComplete="off"
            aria-label="Add a reminder, days before it is due"
            aria-invalid={addError || cadenceError ? true : undefined}
            aria-describedby={describedBy(
              addError && id("add-error"),
              cadenceError && id("cadence-error")
            )}
            placeholder="Days, e.g. 14"
            value={draft}
            disabled={disabled}
            className="w-36"
            onChange={(event) => {
              setDraft(event.target.value)
              setAddError("")
            }}
            onKeyDown={(event) => {
              if (event.key === "Enter") {
                event.preventDefault()
                addDay()
              }
            }}
          />
          <Button
            type="button"
            variant="outline"
            disabled={disabled}
            onClick={() => {
              addDay()
              addRef.current?.focus()
            }}
          >
            <Plus className="size-4" aria-hidden="true" />
            Add
            <span className="sr-only"> reminder day</span>
          </Button>
        </div>
        {addError && (
          <p
            id={id("add-error")}
            role="alert"
            className="text-sm text-destructive"
          >
            {addError}
          </p>
        )}
        <FieldError id={id("cadence-error")} error={cadenceError} />
        <p role="status" className="sr-only">
          {status}
        </p>
      </div>

      {/* Escalation */}
      <div className="grid gap-3 sm:grid-cols-[9rem_minmax(0,1fr)]">
        <div className="grid content-start gap-1.5">
          <label htmlFor={id("after")} className="text-sm font-medium">
            Escalate after (days)
          </label>
          <Input
            id={id("after")}
            type="number"
            inputMode="numeric"
            min={1}
            step={1}
            autoComplete="off"
            aria-invalid={errors.escalateAfterDays ? true : undefined}
            aria-describedby={describedBy(
              id("after-hint"),
              errors.escalateAfterDays && id("after-error")
            )}
            value={
              typeof afterDays === "number" && !Number.isNaN(afterDays)
                ? afterDays
                : ""
            }
            disabled={disabled}
            onChange={(event) =>
              set({
                escalateAfterDays:
                  event.target.value === ""
                    ? undefined
                    : Number(event.target.value),
              })
            }
          />
          <p id={id("after-hint")} className="text-xs text-muted-foreground">
            After the due date, if still open.
          </p>
          <FieldError id={id("after-error")} error={errors.escalateAfterDays} />
        </div>
        <div className="grid content-start gap-1.5">
          <label htmlFor={id("to")} className="text-sm font-medium">
            Escalate to (email)
          </label>
          <Input
            id={id("to")}
            type="email"
            inputMode="email"
            autoComplete="off"
            spellCheck={false}
            placeholder="supervisor@example.org"
            aria-invalid={errors.escalateTo ? true : undefined}
            aria-describedby={describedBy(errors.escalateTo && id("to-error"))}
            value={rule.escalateTo ?? ""}
            disabled={disabled}
            onChange={(event) =>
              set({ escalateTo: event.target.value || undefined })
            }
            // Tidy the address once, when the person is done with it.
            onBlur={() => {
              if (rule.escalateTo === undefined) return
              const tidy = rule.escalateTo.trim().toLowerCase()
              if (tidy !== rule.escalateTo)
                set({ escalateTo: tidy || undefined })
            }}
          />
          <FieldError id={id("to-error")} error={errors.escalateTo} />
        </div>
      </div>

      {/* Quiet hours */}
      <div className="grid gap-3">
        <div className="flex items-center justify-between gap-3">
          <label htmlFor={id("quiet")} className="text-sm font-medium">
            Quiet hours
            <span className="block text-xs font-normal text-muted-foreground">
              Nothing is sent in this window.
            </span>
          </label>
          <span className="flex items-center gap-2 text-sm text-muted-foreground">
            <span aria-hidden="true" className="w-6 text-right">
              {quiet ? "On" : "Off"}
            </span>
            <Switch
              id={id("quiet")}
              className="relative after:absolute after:-inset-x-1 after:-inset-y-2.5 after:content-['']"
              checked={quiet !== null}
              disabled={disabled}
              onCheckedChange={(on) =>
                set({
                  quietHours: on
                    ? {
                        start: "21:00",
                        end: "08:00",
                        timeZone: timeZone ?? browserTimeZone(),
                      }
                    : null,
                })
              }
            />
          </span>
        </div>
        {quiet && (
          <div className="grid gap-3 sm:grid-cols-[repeat(2,minmax(0,8rem))_minmax(0,1fr)]">
            <div className="grid content-start gap-1.5">
              <label htmlFor={id("start")} className="text-sm font-medium">
                From
              </label>
              <Input
                id={id("start")}
                type="time"
                aria-invalid={errors["quietHours.start"] ? true : undefined}
                aria-describedby={describedBy(
                  errors["quietHours.start"] && id("start-error")
                )}
                value={quiet.start}
                disabled={disabled}
                onChange={(event) => setQuiet({ start: event.target.value })}
              />
              <FieldError
                id={id("start-error")}
                error={errors["quietHours.start"]}
              />
            </div>
            <div className="grid content-start gap-1.5">
              <label htmlFor={id("end")} className="text-sm font-medium">
                Until
              </label>
              <Input
                id={id("end")}
                type="time"
                aria-invalid={errors["quietHours.end"] ? true : undefined}
                aria-describedby={describedBy(
                  errors["quietHours.end"] && id("end-error")
                )}
                value={quiet.end}
                disabled={disabled}
                onChange={(event) => setQuiet({ end: event.target.value })}
              />
              <FieldError
                id={id("end-error")}
                error={errors["quietHours.end"]}
              />
            </div>
            <div className="grid content-start gap-1.5">
              <label htmlFor={id("zone")} className="text-sm font-medium">
                Time zone
              </label>
              <Input
                id={id("zone")}
                list={zoneListId}
                autoComplete="off"
                spellCheck={false}
                placeholder="America/New_York"
                aria-invalid={quietZoneError ? true : undefined}
                aria-describedby={describedBy(
                  quietZoneError && id("zone-error")
                )}
                value={quiet.timeZone}
                disabled={disabled}
                onChange={(event) => setQuiet({ timeZone: event.target.value })}
              />
              <FieldError id={id("zone-error")} error={quietZoneError} />
            </div>
          </div>
        )}
      </div>

      <p
        id={id("summary")}
        className="rounded-md bg-muted/60 px-3 py-2 text-sm"
      >
        <span className="sr-only">Summary: </span>
        {summarizeRule(rule, { includeTimeZone: true })}
      </p>
    </div>
  )
}

const NO_ZONES: string[] = []
let cachedZones: string[] | undefined

// The IANA zones this runtime knows, with UTC always present. Cached because
// useSyncExternalStore needs the same array on every read.
function supportedTimeZones(): string[] {
  if (cachedZones) return cachedZones
  const supported = (
    Intl as unknown as { supportedValuesOf?: (key: string) => string[] }
  ).supportedValuesOf
  try {
    const list = supported ? supported("timeZone") : []
    cachedZones = list.includes("UTC") ? list : [...list, "UTC"]
  } catch {
    cachedZones = NO_ZONES
  }
  return cachedZones
}

// The list never changes while the page is open, so there is nothing to subscribe to.
const subscribeNothing = () => () => {}

/**
 * An editor for when and to whom a reminder goes: which days before the due
 * date, who hears if it is still open afterwards, and the hours nothing is
 * sent. It is controlled and never saves; pair it with `save-bar`. It edits a
 * saved rule, it does not send or schedule anything, and your server must
 * enforce quiet hours and escalation itself.
 */
function AlertRules({
  rules,
  onChange,
  validateEmail,
  timeZone,
  disabled = false,
  className,
  ...props
}: AlertRulesProps) {
  const zoneListId = React.useId()
  // Empty on the server and during hydration, then the runtime's own list: it can
  // differ between the two, so it is read as an external value, not in an effect.
  const zones = React.useSyncExternalStore(
    subscribeNothing,
    supportedTimeZones,
    () => NO_ZONES
  )

  return (
    <div data-slot="alert-rules" className={cn(className)} {...props}>
      <ul className="divide-y">
        {rules.map((rule, index) => (
          <li key={rule.id} className="py-5 first:pt-0 last:pb-0">
            <RuleRow
              rule={rule}
              validateEmail={validateEmail}
              timeZone={timeZone}
              zoneListId={zoneListId}
              disabled={disabled}
              onChange={(next) =>
                onChange(rules.map((r, i) => (i === index ? next : r)))
              }
            />
          </li>
        ))}
      </ul>
      <datalist id={zoneListId}>
        {zones.map((zone) => (
          <option key={zone} value={zone} />
        ))}
      </datalist>
    </div>
  )
}

export { AlertRules }
```

**Step 2.** And the helpers it imports.

```ts title="lib/alert-rules-lib.ts"
/**
 * Pure helpers for the alert-rules pattern: validate and describe a reminder
 * rule, and answer "is this a quiet time?" for a time zone. No React, no
 * network, no clock (every instant is passed in), so it is safe to unit test
 * and to run on a server.
 *
 * This is the rule, not the scheduler. Nothing here sends or schedules
 * anything, and a setting in a UI is not enforcement: the server that sends
 * must call `isQuietTime` / `nextAllowedSendTime` itself.
 */

/** A daily window in which nothing is sent. Wall-clock times in `timeZone`. */
export interface QuietHours {
  /** "HH:MM", 24-hour. The window includes this minute. */
  start: string
  /** "HH:MM", 24-hour. The window ends just before this minute. */
  end: string
  /** An IANA zone name such as "America/New_York". */
  timeZone: string
}

export interface AlertRule {
  id: string
  /** What the rule is for, e.g. "Calibration due". */
  label: string
  enabled: boolean
  /** Days BEFORE the due date to remind, e.g. [30, 14, 7, 1]. */
  cadenceDays: number[]
  /** Escalate when the item is still open this many days after the due date. */
  escalateAfterDays?: number
  /** Who hears about an escalation. An email address. */
  escalateTo?: string
  /** Hours when nothing is sent. `null` or missing means any hour. */
  quietHours?: QuietHours | null
}

/** The largest cadence step accepted, to keep date maths sane. */
export const MAX_CADENCE_DAYS = 3650

export type RuleField =
  | "cadenceDays"
  | "escalateAfterDays"
  | "escalateTo"
  | "quietHours.start"
  | "quietHours.end"
  | "quietHours.timeZone"

export type RuleErrorCode =
  | "cadence-empty"
  | "cadence-invalid"
  | "cadence-too-large"
  | "cadence-duplicate"
  | "escalation-days-invalid"
  | "escalation-days-missing"
  | "escalation-target-missing"
  | "escalation-target-invalid"
  | "time-invalid"
  | "quiet-same"
  | "zone-invalid"

export interface RuleError {
  /** Stable, for tests and for translating the message. */
  code: RuleErrorCode
  /** A calm sentence a person can act on. */
  message: string
}

/** At most one error per field. An empty object means the rule is valid. */
export type RuleErrors = Partial<Record<RuleField, RuleError>>

// Deliberately close to what servers accept (e.g. zod's .email()): no empty or
// dotted-edge local parts, no doubled dots, a real domain and a 2+ letter TLD.
// Same check as `recipient-roster`. Pass `validateEmail` to match your backend.
const EMAIL =
  /^(?!\.)(?!.*\.\.)[^\s@,;]+(?<!\.)@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/i

/** The default email check used when no `validateEmail` is passed. */
export function isEmailAddress(email: string): boolean {
  return EMAIL.test(email)
}

const TIME_OF_DAY = /^([01]\d|2[0-3]):([0-5]\d)$/
const MINUTE = 60_000
const DAY = 24 * 60 * MINUTE

/** "HH:MM" to minutes after midnight, or null if it is not a valid 24-hour time. */
export function parseTimeOfDay(value: string): number | null {
  const match = typeof value === "string" ? TIME_OF_DAY.exec(value) : null
  return match ? Number(match[1]) * 60 + Number(match[2]) : null
}

/**
 * True for a region name the runtime knows ("America/New_York", "Asia/Kolkata",
 * "UTC"). Fixed offsets such as "+05:30" are refused: they cannot follow
 * daylight saving, which is the point of naming a zone.
 */
export function isValidTimeZone(timeZone: string): boolean {
  if (typeof timeZone !== "string") return false
  if (timeZone === "" || timeZone !== timeZone.trim()) return false
  if (/^[+-]/.test(timeZone)) return false
  try {
    new Intl.DateTimeFormat("en-US", { timeZone })
    return true
  } catch {
    return false
  }
}

const isWholeDays = (value: unknown): value is number =>
  typeof value === "number" && Number.isInteger(value) && value > 0

/**
 * Check one rule. Returns an object keyed by field, empty when valid:
 *
 * - cadence: whole numbers of 1 or more, no duplicates, at most 3650, and not
 *   empty while the rule is enabled (a disabled draft may be empty);
 * - escalation: days and an address go together, days are a whole number of 1
 *   or more, and the address passes `validateEmail`;
 * - quiet hours (when set): both times are "HH:MM", they differ, and the zone
 *   is a real IANA name.
 *
 * Run it on the server before saving as well. It does not check `id` or `label`.
 */
export function validateRule(
  rule: AlertRule,
  {
    validateEmail = isEmailAddress,
  }: { validateEmail?: (email: string) => boolean } = {}
): RuleErrors {
  const errors: RuleErrors = {}

  // Cadence.
  const days = Array.isArray(rule.cadenceDays) ? rule.cadenceDays : []
  const bad = days.filter((d) => !isWholeDays(d))
  const tooBig = days.filter((d) => isWholeDays(d) && d > MAX_CADENCE_DAYS)
  const repeated = [
    ...new Set(days.filter((d, i) => days.indexOf(d) !== i && isWholeDays(d))),
  ]
  if (bad.length > 0) {
    errors.cadenceDays = {
      code: "cadence-invalid",
      message: `Days must be whole numbers of 1 or more. Check ${bad.map(String).join(", ")}.`,
    }
  } else if (tooBig.length > 0) {
    errors.cadenceDays = {
      code: "cadence-too-large",
      message: `Days can be at most ${MAX_CADENCE_DAYS}. Check ${tooBig.join(", ")}.`,
    }
  } else if (repeated.length > 0) {
    errors.cadenceDays = {
      code: "cadence-duplicate",
      message: `${repeated.join(", ")} ${repeated.length === 1 ? "appears" : "appear"} more than once.`,
    }
  } else if (days.length === 0 && rule.enabled) {
    errors.cadenceDays = {
      code: "cadence-empty",
      message: "Add at least one reminder day, or turn the rule off.",
    }
  }

  // Escalation: days and a target go together.
  const afterDays = rule.escalateAfterDays
  const hasDays = afterDays !== undefined && afterDays !== null
  const target =
    typeof rule.escalateTo === "string" ? rule.escalateTo.trim() : ""
  if (hasDays && !isWholeDays(afterDays)) {
    errors.escalateAfterDays = {
      code: "escalation-days-invalid",
      message: "Use a whole number of days, 1 or more.",
    }
  } else if (!hasDays && target) {
    errors.escalateAfterDays = {
      code: "escalation-days-missing",
      message: "Say how many days after the due date, or clear the address.",
    }
  }
  if (hasDays && !target) {
    errors.escalateTo = {
      code: "escalation-target-missing",
      message: "Add who to escalate to, or clear the days.",
    }
  } else if (target && !validateEmail(target.toLowerCase())) {
    errors.escalateTo = {
      code: "escalation-target-invalid",
      message: `“${target}” doesn’t look like an email address.`,
    }
  }

  // Quiet hours.
  const quiet = rule.quietHours
  if (quiet) {
    const start = parseTimeOfDay(quiet.start)
    const end = parseTimeOfDay(quiet.end)
    if (start === null) {
      errors["quietHours.start"] = {
        code: "time-invalid",
        message: "Enter a start time such as 21:00.",
      }
    }
    if (end === null) {
      errors["quietHours.end"] = {
        code: "time-invalid",
        message: "Enter an end time such as 08:00.",
      }
    }
    if (start !== null && end !== null && start === end) {
      errors["quietHours.end"] = {
        code: "quiet-same",
        message: "The end time must differ from the start time.",
      }
    }
    if (!isValidTimeZone(quiet.timeZone)) {
      errors["quietHours.timeZone"] = {
        code: "zone-invalid",
        message: quiet.timeZone
          ? `“${quiet.timeZone}” is not a time zone name. Use one like America/New_York.`
          : "Choose a time zone, such as America/New_York.",
      }
    }
  }

  return errors
}

/** True when `validateRule` found nothing wrong. */
export function isRuleValid(errors: RuleErrors): boolean {
  return Object.keys(errors).length === 0
}

/**
 * The canonical form to save: cadence unique and sorted largest first, the
 * escalation address trimmed and lowercased (an empty one is dropped), the
 * quiet-hours strings trimmed. It does not drop invalid values, so
 * `validateRule` still sees what was typed.
 */
export function normalizeRule(rule: AlertRule): AlertRule {
  const out: AlertRule = {
    id: rule.id,
    label: rule.label,
    enabled: rule.enabled,
    cadenceDays: [...new Set(rule.cadenceDays)].sort((a, b) => b - a),
  }
  if (rule.escalateAfterDays !== undefined && rule.escalateAfterDays !== null) {
    out.escalateAfterDays = rule.escalateAfterDays
  }
  const target = rule.escalateTo?.trim().toLowerCase()
  if (target) out.escalateTo = target
  if (rule.quietHours === null) {
    out.quietHours = null
  } else if (rule.quietHours) {
    out.quietHours = {
      start: rule.quietHours.start.trim(),
      end: rule.quietHours.end.trim(),
      timeZone: rule.quietHours.timeZone.trim(),
    }
  }
  return out
}

const ruleKey = (rule: AlertRule) => {
  const r = normalizeRule(rule)
  return JSON.stringify([
    r.id,
    r.label,
    r.enabled,
    r.cadenceDays,
    r.escalateAfterDays ?? null,
    r.escalateTo ?? null,
    r.quietHours
      ? [r.quietHours.start, r.quietHours.end, r.quietHours.timeZone]
      : null,
  ])
}

/**
 * Same rules in the same order, compared in normalized form, so reordering a
 * rule's cadence or retyping an address in capitals is not a change worth
 * saving. Use it to decide `dirty` for `save-bar`.
 */
export function sameRules(a: AlertRule[], b: AlertRule[]): boolean {
  return (
    a.length === b.length &&
    a.every((rule, i) => ruleKey(rule) === ruleKey(b[i]))
  )
}

// ---------------------------------------------------------------------------
// Quiet hours
// ---------------------------------------------------------------------------

interface Window {
  start: number
  end: number
  timeZone: string
}

/** Validates and parses; throws so a bad rule can never silently mean "send". */
function toWindow(quiet: QuietHours | null | undefined): Window | null {
  if (!quiet) return null
  const start = parseTimeOfDay(quiet.start)
  const end = parseTimeOfDay(quiet.end)
  if (start === null)
    throw new RangeError(`Invalid quiet-hours start: "${quiet.start}"`)
  if (end === null)
    throw new RangeError(`Invalid quiet-hours end: "${quiet.end}"`)
  if (start === end) {
    throw new RangeError("Quiet-hours start and end must differ")
  }
  if (!isValidTimeZone(quiet.timeZone)) {
    throw new RangeError(`Invalid time zone: "${quiet.timeZone}"`)
  }
  return { start, end, timeZone: quiet.timeZone }
}

function assertInstant(instant: Date, name: string): void {
  if (!(instant instanceof Date) || Number.isNaN(instant.getTime())) {
    throw new RangeError(`${name} must be a valid Date`)
  }
}

const formatters = new Map<string, Intl.DateTimeFormat>()

/** Minutes after midnight on the wall clock of `timeZone` at `ms`. */
function minutesOfDay(ms: number, timeZone: string): number {
  let format = formatters.get(timeZone)
  if (!format) {
    format = new Intl.DateTimeFormat("en-US", {
      timeZone,
      hourCycle: "h23",
      hour: "numeric",
      minute: "numeric",
    })
    formatters.set(timeZone, format)
  }
  let hour = NaN
  let minute = NaN
  for (const part of format.formatToParts(ms)) {
    if (part.type === "hour") hour = Number(part.value)
    else if (part.type === "minute") minute = Number(part.value)
  }
  return (hour % 24) * 60 + minute
}

/** Start is inside the window, end is outside. Handles windows that cross midnight. */
function isQuietMinute(minute: number, { start, end }: Window): boolean {
  return start < end
    ? minute >= start && minute < end
    : minute >= start || minute < end
}

const isQuietAt = (ms: number, win: Window) =>
  isQuietMinute(minutesOfDay(ms, win.timeZone), win)

/**
 * Is `instant` inside the quiet window, by the wall clock in the window's time
 * zone? The window includes its start minute and excludes its end minute, so
 * "21:00 to 08:00" is quiet at 21:00 and sends are allowed from 08:00. It may
 * cross midnight.
 *
 * Wall-clock semantics: on a clock-change day the window follows the clock, so
 * the 21:00 to 08:00 night that spans a spring-forward is 10 real hours and the
 * one that spans a fall-back is 12. A window inside the skipped hour never
 * matches; one inside the repeated hour matches both times round.
 *
 * `null` or `undefined` means no quiet hours, so it is never quiet. A window
 * that is not valid (bad time, equal start and end, unknown zone) throws a
 * `RangeError` instead of guessing; run `validateRule` first. Throws on an
 * invalid `instant` too.
 */
export function isQuietTime(
  instant: Date,
  quietHours: QuietHours | null | undefined
): boolean {
  const win = toWindow(quietHours)
  assertInstant(instant, "instant")
  if (!win) return false
  return isQuietAt(instant.getTime(), win)
}

/**
 * The earliest instant at or after `instant` when sending is allowed, to the
 * minute. It is `instant` itself (a copy) when that is not a quiet time, so it
 * is safe to call on every send. Otherwise it is the first minute the quiet
 * window is over, which is the end time on the wall clock, or the first
 * instant after the clock skipped it. Same errors as `isQuietTime`.
 */
export function nextAllowedSendTime(
  instant: Date,
  quietHours: QuietHours | null | undefined
): Date {
  const win = toWindow(quietHours)
  assertInstant(instant, "instant")
  const at = instant.getTime()
  if (!win || !isQuietAt(at, win)) return new Date(at)

  // Jump to the next time the wall clock reads the end time. A clock change in
  // between makes the jump land early (fall back: still quiet, so jump again)
  // or late (spring forward: walk back below).
  let t = Math.floor(at / MINUTE) * MINUTE
  for (let attempt = 0; attempt < 4 && isQuietAt(t, win); attempt++) {
    const minute = minutesOfDay(t, win.timeZone)
    t += ((win.end - minute + 24 * 60) % (24 * 60)) * MINUTE
  }
  if (isQuietAt(t, win)) {
    throw new Error("Could not find the end of the quiet window")
  }
  // Walk back to the first minute of the allowed stretch that follows `at`.
  while (t - MINUTE > at && !isQuietAt(t - MINUTE, win)) t -= MINUTE
  return new Date(t)
}

// ---------------------------------------------------------------------------
// Cadence
// ---------------------------------------------------------------------------

export interface ReminderStep {
  /** How many days before the due date this step falls. */
  days: number
  /** The instant: exactly `days` x 24 hours before the due date. */
  at: Date
}

export interface RemindersDue {
  /** Steps whose time has come (at or before `now`), earliest first. */
  passed: ReminderStep[]
  /** The next step still ahead, or null if there is none. */
  next: ReminderStep | null
  /** True when the due date itself is at or before `now`. */
  pastDue: boolean
}

/**
 * Which cadence steps have passed as of `now`, and which is next. A step is
 * the due date minus `days` whole 24-hour days (not calendar days, so across a
 * clock change it can sit an hour off local wall time). Values that are not
 * whole numbers of 1 or more are ignored and repeats count once, so run
 * `validateRule` first. It does not know what you have already sent: keep that
 * record yourself and send only what is missing. Throws on an invalid date.
 */
export function remindersDue(
  dueDate: Date,
  now: Date,
  cadenceDays: number[]
): RemindersDue {
  assertInstant(dueDate, "dueDate")
  assertInstant(now, "now")
  const steps: ReminderStep[] = [...new Set(cadenceDays)]
    .filter(isWholeDays)
    .sort((a, b) => b - a)
    .map((days) => ({ days, at: new Date(dueDate.getTime() - days * DAY) }))
  return {
    passed: steps.filter((step) => step.at.getTime() <= now.getTime()),
    next: steps.find((step) => step.at.getTime() > now.getTime()) ?? null,
    pastDue: dueDate.getTime() <= now.getTime(),
  }
}

// ---------------------------------------------------------------------------
// Plain language
// ---------------------------------------------------------------------------

/** "21:00" to "9:00 pm". Returns the input unchanged if it is not a valid time. */
export function formatTimeOfDay(value: string): string {
  const minutes = parseTimeOfDay(value)
  if (minutes === null) return value
  const hour = Math.floor(minutes / 60)
  const minute = String(minutes % 60).padStart(2, "0")
  return `${hour % 12 || 12}:${minute} ${hour >= 12 ? "pm" : "am"}`
}

const unit = (n: number) => (n === 1 ? "day" : "days")

function joinNumbers(list: number[]): string {
  if (list.length <= 1) return list.join("")
  return `${list.slice(0, -1).join(", ")} and ${list[list.length - 1]}`
}

/**
 * The sentence a person reads to check a rule, for example "Reminds 30, 14, 7
 * and 1 days before. Escalates to ops@example.org after 3 days. Never sends
 * between 9:00 pm and 8:00 am." It says out loud what the rule does not do
 * ("Does not escalate.", "Can send at any hour."). Safe to call on a rule that
 * is half edited; unfinished parts are named as unfinished. A disabled rule
 * says it is off first.
 *
 * Pass `includeTimeZone` to add the zone after the quiet hours.
 */
export function summarizeRule(
  rule: AlertRule,
  { includeTimeZone = false }: { includeTimeZone?: boolean } = {}
): string {
  const parts: string[] = []

  const days = [...new Set(rule.cadenceDays)]
    .filter(isWholeDays)
    .sort((a, b) => b - a)
  parts.push(
    days.length === 0
      ? "Sends no reminders yet."
      : `Reminds ${joinNumbers(days)} ${days.length === 1 ? unit(days[0]) : "days"} before.`
  )

  const after = rule.escalateAfterDays
  const target = rule.escalateTo?.trim().toLowerCase() ?? ""
  if (isWholeDays(after) && target) {
    parts.push(`Escalates to ${target} after ${after} ${unit(after)}.`)
  } else if ((after !== undefined && after !== null) || target) {
    parts.push("Escalation is not finished.")
  } else {
    parts.push("Does not escalate.")
  }

  const quiet = rule.quietHours
  if (!quiet) {
    parts.push("Can send at any hour.")
  } else {
    const start = parseTimeOfDay(quiet.start)
    const end = parseTimeOfDay(quiet.end)
    if (start === null || end === null || start === end) {
      parts.push("Quiet hours are not finished.")
    } else {
      const zone =
        includeTimeZone && quiet.timeZone ? ` (${quiet.timeZone})` : ""
      parts.push(
        `Never sends between ${formatTimeOfDay(quiet.start)} and ${formatTimeOfDay(quiet.end)}${zone}.`
      )
    }
  }

  const text = parts.join(" ")
  return rule.enabled
    ? text
    : `This rule is off, so nothing is sent. If turned on: ${text.charAt(0).toLowerCase()}${text.slice(1)}`
}
```

**Step 3.** Add the shadcn `button`, `input` and `switch`, and install `lucide-react`.
Update the import paths to match your project setup.

## Usage

```tsx
import {
  isRuleValid,
  normalizeRule,
  sameRules,
  validateRule,
  type AlertRule,
} from "@/lib/alert-rules-lib"
import { AlertRules } from "@/components/ui/alert-rules"
import { SaveBar } from "@/components/ui/save-bar"
```

```tsx
const [saved, setSaved] = React.useState<AlertRule[]>(rulesFromYourApi)
const [rules, setRules] = React.useState(saved)
const [saving, setSaving] = React.useState(false)
```

```tsx
<AlertRules
  rules={rules}
  onChange={setRules}
  timeZone="America/Chicago"
  disabled={saving}
/>
<SaveBar
  dirty={!sameRules(rules, saved)}
  saving={saving}
  onDiscard={() => setRules(saved)}
  onSave={async () => {
    if (!rules.every((rule) => isRuleValid(validateRule(rule)))) return
    setSaving(true)
    try {
      const next = rules.map(normalizeRule)
      await api.saveRules(next) // throw to fail
      setSaved(next) // <- required
      setRules(next)
    } finally {
      setSaving(false)
    }
  }}
/>
```

The editor is controlled. It never saves anything. It calls `onChange` with the next list of rules and you keep the state, so you decide when a change is saved. Pair it with [`save-bar`](https://realgood.site/docs/components/save-bar.md), as above. `save-bar` has no way to be switched off, so check the rules in `onSave` and show your own message when one is not valid. The fields already show what is wrong.

## How a rule reads

- **Cadence is days before the due date.** `[30, 14, 7, 1]` means one reminder 30 days before, one 14 days before, and so on. Days are whole numbers, each at most once, shown largest first after `normalizeRule`.
- **Escalation is days after the due date.** `escalateAfterDays: 3` with `escalateTo: "supervisor@example.org"` means: if the item is still open three days after it was due, tell that address. Days and address come as a pair.
- **Escalation goes to an email address.** Contacts in crisp-ui are email only, as in [`recipient-roster`](https://realgood.site/docs/components/recipient-roster.md). There are no phone numbers.
- **Quiet hours are a daily window on a wall clock.** `{ start: "21:00", end: "08:00", timeZone: "America/New_York" }` means nothing is sent from 9:00 pm up to, but not including, 8:00 am in New York. The window may cross midnight. The start minute is quiet and the end minute is not.
- **The time zone is a name, not an offset.** `America/New_York` follows daylight saving, so on the night the clocks go forward the window is 10 real hours and on the night they go back it is 12. `+05:30` style offsets are refused. Zones with half-hour offsets, such as `Asia/Kolkata`, work.
- **Quiet hours off means any hour,** and the summary says so: "Can send at any hour."

## On the server

The helpers in `lib/alert-rules-lib.ts` have no React and no clock, so the code that sends can use them. Every function takes the instant to look at, so tests need no mocking.

```ts
import {
  nextAllowedSendTime,
  remindersDue,
  validateRule,
} from "@/lib/alert-rules-lib"

// 1. Never trust the screen: validate what you are about to save.
const errors = validateRule(rule)

// 2. Which reminders have come due for this item?
const { passed, next, pastDue } = remindersDue(
  item.dueAt,
  new Date(),
  rule.cadenceDays
)

// 3. Before sending, ask whether now is allowed. If not, hold it until it is.
const sendAt = nextAllowedSendTime(new Date(), rule.quietHours)
if (sendAt.getTime() > Date.now()) {
  holdUntil(sendAt) // your scheduler, not part of crisp-ui
} else {
  send()
}
```

`remindersDue` does not know what you already sent. Keep that record yourself and send only the steps that are missing, or a late deploy will send every step at once.

## Notes

- **You decide when it is saved.** The editor only reads `rules`. Compare with the saved copy using `sameRules`, which ignores the order of the reminder days and the case of an address, so those are not changes worth saving. After a failed save, leave `saved` alone and the save bar stays.
- **Normalize before you store.** `normalizeRule` sorts the days largest first, drops repeats, and lowercases and trims the address. The editor adds days in order and tidies the address when the field loses focus, but it keeps what was typed while it is being edited so that `validateRule` can show what is wrong.
- **Errors are shown beside the field.** Each message is tied to its input and the input is marked invalid. A message names what to do, for example "Add who to escalate to, or clear the days." An enabled rule with no reminder days is an error. A disabled one may be empty while someone drafts it.
- **A rule that is off says so.** The switch has a text label and the summary begins "This rule is off, so nothing is sent." The other fields stay editable.
- **Adding a day.** The input adds on Enter or the Add button. A day that is not a whole number, is above 3650, or is already there is refused with a message and the field keeps focus. Removing a day moves focus to the next one, or to the input when none are left.
- **Time zones.** The field suggests zone names from the browser once the page has loaded, and accepts any name the runtime knows. Turning quiet hours on uses the `timeZone` prop, else the browser's zone, else UTC, with 21:00 to 08:00 as the starting window.
- **The summary names what is missing.** An unfinished escalation reads "Escalation is not finished." A rule with no quiet hours reads "Can send at any hour." Pass `includeTimeZone` to `summarizeRule` to add the zone, as the editor does.
- **Quiet hours follow the wall clock.** `isQuietTime` and `nextAllowedSendTime` read the local time in the rule's zone with `Intl`, so they follow daylight saving. A window that falls inside the hour the clocks skip never matches, and one inside the hour they repeat matches both times. They throw a `RangeError` for an invalid zone, time or date, or when start equals end, rather than guessing, so run `validateRule` first.
- **Reminder steps are 24-hour days.** `remindersDue` counts back whole 24-hour days from the due instant, not calendar days, so across a clock change a step can sit an hour off local time. Choose the send time with `nextAllowedSendTime`.
- **Built to be used by keyboard.** Every control is a native input, button or switch with a visible label or an accessible name, remove buttons say what they remove ("Remove 7 days before"), and focus rings come from the shadcn parts. State is always written as well as shown: the switch has an On or Off label and errors are text. This has not been tested with assistive technology, so test it in your own screens.
- **No theme work.** It uses the shadcn tokens (`border`, `muted`, `destructive`), so it follows your theme.

## What it does not do

- **It does not send or schedule anything.** It edits a saved rule. The server that sends the messages must read the saved rule and apply quiet hours and escalation itself. A setting in this editor is not enforcement.
- **It does not remember what was already sent.** `remindersDue` does not know either, so keep that record yourself.
- **Contacts are email addresses only.** There are no phone numbers and no SMS or text.
- **It does not add or delete rules, or rename them.** It edits the rules you pass. Each rule's name is shown as an `h3`.
- **It makes no compliance claim.** It is a clear way to set and review a rule, and the rest is your backend. It is UI cues, not compliance.

## Props

### AlertRules

Also accepts the props of a `div` (except `onChange`, which is the editor's own), including `className` and `ref`. They are applied to the root element. The prop types are exported as `AlertRulesProps`. `AlertRule` and `QuietHours` come from `lib/alert-rules-lib`.

| Prop            | Type                           | Description                                                                                  |
| --------------- | ------------------------------ | -------------------------------------------------------------------------------------------- |
| `rules`         | `AlertRule[]`                  | The current rules, including unsaved edits. One row each. Required.                          |
| `onChange`      | `(rules: AlertRule[]) => void` | Called with the full next list after any edit. Required.                                     |
| `validateEmail` | `(email: string) => boolean`   | Return true if the address is acceptable. It receives the lowercased address. Has a default. |
| `timeZone`      | `string`                       | Zone given to quiet hours when someone turns them on. Defaults to the browser's.             |
| `disabled`      | `boolean`                      | Disables every control, for example while saving.                                            |
| `className`     | `string`                       | Merged onto the root element.                                                                |

### AlertRule

| Field               | Type                 | Description                                                               |
| ------------------- | -------------------- | ------------------------------------------------------------------------- |
| `id`                | `string`             | Stable and unique in the list. Used as the React key.                     |
| `label`             | `string`             | What the rule is for. Shown as the rule's heading.                        |
| `enabled`           | `boolean`            | Whether the rule is on.                                                   |
| `cadenceDays`       | `number[]`           | Days before the due date to remind. Whole numbers, 1 to 3650, no repeats. |
| `escalateAfterDays` | `number`             | Days after the due date, if still open. Needs `escalateTo`.               |
| `escalateTo`        | `string`             | Email address to tell. Needs `escalateAfterDays`.                         |
| `quietHours`        | `QuietHours \| null` | When nothing is sent. `null` or missing means any hour.                   |

### QuietHours

| Field      | Type     | Description                                                                      |
| ---------- | -------- | -------------------------------------------------------------------------------- |
| `start`    | `string` | "HH:MM", 24-hour. The window includes this minute.                               |
| `end`      | `string` | "HH:MM", 24-hour. The window ends just before this minute. Not equal to `start`. |
| `timeZone` | `string` | An IANA zone name such as `America/New_York`.                                    |

## Reference

All in `lib/alert-rules-lib.ts`. No React, no network, no clock: every instant is passed in.

### validateRule

`(rule: AlertRule, options?: { validateEmail?: (email: string) => boolean }) => RuleErrors`

Returns an object keyed by field (`cadenceDays`, `escalateAfterDays`, `escalateTo`, `quietHours.start`, `quietHours.end`, `quietHours.timeZone`), each with a `code` and a `message`. An empty object means the rule is valid. `isRuleValid(errors)` tells you which.

### normalizeRule

`(rule: AlertRule) => AlertRule`

The form to save. It does not change the rule you pass in.

### sameRules

`(a: AlertRule[], b: AlertRule[]) => boolean`

True when the lists hold the same rules in the same order, compared in normalized form. Use it for `dirty`.

### isQuietTime

`(instant: Date, quietHours: QuietHours | null | undefined) => boolean`

Whether `instant` is inside the window, by the wall clock in the window's zone. `null` is never quiet.

### nextAllowedSendTime

`(instant: Date, quietHours: QuietHours | null | undefined) => Date`

`instant` itself when sending is allowed, otherwise the first minute after the window ends.

### remindersDue

`(dueDate: Date, now: Date, cadenceDays: number[]) => { passed: ReminderStep[]; next: ReminderStep | null; pastDue: boolean }`

The steps whose time has come, the next one ahead, and whether the due date has passed. A `ReminderStep` is `{ days, at }`.

### summarizeRule

`(rule: AlertRule, options?: { includeTimeZone?: boolean }) => string`

The sentence shown under each rule, for example "Reminds 30, 14, 7 and 1 days before. Escalates to ops@example.org after 3 days. Never sends between 9:00 pm and 8:00 am."

### Also exported

`isEmailAddress`, `isValidTimeZone`, `parseTimeOfDay`, `formatTimeOfDay`, `MAX_CADENCE_DAYS`, and the types `RuleError`, `RuleErrors`, `RuleField`, `RuleErrorCode`, `ReminderStep` and `RemindersDue`.

## Agent prompt

Paste this into your coding agent in your project. Replace the last line with your own items.

```text
GOAL: Add a reminder-rules editor (cadence, escalation, quiet hours) to my internal tool so a person can review and save WHEN and to WHOM reminders go. It edits saved rules. It never sends anything.

INSTALL (run exactly):
npx shadcn@latest add https://realgood.site/r/alert-rules.json
npx shadcn@latest add https://realgood.site/r/save-bar.json
Read the installed files (components/ui/alert-rules.tsx, lib/alert-rules-lib.ts, components/ui/save-bar.tsx) before writing any code. Do not guess props.

PROPS:
<AlertRules rules={AlertRule[]} onChange={(rules) => void} validateEmail?={(email) => boolean} timeZone?={IANA name, default for new quiet hours} disabled?={boolean} />
AlertRule = { id, label, enabled, cadenceDays: number[] (days BEFORE due), escalateAfterDays?: number (days AFTER due), escalateTo?: string (an email), quietHours?: { start: "HH:MM", end: "HH:MM", timeZone: string } | null }
Helpers in lib/alert-rules-lib: validateRule, normalizeRule, sameRules, isQuietTime, nextAllowedSendTime, remindersDue, summarizeRule.

WIRING: AlertRules is controlled and does not save. Keep `saved` and `rules` in state and render <SaveBar dirty={!sameRules(rules, saved)} />. On Save: validateRule every rule, stop if any has errors, send normalizeRule(rule) to my own API, and set `saved` only after it succeeds. The SERVER must validate again and must enforce quiet hours (isQuietTime or nextAllowedSendTime) and escalation when it sends; a UI setting is not enforcement. Contacts are email only. No phone numbers, no texting.

STATES: clean (no save bar), dirty, saving, save failed (stay dirty, show why), invalid field (message tied to its input), rule off, no quiet hours, empty cadence.

ACCEPTANCE (show me each result):
1. pnpm typecheck and pnpm lint pass.
2. A test: with quietHours 21:00-08:00 America/New_York, isQuietTime is true at 2026-01-16T02:00:00Z and false at 2026-01-16T13:00:00Z.
3. Cadence 7 twice shows an error that names it. Escalation days with no email puts an error on the email field.
4. Keyboard only: Tab reaches every control, Enter adds a day, and the remove buttons are named like "Remove 7 days before".

Do not add scheduling, a job queue, SMS or new packages. Build only the settings screen and the server check. If something is unclear, ask.

MY ITEMS: <what has a due date, who owns it, who should hear if it is overdue>
```

## Examples by sector

Example data only. The component is the same in every sector; the words and the numbers change. The `dueAt` for each item lives in your own data. These are not claims about any real organisation.

Appointment-reminder **texting is out of scope.** Contacts here are email addresses, so a rule can reach a person by email only. Patient and family texts need phone numbers, consent and opt-out handling that this pattern does not have.

- **Education.** Attendance notices: a reminder to send the absence note to the school, nothing between 9 pm and 8 am, and the attendance office hears after two days. The 9 pm to 8 am window is borrowed from an attendance-texting guide published by the US Department of Education's IES; whether it applies to you is for you to decide.
- **Manufacturing.** Gauge calibration due: reminders 30, 14, 7 and 1 days before, and the supervisor hears three days after it is overdue.
- **Engineering.** ECO review reminders: reviewers hear five, two and one days before the review closes, and the engineering manager hears a day after.
- **Health.** Licence expiry at 60, 30 and 7 days, and the practice manager hears a day after it lapses.

### Rule objects

**Education**

```ts
{
  id: "absence-note",
  label: "Absence note due",
  enabled: true,
  cadenceDays: [3, 1],
  escalateAfterDays: 2,
  escalateTo: "attendance@example.org",
  quietHours: { start: "21:00", end: "08:00", timeZone: "America/New_York" },
}
```

**Manufacturing**

```ts
{
  id: "calibration-due",
  label: "Gauge calibration due",
  enabled: true,
  cadenceDays: [30, 14, 7, 1],
  escalateAfterDays: 3,
  escalateTo: "supervisor@example.org",
  quietHours: { start: "21:00", end: "08:00", timeZone: "America/Chicago" },
}
```

**Engineering**

```ts
{
  id: "eco-review",
  label: "ECO review due",
  enabled: true,
  cadenceDays: [5, 2, 1],
  escalateAfterDays: 1,
  escalateTo: "engineering-manager@example.org",
  quietHours: null,
}
```

**Health**

```ts
{
  id: "licence-expiry",
  label: "Staff licence expires",
  enabled: true,
  cadenceDays: [60, 30, 7],
  escalateAfterDays: 1,
  escalateTo: "practice-manager@example.org",
  quietHours: { start: "20:00", end: "07:00", timeZone: "America/Denver" },
}
```

## Next

Comes after: [`recipient-roster`](https://realgood.site/docs/components/recipient-roster.md). Leads to: [`confirm-send`](https://realgood.site/docs/components/confirm-send.md).
