# Audit timeline

> A read-only "who did what, when, and why" list of events your server has recorded, grouped by day, with honest empty, error and stale states.

```tsx
"use client"

import * as React from "react"

import {
  buildAuditEvent,
  type AuditEvent,
} from "@/lib/audit-event"
import { AuditTimeline } from "@/components/ui/audit-timeline"
import { ConfirmSend } from "@/components/ui/confirm-send"
import { Switch } from "@/components/ui/switch"

const TIME_ZONE = "America/New_York"
// Fixed so the demo reads the same every time. A real clock is the server's.
const START = Date.parse("2026-10-02T14:30:00Z")

// Fictional people and data. Nothing here is saved or sent anywhere.
const SEED: AuditEvent[] = [
  {
    id: "evt_6",
    at: "2026-10-02T14:12:00Z",
    actor: { name: "Mara Lopez", role: "Registrar" },
    action: "sent the attendance summary to",
    target: "3 guardians",
    reason: "Weekly summary",
    outcome: "succeeded",
    detail: "First names only. Sent to the saved list of 3.",
  },
  {
    id: "evt_5",
    at: "2026-10-02T13:58:00Z",
    actor: { name: "Mara Lopez", role: "Registrar" },
    action: "saved the guardian list",
    outcome: "failed",
    detail: "The server rejected the save, so the list is unchanged.",
  },
  {
    id: "evt_4",
    at: "2026-10-01T20:20:00Z",
    actor: { name: "Sam Okafor", role: "Project engineer" },
    action: "approved",
    target: "ECO-2041",
    reason: "Rev C fixes the tolerance stack-up",
    outcome: "succeeded",
    detail: "Signature meaning: approval.",
  },
  {
    id: "evt_3",
    at: "2026-10-01T13:05:00Z",
    actor: { name: "Priya Nair", role: "Clinic manager" },
    action: "tried to send reminders to",
    target: "12 patients",
    reason: "Quiet hours: no sends between 9 pm and 8 am",
    outcome: "blocked",
    detail: "Nothing was sent.",
  },
  {
    id: "evt_2",
    at: "2026-09-30T19:41:00Z",
    actor: { name: "Dev Patel", role: "Quality engineer" },
    action: "recalled",
    target: "Gauge G-114",
    reason: "Last calibration was 14 months ago",
    outcome: "succeeded",
  },
  {
    id: "evt_1",
    at: "2026-09-30T19:40:00Z",
    actor: { name: "Dev Patel", role: "Quality engineer" },
    action: "marked calibration overdue on",
    target: "Gauge G-114",
    outcome: "succeeded",
  },
]

export function AuditTimelineDemo() {
  const [events, setEvents] = React.useState(SEED)
  const [sending, setSending] = React.useState(false)
  const [sentCount, setSentCount] = React.useState<number | null>(null)
  const [offline, setOffline] = React.useState(false)
  const sends = events.length - SEED.length

  // Pretend server. In your app this runs on YOUR server, in the same handler
  // that sends the message, so the record exists even if this page is closed.
  const sendSummaryOnServer = () =>
    buildAuditEvent(
      {
        actor: { name: "Mara Lopez", role: "Registrar" },
        action: "sent the attendance summary to",
        target: "3 guardians",
        reason: "Weekly summary",
        outcome: "succeeded",
        detail: "First names only. Sent to the saved list of 3.",
      },
      {
        now: () => new Date(START + (sends + 1) * 60_000),
        newId: () => `evt_sent_${sends + 1}`,
        requireReason: true,
      }
    )

  return (
    <div className="flex w-full max-w-2xl flex-col gap-6">
      <div className="flex flex-wrap items-center gap-3">
        <ConfirmSend
          count={3}
          noun="guardian"
          label="Email this summary"
          sending={sending}
          sentCount={sentCount}
          onSend={async () => {
            setSending(true)
            await new Promise((resolve) => setTimeout(resolve, 600))
            // The UI shows the record. It never makes it.
            setEvents((current) => [sendSummaryOnServer(), ...current])
            setSending(false)
            setSentCount(3)
          }}
        />
      </div>
      <AuditTimeline
        events={events}
        timeZone={TIME_ZONE}
        initialCount={4}
        filterable
        now={START + (sends + 2) * 60_000}
        error={
          offline
            ? "The server did not answer. Showing what loaded earlier."
            : undefined
        }
        onRetry={() => setOffline(false)}
        stale={offline}
      />
      <label className="flex items-center gap-2 text-sm text-muted-foreground">
        <Switch checked={offline} onCheckedChange={setOffline} />
        Pretend the last refresh failed
      </label>
    </div>
  )
}
```

Use it wherever people need to see what happened and who did it: who sent the summary, which save failed, who approved a change and why. Each entry shows the person, what they did, what it acted on, the reason, how it ended, and the time with its time zone.

It is the **Record** stage of the story See, Decide, Act, Confirm, Record.

