Home/ Cookbook/ Lists/ Activity log

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.

Lists 4 primitives · 1 block · 1 pattern ~35 min React · @corelithzw/react

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.

Activity

All activity

Updated 3s ago

T
Tendai updated price on Acme Wrench
2m
R
Rumbi approved PO #1284
8m
F
Farai created supplier Sibanda Co.
14m
T
Tendai deleted line item on Invoice #91
1h
R
Rumbi closed shift Till 2
3h
01 Timeline — actor + verb + target + when
Filters

Filter activity

Actor

TendaiRumbiFarai

Event type

createdupdateddeletedapproved

Date

TodayThis weekThis month
Export CSV
02 Filters — actor, type, date, export
Event

Tendai updated Acme Wrench

2m ago · price changed

Diff
price
$12.50$13.00
tax_code
VAT-15VAT-14.5
reason
supplier rate change

Tap any field to open the record

03 Drawer — before/after diff

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

01

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.

Code-only step

DiffEntry[] = drawer-only payload (lazy fetch)

Switch to the Code tab to see the snippet.

Step 1 · Model@corelithzw/react
02

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.

Code-only step

Paste the URL → same view, same filters.

Switch to the Code tab to see the snippet.

Step 2 · URL state
03

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.

Code-only step

Diff new items → prepend → 2s highlight

15s cadence. No socket, no auth juggling.

Switch to the Code tab to see the snippet.

Step 3 · Polling
04

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.

T
Tendai updated Acme Wrench
price
$12.50$13.00
tax
VAT-15VAT-14.5
Step 4 · Diff drawer

Final composition

The whole ActivityLog: filters in URL, polling, diff drawer, CSV export, empty/loading states.

Actor: Tendai updated This week
T
Tendai updated Acme Wrench
R
Rumbi approved PO #1284
ActivityLog.tsx@corelithzw/react

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" with role="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; Drawer traps 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-live is not set on the feed because announcing every new event would be unbearable.
  • Empty state is informative. EmptyState tells 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.