Skip to main content

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:

PreferenceUpdate route
Pinned / hidden camerasPUT /api/user/pin-hide-camera-update
Map positionPUT /api/user/map-options-update
Layer visibilityPUT /api/user/layer-visibility-update
Camera list optionsPUT /api/user/camera-options-update
AI detection settingsPUT /api/user/ai-settings-update
Activity-log read markerPUT /api/user/activity-log-read-date
Preferred reason codePUT /api/user/preferred-reason-code-update
Area filterPUT /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.