Skip to main content

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:

sourceTypeWhat produced itHow it renders
CameraLeaseSomebody took control and moved the cameraOne row per lease, movements nested inside
ActivityLogA user-typed messageOne row, message text on expand
AIDetectionCondor AI flagged somethingOne 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:

CheckLabel
reasonCode/message is a home movementMove Camera Home
reasonCode is Focus AdjustmentFocus Adjustment
reasonCode is Brightness AdjustmentBrightness Adjustment
zoom moved beyond toleranceZoom
pan or tilt moved beyond tolerancePan/Tilt
a reasonCode existsthe reason code, verbatim
nothing else matchedCamera 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 one setTimeout for it; when it fires, pinClock advances, 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 visibilitychange listener 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.