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} {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. function Face({ image, name }) { if (image?.available) { return } return ( ) } 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(' · ')} }