The App That Remembers You
An operator pins four cameras at their desk, drives to an incident, opens the app on a
tablet in the truck — and their four cameras are already pinned, the map already parked over
their district. Everything the app remembers about a person — pins, map position, layer
toggles, AI thresholds — is one object, UISettings, fetched once and updated field by
field. One storage system, one lesson.
The shape
UISettings (generated from the backend schema — see lib/generated/backend-schemas.ts):
{
pinnedCameras?: string[] | null;
hiddenCameras?: string[] | null;
layerVisibility?: { label: string; id?: string; isLayerOn: boolean }[] | null;
mapOptions?: { lat: number; lon: number; zoom: number; scale?: number };
cameraOptions?: { hideInactiveCameras: boolean; sortBy?: string; layout?: string; ... };
aiSettings?: { aiButtonEnabled: boolean; aiConfidence: number; aiSoundEnabled: boolean; ... };
activityLogLastReadDateTime?: string | null;
preferredReasonCode?: string | null;
areaFilter?: AreaFilter;
}
Every field is optional. A brand-new user gets {} back, so always code for absent:
const layers = data?.layerVisibility ?? [];
Read once, write per field
Reading is a single hook — useUiSettings() — backed by GET /api/user/ui-settings. All
preferences arrive together, so components pull what they need from one cached query rather
than each firing their own request.
Writing is deliberately not symmetric. There is no "save all settings" endpoint. Each concern has its own narrow route, so two browser tabs changing different preferences can't clobber each other:
| Preference | Update route |
|---|---|
| Pinned / hidden cameras | PUT /api/user/pin-hide-camera-update |
| Map position | PUT /api/user/map-options-update |
| Layer visibility | PUT /api/user/layer-visibility-update |
| Camera list options | PUT /api/user/camera-options-update |
| AI detection settings | PUT /api/user/ai-settings-update |
| Activity-log read marker | PUT /api/user/activity-log-read-date |
| Preferred reason code | PUT /api/user/preferred-reason-code-update |
| Area filter | PUT /api/user/area-filter-update |
After a successful write, invalidate the settings query so the cache reflects the new value — the routes return the updated field, not the whole object.
Persistence depends on sign-in
Preferences live server-side, keyed to the Entra user id. Sign in on another machine and your pinned cameras follow you.
There is no anonymous mode — the app requires authentication (see
security/authentication), so there is no local-storage fallback path for these values.
The one exception is a shared view, which deliberately overrides your saved preferences
for the duration of a visit; that flow is on its own page.
Debounce anything continuous
Map position updates fire on every pan and zoom. Writing per event would flood the BFF, so
map-extent updates are debounced before the PUT — batch the trailing value rather than
sending intermediate frames.
The same applies to any slider-backed preference, notably aiConfidence.
Next: Shared views — how a shared link temporarily overrides all of this.