import { useEffect, useRef, useState } from 'react' import { api, streamArrivals } from '../api.js' import { ago, Loading, Problem } from './Sites.jsx' import Shot from './Shot.jsx' // Who just walked in, pushed as it happens. // // The list is capped rather than unbounded: this screen is left open on a wall // display for days, and an ever-growing array of arrivals — each holding an // image URL — is a tab that eventually falls over. Older arrivals are in the // reports; this view is about the last few minutes. const KEEP = 60 export default function Live() { const [arrivals, setArrivals] = useState([]) const [status, setStatus] = useState('connecting') const [error, setError] = useState(null) const seen = useRef(new Set()) useEffect(() => { const ac = new AbortController() let stop = () => {} ;(async () => { try { // One poll for the recent window, so the screen is never blank while // waiting for the next person to walk through a door. const page = await api.arrivals({ limit: 30 }) const rows = page.arrivals || [] rows.forEach(a => seen.current.add(a.visit_id)) setArrivals(rows.slice().reverse()) setStatus('live') stop = streamArrivals({ cursor: page.cursor, signal: ac.signal, onPage: (p) => { const fresh = (p.arrivals || []).filter(a => !seen.current.has(a.visit_id)) if (!fresh.length) return fresh.forEach(a => seen.current.add(a.visit_id)) setStatus('live') // Newest first on screen; the feed delivers oldest first. setArrivals(prev => [...fresh.reverse(), ...prev].slice(0, KEEP)) }, onError: () => setStatus('reconnecting'), }) } catch (err) { setError(err) } })() return () => { ac.abort(); stop() } }, []) if (error) return if (status === 'connecting' && !arrivals.length) return return ( <>

Live

{arrivals.length === 0 ? (

Nobody yet

Arrivals appear here the moment a camera recognises someone.

) : ( )} ) } function Arrival({ a }) { const name = a.name || a.label || 'Unidentified' return (
  • {name} {/* Only beside a name somebody typed. The auto label already IS the number, so "Visitor 13 · V-13" would read as two identifiers for one person. */} {a.name && a.visitor_ref ? `${a.visitor_ref} · ` : ''} {a.site}{a.camera_id ? ` · ${a.camera_id}` : ''} · {ago(a.occurred_at)} {a.attributes && }
    {a.is_new_visitor ? New : Returning}
  • ) } // Photos are off unless a shop turns them on, so "no photo" is the ordinary // case. Initials, never an error state — a screen full of red for a system // working exactly as configured teaches people to ignore it. // // The picture goes through Shot rather than a bare because a face can // now come from either of two places: a presigned link to object storage, or // this server's own database on a deployment with no bucket. The second cannot // be loaded by an at all — it needs the session — so a plain src here // showed a broken image on exactly the deployments that had just started // storing photos. // customerRef, not `ref`: React reserves that prop name, so it would never // reach this component's props. function Face({ image, name, customerRef }) { if (image?.available) { return } return ( ) } // What goes in the circle when there is no photograph. // // Initials of a name a human typed; the NUMBER for a customer the system named // itself. Running initials() over "Visitor 13" takes the first letter of each // word and produces "V1" - which is also what "Visitor 10" and "Visitor 15" // produce, so three different people wore the same badge, and it read as the // V-1 reference for a fourth. Found by opening the page: every unit test here // passes a human name. function avatarText(name, customerRef) { const auto = /^Visitor (\d+)$/.exec(String(name).trim()) if (auto) return auto[1] if (customerRef) { const n = /^V-(\d+)$/.exec(customerRef) if (n) return n[1] } return initials(name) } function initials(name) { const parts = String(name).trim().split(/\s+/).filter(Boolean) if (!parts.length) return '?' if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase() return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase() } function Attributes({ attrs }) { const bits = [] if (attrs.gender) bits.push(attrs.gender) if (attrs.age) bits.push(`~${Math.round(attrs.age)}`) if (attrs.emotion) bits.push(attrs.emotion) if (!bits.length) return null return {bits.join(' · ')} }