"Sent to 3 people" from [`confirm-send`](https://realgood.site/docs/components/confirm-send.md) becomes an audit entry, and so does every save and decision. It closes the loop back to [`status-strip`](https://realgood.site/docs/components/status-strip.md), because the next number you look at is the result of what was recorded.

> **What this is not.** It only displays events. It does not create, store,
> protect, retain or export them. An audit log is only as trustworthy as the
> server that writes it, so the record has to be written there, when the action
> happens. Using this component does not make a tool compliant with FERPA,
> HIPAA, 21 CFR Part 11, ISO 9001 or anything else.

## Installation

**Command**

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

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

```bash
npx shadcn@latest add @crisp/audit-timeline
```

This also adds the shared [`audit-event`](#the-auditevent-type) helpers, the shadcn `button` and `lucide-react`.

**Manual**

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

```tsx title="components/ui/audit-timeline.tsx"
"use client"

import * as React from "react"
import {
  Ban,
  CircleAlert,
  CircleCheck,
  CircleX,
  Clock,
  LoaderCircle,
} from "lucide-react"

import { cn } from "@/lib/utils"
import {
  actorName,
  actorRole,
  distinctActions,
  distinctActors,
  filterAuditEvents,
  formatEventTime,
  formatRelativeTime,
  groupByDay,
  type AuditDay,
  type AuditEvent,
  type AuditOutcome,
} from "@/lib/audit-event"
import { Button } from "@/components/ui/button"

const OUTCOME: Record<
  AuditOutcome,
  { label: string; icon: React.ComponentType<{ className?: string }> }
> = {
  succeeded: { label: "Succeeded", icon: CircleCheck },
  failed: { label: "Failed", icon: CircleX },
  blocked: { label: "Blocked", icon: Ban },
}

const SELECT =
  "h-9 w-full min-w-40 rounded-md border border-input bg-transparent px-3 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:bg-input/30"

// Once a minute is enough for "5 minutes ago". The server snapshot is null, so
// the relative time is left out of the server render and the first client
// render, and never causes a hydration mismatch.
const subscribeToMinute = (notify: () => void) => {
  const timer = setInterval(notify, 30_000)
  return () => clearInterval(timer)
}
const currentMinute = () => Math.floor(Date.now() / 60_000) * 60_000
const noMinuteOnServer = () => null

export interface AuditTimelineProps
  extends Omit<React.ComponentProps<"div">, "children"> {
  /** The recorded events, in any order. They are shown newest first. Written by your SERVER, never by this component. */
  events: AuditEvent[]
  /** IANA zone the days and times are shown in, e.g. "America/New_York". Required: an audit time with no zone is ambiguous. */
  timeZone: string
  /** How many entries show before "Show N more". Also how many each press adds. Default 10. */
  initialCount?: number
  /** Fetch older events from your server. Called when everything loaded is showing and `hasMore` is not false. */
  onLoadMore?: () => void | Promise<void>
  /** Whether your server has older events. Defaults to true when `onLoadMore` is set. */
  hasMore?: boolean
  /** Shown when there are no events. Default "No activity recorded yet". */
  emptyMessage?: string
  /** Show Person and Action filters above the list. Default false. */
  filterable?: boolean
  /** The events are still being fetched. Says so instead of claiming there is no activity. */
  loading?: boolean
  /** The events could not be fetched. Says so instead of claiming there is no activity. */
  error?: string
  /** Shown as a Retry button beside `error`. */
  onRetry?: () => void
  /** The list may be behind. `true` uses a default message, a string replaces it. */
  stale?: boolean | string
  /** Pin "now" for the relative times, e.g. in a demo or a test. Defaults to the real clock. */
  now?: Date | string | number
  /** BCP 47 locale for the labels. Default "en-US". */
  locale?: string
  /** Level of each day's heading, so the list fits your page outline. Default 3. */
  headingLevel?: 2 | 3 | 4
}

/**
 * "Who did what, when, and why." Shows events your server has already
 * recorded, grouped by day. It never creates, edits or deletes one, and a log
 * is only as trustworthy as the server that wrote it.
 */
function AuditTimeline({
  events,
  timeZone,
  initialCount = 10,
  onLoadMore,
  hasMore,
  emptyMessage = "No activity recorded yet",
  filterable = false,
  loading = false,
  error,
  onRetry,
  stale = false,
  now,
  locale = "en-US",
  headingLevel = 3,
  className,
  ...props
}: AuditTimelineProps) {
  const pageSize = Math.max(1, Math.floor(initialCount) || 1)
  const [count, setCount] = React.useState(pageSize)
  const [actor, setActor] = React.useState("")
  const [action, setAction] = React.useState("")
  const [loadingMore, setLoadingMore] = React.useState(false)
  const [loadFailed, setLoadFailed] = React.useState(false)
  const listRef = React.useRef<HTMLDivElement>(null)
  // Flat index of the first entry a press added, so focus can follow it.
  const focusIndex = React.useRef<number | null>(null)
  const baseId = React.useId()
  const Heading = `h${headingLevel}` as "h3"

  const tick = React.useSyncExternalStore(
    subscribeToMinute,
    currentMinute,
    noMinuteOnServer
  )
  const pinned = now === undefined ? Number.NaN : new Date(now).getTime()
  const relativeNow = Number.isNaN(pinned) ? tick : pinned

  const filtered = React.useMemo(
    () => filterAuditEvents(events, { actor, action }),
    [events, actor, action]
  )
  const days = React.useMemo(
    () => groupByDay(filtered, timeZone, { locale }),
    [filtered, timeZone, locale]
  )
  const actors = React.useMemo(() => distinctActors(events), [events])
  const actions = React.useMemo(() => distinctActions(events), [events])

  // Newest first, cut to `count` entries (a day may be cut part way).
  const shown = Math.min(count, filtered.length)
  const visibleDays = React.useMemo(() => {
    let budget = count
    const result: AuditDay[] = []
    for (const day of days) {
      if (budget <= 0) break
      result.push({ ...day, events: day.events.slice(0, budget) })
      budget -= day.events.length
    }
    return result
  }, [days, count])

  React.useEffect(() => {
    const index = focusIndex.current
    if (index === null) return
    focusIndex.current = null
    listRef.current
      ?.querySelector<HTMLElement>(`[data-index="${index}"]`)
      ?.focus()
  }, [count])

  const hidden = filtered.length - shown
  const filtering = Boolean(actor || action)
  const canLoadMore = Boolean(onLoadMore) && hasMore !== false
  const hasEvents = events.length > 0

  const showMore = () => {
    // The button goes away when this reveals the last entry, so move focus to
    // the first new one rather than losing it.
    if (hidden <= pageSize && !canLoadMore) focusIndex.current = shown
    setCount(shown + pageSize)
  }
  const loadMore = async () => {
    if (!onLoadMore || loadingMore) return
    setLoadingMore(true)
    setLoadFailed(false)
    try {
      await onLoadMore()
      setCount(shown + pageSize)
    } catch {
      setLoadFailed(true)
    } finally {
      setLoadingMore(false)
    }
  }
  const resetCount = () => setCount(pageSize)

  let index = 0
  return (
    <div
      data-slot="audit-timeline"
      className={cn("space-y-4", className)}
      {...props}
    >
      {error ? (
        <div
          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
            aria-hidden="true"
            className="size-4 shrink-0 text-destructive"
          />
          <span className="min-w-0 flex-1">
            <span className="font-medium">Activity could not be loaded.</span>{" "}
            {error}
          </span>
          {onRetry && (
            <Button variant="outline" size="sm" onClick={onRetry}>
              Retry
            </Button>
          )}
        </div>
      ) : null}
      {stale ? (
        <p
          role="status"
          className="flex items-center gap-2 text-sm text-muted-foreground"
        >
          <Clock aria-hidden="true" className="size-4 shrink-0" />
          {typeof stale === "string"
            ? stale
            : "This list may be out of date. Newer activity may not be shown yet."}
        </p>
      ) : null}

      {filterable && hasEvents ? (
        <div
          role="group"
          aria-label="Filter activity"
          className="flex flex-wrap items-end gap-3"
        >
          <div className="space-y-1.5">
            <label htmlFor={`${baseId}-actor`} className="text-sm font-medium">
              Person
            </label>
            <select
              id={`${baseId}-actor`}
              className={SELECT}
              value={actor}
              onChange={(event) => {
                setActor(event.target.value)
                resetCount()
              }}
            >
              <option value="">Everyone</option>
              {actors.map((name) => (
                <option key={name} value={name}>
                  {name}
                </option>
              ))}
            </select>
          </div>
          <div className="space-y-1.5">
            <label htmlFor={`${baseId}-action`} className="text-sm font-medium">
              Action
            </label>
            <select
              id={`${baseId}-action`}
              className={SELECT}
              value={action}
              onChange={(event) => {
                setAction(event.target.value)
                resetCount()
              }}
            >
              <option value="">Any action</option>
              {actions.map((name) => (
                <option key={name} value={name}>
                  {name}
                </option>
              ))}
            </select>
          </div>
          {filtering && (
            <Button
              variant="ghost"
              onClick={() => {
                setActor("")
                setAction("")
                resetCount()
              }}
            >
              Clear filters
            </Button>
          )}
        </div>
      ) : null}

      {loading ? (
        <p
          role="status"
          className="flex items-center gap-2 text-sm text-muted-foreground"
        >
          <LoaderCircle
            aria-hidden="true"
            className="size-4 animate-spin motion-reduce:animate-none"
          />
          Loading activity…
        </p>
      ) : null}

      {!hasEvents && !loading && !error ? (
        <p className="rounded-md border border-dashed px-4 py-6 text-center text-sm text-muted-foreground">
          {emptyMessage}
        </p>
      ) : null}

      {hasEvents && filtered.length === 0 ? (
        <p className="text-sm text-muted-foreground">
          No events match these filters.
        </p>
      ) : null}

      <div ref={listRef} className="space-y-6">
        {visibleDays.map((day) => {
          const headingId = `${baseId}-${day.day}`
          return (
            <section key={day.day} aria-labelledby={headingId}>
              <Heading
                id={headingId}
                className="mb-1 text-sm font-semibold text-foreground"
              >
                {day.label}
              </Heading>
              <ol role="list" className="divide-y border-y">
                {day.events.map((event) => {
                  const time = formatEventTime(event.at, timeZone, {
                    locale,
                    timeOnly: day.day !== "unknown",
                  })
                  const relative =
                    relativeNow === null
                      ? null
                      : formatRelativeTime(event.at, relativeNow, locale)
                  const outcome = event.outcome ? OUTCOME[event.outcome] : null
                  const role = actorRole(event.actor)
                  return (
                    <li
                      key={event.id}
                      data-index={index++}
                      tabIndex={-1}
                      className="grid gap-x-4 gap-y-1 py-3 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 sm:grid-cols-[11rem_1fr]"
                    >
                      <div className="text-sm tabular-nums">
                        {time.iso ? (
                          <time dateTime={time.iso} className="font-medium">
                            {time.label}
                          </time>
                        ) : (
                          <span className="font-medium">{time.label}</span>
                        )}
                        {relative ? (
                          <span className="block text-xs text-muted-foreground">
                            {relative}
                          </span>
                        ) : null}
                      </div>
                      <div className="min-w-0 space-y-1 text-sm">
                        <p className="flex flex-wrap items-baseline gap-x-1.5 gap-y-1">
                          <span>
                            <span className="font-semibold">
                              {actorName(event.actor)}
                            </span>
                            {role ? (
                              <span className="text-muted-foreground">
                                {" "}
                                ({role})
                              </span>
                            ) : null}{" "}
                            {event.action}
                            {event.target ? (
                              <>
                                {" "}
                                <span className="font-semibold">
                                  {event.target}
                                </span>
                              </>
                            ) : null}
                          </span>
                          {outcome ? (
                            <span
                              className={cn(
                                "inline-flex items-center gap-1 rounded-sm border px-1.5 py-0.5 text-xs font-medium",
                                event.outcome === "failed" &&
                                  "border-destructive/50 text-destructive"
                              )}
                            >
                              <outcome.icon
                                aria-hidden="true"
                                className="size-3.5"
                              />
                              {outcome.label}
                            </span>
                          ) : null}
                        </p>
                        {event.reason ? (
                          <p>
                            <span className="text-muted-foreground">
                              Reason:
                            </span>{" "}
                            {event.reason}
                          </p>
                        ) : null}
                        {event.detail ? (
                          <p className="text-muted-foreground">
                            {event.detail}
                          </p>
                        ) : null}
                      </div>
                    </li>
                  )
                })}
              </ol>
            </section>
          )
        })}
      </div>

      {hasEvents && filtered.length > 0 ? (
        <div className="flex flex-wrap items-center gap-3">
          {hidden > 0 ? (
            <Button variant="outline" onClick={showMore}>
              Show {Math.min(pageSize, hidden)} more
            </Button>
          ) : canLoadMore ? (
            <Button
              variant="outline"
              // aria-disabled, not disabled, so keyboard focus stays on the button.
              aria-disabled={loadingMore}
              className="aria-disabled:pointer-events-none aria-disabled:opacity-50"
              onClick={loadMore}
            >
              {loadingMore
                ? "Loading…"
                : loadFailed
                  ? "Try again"
                  : "Load older activity"}
            </Button>
          ) : null}
          <p role="status" className="text-sm text-muted-foreground">
            Showing {shown} of {filtered.length}{" "}
            {filtered.length === 1 ? "event" : "events"}
            {filtering ? " that match" : ""}
            {filtering && canLoadMore
              ? ". Filters apply only to events already loaded"
              : ""}
            .
          </p>
        </div>
      ) : null}
      {loadFailed ? (
        <p role="alert" className="flex items-center gap-2 text-sm">
          <CircleAlert
            aria-hidden="true"
            className="size-4 shrink-0 text-destructive"
          />
          Older activity could not be loaded.
        </p>
      ) : null}
    </div>
  )
}

export { AuditTimeline }
```

**Step 2.** Add the shared event type and helpers. They have no React and no dependencies,
so you can import them on your server too.

```ts title="lib/audit-event.ts"
/**
 * The shared shape of an audit entry, plus pure helpers to build, group, filter
 * and format them. No React, no network, so it is safe on a server (where the
 * record must be written) and to unit test.
 *
 * Nothing here stores or protects a record. An audit log is only as trustworthy
 * as the server that writes it.
 */

export type AuditOutcome = "succeeded" | "failed" | "blocked"

/** A person, optionally with the role they acted in. A plain string is just a name. */
export type AuditActor = string | { name: string; role?: string }

/** One line of "who did what, when, and why". */
export interface AuditEvent {
  /** Unique and stable. Used as the React key and to de-duplicate. */
  id: string
  /** When it happened, as an ISO 8601 string. Set by the SERVER's clock. */
  at: string
  /** Who did it. */
  actor: AuditActor
  /** A short verb phrase that reads between actor and target: "approved", "sent the summary to". */
  action: string
  /** What it acted on: "ECO-2041", "3 guardians". */
  target?: string
  /** Why. Required by your policy for some actions, e.g. approvals and overrides. */
  reason?: string
  /** How it ended. Leave out when the action has no pass/fail result. */
  outcome?: AuditOutcome
  /** Anything else worth reading: counts, ids, the meaning of a signature. No sensitive details. */
  detail?: string
}

/** What a caller supplies. The id and the time come from `buildAuditEvent`. */
export type AuditEventInput = Omit<AuditEvent, "id" | "at">

export interface BuildAuditEventOptions {
  /** The clock. Defaults to `new Date()`. Inject a fixed one in tests. */
  now?: () => Date
  /** The id source. Defaults to `crypto.randomUUID()`. Inject a counter in tests. */
  newId?: () => string
  /** Throw unless `input.reason` is a non-blank string. */
  requireReason?: boolean
}

/** Thrown by `buildAuditEvent` when the input cannot make a valid entry. */
export class AuditEventError extends Error {
  constructor(message: string) {
    super(message)
    this.name = "AuditEventError"
  }
}

const clean = (value: string | undefined) => {
  const trimmed = value?.trim()
  return trimmed ? trimmed : undefined
}

/**
 * Build one entry on the server, with the server's clock and a fresh id.
 * Throws an `AuditEventError` if there is no actor or action, or if
 * `requireReason` is set and the reason is missing or blank. Blank optional
 * fields are dropped, so a stored entry never carries empty strings.
 */
export function buildAuditEvent(
  input: AuditEventInput,
  options: BuildAuditEventOptions = {}
): AuditEvent {
  const now = options.now ?? (() => new Date())
  const newId = options.newId ?? (() => crypto.randomUUID())

  const name = clean(
    typeof input.actor === "string" ? input.actor : input.actor?.name
  )
  if (!name) {
    throw new AuditEventError("An audit event needs an actor: who did this?")
  }
  const role =
    typeof input.actor === "string" ? undefined : clean(input.actor.role)
  const actor: AuditActor =
    typeof input.actor === "string" ? name : role ? { name, role } : { name }
  const action = clean(input.action)
  if (!action) {
    throw new AuditEventError("An audit event needs an action: what was done?")
  }
  const reason = clean(input.reason)
  if (options.requireReason && !reason) {
    throw new AuditEventError(
      `An audit event for "${action}" needs a reason, and none was given.`
    )
  }
  const at = now()
  if (!(at instanceof Date) || Number.isNaN(at.getTime())) {
    throw new AuditEventError("The clock did not return a valid date.")
  }

  const target = clean(input.target)
  const detail = clean(input.detail)
  return {
    id: newId(),
    at: at.toISOString(),
    actor,
    action,
    ...(target ? { target } : {}),
    ...(reason ? { reason } : {}),
    ...(input.outcome ? { outcome: input.outcome } : {}),
    ...(detail ? { detail } : {}),
  }
}

/** The actor's display name. */
export function actorName(actor: AuditActor): string {
  return typeof actor === "string" ? actor : actor.name
}

/** The actor's role, if one was given. */
export function actorRole(actor: AuditActor): string | undefined {
  return typeof actor === "string" ? undefined : actor.role
}

const DEFAULT_LOCALE = "en-US"

const formatters = new Map<string, Intl.DateTimeFormat>()
function formatter(
  key: string,
  locale: string,
  options: Intl.DateTimeFormatOptions
) {
  const cacheKey = `${key}|${locale}|${options.timeZone}`
  let format = formatters.get(cacheKey)
  if (!format) {
    try {
      format = new Intl.DateTimeFormat(locale, options)
    } catch {
      throw new RangeError(
        `Unknown time zone "${options.timeZone}". Use an IANA name such as "America/New_York" or "UTC".`
      )
    }
    formatters.set(cacheKey, format)
  }
  return format
}

/** Milliseconds since the epoch for an ISO string, or `null` when it does not parse. */
function parseAt(at: string): number | null {
  const ms = typeof at === "string" ? Date.parse(at) : Number.NaN
  return Number.isNaN(ms) ? null : ms
}

/** The calendar date of an instant in a time zone, as "YYYY-MM-DD". */
function dayKey(ms: number, timeZone: string): string {
  const parts = formatter("day", "en-US", {
    timeZone,
    year: "numeric",
    month: "2-digit",
    day: "2-digit",
  }).formatToParts(ms)
  const get = (type: string) => parts.find((p) => p.type === type)?.value ?? ""
  return `${get("year")}-${get("month")}-${get("day")}`
}

export interface AuditDay {
  /** "YYYY-MM-DD" in the requested time zone, or "unknown" for entries whose date does not parse. */
  day: string
  /** A readable heading: "Friday, October 2, 2026", or "Date unknown". */
  label: string
  /** Newest first. Entries with the same instant keep the order they were given in. */
  events: AuditEvent[]
}

export interface GroupOptions {
  /** BCP 47 locale for the day heading. Defaults to "en-US". */
  locale?: string
}

/**
 * Group entries into calendar days in `timeZone`, newest day first and newest
 * entry first within a day. Entries with the same instant keep their input
 * order. A day is a day in that zone, so a 23-hour or 25-hour day on a clock
 * change is still one group. Entries whose `at` does not parse are collected in
 * one "Date unknown" group at the end rather than dropped or thrown on.
 * Throws a `RangeError` for an unknown `timeZone`.
 */
export function groupByDay(
  events: AuditEvent[],
  timeZone: string,
  { locale = DEFAULT_LOCALE }: GroupOptions = {}
): AuditDay[] {
  const dated: { event: AuditEvent; ms: number; index: number }[] = []
  const unknown: AuditEvent[] = []
  events.forEach((event, index) => {
    const ms = parseAt(event.at)
    if (ms === null) unknown.push(event)
    else dated.push({ event, ms, index })
  })
  // Validates the time zone even when every entry is undated.
  dayKey(0, timeZone)

  dated.sort((a, b) => b.ms - a.ms || a.index - b.index)

  const days: AuditDay[] = []
  const headingFormat = formatter("heading", locale, {
    timeZone,
    weekday: "long",
    year: "numeric",
    month: "long",
    day: "numeric",
  })
  for (const { event, ms } of dated) {
    const key = dayKey(ms, timeZone)
    const last = days[days.length - 1]
    if (last && last.day === key) {
      last.events.push(event)
    } else {
      days.push({ day: key, label: headingFormat.format(ms), events: [event] })
    }
  }
  if (unknown.length > 0) {
    days.push({ day: "unknown", label: "Date unknown", events: unknown })
  }
  return days
}

export interface FormatEventTimeOptions {
  /** BCP 47 locale. Defaults to "en-US". */
  locale?: string
  /** Leave out the date: "2:05:09 PM EDT". Use under a day heading. */
  timeOnly?: boolean
}

export interface FormattedEventTime {
  /** The absolute time, with the zone: "Oct 2, 2026, 2:05:09 PM EDT". "Time unknown" when `at` does not parse. */
  label: string
  /** The instant as a normalised ISO string for `<time dateTime>`, or `null` when `at` does not parse. */
  iso: string | null
}

/** An absolute label (to the second, with the zone) and the ISO string for `<time dateTime>`. */
export function formatEventTime(
  at: string,
  timeZone: string,
  { locale = DEFAULT_LOCALE, timeOnly = false }: FormatEventTimeOptions = {}
): FormattedEventTime {
  const ms = parseAt(at)
  const format = formatter(timeOnly ? "time" : "datetime", locale, {
    timeZone,
    ...(timeOnly ? {} : { year: "numeric", month: "short", day: "numeric" }),
    hour: "numeric",
    minute: "2-digit",
    second: "2-digit",
    timeZoneName: "short",
  })
  if (ms === null) return { label: "Time unknown", iso: null }
  return { label: format.format(ms), iso: new Date(ms).toISOString() }
}

/**
 * "5 minutes ago". Secondary to the absolute time; never the only time shown.
 * Returns `null` when `at` does not parse.
 */
export function formatRelativeTime(
  at: string,
  now: Date | number,
  locale: string = DEFAULT_LOCALE
): string | null {
  const ms = parseAt(at)
  if (ms === null) return null
  const diff = ms - (typeof now === "number" ? now : now.getTime())
  const abs = Math.abs(diff)
  const units: [Intl.RelativeTimeFormatUnit, number][] = [
    ["day", 86_400_000],
    ["hour", 3_600_000],
    ["minute", 60_000],
  ]
  const format = new Intl.RelativeTimeFormat(locale, { numeric: "auto" })
  if (abs < 60_000) return format.format(0, "second")
  for (const [unit, size] of units) {
    if (abs >= size) return format.format(Math.trunc(diff / size), unit)
  }
  return null
}

export interface AuditFilter {
  /** Match this actor's name exactly. */
  actor?: string
  /** Match this action exactly. */
  action?: string
}

/** Keep the entries that match every filter given. An empty filter keeps everything. */
export function filterAuditEvents(
  events: AuditEvent[],
  { actor, action }: AuditFilter
): AuditEvent[] {
  return events.filter(
    (event) =>
      (!actor || actorName(event.actor) === actor) &&
      (!action || event.action === action)
  )
}

/** The distinct actor names, sorted, for a filter control. */
export function distinctActors(events: AuditEvent[]): string[] {
  return [...new Set(events.map((e) => actorName(e.actor)))].sort((a, b) =>
    a.localeCompare(b)
  )
}

/** The distinct actions, sorted, for a filter control. */
export function distinctActions(events: AuditEvent[]): string[] {
  return [...new Set(events.map((e) => e.action))].sort((a, b) =>
    a.localeCompare(b)
  )
}
```

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

## Usage

```tsx
import { AuditTimeline } from "@/components/ui/audit-timeline"
```

```tsx
<AuditTimeline
  events={events} // already recorded by your server
  timeZone="America/New_York"
  initialCount={10}
  filterable
/>
```

The page fetches the events and passes them in. Write them on the server, in the same handler that does the work:

```ts
import { buildAuditEvent } from "@/lib/audit-event"

// Server only. Same handler that sends the summary.
const sent = await sendSummary(recipients)
await db.auditEvents.insert(
  buildAuditEvent(
    {
      actor: { name: user.name, role: user.role },
      action: "sent the attendance summary to",
      target: `${sent} guardians`,
      reason: "Weekly summary",
      outcome: "succeeded",
    },
    { requireReason: true }
  )
)
```

`buildAuditEvent` takes the time from the clock and a fresh id, so the page never decides what time something happened.

## Notes

- **The UI never creates the record.** Write an event on the server for every send, save and decision, including the ones that fail or are blocked. If the only copy of a record is in the browser, it is not a record.
- **Newest first, by day in your time zone.** `timeZone` is required, because a time with no zone is ambiguous. Days are calendar days in that zone, so a 23-hour or 25-hour day on a clock change is still one day. An unknown zone throws a `RangeError` that names it.
- **Absolute time first.** Each entry shows a time with the zone, such as "10:12:00 AM EDT", in a `<time>` element whose `dateTime` is the full ISO string. The relative time ("5 minutes ago") sits under it as secondary text. It is left out of the server render and appears after the page loads, so it can never disagree between the two.
- **A bad date does not break the list.** Entries whose `at` does not parse are shown last under "Date unknown", with "Time unknown" in place of a time, rather than being dropped.
- **Outcome is text plus an icon.** "Succeeded", "Failed" and "Blocked" each have a different icon and a word. Colour only adds to it. Leave `outcome` out when an action has no pass or fail.
- **The reason is its own line.** It reads "Reason: …" under the entry. If your policy needs one for approvals and overrides, enforce it on the server with `requireReason`.
- **Keep sensitive details out.** Do not put message bodies, diagnoses, grades or other personal data in `target`, `reason` or `detail`. Say what was done and to how many, not the content.
- **Truncation.** Only `initialCount` entries show, newest first, with a "Show N more" button that adds that many again. When that press shows the last entry and the button goes away, focus moves to the first entry that was added instead of being lost.
- **Older events from the server.** Pass `onLoadMore`. When everything loaded is showing, the button becomes "Load older activity" and calls it. If it throws, the button says "Try again". Pass `hasMore={false}` once the server has no more.
- **Filters.** `filterable` adds a Person and an Action select above the list, built from the events passed in, plus "Clear filters". They apply only to events already loaded, and the page says so when more can be loaded. A change is announced through the "Showing N of M events" line.
- **Honest states.** With no events it says "No activity recorded yet" (change it with `emptyMessage`). While `loading` it says "Loading activity…", and with an `error` it says the activity could not be loaded. Neither claims there is no activity. `error` can sit above events that did load, and `stale` warns the list may be behind.
- **Structure.** One heading per day (level 3 by default, set `headingLevel`), each followed by an ordered list. Controls are a native `select` with a label and standard buttons.
- **Colour.** It uses the shadcn tokens (`border`, `muted-foreground`, `destructive`), so it follows your theme.

## What it does not do

- **It does not create, store, protect, retain or export events.** It only displays events your server recorded. Write them on the server, in the same handler that does the work.
- **It is not tamper-proof.** An audit log is only as trustworthy as the server that writes it. If the only copy of a record is in the browser, it is not a record.
- **It does not filter or page on the server.** Filters apply only to the events already loaded. Older events come from `onLoadMore`.
- **It does not remove sensitive details.** Keep message bodies, diagnoses, grades and other personal data out of `target`, `reason` and `detail`.
- **It makes no compliance claim.** Using it does not make a tool compliant with FERPA, HIPAA, 21 CFR Part 11, ISO 9001 or anything else. It is UI cues, not compliance.

## Props

### AuditTimeline

Also accepts the props of a `div` (except `children`), including `className` and `ref`. The prop types are exported as `AuditTimelineProps`.

| Prop           | Type                          | Description                                                                             |
| -------------- | ----------------------------- | --------------------------------------------------------------------------------------- |
| `events`       | `AuditEvent[]`                | Recorded events, in any order. Shown newest first. Required.                            |
| `timeZone`     | `string`                      | IANA zone for days and times, such as `"America/New_York"`. Required.                   |
| `initialCount` | `number`                      | Entries shown before "Show N more", and how many each press adds. Default `10`.         |
| `onLoadMore`   | `() => void \| Promise<void>` | Fetch older events from your server. Throw to show "Try again".                         |
| `hasMore`      | `boolean`                     | Whether your server has older events. Defaults to `true` when `onLoadMore` is set.      |
| `emptyMessage` | `string`                      | Shown when there are no events. Default "No activity recorded yet".                     |
| `filterable`   | `boolean`                     | Show the Person and Action filters. Default `false`.                                    |
| `loading`      | `boolean`                     | Says "Loading activity…" and does not claim the list is empty. Default `false`.         |
| `error`        | `string`                      | Says activity could not be loaded, then your message. Does not claim the list is empty. |
| `onRetry`      | `() => void`                  | Adds a Retry button beside `error`.                                                     |
| `stale`        | `boolean \| string`           | The list may be behind. `true` uses a default message, a string replaces it.            |
| `now`          | `Date \| string \| number`    | Pins the clock for relative times, for a demo or a test. Defaults to the real clock.    |
| `locale`       | `string`                      | BCP 47 locale for labels. Default `"en-US"`.                                            |
| `headingLevel` | `2 \| 3 \| 4`                 | Level of each day's heading. Default `3`.                                               |

### The AuditEvent type

Exported from `audit-event`, along with `AuditActor`, `AuditOutcome` and `AuditEventInput`.

| Field     | Type                                        | Description                                                                                 |
| --------- | ------------------------------------------- | ------------------------------------------------------------------------------------------- |
| `id`      | `string`                                    | Unique and stable. Used as the React key.                                                   |
| `at`      | `string`                                    | When it happened, as an ISO 8601 string, from the server's clock.                           |
| `actor`   | `string \| { name: string; role?: string }` | Who did it.                                                                                 |
| `action`  | `string`                                    | A short verb phrase that reads between actor and target: "approved", "sent the summary to". |
| `target`  | `string`                                    | What it acted on: "ECO-2041", "3 guardians".                                                |
| `reason`  | `string`                                    | Why. Shown on its own line.                                                                 |
| `outcome` | `"succeeded" \| "failed" \| "blocked"`      | How it ended. Shown as a word and an icon.                                                  |
| `detail`  | `string`                                    | Anything else worth reading. No sensitive details.                                          |

## Helpers

All of these are pure and are exported from `audit-event`, so they work on a server and are unit tested.

### buildAuditEvent

`(input: AuditEventInput, options?: { now?: () => Date; newId?: () => string; requireReason?: boolean }) => AuditEvent`

Builds one entry with the clock's time and a fresh id. Defaults to `new Date()` and `crypto.randomUUID()`; pass your own in tests. Trims text and drops blank optional fields. Throws an `AuditEventError` with a plain message when there is no actor or action, when the clock returns an invalid date, or, with `requireReason: true`, when the reason is missing or blank.

### groupByDay

`(events: AuditEvent[], timeZone: string, options?: { locale?: string }) => AuditDay[]`

Groups into calendar days in the zone, newest day first and newest entry first within a day. Entries with the same instant keep the order you gave them. Entries with a malformed `at` go in one "Date unknown" group at the end. It never changes the array it is given.

### formatEventTime

`(at: string, timeZone: string, options?: { locale?: string; timeOnly?: boolean }) => { label: string; iso: string | null }`

The absolute label with the zone, and the ISO string for `<time dateTime>`. For a malformed date the label is "Time unknown" and `iso` is `null`.

### Also exported

`formatRelativeTime(at, now, locale?)`, `filterAuditEvents(events, { actor, action })`, `distinctActors(events)`, `distinctActions(events)`, `actorName(actor)` and `actorRole(actor)`.

## Why it exists

Several rules and standards expect a record of who did what, when and why, for example the disclosure log in FERPA (34 CFR 99.32), audit controls in the HIPAA Security Rule (45 CFR 164.312), a name, time and meaning on a signed record in 21 CFR Part 11, and documented-information control in ISO 9001 (7.5.3). OWASP lists missing logging and monitoring as a top risk. Tools built quickly, by hand or with an AI agent, often leave the record out.

Whether any of these applies to your tool is not something this page can tell you. crisp-ui is UI only: access control, retention, encryption and consent live in your backend, and nothing here makes a tool compliant.

## Agent prompt

Paste this into your coding agent (Claude Code, Cursor, Codex or similar) in your project.

```text
Goal: add a read-only audit timeline ("who did what, when, and why") to my internal tool. It comes after confirm-send: "Sent to 3 people" becomes an event.

Install: npx shadcn@latest add https://realgood.site/r/audit-timeline.json
Read the installed files (components/ui/audit-timeline.tsx, lib/audit-event.ts) before writing any code. Do not guess props.

Contract:
type AuditEvent = { id: string; at: string /* ISO */; actor: string | { name: string; role?: string }; action: string; target?: string; reason?: string; outcome?: "succeeded" | "failed" | "blocked"; detail?: string }
<AuditTimeline events timeZone initialCount? onLoadMore? hasMore? emptyMessage? filterable? loading? error? onRetry? stale? />
buildAuditEvent(input, { now?, newId?, requireReason? }) throws if requireReason is set and the reason is blank.

Wiring rule: write an AuditEvent on the SERVER for every send, save and decision, in the same handler that does the work, with buildAuditEvent and the server clock. Record failed and blocked attempts too. The UI never creates the record; the page only fetches events and passes them in. Use requireReason for approvals and overrides. Never put message bodies or personal data in reason or detail.

States to handle: loading, empty ("No activity recorded yet"), error with retry, stale, failed and blocked outcomes, and a long list (initialCount, Show more).

Acceptance checks (run them and show me the output):
1. Typecheck and lint pass.
2. A send, a failed save and an approval each add one event with actor, time and reason visible.
3. Approving without a reason is rejected by the server.
4. Times show a time zone, and outcomes are readable without colour.
5. Keyboard only: the filters and Show more can be reached and used.

Do not build or claim tamper-proofing, retention, export, a log database or compliance. Reuse my existing storage; if there is none, keep events in memory and say so. No new dependencies or abstractions beyond this. If something is unclear, ask me.
```

## Examples by sector

Fictional data, to show the wording. Each is something the server wrote, not something the page made.

- **Education.** A record of a disclosure to a guardian: the registrar disclosed a student's attendance record, with the reason given and the fields shared, dates and attendance marks only.
- **Manufacturing.** A calibration marked overdue by a scheduler, then the gauge recalled by a quality engineer.
- **Engineering.** An approval of ECO-2041, with its reason and what the signature means.
- **Health.** An appointment reminder with nothing about the patient in the body: time and place only.

### Example events

```ts
const events: AuditEvent[] = [
  // Education: a record of a disclosure to a guardian.
  {
    id: "evt_2041",
    at: "2026-10-02T14:12:00Z",
    actor: { name: "Mara Lopez", role: "Registrar" },
    action: "disclosed the attendance record of",
    target: "student 0412 to their guardian",
    reason: "Guardian asked for this week's attendance",
    outcome: "succeeded",
    detail: "Fields shared: dates and attendance marks only.",
  },
  // Manufacturing: overdue, then recalled.
  {
    id: "evt_2042",
    at: "2026-09-30T19:40:00Z",
    actor: "Calibration scheduler",
    action: "marked calibration overdue on",
    target: "Gauge G-114",
    reason: "Due 15 Sep, no certificate on file",
    outcome: "succeeded",
  },
  {
    id: "evt_2043",
    at: "2026-09-30T19:41:00Z",
    actor: { name: "Dev Patel", role: "Quality engineer" },
    action: "recalled",
    target: "Gauge G-114",
    reason: "Last calibration was 14 months ago",
    outcome: "succeeded",
  },
  // Engineering: an approval with its reason and what the signature means.
  {
    id: "evt_2044",
    at: "2026-10-01T20:20:00Z",
    actor: { name: "Sam Okafor", role: "Project engineer" },
    action: "approved",
    target: "ECO-2041",
    reason: "Rev C fixes the tolerance stack-up",
    outcome: "succeeded",
    detail: "Signature meaning: approval.",
  },
  // Health: a reminder with nothing about the patient in the body.
  {
    id: "evt_2045",
    at: "2026-10-02T13:00:00Z",
    actor: { name: "Priya Nair", role: "Clinic manager" },
    action: "sent an appointment reminder to",
    target: "1 patient",
    reason: "Reminder 24 hours before the appointment",
    outcome: "succeeded",
    detail: "Message body: time and place only. No patient details.",
  },
]
```

## Next

Comes after: [`confirm-send`](https://realgood.site/docs/components/confirm-send.md), [`approval-step`](https://realgood.site/docs/components/approval-step.md). Leads to: [`status-strip`](https://realgood.site/docs/components/status-strip.md).
