Build an activity log operators actually trust
By the end of this recipe you'll have a paginated, polling timeline of every mutation in the tenant — actor + verb + target + relative time + a diff drawer — with filters for actor / event type / date, live updates, and CSV export.
Overview
A reverse-chronological list of events. Each row is one mutation: who, what, where, when. Click → a drawer slides in with the before/after diff. Polls every 15 seconds; new events fade in at the top.
SMBs run on trust, and trust runs on receipts. When the till is short ZWL 4,000, the operator wants to see who closed it, when, and what they marked it as — not "ask the manager". The activity log is the receipt. It's how you settle the question without a phone call, and it's why your power users put up with you charging them more than a spreadsheet.
Every action that mutates data MUST log to this trail. The trail is the receipt — never quiet a write. If you skip the log on "small" mutations, you've quietly told the operator their book is unreliable. There are no small mutations.
The data model is one row per event: {id, actorId, verb, targetType, targetId, targetLabel, at, diff?}. The verb is a string slug ("created", "updated", "deleted", "approved") so it sorts and filters cleanly. The diff is optional — only updates have one — and lives in a separate field so a 200-row list doesn't ship 200 JSON blobs over the wire.
Skip this recipe if your app is read-only, or if you're shipping a one-user tool that doesn't need post-hoc accountability. Activity logs are for shared data — solo tools don't need them.
What you'll build
Three states: the timeline, filters open, and a diff drawer.
All activity
Updated 3s ago
Filter activity
Actor
Event type
Date
Tendai updated Acme Wrench
2m ago · price changed
Diff
Tap any field to open the record
Required pieces
Everything this recipe pulls from @corelithzw/react.
@corelithzw/react exports used here: Stack, Avatar, FilterChips, Drawer, Button, Skeleton, EmptyState, useUrlState. A tiny relativeTime helper ships with the recipe.
Step-by-step build
Model the event as one flat row, diff as a side-channel
An event is a tuple, not a tree: {actor, verb, target, at} are everything the timeline needs to render. The diff is an optional field — only updates have one — and it loads lazily when the drawer opens. That keeps the timeline payload small even if the diff is a 4KB JSON blob.
DiffEntry[] = drawer-only payload (lazy fetch)
Switch to the Code tab to see the snippet.
Filters live in the URL, not local state
useUrlState serialises the filter set into query params (?actor=tendai&verb=updated&from=...&to=...). That makes filters share-safe — a manager can paste "show me Tendai's edits this week" into Slack — and survives reloads. No "I had filters set and they vanished" bug reports.
Paste the URL → same view, same filters.
Switch to the Code tab to see the snippet.
Poll for new events; fade them in at the top
Polling every 15 seconds with an If-Modified-Since header is good enough for an audit trail — operators don't need millisecond freshness, and pushing WebSockets through African mobile networks is a tax. New events get an animation class for the first 2 seconds so the user sees what changed.
Diff new items → prepend → 2s highlight
15s cadence. No socket, no auth juggling.
Switch to the Code tab to see the snippet.
Diff drawer fetches on open; verb-tone the avatar
The drawer only opens on demand, so the diff fetch is fine. Colour-tone the actor's avatar by verb so the eye can scan a 200-row page — green for approved, amber for updated, red for deleted. The verb stays the source of truth; the colour is a visual shortcut.
Final composition
The whole ActivityLog: filters in URL, polling, diff drawer, CSV export, empty/loading states.
Variations
Three forks.
Record-scoped log
Embed the same component inside a record detail page, scoped to one target. Fewer filters, no actor chip — the actor matters less when you're already on the record.
<ActivityLog
scope={{
targetType: 'invoice',
targetId: invoice.id,
}}
hideActorFilter
/>
// Backend filters by target_id.
Expand-all diffs inline
For compliance reviews, render diffs inline under each row with a single "Expand all" toggle. Heavy on payload — only enable for short ranges.
const [expandAll, setExpandAll] = useState(false);
{events.map((e) => (
<li>
{/* …row… */}
{expandAll && e.hasDiff &&
<InlineDiff eventId={e.id} />}
</li>
))}
Server-sent events
If you already run SSE for notifications, swap the poll for an event stream — instant updates with less mobile data than a 15s poll.
useEffect(() => {
const s = new EventSource('/api/activity/stream');
s.onmessage = (m) =>
setEvents((p) => [JSON.parse(m.data), ...p]);
return () => s.close();
}, []);
Accessibility
What this recipe takes care of for you.
- Feed semantics, not list semantics. The timeline uses
role="feed"withrole="article"children — screen readers announce position ("article 3 of 200") rather than the meaningless "list item 3 of 200". - Times are machine-readable. Every row uses
<time dateTime={iso}>{relative}</time>, so screen readers can read the absolute time on demand and AT can re-render the relative. - Verbs are real words, not icons. The colour-toned avatar is a shortcut; the verb is the source of truth — a screen reader hears "Tendai updated Acme Wrench", not "Tendai pencil Acme Wrench".
- Keyboard opens the drawer. Each row is
tabIndex={0}with an Enter handler;Drawertraps focus on open and returns it to the row on close. - Live updates are polite, not assertive. The new-row animation is visual only —
aria-liveis not set on the feed because announcing every new event would be unbearable. - Empty state is informative.
EmptyStatetells the user what to do ("clear a filter") rather than "no results". - CSV export is keyboard-only safe. Triggered by a real
Button; no drag-to-export, no right-click menu.