Skip to content

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.

Build this with your agent

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

Friday, October 2, 2026

  1. 20 minutes ago

    Mara Lopez (Registrar) sent the attendance summary to 3 guardiansSucceeded

    Reason: Weekly summary

    First names only. Sent to the saved list of 3.

  2. 34 minutes ago

    Mara Lopez (Registrar) saved the guardian listFailed

    The server rejected the save, so the list is unchanged.

Thursday, October 1, 2026

  1. 18 hours ago

    Sam Okafor (Project engineer) approved ECO-2041Succeeded

    Reason: Rev C fixes the tolerance stack-up

    Signature meaning: approval.

  2. yesterday

    Priya Nair (Clinic manager) tried to send reminders to 12 patientsBlocked

    Reason: Quiet hours: no sends between 9 pm and 8 am

    Nothing was sent.

Showing 4 of 6 events.

"use client"

import * as React from "react"

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 becomes an audit entry, and so does every save and decision. It closes the loop back to status-strip, because the next number you look at is the result of what was recorded.

Installation

pnpm dlx shadcn@latest add https://realgood.site/r/audit-timeline.json

Or, with the @crisp namespace set up:

pnpm dlx shadcn@latest add @crisp/audit-timeline

This also adds the shared audit-event helpers, the shadcn button and lucide-react.

Usage

import { AuditTimeline } from "@/components/ui/audit-timeline"
<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:

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.

PropTypeDescription
eventsAuditEvent[]Recorded events, in any order. Shown newest first. Required.
timeZonestringIANA zone for days and times, such as "America/New_York". Required.
initialCountnumberEntries 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".
hasMorebooleanWhether your server has older events. Defaults to true when onLoadMore is set.
emptyMessagestringShown when there are no events. Default "No activity recorded yet".
filterablebooleanShow the Person and Action filters. Default false.
loadingbooleanSays "Loading activity…" and does not claim the list is empty. Default false.
errorstringSays activity could not be loaded, then your message. Does not claim the list is empty.
onRetry() => voidAdds a Retry button beside error.
staleboolean | stringThe list may be behind. true uses a default message, a string replaces it.
nowDate | string | numberPins the clock for relative times, for a demo or a test. Defaults to the real clock.
localestringBCP 47 locale for labels. Default "en-US".
headingLevel2 | 3 | 4Level of each day's heading. Default 3.

The AuditEvent type

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

FieldTypeDescription
idstringUnique and stable. Used as the React key.
atstringWhen it happened, as an ISO 8601 string, from the server's clock.
actorstring | { name: string; role?: string }Who did it.
actionstringA short verb phrase that reads between actor and target: "approved", "sent the summary to".
targetstringWhat it acted on: "ECO-2041", "3 guardians".
reasonstringWhy. Shown on its own line.
outcome"succeeded" | "failed" | "blocked"How it ended. Shown as a word and an icon.
detailstringAnything 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.

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

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, approval-step. Leads to: status-strip.