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 (
<>
{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 (
{avatarText(name, customerRef)}
)
}
// 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(' · ')}
}