Behavision: face recognition for retail, edge to head office

Five components that ship as one product:

- behavision/  the recognition engine. RTSP ingest, YuNet detection, IoU
               tracking, ArcFace embeddings, a FAISS/SQLite gallery, and a
               FastAPI dashboard. Identity is decided once per TRACK from an
               average of at least three embeddings, never per frame.
- agent/       the Go edge agent: supervises the engine, holds a durable
               spool, and drains it to MQTT. Nothing is acked before the
               broker confirms.
- desktop/     the shop PC application (Wails + React + tray).
- server/      the cloud API, MQTT consumer, reports and assistant.
- web/         platform.loyaly.ai, the head-office app, embedded in the
               server binary.

The gallery stores 512-float embeddings and timestamps - no images unless
`app.store_faces` is switched on. Those embeddings are biometric personal
data under GDPR and India's DPDP: template inversion reconstructs a
recognisable face from an ArcFace vector, so data/behavision.db is treated
as a biometric database and DELETE /api/visitors/{id} is a real erasure.

CLAUDE.md carries the reasoning behind every non-obvious decision here,
including the ones that were measured and the ones that were wrong first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
This commit is contained in:
2026-09-04 11:14:18 +05:30
commit dad04e8cda
216 changed files with 40473 additions and 0 deletions

128
web/src/views/Assistant.jsx Normal file
View File

@@ -0,0 +1,128 @@
import { useEffect, useRef, useState } from 'react'
import { api } from '../api.js'
// The assistant, as a panel that follows you between screens.
//
// A docked panel rather than its own page, deliberately: every question worth
// asking it is about what is on screen — "why is this shop showing zero", "is
// this camera working" — and sending someone to a separate page to ask about
// the page they just left is how a help feature goes unused.
const SUGGESTIONS = [
'Is everything working today?',
'Why is footfall low at Chennai?',
'How many people came in last week?',
'Which cameras still need checking?',
]
export default function Assistant({ open, onClose }) {
const [history, setHistory] = useState([])
const [draft, setDraft] = useState('')
const [busy, setBusy] = useState(false)
const [off, setOff] = useState(false)
const endRef = useRef(null)
const inputRef = useRef(null)
useEffect(() => {
if (open) inputRef.current?.focus()
}, [open])
useEffect(() => {
endRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
}, [history, busy])
const ask = async (text) => {
const question = (text ?? draft).trim()
if (!question || busy) return
// The client holds the transcript. Nothing is stored server-side, so there
// is no per-user chat log sitting in a database nobody agreed to.
const next = [...history, { role: 'user', text: question }]
setHistory(next)
setDraft('')
setBusy(true)
try {
const answer = await api.ask(next)
setHistory(h => [...h, { role: 'assistant', text: answer.text, used: answer.used }])
} catch (err) {
if (err.code === 'assistant_off') {
setOff(true)
} else {
setHistory(h => [...h, { role: 'assistant', text: err.message, failed: true }])
}
} finally {
setBusy(false)
}
}
if (!open) return null
return (
<aside className="assistant" role="complementary" aria-label="Assistant">
<header className="assistant-head">
<div>
<b>Ask Behavision</b>
<span className="sub">It reads your shops’ own data to answer.</span>
</div>
<button className="ghost" onClick={onClose} aria-label="Close assistant">✕</button>
</header>
<div className="assistant-body">
{off ? (
<p className="sub pad">
The assistant is not switched on for this server.
</p>
) : history.length === 0 ? (
<div className="suggest">
<p className="sub">Try asking:</p>
{SUGGESTIONS.map(s => (
<button key={s} className="chip" onClick={() => ask(s)}>{s}</button>
))}
</div>
) : (
history.map((m, i) => (
<div key={i} className={'bubble ' + m.role + (m.failed ? ' failed' : '')}>
{m.text}
{m.used?.length > 0 && (
// Said out loud. An assistant that silently ran a camera check
// would be alarming, and naming what it looked at is what makes
// a wrong answer traceable rather than mysterious.
<span className="used">looked at: {m.used.map(pretty).join(', ')}</span>
)}
</div>
))
)}
{busy && (
<div className="bubble assistant-thinking">
<span className="spinner" aria-hidden="true" /> Looking…
</div>
)}
<div ref={endRef} />
</div>
{!off && (
<form className="assistant-ask" onSubmit={e => { e.preventDefault(); ask() }}>
<input
ref={inputRef}
value={draft}
onChange={e => setDraft(e.target.value)}
placeholder="Ask about your shops…"
disabled={busy}
/>
<button className="primary" disabled={busy || !draft.trim()}>Ask</button>
</form>
)}
</aside>
)
}
// Tool names are internal. Nobody should have to learn them to read an answer.
function pretty(name) {
return {
list_shops: 'your shops',
check_shop: 'a shop check',
list_cameras: 'your cameras',
check_camera: 'a camera check',
footfall: 'footfall',
sales: 'sales',
find_customer: 'customer records',
}[name] || name
}

View File

