The Shared Logbook
Before an operator swings a shared camera toward their own smoke, they check the logbook: who moved it last, why, what did they say, what did the AI see? That's the activity log — the side drawer that turns a camera from a device into a conversation between agencies.
The engineering story here is a translation problem. The backend hands the drawer a flat audit trail — every PTZ sample, every message, every AI hit, as raw rows. During an incident, nobody can scan that. The drawer's whole job is turning forty rows of motor positions into one line a human reads in a second: "Jane Doe, CAL FIRE — Report of Fire — 4m ago." One row per event, not one row per sample. Everything below is in service of that translation.
One row in, one group out
The BFF route GET /api/activity?cameraId= returns ActivityLogResponse[]. Three
sourceType values matter:
sourceType | What produced it | How it renders |
|---|---|---|
CameraLease | Somebody took control and moved the camera | One row per lease, movements nested inside |
ActivityLog | A user-typed message | One row, message text on expand |
AIDetection | Condor AI flagged something | One row, image carousel on expand |
Every entry also carries entraDisplayName and companyName (who), reasonCode (why),
messageDate (when), isHighPriority, isSystem, and nullable pan/tilt/zoom.
Rows sharing a sourceId are merged into one ActivityLogGroup. A lease that produced 40 PTZ
samples becomes one row you can expand, not 40 rows. Everything else — messages, system
events — becomes a single-entry group of its own.
The pipeline
Two useMemos in ActivityLog.tsx, deliberately split:
activityLog
└─ filterActivityLog(log, settings) ← the settings panel's doing
└─ groupActivityLog(filtered, log) ← merge by sourceId, dedupe, label, key
└─ sortGroups(groups, settings, now) ← pinned block first
└─ visibleGroups ← AI rows dropped unless the AI toggle is on
Filtering and grouping share one memo keyed on [activityLog, settings]. Sorting is a second
memo that also depends on pinClock, so a pin window can expire and reorder the list without
regrouping anything.
Note the second argument to groupActivityLog — the unfiltered log. Movement labels are
inferred by comparing an entry to the previous one in the same lease, and when filters have
hidden that previous entry the labeller still needs it. Pass only the filtered list and the
first row of every lease silently mislabels itself.
Why a 40-sample lease shows six movements
deduplicateNearDuplicates drops an entry when it is within PTZ_TOLERANCE (0.01) of the
previous kept entry on all three axes and within 5 seconds of it. A continuous pan emits
intermediate positions the operator does not care about; this is what stops them from filling
the drawer.
What survives gets a label from getMovementTypeLabel, checked in this order:
| Check | Label |
|---|---|
reasonCode/message is a home movement | Move Camera Home |
reasonCode is Focus Adjustment | Focus Adjustment |
reasonCode is Brightness Adjustment | Brightness Adjustment |
| zoom moved beyond tolerance | Zoom |
| pan or tilt moved beyond tolerance | Pan/Tilt |
a reasonCode exists | the reason code, verbatim |
| nothing else matched | Camera Movement |
Zoom is checked before pan/tilt on purpose: a zoom usually nudges pan and tilt slightly, and "Zoom" is the honest description of what the operator did.
High-priority rows pin for an hour
An entry with isHighPriority floats to the top of the list for 60 minutes
(HIGH_PRIORITY_PIN_DURATION_MS) measured from the time it was logged, not from fetch
time. Inside the pinned block and the unpinned block the comparator is the same, so pinned rows
still read in the direction sort-by asks for.
The window is driven by a real clock, not by polling:
nextPinExpiry(groups, now)returns the earliest still-pinned deadline. An effect schedules exactly onesetTimeoutfor it; when it fires,pinClockadvances, the sort re-runs, the row drops, and the next deadline is scheduled.- The sort reads
Math.max(Date.now(), pinClock), so a timer that fires a hair early still applies the expiry it was scheduled for instead of stalling. - Hidden tabs freeze timers, so a
visibilitychangelistener re-checks on the way back. - Pinning runs after filtering — a high-priority entry the current filters exclude does not reappear.
Live updates
signalRStore.activityLogReceivedMessages is the feed. When it changes, the newest message is
appended if isLogEntryPresent says it is not already in the log, and the result runs through
dedupeLog. The whole pipeline above then re-runs.
A separate debounced effect (100 ms, keyed on activityLog) does two things every time the log
changes: it expands all rows, and it calls useUpdateActivityLogReadDateMutation to tell the
backend the user has seen everything current. That read date is what drives unread badges
elsewhere in the app.
Feature flags
Four compile-time constants at the top of ActivityLog.tsx, all true: ENABLE_REFRESH (the
refresh button it gates is commented out in the JSX), ENABLE_RESET, ENABLE_MESSAGE,
ENABLE_FILTER. They are not runtime flags — flipping one is a code change.
Next: the three panels — the list rows, the message form, and the filter settings.