import { useEffect, useRef, useState } from 'react' import { api } from '../bridge.js' import { usePolled, fmtTime } from '../hooks.js' import * as Icon from '../ui/icons.jsx' // The shop floor screen. // // Rebuilt around what somebody standing at the counter is actually here for: // WHO JUST WALKED IN. The previous version led with four large stat boxes and // left arrivals as a thin list of "person.seen" rows in the corner — the least // actionable content taking the most space, and the product's whole reason for // existing rendered as a log. // // Now: a status strip that answers "is this working" in one line, and arrivals // as cards big enough to recognise a customer from while looking up at them. // No camera picture here - the person at the counter is not watching CCTV, // and a live video tile costs CPU the recognition pipeline needs. The picture // lives on the Cameras screen, where it is a setup tool. export default function Live({ onNavigate }) { const { data, error } = usePolled(() => api.live(), 3000) const { data: pipe } = usePolled(() => api.pipelineStatus(), 5000) const cameras = data?.stats?.cameras ?? [] const gallery = data?.stats?.gallery ?? {} const events = data?.events ?? [] // fraction_below_gate is the number that decides a site: what share of the // faces this camera saw were too poor to enrol. Surfaced rather than buried, // because a high value looks exactly like "a quiet day". const worst = cameras.reduce((acc, c) => { const f = c?.pipeline?.best_quality?.fraction_below_gate return typeof f === 'number' && f > acc ? f : acc }, 0) const up = cameras.filter(c => c.connected).length const arrivals = events.filter(e => e.type === 'person.new' || e.type === 'person.seen') const freshest = useFreshest(arrivals[0]) return (

Live

Who is in the shop, and whether it is reaching head office.

{error &&
{error}
}

Who just walked in

{arrivals.length > 0 && {arrivals.length} today}
{arrivals.length === 0 ?
Nobody yet

Customers appear here the moment a camera recognises a face.

:
{arrivals.slice(0, 30).map((e, i) => ( ))}
}
0.5 ? 'bad' : worst > 0.2 ? 'warn' : 'ok'} s={worst > 0.5 ? 'Most visitors are being missed — move the camera' : 'Share of faces below the enrolment gate'} />
) } // The first five minutes, as a checklist that ticks itself. // // Before this the Live screen after install was "Nobody yet" over a row of // dashes, with an amber "No cameras" in the far corner. Nothing said what to // do next. This says the three things, in order, and each step reads its own // state from the engine: recognition ready, a camera added, the camera // proven by a walk-past. It disappears on its own once someone has actually // been recognised, because at that point the product has explained itself. function GettingStarted({ cameras, arrivals, onNavigate }) { const { data: eng } = usePolled(() => api.engineStatus(), 4000) const [hidden, setHidden] = useState(() => { try { return localStorage.getItem('bv.gettingStarted') === 'done' } catch { return false } }) const hasCamera = cameras.length > 0 const anyUp = cameras.some(c => c.connected) const { data: checks } = usePolled(async () => { const out = {} for (const c of cameras) { try { out[c.camera_id] = await api.placementResult(c.camera_id) } catch { /* not yet */ } } return out }, 10000, [cameras.map(c => c.camera_id).join('|')]) const proven = Object.values(checks ?? {}).some(r => r && !r.running && r.verdict === 'good') const recognised = arrivals.length > 0 if (hidden || recognised) return null const engineReady = Boolean(eng?.reachable && eng?.recognition_model) const progress = eng?.progress const steps = [ { done: engineReady, now: !engineReady, title: engineReady ? `Recognition ready (${eng.recognition_model})` : progress ? `Downloading ${progress.what}… ${progress.percent}%` : 'Starting recognition…', text: engineReady ? null : 'First start downloads about 275 MB of recognition models. A few minutes on a normal connection; nothing to do meanwhile.' }, { done: hasCamera && anyUp, now: engineReady && !(hasCamera && anyUp), title: hasCamera ? (anyUp ? 'Camera connected' : 'Camera added — not connecting yet') : 'Add your camera', text: hasCamera ? (anyUp ? null : 'Check its password and stream path under Cameras → Edit.') : 'Behavision can find it on the network; you type only its password.', action: hasCamera ? null : { label: 'Add camera', go: 'cameras' } }, { done: proven, now: hasCamera && anyUp && !proven, title: proven ? 'Camera proven — it can recognise faces' : 'Walk past the camera', text: proven ? null : 'Run Check placement and walk past like a customer for 25 seconds. Only a “good” verdict means it will recognise people.', action: hasCamera && anyUp && !proven ? { label: 'Check placement', go: 'cameras' } : null }, ] return (

Getting started

    {steps.map((st, i) => (
  1. {st.title} {st.text && {st.text}} {st.action && }
  2. ))}

The moment a customer is recognised, this list goes away.

) } // One customer, big enough to match against the person in front of you. function Arrival({ e, fresh }) { const isNew = e.type === 'person.new' const name = e.data?.label || 'Unrecognised' const bits = [e.data?.gender, e.data?.age ?? e.data?.age_range, e.data?.emotion].filter(Boolean) const sim = typeof e.data?.similarity === 'number' ? e.data.similarity : null return (
{avatarText(name)}
{name}
{e.camera_id} {bits.length > 0 && <> · {bits.join(', ')}} {sim !== null && !isNew && <> · match {sim.toFixed(2)}}
{isNew ? 'new' : 'returning'} {fmtTime(e.ts)}
) } // "Visitor 13" must show 13, not V1 — initials() would give the same two // characters to Visitor 10, 13 and 15, and read as the reference V-1 for a // fourth person. Found by looking at the screen, not by a test. function avatarText(name) { const auto = /^Visitor (\d+)$/.exec(String(name).trim()) if (auto) return auto[1] const words = String(name).trim().split(/\s+/).filter(Boolean) if (!words.length) return '?' return (words[0][0] + (words[1]?.[0] ?? '')).toUpperCase() } // One line, above everything, answering the question every other screen is a // detail of: is this shop working, and is anything leaving it. function PipelineStrip({ pipe, cameras, up }) { if (!pipe) return null // A PC set up on its own is not "not linked yet" — nothing is coming, and an // idle dot beside a count of zero reads as a fault. if (pipe.standalone) { return (
Running on this PC only Recognition and customers stay here. {up} of {cameras.length} cameras
) } const stuck = pipe.claimed && !pipe.broker_up const tone = !pipe.claimed ? 'idle' : pipe.broker_up ? 'ok' : 'bad' const text = !pipe.claimed ? 'Not linked to head office' : pipe.broker_up ? 'Sending to head office' : 'Offline — saving locally' return (
{pipe.broker_up ? : } {text} {pipe.accepted} recorded today {pipe.queued > 0 && ( {pipe.queued} waiting to send )} {pipe.dropped > 0 && ( {pipe.dropped} lost — this PC was offline too long )} {up} of {cameras.length} cameras
) } function Metric({ k, v, s, tone }) { return (
{k}
{v}
{s &&
{s}
}
) } // True for a few seconds after a genuinely new arrival, so the top card can // announce itself once. Keyed on the timestamp rather than the array, which // changes identity on every poll. function useFreshest(top) { const [fresh, setFresh] = useState(false) const seen = useRef(null) useEffect(() => { if (!top || top.ts === seen.current) return const first = seen.current === null seen.current = top.ts if (first) return // do not flash the whole list on mount setFresh(true) const id = setTimeout(() => setFresh(false), 1200) return () => clearTimeout(id) }, [top?.ts]) return fresh }