import { action, type AddIntegrity, computed, Default, generateObject, handler, ImageData, NAME, nonPrivateRandom, pattern, type PerSpace, type PerUser, type RequiresIntegrity, safeDateNow, Stream, UI, type VNode, wish, Writable, } from "commonfabric"; import { type AdminManagerCredential, adminManagerCredentialIsActive, adminRegistryEntries, type EmptyAdminRegistryValue, } from "../../cfc/admin/mod.ts"; import { normalizePlateId, US_STATES } from "../../vehicles.ts"; // ============================================================ // Domain Types (DESIGN §4) // ============================================================ export type Classification = "ours" | "guest" | "offender" | "unknown"; // Phase 3b: known-vehicle registry entry (DESIGN §8). category "ours" is // derived from people[].vehicles — only guest/offender live here. export interface KnownVehicle { plateNumber: string; // normalized uppercase alphanumerics plateState: string; // uppercase 2-letter description: string; // human note, e.g. "white delivery van" category: "guest" | "offender"; name?: string; // optional display name for guests (e.g. "Mary Friend") org: string; // e.g. "Local Butcher Shop" label: string; // e.g. "delivery van, Tue mornings" } // Phase 2: structured result of LLM plate/vehicle extraction from a photo. export interface PlateExtraction { description: string; // color + make + model in plain words; "" if unclear plateNumber: string; // characters only; "" if not legible plateState: string; // 2-letter US state; "" if not visible confidence: "high" | "medium" | "low"; } // Lightweight persisted image reference. We deliberately store only the blob // `url` (+ filename), NOT the full `ImageData` — its required `data` field is a // ~700KB inline base64 string, and inlining that into the perSpace `sightings` // array destabilizes the cell's sync. The blob bytes live out-of-band at `url`. export interface SightingImage { url: string; name: string; } export interface Sighting { id: string; spotNumber: string; capturedAt: number; reportedBy: string; image: SightingImage; description: string; plateNumber: string; // normalized uppercase alphanumerics plateState: string; // uppercase 2-letter extractionPending: boolean; // Phase 2: true while LLM call is in flight extractionError: string; // Phase 2: non-empty if extraction failed humanCorrected: boolean; // Phase 2: true once a person edited the extracted fields classification: Classification; notes: string; } // ============================================================ // Cell Types (DESIGN §5) // ============================================================ // Spots cell — reuse parking-coordinator's shape (spotNumber + label) export interface ParkingSpot { spotNumber: string; label: string; notes?: string; active?: boolean; } type SpotsCell = Writable< | ParkingSpot[] | Default<[ { spotNumber: "1"; label: "Near entrance" }, { spotNumber: "5"; label: "" }, { spotNumber: "12"; label: "Compact only" }, { spotNumber: "13"; label: "" }, ]> >; type SightingsCell = Writable>; // Phase 3b: known-vehicle registry cell type KnownVehiclesCell = Writable>; // Phase 3b: loose person shape — we read only vehicles from it. // The full parking-coordinator Person has more fields; this covers what we need. // Phase 3c: vehicle element type includes the full coordinator shape so writes // from assignToPerson are compatible with the coordinator's UI. interface PersonWithVehicles { name: string; vehicles?: { plateId: string; plateState: string; color?: string; make?: string; model?: string; }[]; } type PeopleCell = Writable>; // ============================================================ // Admin Types (DESIGN §6) — mirror parking-coordinator // ============================================================ export const LOT_WATCH_ADMIN_INTEGRITY = "lot-watch-admin" as const; export const LOT_WATCH_ADMIN_MANAGER_INTEGRITY = "lot-watch-admin-manager" as const; export interface LotWatchAdminSubject { personName: string; } export interface LotWatchAdminRoleAssignment { subject: LotWatchAdminSubject; displayName: string; } export type LotWatchAdminRole = AddIntegrity< LotWatchAdminRoleAssignment, readonly [typeof LOT_WATCH_ADMIN_INTEGRITY] >; export type LotWatchAdminManagerCredential = AdminManagerCredential< typeof LOT_WATCH_ADMIN_MANAGER_INTEGRITY >; export type LotWatchAdminList = RequiresIntegrity< LotWatchAdminRole[], readonly [typeof LOT_WATCH_ADMIN_MANAGER_INTEGRITY] >; export interface LotWatchAdminRegistryStoredValue { admins?: LotWatchAdminList; } export type LotWatchAdminRegistryValue = | LotWatchAdminRegistryStoredValue | Default; export type LotWatchAdminRegistryCell = Writable; export type LotWatchAdminManagerCredentialCell = Writable< LotWatchAdminManagerCredential | null >; // ============================================================ // Pattern I/O (DESIGN §12, trimmed to Phase 1) // ============================================================ export interface LotWatchInput { spots?: PerSpace; sightings?: PerSpace; // Phase 3b: read employee vehicles → "ours" classification people?: PerSpace; // Phase 3b: guest/offender registries knownVehicles?: PerSpace; // Phase 3c: admin gating (DESIGN §6) adminRegistry?: PerSpace; adminManagerCredential?: PerUser; } export interface LotWatchOutput { [NAME]: string; [UI]: VNode; sightings: Sighting[]; knownVehicles: KnownVehicle[]; people: PersonWithVehicles[]; captureSighting: Stream<{ spotNumber: string; image: ImageData; description: string; plateNumber: string; plateState: string; notes: string; }>; deleteSighting: Stream<{ id: string }>; selectTab: Stream<{ tab: "capture" | "sightings" | "report" }>; setReporterName: Stream<{ name: string }>; markVehicle: Stream<{ plateNumber: string; plateState: string; category: "guest" | "offender"; org: string; label?: string; name?: string; }>; removeKnownVehicle: Stream<{ plateNumber: string; plateState: string }>; openAssign: Stream<{ id: string }>; cancelAssign: Stream; assignToPerson: Stream; openGuest: Stream<{ id: string }>; cancelGuest: Stream; saveGuest: Stream; enableAdminManager: Stream; togglePersonAdmin: Stream<{ name: string }>; toggleAdminMode: Stream; } // ============================================================ // Utilities // ============================================================ const genId = (): string => `sighting-${safeDateNow()}-${nonPrivateRandom().toString(36).slice(2, 10)}`; type ImageUploadEvent = { detail?: { images?: ImageData[]; allImages?: ImageData[]; files?: ImageData[]; allFiles?: ImageData[]; }; }; // ============================================================ // Module-scope handlers (MUST be at module scope — not inside pattern()) // ============================================================ const onPhotoCaptured = handler< ImageUploadEvent, { draftImage: Writable; captureStep: Writable<"photo" | "spot" | "review" | "saved">; } >(({ detail }, { draftImage, captureStep }) => { const img = (detail?.allImages ?? detail?.images ?? [])[0] ?? null; draftImage.set(img); // Auto-advance the wizard — once we have a photo, jump to spot picker. if (img) captureStep.set("spot"); }); // ============================================================ // Classification display helpers (module scope — not inside pattern()) // ============================================================ const classificationColor = (c: Classification): string => { if (c === "ours") return "#166534"; if (c === "guest") return "#1e40af"; if (c === "offender") return "#991b1b"; return "#374151"; // unknown }; const classificationBg = (c: Classification): string => { if (c === "ours") return "#dcfce7"; if (c === "guest") return "#dbeafe"; if (c === "offender") return "#fee2e2"; return "#f3f4f6"; // unknown }; // ============================================================ // Phase 3b: Classification helper (DESIGN §8) — module-scope pure function // so it can be called from inside computed() without capturing cells. // Priority: ours > offender > guest > unknown. // ============================================================ export const classifyPlate = ( plateNumber: string, plateState: string, ours: readonly { plateId: string; plateState: string }[], known: readonly KnownVehicle[], ): Classification => { if (!plateNumber) return "unknown"; const normPlate = normalizePlateId(plateNumber); const normState = plateState.toUpperCase().trim(); // 1. ours — from people[].vehicles for (const v of ours) { if ( normalizePlateId(v.plateId) === normPlate && v.plateState.toUpperCase().trim() === normState ) { return "ours"; } } // 2. offender takes priority over guest for (const kv of known) { if ( normalizePlateId(kv.plateNumber) === normPlate && kv.plateState.toUpperCase().trim() === normState && kv.category === "offender" ) { return "offender"; } } // 3. guest for (const kv of known) { if ( normalizePlateId(kv.plateNumber) === normPlate && kv.plateState.toUpperCase().trim() === normState && kv.category === "guest" ) { return "guest"; } } return "unknown"; }; // Phase 3: group sightings by normalized plate (plateNumber|plateState). // Module-scope pure helper so both the sightings list and the repeat-offender // summary derive from it without one computed reading another. Sightings with // no readable plate are skipped (can't be matched/deduped). export const plateKey = (plateNumber: string, plateState: string): string => `${plateNumber}|${plateState}`; export interface PlateGroup { plate: string; state: string; description: string; count: number; spots: string[]; firstSeen: number; lastSeen: number; isRepeat: boolean; // seen 2+ times => repeat offender } const groupSightingsByPlate = (all: readonly Sighting[]): PlateGroup[] => { const map = new Map(); for (const s of all) { if (!s.plateNumber) continue; const key = plateKey(s.plateNumber, s.plateState); const ts = Number(s.capturedAt); // coerce proxy → plain number const g = map.get(key); if (g) { g.count += 1; if (!g.spots.includes(s.spotNumber)) g.spots.push(s.spotNumber); g.firstSeen = Math.min(g.firstSeen, ts); g.lastSeen = Math.max(g.lastSeen, ts); if (!g.description && s.description) g.description = s.description; } else { map.set(key, { plate: s.plateNumber, state: s.plateState, description: s.description, count: 1, spots: [s.spotNumber], firstSeen: ts, lastSeen: ts, isRepeat: false, }); } } const groups: PlateGroup[] = []; for (const g of map.values()) { g.isRepeat = g.count >= 2; groups.push(g); } return groups; }; const fmtWhen = (ts: number): string => { const d = new Date(ts); return `${ d.toLocaleDateString("en-US", { month: "short", day: "numeric" }) } ${d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })}`; }; // ============================================================ // Admin helpers (DESIGN §6) — module-scope pure functions, mirror coordinator // ============================================================ // Demo-only identity model: the admin SUBJECT keys on the actor's free-text // `reporterName` (a perUser cell set by the user themselves on the capture // tab), exactly like parking-coordinator's `personName` subject. That means // any user can become an admin by typing an existing admin's name into the // reporter field — fine for a single-tenant lot demo, NOT acceptable for // production. Replace `reporterName` with a stable identity (user DID / // profile cell) before relying on this gate for real authorization. const lotWatchAdminSubject = (personName: string): LotWatchAdminSubject => ({ personName, }); const lotWatchAdminRolesValue = ( registry: LotWatchAdminRegistryCell, ): LotWatchAdminRole[] => adminRegistryEntries(registry); const personIsLotWatchAdmin = ( registry: LotWatchAdminRegistryCell, personName: string | undefined, ): boolean => { const trimmed = (personName ?? "").trim(); if (!trimmed) return false; return lotWatchAdminRolesValue(registry).some( (role) => role.subject.personName === trimmed, ); }; const prepareLotWatchAdminToggle = ( credential: LotWatchAdminManagerCredential | null | undefined, registry: LotWatchAdminRegistryCell, rawName: string, ): LotWatchAdminRole[] | null => { const personName = rawName.trim(); if (!adminManagerCredentialIsActive(credential) || personName === "") { return null; } const adminRoles = lotWatchAdminRolesValue(registry); const nextRoles = adminRoles.filter( (role) => role.subject.personName !== personName, ); if (nextRoles.length !== adminRoles.length) { return nextRoles; } return [ ...nextRoles, { subject: lotWatchAdminSubject(personName), displayName: personName, } as LotWatchAdminRole, ]; }; // ============================================================ // Default seed data // ============================================================ const DEFAULT_SPOTS: ParkingSpot[] = [ { spotNumber: "1", label: "Near entrance", active: true }, { spotNumber: "5", label: "", active: true }, { spotNumber: "12", label: "Compact only", active: true }, { spotNumber: "13", label: "", active: true }, ]; // ============================================================ // Pattern // ============================================================ export default pattern( ({ spots: inputSpots, sightings: inputSightings, people: inputPeople, knownVehicles: inputKnownVehicles, adminRegistry: inputAdminRegistry, }) => { // ---- Cells (DESIGN §5) ---- const spots = inputSpots ?? Writable.perSpace.of(DEFAULT_SPOTS); const sightings = inputSightings ?? Writable.perSpace.of([]); // Phase 3b: known-vehicle registry (guests + offenders). When wired from a // parent space we share the same cell; standalone we own it. const knownVehicles = inputKnownVehicles ?? Writable.perSpace.of([]); // Phase 3b: people cell — read-only for deriving the "ours" vehicle set. // When absent (standalone) the "ours" bucket is empty. const people = inputPeople ?? Writable.perSpace.of([]); // DESIGN §6: admin registry + manager credential (mirror coordinator) const defaultAdminRegistry = new Writable.perSpace< LotWatchAdminRegistryValue >( {} as LotWatchAdminRegistryValue, ); const adminRegistry = (inputAdminRegistry ?? defaultAdminRegistry) as LotWatchAdminRegistryCell; const adminManagerCredential = new Writable.perUser< LotWatchAdminManagerCredential | null >(null); // PerUser: who is reporting. Set from the viewer's shared profile (the // `#profile` wish's built-in UI covers profile create/pick); tests and // headless callers set it via the `setReporterName` stream. const reporterName = new Writable.perUser(""); const profileWish = wish<{ name?: string; avatar?: string }>({ query: "#profile", }); const profileNameWish = wish({ query: "#profileName" }); const profileAvatarWish = wish({ query: "#profileAvatar" }); const profileName = computed(() => (profileNameWish.result ?? "").trim()); const profileAvatar = computed(() => (profileAvatarWish.result ?? "").trim() ); const hasProfile = computed(() => (profileNameWish.result ?? "").trim() !== "" ); const reporterLabel = computed(() => (reporterName.get() || "").trim()); const hasReporter = computed(() => reporterLabel !== ""); // Show the profile avatar only while the reporter IS the profile (an // explicit setReporterName override keeps initials-only rendering). const reporterAvatar = computed(() => reporterLabel !== "" && reporterLabel === profileName ? profileAvatar : "" ); // PerSession: tab navigation const selectedTab = new Writable.perSession< "capture" | "sightings" | "report" >("capture"); // PerSession: admin mode toggle (only active admins can enable) const adminMode = new Writable.perSession(false); // PerSession: report tab filters const reportFilterSpot = new Writable.perSession(""); const reportFilterClassification = new Writable.perSession(""); // PerSession: capture draft fields const draftSpot = new Writable.perSession(""); const draftImage = new Writable.perSession(null); const draftDescription = new Writable.perSession(""); const draftPlateNumber = new Writable.perSession(""); const draftPlateState = new Writable.perSession("CA"); const draftNotes = new Writable.perSession(""); // Wizard step for the capture flow — one decision per screen so the UI // isn't an overwhelming wall of fields. Photo → Spot → Review → Saved. type CaptureStep = "photo" | "spot" | "review" | "saved"; const captureStep = new Writable.perSession("photo"); // PerSession: delete confirm dialog target const deleteConfirmTarget = new Writable.perSession(null); // Phase 3c: assign-to-person dialog state // assignTarget: sighting id whose person-picker is open, or null const assignTarget = new Writable.perSession(null); // assignPersonName: name of the person selected in the picker const assignPersonName = new Writable.perSession(""); // Guest name flow: inline form to optionally name a guest vehicle. // guestTarget: sighting id whose guest-name form is open, or null const guestTarget = new Writable.perSession(null); // guestName: free-text name typed in the guest form const guestName = new Writable.perSession(""); // ---- Phase 2: LLM extraction from the draft photo ---- // Runs reactively when a photo is captured. Uses the draft's inline `data` // (that's why `includeData` stays on the capture input) — transient, the // saved Sighting keeps only the blob url. const extraction = generateObject({ system: "You are reading a photo of a parked car to log a parking violation. " + "Extract the vehicle description (color + make + model in plain words), " + "the license plate characters, and the 2-letter US state if visible. " + "The photo may be rotated. If a field is not legible, return an empty " + "string — do not guess.", prompt: computed(() => { const img = draftImage.get(); const image = img?.data ?? img?.url; if (!image) return []; return [ { type: "image" as const, image }, { type: "text" as const, text: "Extract description, plateNumber (characters only, no spaces or " + "dashes), plateState (2-letter), and your confidence.", }, ]; }), schema: { type: "object", properties: { description: { type: "string", description: "Color + make + model in plain words, e.g. 'white Toyota Corolla'", }, plateNumber: { type: "string", description: "Plate characters only, uppercase, no spaces/dashes; '' if illegible", }, plateState: { type: "string", description: "2-letter US state code if visible, else ''", }, confidence: { type: "string", enum: ["high", "medium", "low"], }, }, }, model: "anthropic:claude-sonnet-4-5", }); // ---- Actions ---- const selectTab = action<{ tab: "capture" | "sightings" | "report" }>( ({ tab }) => { selectedTab.set(tab); }, ); // DESIGN §6: admin actions — mirror coordinator exactly const enableAdminManager = action(() => { adminManagerCredential.set({ canManageAdmins: true, } as LotWatchAdminManagerCredential); }); const togglePersonAdmin = action<{ name: string }>(({ name }) => { const nextAdmins = prepareLotWatchAdminToggle( adminManagerCredential.get(), adminRegistry, name, ); if (nextAdmins === null) return; adminRegistry.set({ admins: nextAdmins as LotWatchAdminList }); }); const toggleAdminMode = action(() => { const isAdmin = personIsLotWatchAdmin( adminRegistry, reporterName.get() || "", ); if (!isAdmin) { adminMode.set(false); return; } adminMode.set(!adminMode.get()); }); // One-shot curator promotion — the lot demo has no separate "admin // manager" persona, so the full CFC ceremony (enable manager → toggle // role → flip view) collapses to a single button. Sets the credential, // promotes the current `reporterName` to lot-watch admin (if not // already), and turns admin view on. `personIsLotWatchAdmin` then // gates curation actions exactly as before — only the UX collapses, // not the underlying integrity model. const becomeCurator = action(() => { const name = (reporterName.get() || "").trim(); if (!name) return; // need a reporter identity to bind the role to adminManagerCredential.set({ canManageAdmins: true, } as LotWatchAdminManagerCredential); // Toggle the role only if not already an admin (so an already-admin // user clicking "Become curator" doesn't accidentally step down). if (!personIsLotWatchAdmin(adminRegistry, name)) { const nextAdmins = prepareLotWatchAdminToggle( adminManagerCredential.get(), adminRegistry, name, ); if (nextAdmins !== null) { adminRegistry.set({ admins: nextAdmins as LotWatchAdminList }); } } adminMode.set(true); }); // Symmetric one-click step-down: drop view + drop the role for the // current reporter, so "Become curator" is again a single click later. const stepDownCurator = action(() => { adminMode.set(false); const name = (reporterName.get() || "").trim(); if (!name) return; if (personIsLotWatchAdmin(adminRegistry, name)) { const nextAdmins = prepareLotWatchAdminToggle( adminManagerCredential.get(), adminRegistry, name, ); if (nextAdmins !== null) { adminRegistry.set({ admins: nextAdmins as LotWatchAdminList }); } } }); // Quick-pick: set the assignPersonName cell to a known name. Used by // the chips in the "It's a known person's car" picker. const setAssignPersonName = action<{ name: string }>(({ name }) => { assignPersonName.set(name); }); // Spot selection — cell mutations must go through an action(), not a bare // `.set()` in an inline onClick. Also auto-prefills the editable fields // from the LLM extraction (if it has resolved) so the user lands on // pre-filled values they can review/correct in the next step, and advances // the wizard. const setDraftSpot = action<{ spot: string }>(({ spot }) => { draftSpot.set(spot); const r = extraction.result; if (r?.description && !draftDescription.get()) { draftDescription.set(r.description); } if (r?.plateNumber && !draftPlateNumber.get()) { draftPlateNumber.set(r.plateNumber); } if (r?.plateState && !draftPlateState.get()) { draftPlateState.set(r.plateState); } captureStep.set("review"); }); // Programmatic setter for the perUser `reporterName`. The capture tab's // "Report as " button adopts the resolved profile name; tests // (and any non-UI caller) use this Stream seam to set an explicit name. const setReporterName = action<{ name: string }>(({ name }) => { reporterName.set(name); }); // Adopt the viewer's resolved shared-profile name as the reporter name. const adoptProfileName = action(() => { const name = (profileNameWish.result ?? "").trim(); if (!name) return; reporterName.set(name); }); // Phase 2: copy the LLM extraction into the editable draft fields. The // extracted values are read in JSX (where `extraction.result` is reactive) // and passed in as plain args — an action can't read a generateObject // result directly. The user can then review/correct before saving. const applyExtraction = action(() => { const r = extraction.result; if (r?.description) draftDescription.set(r.description); if (r?.plateNumber) draftPlateNumber.set(r.plateNumber); if (r?.plateState) draftPlateState.set(r.plateState); }); const captureSighting = action<{ spotNumber: string; image: ImageData; description: string; plateNumber: string; plateState: string; notes: string; }>(({ spotNumber, image, description, plateNumber, plateState, notes }) => { const normalizedPlate = normalizePlateId(plateNumber); const normalizedState = plateState.toUpperCase().trim().slice(0, 2); // Persist ONLY the lightweight blob reference (`url` + `name`), never the // inline base64 `data` — a ~700KB data-URL inline in this perSpace array // destabilizes the cell's sync. The draft kept `data` for transient use // (Phase 2 LLM); the stored record stays light. (Idiom: photo.tsx.) const lightImage = { url: image?.url ?? "", name: image?.name ?? "" }; const sighting: Sighting = { id: genId(), spotNumber, capturedAt: safeDateNow(), reportedBy: reporterName.get() || "Unknown", image: lightImage, description: description.trim(), plateNumber: normalizedPlate, plateState: normalizedState, extractionPending: false, // Phase 2: set true, then resolve via LLM extractionError: "", humanCorrected: false, classification: "unknown", // Phase 2: classify against registries notes: notes.trim(), }; sightings.set([...(sightings.get() ?? []), sighting]); // Reset draft draftSpot.set(""); draftImage.set(null); draftDescription.set(""); draftPlateNumber.set(""); draftPlateState.set("CA"); draftNotes.set(""); // Advance the wizard to the "saved" confirmation step — the user stays // on the Capture tab and can immediately capture another car. captureStep.set("saved"); }); // Wizard navigation helpers const goBackToPhoto = action(() => captureStep.set("photo")); const goBackToSpot = action(() => captureStep.set("spot")); const captureAnother = action(() => { // captureSighting already cleared the drafts; just rewind the step. captureStep.set("photo"); }); // "Discard" from the spot/review steps when the user picked the wrong // photo and wants to start over. const discardDraft = action(() => { draftSpot.set(""); draftImage.set(null); draftDescription.set(""); draftPlateNumber.set(""); draftPlateState.set("CA"); draftNotes.set(""); captureStep.set("photo"); }); const submitCapture = action(() => { const img = draftImage.get(); const spot = draftSpot.get(); if (!img || !spot) return; // perSession reads can be undefined before first write — fall back. captureSighting.send({ spotNumber: spot, image: img, description: draftDescription.get() ?? "", plateNumber: draftPlateNumber.get() ?? "", plateState: draftPlateState.get() ?? "CA", notes: draftNotes.get() ?? "", }); }); const deleteSighting = action<{ id: string }>(({ id }) => { // Admin-gated: only active admins may delete sightings if (!personIsLotWatchAdmin(adminRegistry, reporterName.get() || "")) { return; } sightings.set((sightings.get() ?? []).filter((s) => s.id !== id)); deleteConfirmTarget.set(null); }); const initiateDelete = action<{ id: string }>(({ id }) => { // Admin-gated: only show confirm dialog to active admins if (!personIsLotWatchAdmin(adminRegistry, reporterName.get() || "")) { return; } deleteConfirmTarget.set(id); }); const cancelDelete = action(() => { deleteConfirmTarget.set(null); }); // Phase 3c: assign-to-person actions — write the plate into the shared // parking-coordinator people cell so sightings classify as "ours". const openAssign = action<{ id: string }>(({ id }) => { assignTarget.set(id); // Default to the first person's name so the picker has a sensible value. const first = (people.get() ?? [])[0]; assignPersonName.set(first?.name ?? ""); }); const cancelAssign = action(() => { assignTarget.set(null); }); // Reads assignTarget + assignPersonName from cells (safe inside action). // Writes a coordinator-shaped Vehicle into that person's vehicles array. // Admin-gated: only active admins may assign vehicles to people. const assignToPerson = action(() => { if (!personIsLotWatchAdmin(adminRegistry, reporterName.get() || "")) { return; } const targetId = assignTarget.get(); const personName = assignPersonName.get() ?? ""; if (!targetId || !personName) return; // Find the sighting by id. const all = sightings.get() ?? []; let sightingPlate = ""; let sightingState = ""; for (const s of all) { if (s.id === targetId) { sightingPlate = s.plateNumber; sightingState = s.plateState; break; } } if (!sightingPlate) return; // no readable plate — nothing to write const normPlate = normalizePlateId(sightingPlate); const normState = sightingState.toUpperCase().trim(); // Build a coordinator-compatible Vehicle object (extra fields fine). const newVehicle = { plateId: normPlate, plateState: normState, color: "", make: "", model: "", }; // Update the people list. Two paths: // (a) Named person exists → append the vehicle to their list (dedupe by // plateId|state). // (b) Name doesn't match anyone → CREATE a new person with this // vehicle. This is the "oh, actually that's Gideon's car" path: // the lot operator types a name that isn't in parking-coordinator // yet, and we add Gideon + the plate in one shot instead of // silently dropping the write. const currentPeople = people.get() ?? []; const trimmedName = personName.trim(); const matchIdx = currentPeople.findIndex((p) => p.name.trim().toLowerCase() === trimmedName.toLowerCase() ); let updatedPeople: PersonWithVehicles[]; if (matchIdx >= 0) { updatedPeople = currentPeople.map((p, i) => { if (i !== matchIdx) return p; const existing = p.vehicles ?? []; for (const v of existing) { if ( normalizePlateId(v.plateId) === normPlate && v.plateState.toUpperCase().trim() === normState ) { return p; // already there } } return { ...p, vehicles: [...existing, newVehicle] }; }); } else { updatedPeople = [ ...currentPeople, { name: trimmedName, vehicles: [newVehicle] }, ]; } people.set(updatedPeople); assignTarget.set(null); }); // Guest name flow actions. const openGuest = action<{ id: string }>(({ id }) => { guestTarget.set(id); guestName.set(""); }); const cancelGuest = action(() => { guestTarget.set(null); }); // Reads guestTarget (sighting id) + guestName from cells; writes to registry. const saveGuest = action(() => { // Admin-gated: only active admins may curate guests if (!personIsLotWatchAdmin(adminRegistry, reporterName.get() || "")) { return; } const targetId = guestTarget.get(); if (!targetId) return; // Find the sighting to get its plate/state. const all = sightings.get() ?? []; let sightingPlate = ""; let sightingState = ""; for (const s of all) { if (s.id === targetId) { sightingPlate = s.plateNumber; sightingState = s.plateState; break; } } if (!sightingPlate) return; // no readable plate const normPlate = normalizePlateId(sightingPlate); const normState = sightingState.toUpperCase().trim().slice(0, 2); const guestNameVal = guestName.get() ?? ""; // Write directly to registry (same logic as markVehicle). const entry: KnownVehicle = { plateNumber: normPlate, plateState: normState, description: "", category: "guest", name: guestNameVal, org: "", label: "", }; const current = knownVehicles.get() ?? []; let found = false; const updated: KnownVehicle[] = []; for (const kv of current) { if ( normalizePlateId(kv.plateNumber) === normPlate && kv.plateState.toUpperCase().trim() === normState ) { updated.push({ ...kv, category: "guest", name: guestNameVal }); found = true; } else { updated.push(kv); } } if (!found) updated.push(entry); knownVehicles.set(updated); guestTarget.set(null); }); // Phase 3b: curation actions — add/update a plate in the known registry. // Admin-gated: only active admins may curate the registry. const markVehicle = action<{ plateNumber: string; plateState: string; category: "guest" | "offender"; org: string; label?: string; name?: string; }>(({ plateNumber, plateState, category, org, label, name }) => { if (!personIsLotWatchAdmin(adminRegistry, reporterName.get() || "")) { return; } const normPlate = normalizePlateId(plateNumber); const normState = plateState.toUpperCase().trim().slice(0, 2); const entry: KnownVehicle = { plateNumber: normPlate, plateState: normState, description: "", category, name: name ?? "", org: org ?? "", label: label ?? "", }; const current = knownVehicles.get() ?? []; // Dedupe: if plate|state already exists, update category/org/label/name. let found = false; const updated: KnownVehicle[] = []; for (const kv of current) { if ( normalizePlateId(kv.plateNumber) === normPlate && kv.plateState.toUpperCase().trim() === normState ) { updated.push({ ...kv, category, name: name ?? kv.name, org: org ?? kv.org, label: label ?? kv.label, }); found = true; } else { updated.push(kv); } } if (!found) updated.push(entry); knownVehicles.set(updated); }); const removeKnownVehicle = action<{ plateNumber: string; plateState: string; }>(({ plateNumber, plateState }) => { if (!personIsLotWatchAdmin(adminRegistry, reporterName.get() || "")) { return; } const normPlate = normalizePlateId(plateNumber); const normState = plateState.toUpperCase().trim(); knownVehicles.set( (knownVehicles.get() ?? []).filter( (kv) => !(normalizePlateId(kv.plateNumber) === normPlate && kv.plateState.toUpperCase().trim() === normState), ), ); }); // ---- Pre-computed display data (avoid OpaqueCell closures in .map()) ---- // Active spots for the spot picker. We read the perSession `draftSpot` // HERE (top-level computed) and emit `selected` per spot — reading it in a // `computed()` nested inside the `.map()` below silently returns undefined // (a narrower perSession cell can't be followed from this space-scoped // render context), so the selected highlight would never update. const activeSpots = computed(() => { const chosen = draftSpot.get(); return (spots.get() ?? []).filter((s) => { // active field may be undefined on spots from coordinator — treat as active const isActive = (s as ParkingSpot).active; return isActive === undefined || isActive === true; }).map((s) => ({ spotNumber: s.spotNumber, label: s.label, selected: chosen === s.spotNumber, })); }); // Sightings in reverse-chronological order with display-ready data. // Phase 3b: classification is derived LIVE from registries here — not from // s.classification — so promoting a plate instantly reclassifies all rows. const sightingRows = computed(() => { // Use .map() directly on the cell array (spread `[...cell.get()]` throws // "not iterable" inside a computed), then reverse the resulting plain // array for newest-first order. const all = sightings.get() ?? []; // Phase 3b: read registries ONCE at the top of this computed. const ourVehicles = (people.get() ?? []).flatMap( (p) => p.vehicles ?? [], ); const knownList = knownVehicles.get() ?? []; // GOTCHA §1: read adminMode + reporterName HERE at the top of this // computed and bake plain booleans per row. Do NOT read perSession cells // in a computed() nested inside the .map() below — it silently never // re-renders. const isAdminValue = adminMode.get() && personIsLotWatchAdmin(adminRegistry, reporterName.get() || ""); // Per-row inline-open state. We read the perSession "which row's form is // open" cells HERE, at the top of this computed, and emit a plain boolean // per row. Defining a `computed()` *inside* the `.map()` below that reads // these perSession cells does NOT reliably re-render when they change; // deriving the flags in this single top-level computed does. const confirmId = deleteConfirmTarget.get(); const guestId = guestTarget.get(); const assignId = assignTarget.get(); const repeatKeys = new Set( groupSightingsByPlate(all) .filter((g) => g.isRepeat) .map((g) => plateKey(g.plate, g.state)), ); return all.map((s) => { const date = new Date(s.capturedAt); // capturedAt is a plain number stored in perSpace const dateStr = date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", }); const timeStr = date.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", }); const plateDisplay = s.plateNumber ? `${s.plateNumber}${s.plateState ? " (" + s.plateState + ")" : ""}` : ""; const imgSrc = s.image?.url ?? ""; // Phase 3b: live classification from registries (not stored field) const cls = classifyPlate( s.plateNumber, s.plateState, ourVehicles, knownList, ); // Phase 3b: show curation buttons only for unknown plates with a // readable plate number. Pre-compute plate/state as plain strings so // the onClick arrow can read them without touching any cell. const canMark = cls === "unknown" && !!s.plateNumber; // Phase 3c: "assign to person" button — show when plate is readable // and currently unknown (same gate as canMark, kept separate for clarity). const canAssign = !!s.plateNumber && cls === "unknown"; // knownTag: supplemental label shown next to the classification chip. // For guests: the optional name. For offenders: the org name. let knownTag = ""; if (cls === "guest" || cls === "offender") { const normPlate = normalizePlateId(s.plateNumber); const normState = s.plateState.toUpperCase().trim(); for (const kv of knownList) { if ( normalizePlateId(kv.plateNumber) === normPlate && kv.plateState.toUpperCase().trim() === normState ) { knownTag = cls === "guest" ? (kv.name ?? "") : (kv.org ?? ""); break; } } } return { id: s.id, spotNumber: s.spotNumber, description: s.description, plateDisplay, plateNumber: s.plateNumber, plateState: s.plateState, reportedBy: s.reportedBy, dateStr, timeStr, imgSrc, notes: s.notes, classificationLabel: cls, classificationColor: classificationColor(cls), classificationBg: classificationBg(cls), isRepeat: s.plateNumber ? repeatKeys.has(plateKey(s.plateNumber, s.plateState)) : false, canMark, canAssign, canCurate: isAdminValue, // baked boolean — GOTCHA §1 knownTag, isConfirmOpen: confirmId === s.id, isGuestOpen: guestId === s.id, isAssignOpen: assignId === s.id, }; }).reverse(); }); // Phase 3: repeat offenders — plates seen 2+ times, newest activity first. const repeatOffenders = computed(() => groupSightingsByPlate(sightings.get() ?? []) .filter((g) => g.isRepeat) .map((g) => ({ plate: g.plate, state: g.state, description: g.description, count: g.count, spotsLabel: g.spots.map((n) => "#" + n).join(", "), firstSeen: fmtWhen(g.firstSeen), lastSeen: fmtWhen(g.lastSeen), })) .sort((a, b) => b.count - a.count) ); const hasRepeatOffenders = computed(() => groupSightingsByPlate(sightings.get() ?? []).some((g) => g.isRepeat) ); // Save is disabled until an image is captured AND a spot is chosen. // (Read writables with .get() and return a real boolean — referencing a // computed inside JSX props and negating it would coerce the cell object, // not its value.) // Reverse-chron count for the Sightings header / empty-state. const sightingCount = computed(() => (sightings.get() ?? []).length); const noSightings = computed(() => (sightings.get() ?? []).length === 0); // Tab visibility — used as ternary conditions directly in JSX. NOTE: // perSession `.get()` returns `undefined` until first written (the // constructor default is NOT returned by `.get()`), so fall back to // "capture" — otherwise the whole body renders blank on first load. const isCaptureTab = computed(() => (selectedTab.get() ?? "capture") === "capture" ); const isSightingsTab = computed(() => selectedTab.get() === "sightings"); const isReportTab = computed(() => selectedTab.get() === "report"); // Wizard step gates — same `?? "photo"` fallback pattern as `isCaptureTab` // (perSession defaults aren't returned by `.get()` on first read). const isPhotoStep = computed(() => (captureStep.get() ?? "photo") === "photo" ); const isSpotStep = computed(() => captureStep.get() === "spot"); const isReviewStep = computed(() => captureStep.get() === "review"); const isSavedStep = computed(() => captureStep.get() === "saved"); // Source for the in-flight draft photo — prefer the transient `data` URL // (set by `includeData`) so the user sees their photo immediately even // before the blob has been served back. Falls back to the blob `url`. const draftImageSrc = computed(() => { const img = draftImage.get(); return img?.data ?? img?.url ?? ""; }); // "Spot #X — label" for the review step header. const draftSpotLabel = computed(() => { const num = draftSpot.get(); if (!num) return ""; const found = (spots.get() ?? []).find((s) => s.spotNumber === num); const lbl = found?.label; return lbl ? `Spot #${num} — ${lbl}` : `Spot #${num}`; }); // DESIGN §6: only `adminModeEnabled` survives the single-button curator // collapse — the multi-admin computeds (currentPersonIsAdmin / // currentUserCanManageAdmins / adminAccessRows / reporterAdminInfo) were // removed along with the old Admin Panel UI they fed; the gating itself // still keys on personIsLotWatchAdmin(reporterName) per action. const adminModeEnabled = computed(() => adminMode.get() && personIsLotWatchAdmin(adminRegistry, reporterName.get() || "") ); // DESIGN §10: Report tab computeds — all over PerSpace sightings (guard ?? []) // Spot occupancy frequency — derived from the `spots` cell (NOT a hardcoded // ["1","5","12","13"] list) so the report stays correct when spots are // added/removed/relabeled in parking-coordinator. const spotOccupancy = computed(() => { const all = sightings.get() ?? []; const ourVehicles = (people.get() ?? []).flatMap((p) => p.vehicles ?? []); const knownList = knownVehicles.get() ?? []; const allSpots = (spots.get() ?? []).filter((s) => { const isActive = (s as ParkingSpot).active; return isActive === undefined || isActive === true; }); return allSpots.map((spot) => { const spotNum = spot.spotNumber; const forSpot = all.filter((s) => s.spotNumber === spotNum); const nonOurs = forSpot.filter((s) => { const cls = classifyPlate( s.plateNumber, s.plateState, ourVehicles, knownList, ); return cls !== "ours"; }); return { spotNum, total: forSpot.length, nonOursCount: nonOurs.length, }; }); }); // Repeat-offender leaderboard: offender-classified plates ranked by count, // plus frequent unknowns (seen 3+ times) const offenderLeaderboard = computed(() => { const all = sightings.get() ?? []; const ourVehicles = (people.get() ?? []).flatMap((p) => p.vehicles ?? []); const knownList = knownVehicles.get() ?? []; const groups = groupSightingsByPlate(all); return groups .filter((g) => { // Find the classification of this plate group if (!g.plate) return false; const cls = classifyPlate(g.plate, g.state, ourVehicles, knownList); // Include offenders + frequent unknowns (3+ sightings) return cls === "offender" || (cls === "unknown" && g.count >= 3); }) .map((g) => { const cls = classifyPlate(g.plate, g.state, ourVehicles, knownList); // Find org/name from registry for offenders let org = ""; if (cls === "offender") { const normP = normalizePlateId(g.plate); const normS = g.state.toUpperCase().trim(); for (const kv of knownList) { if ( normalizePlateId(kv.plateNumber) === normP && kv.plateState.toUpperCase().trim() === normS ) { org = kv.org ?? ""; break; } } } return { plate: g.plate, state: g.state, description: g.description, count: g.count, spotsLabel: g.spots.map((n) => "#" + n).join(", "), lastSeen: fmtWhen(g.lastSeen), cls, org, }; }) .sort((a, b) => b.count - a.count); }); // Recent activity feed — reverse-chron with filters applied const recentActivity = computed(() => { const all = sightings.get() ?? []; const ourVehicles = (people.get() ?? []).flatMap((p) => p.vehicles ?? []); const knownList = knownVehicles.get() ?? []; const filterSpot = reportFilterSpot.get() ?? ""; const filterCls = reportFilterClassification.get() ?? ""; return all .map((s) => { const cls = classifyPlate( s.plateNumber, s.plateState, ourVehicles, knownList, ); const capturedAtMs = Number(s.capturedAt); return { id: s.id, spotNumber: s.spotNumber, imgSrc: s.image?.url ?? "", reportedBy: s.reportedBy, when: fmtWhen(capturedAtMs), capturedAt: capturedAtMs, cls, clsColor: classificationColor(cls), clsBg: classificationBg(cls), plateDisplay: s.plateNumber ? `${s.plateNumber}${ s.plateState ? " (" + s.plateState + ")" : "" }` : "", description: s.description, }; }) .filter((r) => { if (filterSpot && r.spotNumber !== filterSpot) return false; if (filterCls && r.cls !== filterCls) return false; return true; }) .sort((a, b) => b.capturedAt - a.capturedAt); }); // Boolean gates for report tab empty states const noRecentActivity = computed(() => { const all = sightings.get() ?? []; const filterSpot = reportFilterSpot.get() ?? ""; const filterCls = reportFilterClassification.get() ?? ""; const ourVehicles = (people.get() ?? []).flatMap((p) => p.vehicles ?? []); const knownList = knownVehicles.get() ?? []; if (all.length === 0) return true; for (const s of all) { if (filterSpot && s.spotNumber !== filterSpot) continue; if (filterCls) { const cls = classifyPlate( s.plateNumber, s.plateState, ourVehicles, knownList, ); if (cls !== filterCls) continue; } return false; // found at least one match } return true; }); // Filter options for report tab const reportSpotOptions = computed(() => { const all = sightings.get() ?? []; const usedSpots = [...new Set(all.map((s) => s.spotNumber))].sort(); return [ { label: "All spots", value: "" }, ...usedSpots.map((s) => ({ label: `Spot #${s}`, value: s })), ]; }); const reportClsOptions = [ { label: "All classifications", value: "" }, { label: "Ours", value: "ours" }, { label: "Guest", value: "guest" }, { label: "Offender", value: "offender" }, { label: "Unknown", value: "unknown" }, ]; // State select items const stateSelectItems = US_STATES.map((s) => ({ label: s, value: s })); // Phase 3c: gate the assign picker's chip list on whether any people // exist. The chips themselves map `people` directly (recipe A from // `gotchas/closure-capture-in-nested-map.md` — works post-CT-1626). const hasPeople = computed(() => (people.get() ?? []).length > 0); // Disable "Become curator" until a reporter identity exists; the admin // role keys on `reporterName`. const noReporterName = computed(() => !(reporterName.get() ?? "").trim()); // ---- UI ---- return { [NAME]: "Lot Watch", [UI]: ( {/* Header with tab navigation */}
Lot Watch { /* One-click curator toggle — collapses the parking-coordinator CFC ceremony (enable manager → toggle role → flip view) into a single button per `becomeCurator` / `stepDownCurator`. */ } {adminModeEnabled ? ( stepDownCurator.send()} > 🔒 Step down ) : ( becomeCurator.send()} > 🔓 Curator mode )} (selectedTab.get() ?? "capture") === "capture" ? "primary" : "secondary" )} size="sm" onClick={() => selectTab.send({ tab: "capture" })} > 📸 Capture selectedTab.get() === "sightings" ? "primary" : "secondary" )} size="sm" onClick={() => selectTab.send({ tab: "sightings" })} > 🚗 Sightings selectedTab.get() === "report" ? "primary" : "secondary" )} size="sm" onClick={() => selectTab.send({ tab: "report" })} > 📊 Report
{/* ====== CAPTURE TAB ====== */} {isCaptureTab ? ( { /* Persistent reporter banner (always visible, small). Identity comes from the viewer's shared profile: the wish UI handles create/pick, then one click adopts the resolved name as the reporter. */ } Reporting as {hasReporter ? ( {reporterLabel} ) : hasProfile ? ( adoptProfileName.send()} > Report as {profileName} ) : (
{profileWish[UI]}
)}
{/* STEP 1 — Photo */} {isPhotoStep ? ( Step 1 of 3 — Take a photo Photograph the car. We'll read the plate automatically while you pick the spot. { /* `includeData` gives the draft both `url` (blob store) and inline `data` for transient LLM use. We persist only the `url` into the sighting; see captureSighting. Idiom per photo.tsx. */ } ) : null} {/* STEP 2 — Pick a spot (show the photo big) */} {isSpotStep ? ( Step 2 of 3 — Which spot? {computed(() => { const src = draftImageSrc; return src ? ( Captured car ) : null; })} {activeSpots.map((spot) => { const spotNum = spot.spotNumber; return ( setDraftSpot.send({ spot: spotNum })} > #{spotNum} {spot.label ? ( {spot.label} ) : null} ); })} goBackToPhoto.send()} > ← Different photo discardDraft.send()} > Discard ) : null} {/* STEP 3 — Confirm (LLM extraction + editable fields) */} {isReviewStep ? ( {/* Header: thumbnail + spot */} Step 3 of 3 — Confirm {computed(() => { const src = draftImageSrc; return src ? ( Car ) : null; })} {draftSpotLabel} {/* AI extraction panel */} ✨ AI extracted {extraction.pending ? ( Reading the plate… ) : extraction.error ? ( Couldn't read the plate — fill it in manually below. ) : ( {extraction.result?.description} {extraction.result?.plateNumber}{" "} {extraction.result?.plateState} confidence:{" "} {extraction.result?.confidence} applyExtraction.send()} > ↻ Use AI's reading )} {/* Editable fields (pre-filled by setDraftSpot) */} Description Plate Number State Notes (optional) {/* Save + back */} goBackToSpot.send()} > ← Change spot submitCapture.send()} > ✓ Save sighting ) : null} {/* STEP 4 — Saved confirmation */} {isSavedStep ? ( Sighting saved! captureAnother.send()} > 📸 Capture another selectTab.send({ tab: "sightings" })} > 🚗 View sightings ) : null}
) : null} {/* ====== SIGHTINGS TAB ====== */} {isSightingsTab ? ( Sightings ({sightingCount}) {noSightings ? ( No sightings yet. Use 📸 Capture to document a car in one of your spots. ) : null} {/* Phase 3: repeat offenders (plates seen 2+ times) */} {hasRepeatOffenders ? ( 🔁 Repeat offenders {repeatOffenders.map((g) => ( {g.plate} ({g.state}) {g.description} · spots {g.spotsLabel} {g.firstSeen} → {g.lastSeen} {g.count}× seen ))} ) : null} {sightingRows.map((row) => { const rowId = row.id; // Per-row open-state booleans are derived in the // `sightingRows` computed (see note there) — reading the // perSession target cells in a `computed()` nested in this // `.map()` does not reliably re-render. const isConfirmTarget = row.isConfirmOpen; const isAssignTarget = row.isAssignOpen; const isGuestTarget = row.isGuestOpen; return ( {/* Thumbnail + spot header */} {row.imgSrc ? ( Sighting photo ) : (
🚗
)} Spot #{row.spotNumber} {row.classificationLabel} {row.knownTag ? ( {row.knownTag} ) : null} {row.isRepeat ? ( 🔁 repeat ) : null} {row.description ? ( {row.description} ) : null} {row.plateDisplay ? ( {row.plateDisplay} ) : null}
{/* Notes */} {row.notes ? ( {row.notes} ) : null} {/* Phase 3b: curation buttons — gated on canMark AND canCurate (admin) */} {row.canMark && row.canCurate ? ( {/* Guest flow: button → inline form */} {isGuestTarget ? ( Mark as guest saveGuest.send()} > Save guest cancelGuest.send()} > Cancel ) : ( openGuest.send({ id: rowId })} > Mark guest markVehicle.send({ plateNumber: row.plateNumber, plateState: row.plateState, category: "offender", org: "Local Butcher Shop", })} > Mark offender {/* Phase 3c: admin-gate */} )} ) : null} {/* Phase 3c: "assign to known person" — gated on canAssign AND canCurate (admin) */} {row.canAssign && row.canCurate ? ( {isAssignTarget ? ( 👤 Whose car is this? { /* Quick-pick chips for existing people. A new name typed into the input also works — assignToPerson creates the person in the shared parking-coordinator `people` cell if no name matches. */ } {hasPeople ? ( {people.map((p) => ( setAssignPersonName.send( { name: p.name }, )} > {p.name} ))} ) : null} assignToPerson.send()} > Add to their vehicles cancelAssign.send()} > Cancel ) : ( openAssign.send({ id: rowId })} > 👤 It's a known person's car )} ) : null} {/* Footer: reporter + time + delete */} {row.reportedBy} — {row.dateStr} {row.timeStr} {/* Delete — only shown to admins (canCurate) */} {row.canCurate ? isConfirmTarget ? ( Delete? deleteSighting.send({ id: rowId })} > Yes cancelDelete.send()} > No ) : ( initiateDelete.send({ id: rowId })} > × ) : null}
); })}
) : null} {/* ====== REPORT TAB ====== */} {isReportTab ? ( 📊 Report {/* Filters */} Filters Spot Classification {/* Spot occupancy frequency */} Spot Occupancy Total sightings per spot, and how many were non-ours. {spotOccupancy.map((row) => ( Spot #{row.spotNum} {row.total} total {row.nonOursCount > 0 ? ( {row.nonOursCount} non-ours ) : ( all ours )} ))} {/* Repeat-offender leaderboard */} {computed(() => groupSightingsByPlate(sightings.get() ?? []).some( (g) => { const cls = classifyPlate( g.plate, g.state, (people.get() ?? []).flatMap((p) => p.vehicles ?? [] ), knownVehicles.get() ?? [], ); return cls === "offender" || (cls === "unknown" && g.count >= 3); }, ) ) ? ( 🚨 Offender Leaderboard {offenderLeaderboard.map((g) => ( {g.plate} ({g.state}) {g.cls} {g.org ? ( {g.org} ) : null} {g.description ? g.description + " · " : ""}spots {g.spotsLabel} · last{" "} {g.lastSeen} {g.count}× seen ))} ) : null} {/* Recent activity feed */} Recent Activity {noRecentActivity ? ( No sightings match the current filters. ) : null} {recentActivity.map((r) => ( {r.imgSrc ? ( Sighting ) : (
🚗
)} Spot #{r.spotNumber} {r.cls} {r.plateDisplay ? ( {r.plateDisplay} ) : null} {r.reportedBy} · {r.when}
))}
) : null} { /* The full multi-admin management UI (per-person Make admin / Remove admin rows) was removed in favor of the single-click `🔓 Curator mode` toggle in the header — the lot demo has no separate "admin manager" persona to model. The CFC integrity-branded gating + the underlying Streams (`enableAdminManager`, `togglePersonAdmin`, `toggleAdminMode`) are unchanged; only the UX collapsed. Re-add an expandable multi-admin panel here if a real production deployment needs separate admin assignment. */ }
), sightings, knownVehicles, people, captureSighting, deleteSighting, selectTab, setReporterName, markVehicle, removeKnownVehicle, openAssign, cancelAssign, assignToPerson, openGuest, cancelGuest, saveGuest, enableAdminManager, togglePersonAdmin, toggleAdminMode, }; }, );