@@ -0,0 +1,333 @@
import { useEffect, useRef, useState } from 'react'
import { api } from '../api.js'
import { MAKES, makeById } from '../../../shared/cameraMakes.js'
// Setting up a camera, for somebody who has never done it.
//
// Four steps, in the order the questions actually arise, and each one blocks
// until it is answered. That is deliberate: the old form let you type an
// address, press Save, and walk away believing you were finished — which is
// exactly how a camera runs for weeks recognising nobody. Here, "added" and
// "proven to work" are different states and the screen says which one you are
// in.
const STEPS = ['Camera', 'Connection', 'Test', 'Walk past']
export default function CameraSetup({ sites, existing, onClose, onSaved }) {
const isNew = !existing?.id
const [step, setStep] = useState(isNew ? 0 : 2)
const [cam, setCam] = useState(existing?.id ? existing : null)
const [form, setForm] = useState({
site_id: existing?.site_id || sites[0]?.site_id || '',
make: 'hikvision',
label: existing?.label || '',
host: existing?.host || '',
port: existing?.port || 554,
path: existing?.path || '/Streaming/Channels/101',
username: existing?.username || '',
password: '',
})
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const set = (k) => (e) => setForm(f => ({ ...f, [k]: e.target.value }))
const chooseMake = (e) => {
const m = makeById(e.target.value)
// Only overwrite the path when the preset has one, so choosing "I know the
// path" does not wipe what the installer already typed.
setForm(f => ({ ...f, make: m.id, path: m.path || f.path }))
}
const save = async () => {
setBusy(true); setError('')
const body = {}
for (const [k, v] of Object.entries(form)) {
if (k === 'site_id' || k === 'make') continue
if (v === '' || v == null) continue
body[k] = k === 'port' ? Number(v) : v
}
try {
const saved = cam?.id
? await api.updateCamera(cam.id, body)
: await api.createCamera(form.site_id, body)
setCam(saved)
setStep(2)
onSaved?.(saved, { keepOpen: true })
} catch (err) {
setError(err.message)
} finally {
setBusy(false)
}
}
return (
<div className="overlay" onClick={onClose}>
<aside className="drawer wizard" onClick={e => e.stopPropagation()}>
<header className="drawer-head">
<div>
<h2>{isNew ? 'Set up a camera' : (cam?.label || existing?.label)}</h2>
<p className="sub">
{step < 2
? 'The shop’s PC connects to the camera — nothing needs opening to the internet.'
: 'Prove it works before you rely on it.'}
</p>
</div>
<button className="ghost" onClick={onClose}>Close</button>
</header>
<ol className="steps">
{STEPS.map((s, i) => (
<li key={s} className={i === step ? 'now' : i < step ? 'done' : ''}>
<span className="dot" aria-hidden="true">{i < step ? '✓' : i + 1}</span>
{s}
</li>
))}
</ol>
{step === 0 && (
<div className="drawer-body">
{isNew && (
<label>Which shop
<select value={form.site_id} onChange={set('site_id')}>
{sites.map(s => <option key={s.site_id} value={s.site_id}>{s.name}</option>)}
</select>
</label>
)}
<label>What should staff call it?
<input value={form.label} onChange={set('label')}
placeholder="Entrance" autoFocus />
<span className="hint">
Visits are recorded against this name, so it cannot be changed later.
</span>
</label>
<label>Make of camera
<select value={form.make} onChange={chooseMake}>
{MAKES.map(m => <option key={m.id} value={m.id}>{m.label}</option>)}
</select>
<span className="hint">
{makeById(form.make).note ||
'This only fills in the stream path for you. You can change it on the next step.'}
</span>
</label>
<button className="primary" disabled={!form.label.trim()}
onClick={() => setStep(1)}>Next</button>
</div>
)}
{step === 1 && (
<div className="drawer-body">
<label>Camera’s address on the shop’s network
<input value={form.host} onChange={set('host')}
placeholder="192.168.0.138" autoFocus />
<span className="hint">
Not a website address. It usually starts 192.168. — look in the
camera’s own app, on its label, or in your router’s device list.
</span>
</label>
<div className="pair">
<label>Port
<input type="number" value={form.port} onChange={set('port')} />
</label>
<label>Stream path
<input value={form.path} onChange={set('path')} />
</label>
</div>
{/* autoComplete is load-bearing here, not boilerplate. Chrome sees
a text field next to a password field and offers the saved
Behavision login - so the first thing a shop owner did was
submit their own email address as the camera's username, which
fails with a message about credentials that points at the
camera. "new-password" is the value Chrome actually honours;
"off" it frequently ignores. */}
<label>Camera username
<input value={form.username} onChange={set('username')} placeholder="admin"
name="camera-account" autoComplete="off"
autoCorrect="off" autoCapitalize="none" spellCheck="false" />
<span className="hint">
The camera’s own login, not your Behavision one.
</span>
</label>
<label>Camera password
<input type="password" value={form.password} onChange={set('password')}
name="camera-secret" autoComplete="new-password"
placeholder={cam?.has_password ? '(unchanged)' : ''} />
</label>
{error && <p className="error" role="alert">{error}</p>}
<div className="pair">
<button className="ghost" onClick={() => setStep(0)}>Back</button>
<button className="primary" disabled={busy || !form.host.trim()} onClick={save}>
{busy ? 'Saving…' : 'Save and test'}
</button>
</div>
</div>
)}
{step >= 2 && cam && (
<CheckPanel
camera={cam}
step={step}
onStep={setStep}
onUpdated={(c) => { setCam(c); onSaved?.(c, { keepOpen: true }) }}
onEdit={() => setStep(1)}
onDone={onClose}
/>
)}
</aside>
</div>
)
}
// CheckPanel drives the two checks and shows whatever the shop PC reported.
//
// It polls, because the answer comes from a PC in another building that has to
// pick the job up on its next sync. The wait is real and is stated rather than
// hidden behind a spinner that looks stuck.
function CheckPanel({ camera, step, onStep, onUpdated, onEdit, onDone }) {
const [cam, setCam] = useState(camera)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const timer = useRef(null)
useEffect(() => setCam(camera), [camera])
const check = cam.check || {}
const waiting = check.state === 'requested' || check.state === 'running'
// Poll only while something is outstanding. A finished check does not change.
useEffect(() => {
if (!waiting) return
let alive = true
const tick = async () => {
try {
const all = await api.cameras()
const fresh = all.find(c => c.id === cam.id)
if (alive && fresh) { setCam(fresh); onUpdated?.(fresh) }
} catch { /* transient; the next tick tries again */ }
}
timer.current = setInterval(tick, 4000)
return () => { alive = false; clearInterval(timer.current) }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [waiting, cam.id])
const run = async (kind, seconds) => {
setBusy(true); setError('')
try {
const updated = await api.checkCamera(cam.id, kind, seconds)
setCam(updated)
onUpdated?.(updated)
onStep(kind === 'placement' ? 3 : 2)
} catch (err) {
setError(err.message)
} finally {
setBusy(false)
}
}
const connectionPassed = check.kind === 'connection' && check.state === 'done' && check.ok
return (
<>
<div className="drawer-body">
{step === 2 ? (
<>
<h3>Can the shop’s PC reach this camera?</h3>
<p className="sub">
The PC opens the stream once and takes a single picture. Nothing is recorded.
</p>
</>
) : (
<>
<h3>Can it actually recognise a face?</h3>
<p className="sub">
Someone needs to <b>walk through the camera’s view and out of it</b>, the
way a customer would. Standing still measures nothing — the check scores
the best view of each person as they leave the frame, which is what
recognition really uses.
</p>
</>
)}
{waiting && (
<div className="waiting">
<span className="spinner" aria-hidden="true" />
<div>
<b>{check.state === 'running' ? 'Running on the shop’s PC…' : 'Waiting for the shop’s PC…'}</b>
<span className="sub">
{check.state === 'running'
? 'This takes about ' + (check.seconds || 25) + ' seconds.'
: 'It picks up the request within a couple of minutes.'}
</span>
</div>
</div>
)}
{check.state === 'done' && <Outcome check={check} />}
{error && <p className="error" role="alert">{error}</p>}
<div className="pair">
{step === 2 ? (
<>
<button className="ghost" onClick={onEdit}>Change details</button>
<button className="primary" disabled={busy || waiting}
onClick={() => run('connection')}>
{check.state === 'done' ? 'Test again' : 'Test connection'}
</button>
</>
) : (
<>
<button className="ghost" onClick={() => onStep(2)}>Back</button>
<button className="primary" disabled={busy || waiting}
onClick={() => run('placement', 25)}>
{check.state === 'done' ? 'Check again' : 'Start walk-past check'}
</button>
</>
)}
</div>
{step === 2 && connectionPassed && !waiting && (
<button className="primary" onClick={() => onStep(3)}>
Next: prove it can recognise faces
</button>
)}
{step === 3 && check.state === 'done' && !waiting && (
<button className="ghost" onClick={onDone}>Finish</button>
)}
</div>
</>
)
}
// Outcome renders the shop PC's answer.
//
// Every word here came from the engine. It already distinguishes a refused
// connection from a wrong path from a stream that opens and sends nothing, and
// each needs a different thing done — so nothing is re-worded on the way.
function Outcome({ check }) {
const tone = check.ok ? 'ok' : check.verdict === 'marginal' ? 'warn' : 'bad'
return (
<div className={'outcome ' + tone}>
<div className="outcome-head">
<span className={'pill ' + tone}>{check.ok ? 'Working' : 'Not ready'}</span>
<b>{check.headline || (check.ok ? 'Working' : 'Could not be verified')}</b>
</div>
{check.image?.available && (
<img className="proof" src={check.image.url}
alt="The view from this camera" loading="lazy" />
)}
{check.advice?.length > 0 && (
<ul className="advice">
{check.advice.map((a, i) => <li key={i}>{a}</li>)}
</ul>
)}
{check.detail?.faces != null && (
<p className="sub">
{check.detail.faces} {check.detail.faces === 1 ? 'person' : 'people'} walked
past during the check.
</p>
)}
</div>
)
}

151
web/src/views/Cameras.jsx Normal file
View File

@@ -0,0 +1,151 @@
import { useState } from 'react'
import { api } from '../api.js'
import { usePolled } from '../hooks.js'
import { ago, Loading, Problem } from './Sites.jsx'
import CameraSetup from './CameraSetup.jsx'
// Cameras, onboarded from head office.
//
// The shop PC is still the thing that CONNECTS — it is the only machine on the
// camera's network — so everything here is desired state that its agent pulls
// and applies. That is why a new camera reads "waiting for the shop PC" rather
// than "connected": saying it is set up would be claiming something nobody has
// verified yet.
// verification is the second claim a camera card makes, and the one that
// matters: connected says the stream opens, verified says a person walking past
// can actually be recognised. Office1 was the first for weeks without ever
// being the second.
function verification(cam) {
const c = cam.check || {}
if (c.state === 'requested' || c.state === 'running') {
return { tone: 'idle', mark: '…', words: 'Checking now' }
}
if (c.state !== 'done') {
return { tone: 'idle', mark: '?', words: 'Not checked yet' }
}
if (c.kind === 'placement') {
return c.ok
? { tone: 'ok', mark: '✓', words: 'Recognises faces here' }
: { tone: 'bad', mark: '✕', words: c.headline || 'Cannot recognise faces here' }
}
return c.ok
? { tone: 'warn', mark: '!', words: 'Stream works — faces not checked yet' }
: { tone: 'bad', mark: '✕', words: c.headline || 'Could not reach the camera' }
}
export default function Cameras({ user }) {
const { data: cams, error, loading, reload } =
usePolled(() => api.cameras(), 20000, [])
const { data: sites } = usePolled(() => api.sites(), 0, [])
const [editing, setEditing] = useState(null)
const canEdit = ['admin', 'owner', 'manager'].includes(user.role)
const list = cams || []
const up = list.filter(c => c.connected).length
const down = list.filter(c => c.connected === false).length
const waiting = list.filter(c => c.connected == null).length
return (
<>
<header className="head">
<h1>Cameras</h1>
<p className="sub">
{list.length} {list.length === 1 ? 'camera' : 'cameras'}
{up > 0 && <> · <b className="ok">{up} connected</b></>}
{down > 0 && <> · <b className="bad">{down} down</b></>}
{waiting > 0 && <> · {waiting} waiting for the shop PC</>}
</p>
{canEdit && (
<button className="primary" onClick={() => setEditing({})}>
Set up a camera
</button>
)}
</header>
{loading && !cams ? <Loading /> : error ? <Problem error={error} /> : (
list.length === 0 ? (
<div className="state">
<h2>No cameras yet</h2>
<p className="sub">
Add one here and the shop’s PC will pick it up within a couple of
minutes. Cameras already set up on a shop PC appear here on their own.
</p>
</div>
) : (
<div className="grid cams">
{list.map(c => (
<CameraCard key={c.id} cam={c} canEdit={canEdit}
onEdit={() => setEditing(c)} />
))}
</div>
)
)}
{editing && (
<CameraSetup
existing={editing.id ? editing : null}
sites={sites || []}
onClose={() => { setEditing(null); reload() }}
onSaved={(_, opts) => { if (!opts?.keepOpen) setEditing(null); reload() }}
/>
)}
</>
)
}
function CameraCard({ cam, canEdit, onEdit }) {
// Three states, not two. A camera nobody has tried yet is not a camera that
// is down, and telling an operator to check the cabling on a camera the shop
// PC has not even seen sends them to the wrong building.
const state = cam.connected == null ? 'idle'
: cam.connected ? 'ok' : 'bad'
const words = cam.connected == null ? 'Waiting for the shop PC'
: cam.connected ? 'Connected' : 'Not connecting'
const verified = verification(cam)
return (
// The picture IS the card. A camera is a thing you look at, and the
// previous layout made it a black rectangle sitting above a table of
// connection settings — which is the view a developer wants, not a shop
// owner. The technical detail moves behind Edit, where it is needed only
// when something is being changed.
<article className={'card cam state-' + state} onClick={canEdit ? onEdit : undefined}
role={canEdit ? 'button' : undefined} tabIndex={canEdit ? 0 : undefined}
onKeyDown={e => canEdit && e.key === 'Enter' && onEdit()}>
<div className="shot">
{cam.snapshot?.available
? <img src={cam.snapshot.url} alt={`View from ${cam.label}`} loading="lazy" />
: <div className="noshot">
<span className="lens" aria-hidden="true" />
{cam.snapshot?.reason || 'No picture yet.'}
</div>}
{/* Over the picture, where a camera label belongs, rather than in a
caption underneath it. */}
<div className="shot-over">
<div className="shot-name">
<b>{cam.label || cam.camera_id}</b>
<span>{cam.site}</span>
</div>
<span className={'status ' + state}>
<i aria-hidden="true" />{words}
</span>
</div>
{cam.snapshot_at && (
<span className="shot-age">{ago(cam.snapshot_at)}</span>
)}
</div>
{/* Connected and verified are different claims, and the gap between them
is where a site gets signed off broken: a camera can be streaming
perfectly and still produce views nothing can recognise. This is the
one fact worth carrying on the front of the card. */}
<div className={'verdict ' + verified.tone}>
<span className="mark" aria-hidden="true">{verified.mark}</span>
<span className="words">{verified.words}</span>
{canEdit && <span className="go" aria-hidden="true">→</span>}
</div>
</article>
)
}

139
web/src/views/Clients.jsx Normal file
View File

@@ -0,0 +1,139 @@
import { useState } from 'react'
import { api } from '../api.js'
import { usePolled } from '../hooks.js'
import { ago, Loading, Problem } from './Sites.jsx'
// Platform administration: the tenants everything else belongs to.
//
// Only reachable by an account with no client of its own. There is no public
// registration — a stranger creating a tenant here is not a customer, it is a
// row nobody asked for in a table every query joins against.
export default function Clients() {
const { data, error, loading, reload } = usePolled(() => api.clients(), 0, [])
const [adding, setAdding] = useState(false)
const [created, setCreated] = useState(null)
const rows = data || []
return (
<>
<header className="head">
<h1>Companies</h1>
<button className="primary" onClick={() => { setCreated(null); setAdding(true) }}>
New company
</button>
</header>
{created && <Credentials result={created} onDismiss={() => setCreated(null)} />}
{loading && !data ? <Loading /> : error ? <Problem error={error} /> : (
rows.length === 0 ? (
<div className="state">
<h2>No companies yet</h2>
<p className="sub">Create one, and its owner can sign in straight away.</p>
</div>
) : (
<div className="tablewrap">
<table className="rows">
<thead>
<tr><th>Company</th><th>Short name</th><th>Sites</th><th>People</th><th>Created</th></tr>
</thead>
<tbody>
{rows.map(c => (
<tr key={c.id}>
<td><strong>{c.name}</strong></td>
<td><code>{c.slug}</code></td>
<td className="num">{c.sites}</td>
<td className="num">{c.users}</td>
<td className="sub">{ago(c.created_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
)
)}
{adding && (
<NewCompany
onClose={() => setAdding(false)}
onCreated={(res) => { setAdding(false); setCreated(res); reload() }}
/>
)}
</>
)
}
function NewCompany({ onClose, onCreated }) {
const [form, setForm] = useState({
company_name: '', owner_name: '', owner_email: '', password: '',
})
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const set = (k) => (e) => setForm({ ...form, [k]: e.target.value })
const submit = async (e) => {
e.preventDefault()
setBusy(true); setError('')
try {
onCreated(await api.createClient(form))
} catch (err) {
setError(err.message)
setBusy(false)
}
}
return (
<div className="overlay" onClick={onClose}>
<aside className="drawer narrow" onClick={e => e.stopPropagation()}>
<header className="drawer-head">
<h2>New company</h2>
<button className="ghost" onClick={onClose}>Close</button>
</header>
<form className="drawer-body" onSubmit={submit}>
<label>Company name
<input value={form.company_name} onChange={set('company_name')} required autoFocus />
</label>
<label>Owner’s name
<input value={form.owner_name} onChange={set('owner_name')} />
</label>
<label>Owner’s email
<input type="email" value={form.owner_email} onChange={set('owner_email')} required />
</label>
<label>Password
<input type="text" value={form.password} onChange={set('password')}
placeholder="Leave empty to generate one" />
<span className="hint">
Generated is better — a password you invent for someone else ends
up weak and sent over chat.
</span>
</label>
{error && <p className="error" role="alert">{error}</p>}
<button className="primary" disabled={busy}>
{busy ? 'Creating…' : 'Create company'}
</button>
</form>
</aside>
</div>
)
}
// Shown once, and said so. The password is bcrypt-hashed on the way in and is
// not recoverable — a credential support can look up later is a credential
// everyone with support access holds.
function Credentials({ result, onDismiss }) {
return (
<div className="banner ok credentials">
<div>
<b>{result.slug} created.</b> These sign-in details are shown once and
cannot be recovered. Send them to the owner now.
<dl className="creds">
<div><dt>Email</dt><dd><code>{result.owner_email}</code></dd></div>
<div><dt>Password</dt><dd><code>{result.password}</code></dd></div>
</dl>
</div>
<button className="ghost" onClick={onDismiss}>Done</button>
</div>
)
}

173
web/src/views/Customers.jsx Normal file
View File

@@ -0,0 +1,173 @@
import { useState } from 'react'
import { api } from '../api.js'
import { usePolled } from '../hooks.js'
import { ago, Loading, Problem } from './Sites.jsx'
export default function Customers({ user }) {
const [query, setQuery] = useState('')
const [open, setOpen] = useState(null)
// Not polled. A customer list does not change under the person reading it,
// and re-fetching every few seconds would fight the search box.
const { data, error, loading, reload } = usePolled(
() => api.visitors(query, 100), 0, [query])
const rows = data || []
return (
<>
<header className="head">
<h1>Customers</h1>
<p className="sub">People your cameras have recognised more than once.</p>
</header>
<div className="toolbar">
<input
className="search"
type="search"
value={query}
placeholder="Search by name, phone or email"
onChange={e => setQuery(e.target.value)}
/>
</div>
{loading && !data ? <Loading /> : error ? <Problem error={error} /> : (
rows.length === 0 ? (
<div className="state">
<h2>{query ? 'Nobody matches that' : 'No customers yet'}</h2>
<p className="sub">
{query
? 'Try part of a name, or a phone number.'
: 'Customers appear once a camera has recognised the same face twice.'}
</p>
</div>
) : (
<div className="tablewrap">
<table className="rows">
<thead>
<tr>
<th>Customer</th><th>Visits</th><th>First seen</th>
<th>Last seen</th><th>Consent</th>
</tr>
</thead>
<tbody>
{rows.map(c => (
<tr key={c.id} onClick={() => setOpen(c)} tabIndex={0}
onKeyDown={e => e.key === 'Enter' && setOpen(c)}>
<td>
<strong>{c.full_name || c.label}</strong>
{c.phone && <span className="sub"> · {c.phone}</span>}
</td>
<td className="num">{c.visit_count}</td>
<td className="sub">{ago(c.first_seen_at)}</td>
<td className="sub">{ago(c.last_seen_at)}</td>
<td>{c.has_consent
? <span className="pill ok">Given</span>
: <span className="pill">—</span>}</td>
</tr>
))}
</tbody>
</table>
</div>
)
)}
{open && (
<Drawer
customer={open}
user={user}
onClose={() => setOpen(null)}
onSaved={() => { setOpen(null); reload() }}
/>
)}
</>
)
}
function Drawer({ customer, user, onClose, onSaved }) {
const [form, setForm] = useState({
full_name: customer.full_name || '',
phone: customer.phone || '',
email: customer.email || '',
consent: !!customer.has_consent,
})
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const { data: history } = usePolled(
() => api.visitorHistory(customer.id, 25), 0, [customer.id])
const canEdit = ['admin', 'owner', 'manager', 'staff'].includes(user.role)
const save = async (e) => {
e.preventDefault()
setSaving(true); setError('')
try {
await api.saveProfile(customer.id, { ...form, visitor_id: customer.id })
onSaved()
} catch (err) {
setError(err.message)
setSaving(false)
}
}
return (
<div className="overlay" onClick={onClose}>
<aside className="drawer" onClick={e => e.stopPropagation()}>
{/* Sticky, so Close stays reachable and the name stays visible while
reading a long visit history. */}
<header className="drawer-head">
<div>
<h2>{customer.full_name || customer.label}</h2>
<p className="sub">{customer.visit_count} visits · last seen {ago(customer.last_seen_at)}</p>
</div>
<button className="ghost" onClick={onClose}>Close</button>
</header>
<form className="drawer-body" onSubmit={save}>
<fieldset disabled={!canEdit}>
<label>Name
<input value={form.full_name}
onChange={e => setForm({ ...form, full_name: e.target.value })} />
</label>
<label>Phone
<input value={form.phone}
onChange={e => setForm({ ...form, phone: e.target.value })} />
</label>
<label>Email
<input type="email" value={form.email}
onChange={e => setForm({ ...form, email: e.target.value })} />
</label>
<label className="check">
<input type="checkbox" checked={form.consent}
onChange={e => setForm({ ...form, consent: e.target.checked })} />
<span>This customer agreed to us keeping their details</span>
</label>
</fieldset>
{error && <p className="error" role="alert">{error}</p>}
{canEdit && (
<button className="primary" disabled={saving}>
{saving ? 'Saving…' : 'Save'}
</button>
)}
</form>
<section className="drawer-body">
<h3>Visits</h3>
{!history ? <p className="sub">Loading…</p> :
history.length === 0 ? <p className="sub">No visits recorded.</p> : (
<ol className="timeline">
{history.map(v => (
<li key={v.id}>
<span className="when">{new Date(v.occurred_at).toLocaleString()}</span>
<span className="where">{v.site}{v.camera_id ? ` · ${v.camera_id}` : ''}</span>
{v.is_new_visitor && <span className="pill new">First visit</span>}
</li>
))}
</ol>
)}
</section>
</aside>
</div>
)
}

131
web/src/views/Live.jsx Normal file
View File

@@ -0,0 +1,131 @@
import { useEffect, useRef, useState } from 'react'
import { api, streamArrivals } from '../api.js'
import { ago, Loading, Problem } from './Sites.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 <Problem error={error} />
if (status === 'connecting' && !arrivals.length) return <Loading />
return (
<>
<header className="head">
<h1>Live</h1>
<p className="sub">
<span className={'dot ' + (status === 'live' ? 'ok' : 'warn')} aria-hidden="true" />
{status === 'live' ? 'Connected' : 'Reconnecting…'}
{' · '}last {KEEP} arrivals
</p>
</header>
{arrivals.length === 0 ? (
<div className="state">
<h2>Nobody yet</h2>
<p className="sub">
Arrivals appear here the moment a camera recognises someone.
</p>
</div>
) : (
<ul className="arrivals">
{arrivals.map(a => <Arrival key={a.visit_id} a={a} />)}
</ul>
)}
</>
)
}
function Arrival({ a }) {
const name = a.name || a.label || 'Unidentified'
return (
<li className="card arrival">
<Face image={a.image} name={name} />
<div className="who-col">
<strong>{name}</strong>
<span className="sub">
{a.site}{a.camera_id ? ` · ${a.camera_id}` : ''} · {ago(a.occurred_at)}
</span>
{a.attributes && <Attributes attrs={a.attributes} />}
</div>
{a.is_new_visitor
? <span className="pill new">New</span>
: <span className="pill">Returning</span>}
</li>
)
}
// 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.
function Face({ image, name }) {
if (image?.available) {
return <img className="face" src={image.url} alt="" loading="lazy" />
}
return (
<span className="face initials" title={image?.reason || ''} aria-hidden="true">
{initials(name)}
</span>
)
}
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 <span className="attrs">{bits.join(' · ')}</span>
}

59
web/src/views/Login.jsx Normal file
View File

@@ -0,0 +1,59 @@
import { useState } from 'react'
import { api } from '../api.js'
export default function Login({ onSignedIn }) {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const submit = async (e) => {
e.preventDefault()
setBusy(true)
setError('')
try {
await api.login(email.trim(), password)
onSignedIn(await api.me())
} catch (err) {
// The server's own wording. It says the same thing for an unknown address
// and a wrong password on purpose - telling them apart turns this form
// into a way to find out who works at a customer.
setError(err.message)
setBusy(false)
}
}
return (
<div className="signin">
<form className="card" onSubmit={submit}>
<span className="mark big" aria-hidden="true" />
<h1>Behavision</h1>
<p className="sub">Sign in to your company account.</p>
<label>
Email
<input
type="email" value={email} autoComplete="username" autoFocus required
onChange={e => setEmail(e.target.value)} placeholder="you@company.com"
/>
</label>
<label>
Password
<input
type="password" value={password} autoComplete="current-password" required
onChange={e => setPassword(e.target.value)}
/>
</label>
{error && <p className="error" role="alert">{error}</p>}
<button className="primary" disabled={busy || !email || !password}>
{busy ? 'Signing in…' : 'Sign in'}
</button>
<p className="foot">
Accounts are created by Loyaly. Ask your account manager if you need one.
</p>
</form>
</div>
)
}

171
web/src/views/Reports.jsx Normal file
View File

@@ -0,0 +1,171 @@
import { useMemo, useState } from 'react'
import { api } from '../api.js'
import { usePolled } from '../hooks.js'
import { Loading, Problem } from './Sites.jsx'
const RANGES = [
{ id: '7', label: 'Last 7 days', days: 6, bucket: 'day' },
{ id: '30', label: 'Last 30 days', days: 29, bucket: 'day' },
{ id: '90', label: 'Last 90 days', days: 89, bucket: 'week' },
]
const day = (d) => d.toISOString().slice(0, 10)
export default function Reports() {
const [rangeId, setRangeId] = useState('30')
const range = RANGES.find(r => r.id === rangeId)
const params = useMemo(() => {
const to = new Date()
const from = new Date(to)
from.setDate(from.getDate() - range.days)
return {
from: day(from), to: day(to), bucket: range.bucket,
// Buckets are cut in this timezone and returned as local wall time with
// no offset, so they must not be parsed as a Date - see Bar below.
tz: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
}
}, [rangeId])
const foot = usePolled(() => api.footfall(params), 0, [rangeId])
const sales = usePolled(() => api.conversion(params), 0, [rangeId])
if (foot.loading && !foot.data) return <Loading />
if (foot.error) return <Problem error={foot.error} />
const f = foot.data
const s = sales.data
return (
<>
<header className="head">
<h1>Reports</h1>
<div className="segmented">
{RANGES.map(r => (
<button key={r.id} onClick={() => setRangeId(r.id)}
aria-current={r.id === rangeId ? 'true' : undefined}>
{r.label}
</button>
))}
</div>
</header>
{f.fraction_below_gate > 0.5 && (
<p className="banner warn">
<b>{Math.round(f.fraction_below_gate * 100)}% of faces seen were below the
enrolment threshold{f.worst_site ? ` (worst: ${f.worst_site})` : ''}.</b>{' '}
These figures count fewer people than actually visited. A camera needs moving.
</p>
)}
<div className="grid stats">
<Stat
value={f.total.toLocaleString()}
label="Unique people"
note={`${f.visits.toLocaleString()} visits in total`}
/>
<Stat
value={sum(f.points, 'new').toLocaleString()}
label="First-time visitors"
note="Never seen at any of your shops before"
/>
{s && (
<Stat
value={pct(s.conversion)}
label="Bought something"
note={`${s.purchasers.toLocaleString()} of ${s.visitors.toLocaleString()} visitors`}
/>
)}
{s && s.revenue > 0 && (
<Stat
value={money(s.revenue, s.currency)}
label="Revenue"
note={`${money(s.average_basket, s.currency)} average basket`}
/>
)}
</div>
<section className="card chart-card">
<header className="chart-head">
<h2>Visitors per {f.bucket}</h2>
{/* Said out loud, because it looks like an error otherwise. A person
who came Monday and Thursday is one unique visitor and two bars. */}
<p className="sub">
Bars count each visit. They add up to more than the {f.total.toLocaleString()}{' '}
unique people above, because regulars appear in more than one bar.
</p>
</header>
<Chart points={f.points} bucket={f.bucket} tz={f.timezone} />
</section>
</>
)
}
function Stat({ value, label, note }) {
return (
<div className="card stat">
<span className="value">{value}</span>
<span className="label">{label}</span>
{note && <span className="note">{note}</span>}
</div>
)
}
function Chart({ points, bucket, tz }) {
if (!points?.length) {
return <p className="sub pad">Nothing recorded in this period.</p>
}
const max = Math.max(...points.map(p => p.visitors), 1)
const peak = points.find(p => p.visitors === max)
return (
<>
{/* A bar chart whose tallest bar names no value is a shape, not a
measurement. One label on the peak is the least that makes the
heights readable, without an axis this chart is too short to carry. */}
<p className="peak">
Peak <b>{max}</b> visits on {label(peak.bucket, bucket)}
</p>
<div className="chart" role="img"
aria-label={`Visitors per ${bucket}, peak ${max}`}>
{points.map(p => (
<div className="col" key={p.bucket} title={`${label(p.bucket, bucket)} — ${p.visitors} visits`}>
<div className="bars">
{/* New sits on top of returning: the two are a breakdown of the
same bar, not two competing series. They can sum to LESS than
the total, because a visit with no identified person is real
footfall by an unknown someone. */}
<span className="bar returning" style={{ height: pctOf(p.returning, max) }} />
<span className="bar new" style={{ height: pctOf(p.new, max) }} />
</div>
<span className="tick">{label(p.bucket, bucket)}</span>
</div>
))}
</div>
<footer className="legend">
<span><i className="swatch new" />First visit</span>
<span><i className="swatch returning" />Returning</span>
<span className="sub">times shown in {tz}</span>
</footer>
</>
)
}
const pctOf = (n, max) => `${Math.max(n > 0 ? 2 : 0, (n / max) * 100)}%`
const sum = (points, key) => (points || []).reduce((t, p) => t + (p[key] || 0), 0)
const pct = (v) => `${Math.round((v || 0) * 100)}%`
function money(v, currency) {
try {
return new Intl.NumberFormat(undefined,
{ style: 'currency', currency: currency || 'INR', maximumFractionDigits: 0 }).format(v)
} catch { return `${Math.round(v)}` }
}
// Buckets arrive as LOCAL wall time with no offset, deliberately: stamping them
// Z would say 09:00 UTC when the shop means 09:00 in Chennai. So they are
// sliced as text, never handed to Date, which would re-interpret them in the
// viewer's own timezone and shift every label.
function label(bucket, size) {
if (size === 'hour') return bucket.slice(11, 16)
return bucket.slice(5, 10).replace('-', '/')
}

171
web/src/views/SiteCheck.jsx Normal file
View File

@@ -0,0 +1,171 @@
import { useState } from 'react'
import { api } from '../api.js'
// The end-to-end answer for one shop.
//
// Not "is a camera plugged in" but "is this shop actually working" — because
// those came apart on a real site, which ran for weeks online, connected, and
// recognising almost nobody. Every step names what to do when it fails, and the
// shop is only "working" when all of them pass: a partial pass is not a working
// shop, and calling it one is how that site got signed off.
export default function SiteCheck({ site, onClose }) {
const [result, setResult] = useState(null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const run = async () => {
setBusy(true); setError(''); setResult(null)
try {
setResult(await api.siteCheck(site.site_id))
} catch (err) {
setError(err.message)
} finally {
setBusy(false)
}
}
return (
<div className="overlay" onClick={onClose}>
<aside className="drawer" onClick={e => e.stopPropagation()}>
<header className="drawer-head">
<div>
<h2>Is {site.name} working?</h2>
<p className="sub">Checks the whole chain, from the shop’s PC to head office.</p>
</div>
<button className="ghost" onClick={onClose}>Close</button>
</header>
<div className="drawer-body">
{!result && !busy && (
<>
<p className="sub">
Nothing is changed and no one is disturbed — this reads what the
shop has already reported.
</p>
<button className="primary" onClick={run}>Run the check</button>
</>
)}
{busy && (
<div className="waiting">
<span className="spinner" aria-hidden="true" />
<b>Checking…</b>
</div>
)}
{error && <p className="error" role="alert">{error}</p>}
{result && (
<>
<div className={'banner ' + (result.ok ? 'ok' : 'warn')}>
<div>
<b>{result.ok
? 'This shop is working.'
: 'This shop needs attention.'}</b>
{!result.ok && (
<> Work down the list — the first failure usually explains the rest.</>
)}
</div>
</div>
<ol className="checklist">
{result.steps.map(s => (
<li key={s.name} className={s.status}>
<span className="mark" aria-hidden="true">
{s.status === 'pass' ? '✓' : s.status === 'fail' ? '✕'
: s.status === 'warn' ? '!' : '–'}
</span>
<div>
<b>{s.name}</b>
<span className="sub">{s.detail}</span>
{s.advice && <span className="advice-line">{s.advice}</span>}
</div>
</li>
))}
</ol>
<button className="ghost" onClick={run}>Check again</button>
</>
)}
<ClaimPC site={site} />
</div>
</aside>
</div>
)
}
// Getting the code that links a shop PC to this shop.
//
// It lives here because this drawer is already "everything about one shop", and
// because the answer to the most common failure the check reports - nothing has
// ever been heard from this shop - is precisely this: the PC was never claimed.
// Before this existed the only way to produce a code was a shell on the server,
// which made replacing a till PC a support ticket.
function ClaimPC({ site }) {
const [code, setCode] = useState(null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [label, setLabel] = useState('')
const issue = async () => {
setBusy(true); setError('')
try {
setCode(await api.enrolmentCode(site.site_id, { label: label.trim() }))
} catch (err) {
setError(err.message)
} finally {
setBusy(false)
}
}
return (
<section className="claim">
<h3>Set up a shop PC</h3>
{!code ? (
<>
<p className="sub">
Creates a one-time code to type into Behavision on the shop&rsquo;s
computer. Use one for a new shop, a replacement PC, or a reinstall.
</p>
<label className="field">
<span>What is this PC? (optional)</span>
<input value={label} placeholder="counter PC"
onChange={e => setLabel(e.target.value)} />
</label>
{error && <p className="error" role="alert">{error}</p>}
<button className="ghost" onClick={issue} disabled={busy}>
{busy ? 'Creating…' : 'Create an installation code'}
</button>
</>
) : (
<>
{/* Shown once, and said so plainly. Only a hash is stored, so nobody
- support included - can read it back; an operator who closes this
without copying it needs a new code, which is cheap. */}
<p className="code" aria-live="polite">{code.code}</p>
<p className="sub">
Copy this now — it cannot be shown again. It works once, and stops
working {expiry(code.expires_at)}.
</p>
<div className="row">
<button className="ghost" onClick={() => copy(code.code)}>Copy</button>
<button className="ghost" onClick={() => setCode(null)}>Done</button>
</div>
</>
)}
</section>
)
}
function copy(text) {
try { navigator.clipboard?.writeText(text) } catch { /* the code is on screen */ }
}
function expiry(iso) {
const t = new Date(iso).getTime()
if (Number.isNaN(t)) return 'shortly'
const days = Math.round((t - Date.now()) / 86400000)
if (days <= 0) return 'today'
return days === 1 ? 'tomorrow' : `in ${days} days`
}

260
web/src/views/Sites.jsx Normal file
View File

@@ -0,0 +1,260 @@
import { useState } from 'react'
import { api } from '../api.js'
import { usePolled } from '../hooks.js'
import SiteCheck from './SiteCheck.jsx'
// The estate at a glance.
//
// The question this answers is not "how busy were we" — it is "is any of this
// actually working". A shop whose PC has been unplugged for a week and a shop
// with no customers produce the same row of zeroes everywhere else in the
// product, and only one of them is something to act on.
//
// Laid out like the camera screen, and for the same reason: the first version
// was a definition list of cameras_up, fraction_below_gate and last_heartbeat,
// which is the view a developer wants. An owner opening this wants to SEE their
// shops. So the shop's own camera view is the card, the numbers sit under it,
// and one line says what to do — with the technical detail one click away
// rather than on the surface.
export default function Sites() {
const { data, error, loading } = usePolled(() => api.sites(), 20000, [])
// Cameras come from a second call and are joined here rather than server-side:
// the picture is decoration on this screen, so it must never be able to make
// the health list fail. If this errors the cards simply have no photograph.
const { data: cams } = usePolled(() => api.cameras(), 60000, [])
const [checking, setChecking] = useState(null)
const sites = data || []
if (loading && !data) return <Loading />
if (error) return <Problem error={error} />
if (!sites.length) return <Empty />
// Counted from the same verdicts the cards show. Summarising with a second,
// simpler rule up here is how a header ends up reading "all working" over a
// grid containing a red card.
const rows = sites.map(s => {
const own = (cams || []).filter(c => c.site_id === s.site_id)
return { site: s, cams: own, verdict: verdictFor(s, own) }
})
const tally = t => rows.filter(r => r.verdict.tone === t).length
const broken = tally('bad'), watch = tally('warn'), fresh = tally('idle')
return (
<>
<header className="head">
<h1>Shops</h1>
<p className="sub">
{sites.length} {sites.length === 1 ? 'shop' : 'shops'}
{broken > 0 && <> · <b className="bad">{broken} not working</b></>}
{watch > 0 && <> · <b className="warn">{watch} needing attention</b></>}
{fresh > 0 && <> · {fresh} not set up yet</>}
{!broken && !watch && !fresh && <> · <b className="ok">all working</b></>}
</p>
</header>
<div className="grid sites">
{rows.map(r => (
<SiteCard key={r.site.site_id} site={r.site} cams={r.cams}
verdict={r.verdict} onCheck={() => setChecking(r.site)} />
))}
</div>
{/* Reachable per shop, at last. The smoke test used to hang off a single
button on the camera screen that always checked sites[0], so with two
shops the second could not be checked at all. */}
{checking && <SiteCheck site={checking} onClose={() => setChecking(null)} />}
</>
)
}
// The one line the card leads with, in severity order. Only the first is shown:
// a shop that is offline AND has a bad camera needs its PC turned on first, and
// listing both invites someone to start with the wrong one.
//
// This is also the ONLY place a shop's health is decided. The pill over the
// picture, the stripe down the edge and this line all read from it, because the
// first version computed the pill separately from online + gate and a shop with
// two dead cameras came out labelled "Working" directly above the words "2 of 3
// cameras not connecting". Two surfaces disagreeing about one fact is worse
// than either being wrong on its own.
function verdictFor(site, cams) {
const gate = site.fraction_below_gate
const total = site.cameras_total || cams.length
if (!site.online) {
return { tone: 'bad', mark: '✕', headline: 'Offline',
words: `Offline — last heard from ${ago(site.last_heartbeat_at)}` }
}
// Footfall this shop saw and can never report. Stored with GREATEST()
// server-side so a restarted agent cannot make it quietly disappear.
if (site.dropped > 0) {
return { tone: 'bad', mark: '✕',
words: `${site.dropped} visits lost and unrecoverable` }
}
if (total === 0) {
return { tone: 'idle', mark: '+', headline: 'Not set up',
words: 'No cameras set up yet' }
}
if (site.cameras_up === 0) {
return { tone: 'bad', mark: '✕', words: 'No cameras connected' }
}
if (site.cameras_up < total) {
return { tone: 'warn', mark: '!',
words: `${total - site.cameras_up} of ${total} cameras not connecting` }
}
// The Office1 case: online, connected, and recognising almost nobody. Over
// half is a failure and not a warning — those visitors are gone.
if (gate > 0.5) {
return { tone: 'bad', mark: '✕',
words: `${pct(gate)} of faces too poor to recognise` }
}
if (gate > 0.2) {
return { tone: 'warn', mark: '!',
words: `${pct(gate)} of faces too poor to recognise` }
}
if (site.queued > 0) {
return { tone: 'warn', mark: '!', words: `${site.queued} visits waiting to upload` }
}
return { tone: 'ok', mark: '✓',
words: `Working — ${total} ${total === 1 ? 'camera' : 'cameras'} connected` }
}
const HEADLINES = { ok: 'Working', warn: 'Needs attention',
bad: 'Not working', idle: 'Not set up' }
function SiteCard({ site, cams, verdict, onCheck }) {
const gate = site.fraction_below_gate
const health = verdict.tone
const headline = verdict.headline || HEADLINES[health]
const view = bestView(cams)
const total = site.cameras_total || cams.length
return (
<article className={'card site state-' + health} onClick={onCheck} role="button"
tabIndex={0} onKeyDown={e => e.key === 'Enter' && onCheck()}>
<div className="shot">
{view.url
? <img src={view.url} alt={`View inside ${site.name}`} loading="lazy" />
: <div className="noshot">
<ShopMark />
{view.reason && <span>{view.reason}</span>}
</div>}
<div className="shot-over">
<div className="shot-name">
<b>{site.name}</b>
<span>{total > 0
? `${total} ${total === 1 ? 'camera' : 'cameras'}`
: 'No cameras yet'}</span>
</div>
<span className={'status ' + health}>
<i aria-hidden="true" />{headline}
</span>
</div>
{view.at && <span className="shot-age">{ago(view.at)}</span>}
</div>
{/* Three numbers, and each one is a different question: is the hardware
up, can it see faces well enough to recognise them, and is anyone
home. The rest moved behind the check. */}
<div className="metrics">
<Metric label="Cameras"
value={total ? `${site.cameras_up}/${total}` : '—'}
tone={!total ? 'idle' : site.cameras_up < total ? 'bad' : 'ok'} />
<Metric label="Faces usable"
value={gate > 0 ? pct(1 - gate) : '—'}
tone={!gate ? 'idle' : gate > 0.5 ? 'bad' : gate > 0.2 ? 'warn' : 'ok'} />
<Metric label="Last seen" value={ago(site.last_heartbeat_at)}
tone={site.online ? 'ok' : 'bad'} />
</div>
<div className={'verdict ' + verdict.tone}>
<span className="mark" aria-hidden="true">{verdict.mark}</span>
<span className="words">{verdict.words}</span>
<span className="go" aria-hidden="true">→</span>
</div>
</article>
)
}
function Metric({ label, value, tone }) {
return (
<div className="metric">
<b className={tone}>{value}</b>
<span>{label}</span>
</div>
)
}
// The freshest picture any of this shop's cameras has sent.
//
// A missing picture is a normal state, not an error — images are off by default
// across the product — so the empty tile explains itself rather than showing a
// black hole with an apology in it. The three absences need different words:
// "nobody has set a camera up", "the PC has not reported yet", and "this system
// stores no photographs" are three different next actions.
function bestView(cams) {
// Nothing written here for a shop with no cameras: the verdict line already
// says exactly that, and three phrasings of one fact on one card reads as a
// fault rather than a state.
if (!cams.length) return {}
let best = null
for (const c of cams) {
if (!c.snapshot?.available || !c.snapshot.url) continue
if (!best || (c.snapshot_at || '') > (best.snapshot_at || '')) best = c
}
if (best) return { url: best.snapshot.url, at: best.snapshot_at }
const reason = cams.map(c => c.snapshot?.reason).find(Boolean)
return { reason: reason || 'No picture from this shop yet.' }
}
// Drawn, not an emoji or an icon font: an empty tile that still reads as a
// shop, in the same spirit as the camera screen's lens.
function ShopMark() {
return (
<svg className="shopmark" viewBox="0 0 40 32" aria-hidden="true">
<path d="M4 12h32v18H4z" />
<path d="M2 12l4-8h28l4 8" />
<path d="M15 30v-9h10v9" />
</svg>
)
}
function pct(f) { return `${Math.round(f * 100)}%` }
export function ago(iso) {
if (!iso) return 'never'
const then = new Date(iso).getTime()
if (Number.isNaN(then)) return '—'
const secs = Math.max(0, (Date.now() - then) / 1000)
if (secs < 90) return 'just now'
const mins = Math.round(secs / 60)
if (mins < 60) return `${mins} min ago`
const hrs = Math.round(mins / 60)
if (hrs < 48) return `${hrs} h ago`
return `${Math.round(hrs / 24)} days ago`
}
export function Loading() {
return <div className="state"><span className="spinner" aria-hidden="true" />Loading…</div>
}
export function Problem({ error }) {
return (
<div className="state">
<p className="error" role="alert">{error.message}</p>
</div>
)
}
function Empty() {
return (
<div className="state">
<h2>No shops yet</h2>
<p className="sub">
A shop appears here once its PC has been claimed with an enrolment code.
</p>
</div>
)
}