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

97
web/src/App.jsx Normal file
View File

@@ -0,0 +1,97 @@
import { useEffect, useState } from 'react'
import { api, signedIn, clearTokens } from './api.js'
import Login from './views/Login.jsx'
import Sites from './views/Sites.jsx'
import Live from './views/Live.jsx'
import CamerasView from './views/Cameras.jsx'
import Assistant from './views/Assistant.jsx'
import Clients from './views/Clients.jsx'
// A platform admin has no client of their own, so the tenant screens have
// nothing to show them. Rather than render empty pages, they get the one screen
// that is theirs.
//
// Customers and Reports were removed from the navigation - not deleted, the
// views and their API routes are untouched - the same trim the desktop app
// already made and for the same reason: this is the screen somebody opens to
// find out whether their shops are WORKING. A customer search and a
// month-on-month chart are a different job, and putting them in the same nav
// implies the estate is healthy enough to be worth reporting on before anyone
// has checked. They come back when there is something to say with them.
const TENANT_VIEWS = [
{ id: 'sites', label: 'Shops', View: Sites },
{ id: 'live', label: 'Live', View: Live },
{ id: 'cameras', label: 'Cameras', View: CamerasView },
]
const ADMIN_VIEWS = [
{ id: 'clients', label: 'Companies', View: Clients },
]
export default function App() {
const [user, setUser] = useState(null)
const [booting, setBooting] = useState(true)
const [view, setView] = useState('sites')
const [asking, setAsking] = useState(false)
useEffect(() => {
(async () => {
if (signedIn()) {
try { setUser(await api.me()) } catch { clearTokens() }
}
setBooting(false)
})()
}, [])
if (booting) {
return <div className="boot"><span className="spinner" aria-hidden="true" />Loading…</div>
}
if (!user) return <Login onSignedIn={setUser} />
const isAdmin = user.role === 'admin' && !user.client_id
const views = isAdmin ? ADMIN_VIEWS : TENANT_VIEWS
const current = views.find(v => v.id === view) || views[0]
const Current = current.View
const signOut = async () => { await api.logout(); setUser(null) }
return (
<div className="app">
<header className="topbar">
<div className="brand">
<span className="mark" aria-hidden="true" />
<div>
<strong>Behavision</strong>
<span className="org">{user.client_name || 'Loyaly platform'}</span>
</div>
</div>
<nav className="tabs">
{views.map(v => (
<button
key={v.id}
onClick={() => setView(v.id)}
aria-current={current.id === v.id ? 'page' : undefined}
>{v.label}</button>
))}
</nav>
<div className="who">
<button className={'ask-btn' + (asking ? ' on' : '')}
onClick={() => setAsking(a => !a)}>
<span aria-hidden="true">✳</span> Ask
</button>
<span className="name">{user.full_name || user.email}</span>
<span className="role">{isAdmin ? 'platform admin' : user.role}</span>
<button className="ghost" onClick={signOut}>Sign out</button>
</div>
</header>
<div className={'with-assistant' + (asking ? ' open' : '')}>
<main className="page">
<Current user={user} />
</main>
<Assistant open={asking} onClose={() => setAsking(false)} />
</div>
</div>
)
}

229
web/src/api.js Normal file
View File

@@ -0,0 +1,229 @@
// The cloud API, as this browser sees it.
//
// Everything the platform does goes through `send` below, so there is exactly
// one place that knows how a session is carried, refreshed and lost.
const ACCESS = 'bv.access'
const REFRESH = 'bv.refresh'
// localStorage, not a cookie. The API authenticates with a bearer token and
// sets no cookie of its own, so there is no CSRF surface to defend - and a
// head-office user who closes the tab expects to still be signed in tomorrow.
function read(k) { try { return localStorage.getItem(k) || '' } catch { return '' } }
function write(k, v) { try { v ? localStorage.setItem(k, v) : localStorage.removeItem(k) } catch { /* private mode */ } }
export function tokens() { return { access: read(ACCESS), refresh: read(REFRESH) } }
export function setTokens(access, refresh) { write(ACCESS, access); write(REFRESH, refresh) }
export function clearTokens() { setTokens('', '') }
export function signedIn() { return !!read(REFRESH) }
export class ApiError extends Error {
constructor(status, code, message) {
super(message)
this.status = status
// The server's own code, kept alongside its prose. A caller that has to
// match on English to tell a normal absence from a fault will get it wrong
// the first time the wording is improved.
this.code = code
}
}
// One refresh at a time, ever.
//
// The refresh token is single-use and rotates. Four screens polling at once
// would each spend it and three would lose, logging the user out at random -
// so every caller that hits an expired token waits on the same promise.
let refreshing = null
async function refresh() {
if (refreshing) return refreshing
refreshing = (async () => {
const { refresh: rt } = tokens()
if (!rt) throw new ApiError(401, 'unauthorized', 'Signed out.')
const res = await fetch('/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: rt }),
})
if (!res.ok) {
clearTokens()
throw new ApiError(res.status, 'unauthorized', 'Your session has ended. Sign in again.')
}
const s = await res.json()
// Persisted BEFORE anything else runs. A tab that refreshes and is then
// closed must not come back holding a token the server already retired.
setTokens(s.access_token, s.refresh_token)
return s
})().finally(() => { refreshing = null })
return refreshing
}
async function send(method, path, body, retry = true) {
const { access } = tokens()
const headers = {}
if (access) headers.Authorization = 'Bearer ' + access
if (body !== undefined) headers['Content-Type'] = 'application/json'
const res = await fetch(path, {
method,
headers,
// Serialised up front: a retry has to send the same body again, and a
// stream would be spent after the first attempt.
body: body === undefined ? undefined : JSON.stringify(body),
})
if (res.status === 204) return null
const text = await res.text()
let parsed = null
try { parsed = text ? JSON.parse(text) : null } catch { /* not json */ }
if (res.ok) return parsed
const code = parsed?.error || ''
if (code === 'token_expired' && retry) {
// Silent. A shop assistant should not be thrown back to a login form twice
// a day because an access token reached twelve hours old.
await refresh()
return send(method, path, body, false)
}
if (res.status === 401) clearTokens()
throw new ApiError(res.status, code,
parsed?.message || `Something went wrong (${res.status}).`)
}
const qs = (params) => {
const p = new URLSearchParams()
for (const [k, v] of Object.entries(params || {})) {
if (v !== undefined && v !== null && v !== '') p.set(k, v)
}
const s = p.toString()
return s ? '?' + s : ''
}
export const api = {
async login(email, password) {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
const body = await res.json().catch(() => null)
if (!res.ok) {
throw new ApiError(res.status, body?.error || '',
body?.message || 'Could not sign in.')
}
setTokens(body.access_token, body.refresh_token)
return body.user
},
async logout() {
try { await send('POST', '/api/auth/logout') } catch { /* already gone */ }
clearTokens()
},
me: () => send('GET', '/api/auth/me'),
sites: () => send('GET', '/api/sites'),
// The live arrivals feed. `cursor` is opaque and must be echoed back.
arrivals: (params) => send('GET', '/api/visits' + qs(params)),
visitors: (q, limit = 50) => send('GET', '/api/visitors' + qs({ q, limit })),
visitorHistory: (id, limit = 50) =>
send('GET', `/api/visitors/${encodeURIComponent(id)}/history` + qs({ limit })),
saveProfile: (id, profile) =>
send('PUT', `/api/visitors/${encodeURIComponent(id)}/profile`, profile),
footfall: (params) => send('GET', '/api/reports/footfall' + qs(params)),
conversion: (params) => send('GET', '/api/reports/conversion' + qs(params)),
cameras: () => send('GET', '/api/cameras'),
createCamera: (siteID, cam) =>
send('POST', `/api/sites/${encodeURIComponent(siteID)}/cameras`, cam),
updateCamera: (id, cam) => send('PATCH', `/api/cameras/${encodeURIComponent(id)}`, cam),
deleteCamera: (id) => send('DELETE', `/api/cameras/${encodeURIComponent(id)}`),
// Prove a camera works. `kind` is "connection" (can the shop PC open the
// stream) or "placement" (does someone walking past produce a usable view).
checkCamera: (id, kind, seconds) =>
send('POST', `/api/cameras/${encodeURIComponent(id)}/check`,
{ kind, ...(seconds ? { seconds } : {}) }),
siteCheck: (siteID) =>
send('GET', `/api/sites/${encodeURIComponent(siteID)}/check`),
// The one-shot code a shop PC is claimed with. Returned in full exactly once
// - only a hash is stored - so whatever calls this has to show it there and
// then, and must not expect to read it back later.
enrolmentCode: (siteID, input) =>
send('POST', `/api/sites/${encodeURIComponent(siteID)}/enrolment-code`,
input || {}),
ask: (history) => send('POST', '/api/assistant', { history }),
clients: () => send('GET', '/api/admin/clients'),
createClient: (input) => send('POST', '/api/admin/clients', input),
}
// The live stream, read with fetch rather than EventSource.
//
// EventSource cannot set headers, so using it would mean putting the session
// token in the query string - where it lands in server logs, browser history
// and any screenshot of the URL bar. Reading the stream by hand costs the
// frame parsing below and keeps the token in an Authorization header.
//
// The cursor is held HERE, not derived from what is on screen. That is what
// makes a reconnect lossless: whatever arrived while the connection was down
// is delivered on the next one.
export function streamArrivals({ cursor, siteId, onPage, onError, signal }) {
let stopped = false
let position = cursor || ''
const run = async () => {
while (!stopped) {
try {
const { access } = tokens()
const res = await fetch('/api/visits/stream' + qs({ cursor: position, site_id: siteId }), {
headers: { Authorization: 'Bearer ' + access, Accept: 'text/event-stream' },
signal,
})
if (res.status === 401) {
await refresh()
continue
}
if (!res.ok || !res.body) throw new Error('stream unavailable')
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buf = ''
while (!stopped) {
const { value, done } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
// Frames are separated by a blank line. Anything after the last one
// is a partial frame and stays in the buffer.
let split
while ((split = buf.indexOf('\n\n')) !== -1) {
const frame = buf.slice(0, split)
buf = buf.slice(split + 2)
for (const line of frame.split('\n')) {
if (line.startsWith('id: ')) position = line.slice(4).trim()
else if (line.startsWith('data: ')) {
try { onPage(JSON.parse(line.slice(6))) } catch { /* partial */ }
}
}
}
}
} catch (err) {
if (stopped || signal?.aborted) return
onError?.(err)
}
if (stopped) return
// Reconnect. `position` survives, so nothing that arrived while we were
// away is missed - that is the whole reason the cursor is held here and
// not derived from what is on screen.
await new Promise(r => setTimeout(r, 3000))
}
}
run()
return () => { stopped = true }
}

42
web/src/hooks.js Normal file
View File

@@ -0,0 +1,42 @@
import { useCallback, useEffect, useRef, useState } from 'react'
// usePolled runs `fn` now and every `everyMs`, and is careful about the two
// things every screen would otherwise get wrong on its own: overlapping
// requests when the server is slower than the interval, and setting state
// after the component has gone.
export function usePolled(fn, everyMs, deps = []) {
const [data, setData] = useState(null)
const [error, setError] = useState(null)
const [loading, setLoading] = useState(true)
const inFlight = useRef(false)
const alive = useRef(true)
const run = useCallback(async () => {
if (inFlight.current) return
inFlight.current = true
try {
const out = await fn()
if (!alive.current) return
setData(out)
setError(null)
} catch (err) {
if (!alive.current) return
setError(err)
} finally {
inFlight.current = false
if (alive.current) setLoading(false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps)
useEffect(() => {
alive.current = true
run()
if (!everyMs) return () => { alive.current = false }
const t = setInterval(run, everyMs)
return () => { alive.current = false; clearInterval(t) }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [run, everyMs])
return { data, error, loading, reload: run }
}

6
web/src/main.jsx Normal file
View File

@@ -0,0 +1,6 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.jsx'
import './styles.css'
createRoot(document.getElementById('root')).render(<App />)

499
web/src/styles.css Normal file
View File

@@ -0,0 +1,499 @@
/* Behavision platform — head office.
Shares the desktop app's palette on purpose: they are one product, and an
owner who sees a shop PC and then this should not wonder whether they are
looking at the same system. The layout differs because the questions do —
the desktop is an instrument panel watched all day, this is read on a laptop
and scrolls. */
:root {
--ground: #0E1317;
--surface: #161D23;
--surface-2: #1D262D;
--line: #27333B;
--line-soft: #1F2A31;
--ink: #E7EEF3;
--ink-2: #B4C2CC;
--muted: #7C8B97;
--accent: #45B0C7;
--accent-dim:#123039;
--ok: #4FB37B;
--ok-dim: #12291F;
--warn: #E0A33A;
--warn-dim: #2C2313;
--bad: #E0655A;
--bad-dim: #2B1917;
--radius: 10px;
--mono: "SFMono-Regular", ui-monospace, Menlo, Consolas, monospace;
}
* { box-sizing: border-box; margin: 0; }
html { color-scheme: dark; }
body {
background: var(--ground);
color: var(--ink);
font: 15px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif;
-webkit-font-smoothing: antialiased;
}
button, input, select, textarea { font: inherit; color: inherit; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
code { font-family: var(--mono); font-size: .9em; }
h1 { font-size: 22px; font-weight: 620; letter-spacing: -.015em; }
h2 { font-size: 16px; font-weight: 600; }
h3 { font-size: 13px; font-weight: 600; text-transform: uppercase;
letter-spacing: .08em; color: var(--muted); }
.sub { color: var(--muted); font-size: 13.5px; }
.num { font-variant-numeric: tabular-nums; }
.ok { color: var(--ok); } .warn { color: var(--warn); } .bad { color: var(--bad); }
/* Those three are text utilities and they are inherited, so a card that carried
`ok` as its own state class tinted every word inside it green. Card severity
is therefore namespaced `state-*` below - a structural state and a colour
utility must not share a name. */
/* ------------------------------------------------------------- shell ---- */
.app { min-height: 100vh; display: flex; flex-direction: column; }
.topbar {
position: sticky; top: 0; z-index: 20;
display: flex; align-items: center; gap: 28px;
padding: 0 24px; height: 60px;
background: var(--surface); border-bottom: 1px solid var(--line);
}
.brand { display: flex; align-items: center; gap: 11px; }
.brand strong { display: block; font-size: 15px; font-weight: 620; letter-spacing: -.01em; }
.brand .org { display: block; font-size: 12px; color: var(--muted); }
.mark {
width: 18px; height: 18px; border-radius: 50%;
border: 2.5px solid var(--accent);
box-shadow: inset 0 0 0 3px var(--ground);
flex: none;
}
.mark.big { width: 30px; height: 30px; border-width: 3px; margin-bottom: 14px; }
.tabs { display: flex; gap: 2px; margin-right: auto; }
.tabs button {
background: none; border: 0; border-radius: 7px; padding: 7px 13px;
color: var(--ink-2); cursor: pointer; font-size: 14px;
}
.tabs button:hover { background: var(--surface-2); color: var(--ink); }
.tabs button[aria-current="page"] { background: var(--accent-dim); color: var(--accent); font-weight: 550; }
.who { display: flex; align-items: center; gap: 12px; }
.who .name { font-size: 13.5px; }
.who .role { font-size: 11px; color: var(--muted); text-transform: uppercase;
letter-spacing: .07em; }
.page { flex: 1; padding: 26px 24px 64px; max-width: 1280px; width: 100%;
margin: 0 auto; display: flex; flex-direction: column; gap: 20px; }
.head { display: flex; align-items: flex-end; gap: 16px; flex-wrap: wrap; }
.head h1 { margin-right: auto; }
.head .sub { padding-bottom: 2px; }
/* ------------------------------------------------------------ buttons --- */
button.primary {
background: var(--accent); color: #04161B; border: 0; border-radius: 7px;
padding: 9px 16px; font-weight: 600; cursor: pointer;
}
button.primary:disabled { opacity: .5; cursor: default; }
button.ghost {
background: none; border: 1px solid var(--line); border-radius: 7px;
padding: 7px 13px; color: var(--ink-2); cursor: pointer;
}
button.ghost:hover { border-color: var(--muted); color: var(--ink); }
.segmented { display: flex; border: 1px solid var(--line); border-radius: 8px;
overflow: hidden; }
.segmented button {
background: none; border: 0; padding: 7px 14px; color: var(--ink-2);
cursor: pointer; font-size: 13.5px;
}
.segmented button + button { border-left: 1px solid var(--line); }
.segmented button[aria-current] { background: var(--accent-dim); color: var(--accent); }
/* -------------------------------------------------------------- cards --- */
.card { background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius); }
.grid { display: grid; gap: 14px; }
.grid.sites { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; }
.grid.stats { grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); }
/* Shops, laid out like cameras: the shop's own camera view IS the card.
The severity stripe survives on the left edge - a card that is entirely red
reads as broken even when the shop is merely busy. */
.card.site { overflow: hidden; padding: 0; cursor: pointer;
border-left: 3px solid var(--line);
transition: border-color .15s ease, transform .15s ease; }
.card.site.state-ok { border-left-color: var(--ok); }
.card.site.state-warn { border-left-color: var(--warn); }
.card.site.state-bad { border-left-color: var(--bad); }
.card.site.state-idle { border-left-color: var(--muted); }
.card.site:hover { transform: translateY(-1px); }
.card.site:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { .card.site { transition: none; } }
/* Three numbers, evenly weighted, because they are three different questions
and none of them is the headline. The headline is the verdict line below. */
.metrics { display: grid; grid-template-columns: repeat(3, 1fr);
border-top: 1px solid var(--line-soft); }
.metric { padding: 11px 6px; text-align: center; }
.metric + .metric { border-left: 1px solid var(--line-soft); }
.metric b { display: block; font-size: 15px; font-weight: 600;
font-variant-numeric: tabular-nums; letter-spacing: -.01em; }
.metric b.ok { color: var(--ink); } .metric b.warn { color: var(--warn); }
.metric b.bad { color: var(--bad); } .metric b.idle { color: var(--muted); }
.metric span { display: block; margin-top: 1px; font-size: 11px; color: var(--muted); }
/* A shopfront, drawn for the same reason the camera tile draws a lens: most
deployments store no images, so an empty tile is the ordinary state and it
should still read as a shop. */
.shopmark { width: 38px; height: 30px; fill: none; stroke: var(--line);
stroke-width: 2; stroke-linejoin: round; }
.pill {
display: inline-block; padding: 3px 9px; border-radius: 20px; flex: none;
font-size: 11.5px; font-weight: 550; letter-spacing: .01em;
background: var(--surface-2); color: var(--ink-2);
}
.pill.ok { background: var(--ok-dim); color: var(--ok); }
.pill.warn { background: var(--warn-dim); color: var(--warn); }
.pill.bad { background: var(--bad-dim); color: var(--bad); }
.pill.new { background: var(--accent-dim); color: var(--accent); }
.dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%;
background: var(--muted); margin-right: 7px; vertical-align: 1px; }
.dot.ok { background: var(--ok); } .dot.warn { background: var(--warn); }
/* ------------------------------------------------------------- stats ---- */
.card.stat { padding: 16px 18px; display: flex; flex-direction: column; gap: 3px; }
.stat .value { font-size: 30px; font-weight: 620; letter-spacing: -.02em;
font-variant-numeric: tabular-nums; line-height: 1.15; }
.stat .label { font-size: 13.5px; color: var(--ink-2); }
.stat .note { font-size: 12.5px; color: var(--muted); margin-top: 3px; }
.banner { padding: 12px 16px; border-radius: var(--radius); font-size: 14px;
display: flex; gap: 16px; align-items: flex-start; }
.banner.warn { background: var(--warn-dim); border: 1px solid #4A3A18; color: #F0D9A6; }
.banner.ok { background: var(--ok-dim); border: 1px solid #1E4534; color: #C6E9D6; }
.banner > div { flex: 1; }
.creds { display: flex; gap: 26px; margin-top: 10px; flex-wrap: wrap; }
.creds dt { font-size: 11px; text-transform: uppercase; letter-spacing: .08em;
color: var(--muted); }
.creds dd { font-family: var(--mono); font-size: 14px; user-select: all; }
/* ----------------------------------------------------------- arrivals --- */
.arrivals { list-style: none; padding: 0; display: grid; gap: 8px; }
.card.arrival { display: flex; align-items: center; gap: 14px; padding: 11px 14px; }
.face { width: 46px; height: 46px; border-radius: 50%; flex: none;
object-fit: cover; background: var(--surface-2); }
.face.initials { display: grid; place-items: center; color: var(--muted);
font-size: 15px; font-weight: 600; letter-spacing: .02em; }
.who-col { display: flex; flex-direction: column; gap: 1px; flex: 1; min-width: 0; }
.who-col strong { font-weight: 570; }
.attrs { font-size: 12.5px; color: var(--muted); text-transform: capitalize; }
/* ------------------------------------------------------------ tables ---- */
.toolbar { display: flex; gap: 10px; }
.search {
flex: 1; max-width: 380px; background: var(--surface);
border: 1px solid var(--line); border-radius: 8px; padding: 9px 13px;
}
.search::placeholder { color: var(--muted); }
/* Every placeholder, not just the search box. On this ground a browser's
default placeholder is nearly as bright as a typed value, so an example in a
field reads as an answer already given - an operator saw "counter PC" in the
PC-label box and reasonably believed they had labelled it. */
input::placeholder, textarea::placeholder { color: var(--muted); opacity: 1; }
.tablewrap { overflow-x: auto; background: var(--surface);
border: 1px solid var(--line); border-radius: var(--radius); }
table.rows { border-collapse: collapse; width: 100%; min-width: 620px; }
table.rows th, table.rows td { text-align: left; padding: 11px 16px;
border-bottom: 1px solid var(--line-soft); }
table.rows tr:last-child td { border-bottom: 0; }
table.rows th { font-size: 11.5px; text-transform: uppercase; letter-spacing: .08em;
color: var(--muted); font-weight: 600; background: var(--surface-2); }
table.rows tbody tr { cursor: pointer; }
table.rows tbody tr:hover { background: var(--surface-2); }
table.rows td.num { font-variant-numeric: tabular-nums; }
/* ------------------------------------------------------------- chart ---- */
.chart-card { padding: 18px; }
.chart-head { margin-bottom: 16px; }
.peak { font-size: 12.5px; color: var(--muted); margin-bottom: 8px; }
.peak b { color: var(--ink-2); font-weight: 600; font-variant-numeric: tabular-nums; }
.chart { display: flex; align-items: flex-end; gap: 3px; height: 190px;
overflow-x: auto; padding-bottom: 4px; }
.col { flex: 1; min-width: 16px; display: flex; flex-direction: column;
align-items: center; gap: 6px; height: 100%; }
.bars { flex: 1; width: 100%; display: flex; flex-direction: column;
justify-content: flex-end; }
.bar { display: block; width: 100%; border-radius: 2px 2px 0 0; }
.bar.new { background: var(--accent); }
.bar.returning { background: var(--accent-dim); border-radius: 0; }
.col:hover .bar.returning { background: #1B4552; }
.tick { font-size: 10.5px; color: var(--muted); white-space: nowrap;
font-variant-numeric: tabular-nums; }
.legend { display: flex; gap: 18px; align-items: center; margin-top: 14px;
padding-top: 12px; border-top: 1px solid var(--line-soft);
font-size: 12.5px; color: var(--ink-2); }
.legend span { display: flex; align-items: center; gap: 7px; }
.swatch { width: 10px; height: 10px; border-radius: 2px; display: inline-block; }
.swatch.new { background: var(--accent); }
.swatch.returning { background: var(--accent-dim); }
.pad { padding: 24px 0; }
/* ------------------------------------------------------------ drawer ---- */
.overlay { position: fixed; inset: 0; background: rgba(4, 8, 10, .62);
display: flex; justify-content: flex-end; z-index: 50; }
.drawer { width: min(560px, 100%); background: var(--surface);
border-left: 1px solid var(--line); height: 100%; overflow-y: auto;
display: flex; flex-direction: column; }
.drawer.narrow { width: min(440px, 100%); }
/* Sticky, and inside the scrolling panel rather than positioned against the
fixed overlay — otherwise Close prints itself over whatever has scrolled
under it. */
.drawer-head { position: sticky; top: 0; z-index: 1; display: flex;
align-items: flex-start; gap: 16px; padding: 18px 22px;
background: var(--surface); border-bottom: 1px solid var(--line); }
.drawer-head h2 { margin-right: auto; }
.drawer-head > div { flex: 1; }
.drawer-body { padding: 20px 22px; display: flex; flex-direction: column; gap: 14px; }
.drawer-body + .drawer-body { border-top: 1px solid var(--line-soft); }
fieldset { border: 0; padding: 0; margin: 0; display: flex;
flex-direction: column; gap: 14px; }
fieldset:disabled { opacity: .6; }
label { display: flex; flex-direction: column; gap: 6px; font-size: 13px;
color: var(--ink-2); }
label input[type="text"], label input[type="email"], label input[type="password"],
label input:not([type]), .drawer input {
background: var(--ground); border: 1px solid var(--line); border-radius: 7px;
padding: 9px 12px; color: var(--ink); font-size: 14.5px;
}
label .hint { font-size: 12px; color: var(--muted); line-height: 1.45; }
label.check { flex-direction: row; align-items: center; gap: 9px; }
label.check input { width: auto; }
.timeline { list-style: none; padding: 0; display: grid; gap: 8px; }
.timeline li { display: flex; align-items: center; gap: 12px; font-size: 13.5px;
padding: 9px 12px; background: var(--ground); border-radius: 7px; }
.timeline .when { font-variant-numeric: tabular-nums; }
.timeline .where { color: var(--muted); margin-right: auto; }
/* ------------------------------------------------------------ states ---- */
.state { display: flex; flex-direction: column; align-items: center; gap: 8px;
padding: 64px 20px; text-align: center; color: var(--ink-2); }
.state .sub { max-width: 46ch; }
.boot { min-height: 100vh; display: flex; align-items: center; justify-content: center;
gap: 10px; color: var(--muted); }
.error { color: var(--bad); font-size: 13.5px; }
.spinner { width: 14px; height: 14px; border-radius: 50%;
border: 2px solid var(--line); border-top-color: var(--accent);
animation: spin .7s linear infinite; display: inline-block; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; } }
/* ------------------------------------------------------------ sign in --- */
.signin { min-height: 100vh; display: grid; place-items: center; padding: 24px; }
.signin .card { width: min(380px, 100%); padding: 30px; display: flex;
flex-direction: column; gap: 14px; }
.signin h1 { font-size: 20px; }
.signin .sub { margin-top: -8px; margin-bottom: 6px; }
.signin .foot { font-size: 12.5px; color: var(--muted); text-align: center;
margin-top: 4px; line-height: 1.5; }
@media (max-width: 720px) {
.topbar { height: auto; flex-wrap: wrap; padding: 12px 16px; gap: 12px; }
.tabs { order: 3; width: 100%; overflow-x: auto; }
.page { padding: 18px 16px 48px; }
.who .name { display: none; }
}
.pair { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
select { background: var(--ground); border: 1px solid var(--line);
border-radius: 7px; padding: 9px 12px; color: var(--ink);
font-size: 14.5px; }
button.ghost.danger { border-color: #4A211D; color: var(--bad); }
button.ghost.danger:hover { border-color: var(--bad); }
/* ------------------------------------------------------- camera setup --- */
.drawer.wizard { width: min(560px, 100%); }
/* The step rail encodes real sequence: each step is unanswerable until the one
before it has passed, which is why they are numbered rather than tabbed. */
.steps { list-style: none; display: flex; gap: 4px; padding: 14px 22px;
margin: 0; border-bottom: 1px solid var(--line-soft);
background: var(--surface); position: sticky; top: 62px; z-index: 1; }
.steps li { flex: 1; display: flex; align-items: center; gap: 7px;
font-size: 12.5px; color: var(--muted); min-width: 0; }
.steps li .dot { width: 20px; height: 20px; border-radius: 50%; flex: none;
display: grid; place-items: center; font-size: 11px;
background: var(--surface-2); color: var(--muted); }
.steps li.now { color: var(--ink); font-weight: 550; }
.steps li.now .dot { background: var(--accent); color: #04161B; }
.steps li.done .dot { background: var(--ok-dim); color: var(--ok); }
.waiting { display: flex; gap: 12px; align-items: center; padding: 14px 16px;
background: var(--ground); border: 1px solid var(--line);
border-radius: var(--radius); }
.waiting > div { display: flex; flex-direction: column; gap: 2px; }
.outcome { border: 1px solid var(--line); border-left-width: 3px;
border-radius: var(--radius); padding: 14px 16px;
display: flex; flex-direction: column; gap: 12px; }
.outcome.ok { border-left-color: var(--ok); background: var(--ok-dim); }
.outcome.warn { border-left-color: var(--warn); background: var(--warn-dim); }
.outcome.bad { border-left-color: var(--bad); background: var(--bad-dim); }
.outcome-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.outcome-head b { font-size: 14.5px; }
.proof { width: 100%; border-radius: 6px; display: block; background: #05090B; }
.advice { margin: 0; padding-left: 18px; display: grid; gap: 6px;
font-size: 13.5px; color: var(--ink-2); line-height: 1.5; }
/* Connected and verified are different claims; the card states both. */
.verified { display: flex; align-items: center; gap: 8px; margin-top: 10px;
font-size: 13px; }
.verified .mark { width: 17px; height: 17px; border-radius: 50%; flex: none;
display: grid; place-items: center; font-size: 10.5px; }
.verified.ok { color: var(--ok); }
.verified.ok .mark { background: var(--ok-dim); }
.verified.warn { color: var(--warn); }
.verified.warn .mark { background: var(--warn-dim); }
.verified.bad { color: var(--bad); }
.verified.bad .mark { background: var(--bad-dim); }
.verified.idle { color: var(--muted); }
.verified.idle .mark { background: var(--surface-2); }
/* ------------------------------------------------------- site check ----- */
.checklist { list-style: none; padding: 0; margin: 0; display: grid; gap: 2px; }
.checklist li { display: flex; gap: 12px; padding: 13px 14px;
background: var(--ground); border-radius: 8px;
border-left: 3px solid var(--line); }
.checklist li > div { display: flex; flex-direction: column; gap: 3px; flex: 1; }
.checklist li.pass { border-left-color: var(--ok); }
.checklist li.warn { border-left-color: var(--warn); }
.checklist li.fail { border-left-color: var(--bad); }
.checklist li.unknown { border-left-color: var(--muted); }
.checklist .mark { width: 20px; height: 20px; border-radius: 50%; flex: none;
display: grid; place-items: center; font-size: 11px;
background: var(--surface-2); color: var(--muted); }
.checklist li.pass .mark { background: var(--ok-dim); color: var(--ok); }
.checklist li.warn .mark { background: var(--warn-dim); color: var(--warn); }
.checklist li.fail .mark { background: var(--bad-dim); color: var(--bad); }
.advice-line { font-size: 13px; color: var(--warn); line-height: 1.45; }
.checklist li.pass .advice-line { color: var(--muted); }
/* ------------------------------------------------- pictures as cards --- */
/* Shared by the camera and shop cards: the picture IS the card, and every
technical detail lives one click away. */
.grid.cams { grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; }
.card.cam { overflow: hidden; border-left: 0; padding: 0; cursor: pointer;
transition: border-color .15s ease, transform .15s ease; }
.card.cam:hover { border-color: var(--muted); transform: translateY(-1px); }
.card.cam:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { .card.cam { transition: none; } }
.shot { position: relative; aspect-ratio: 16 / 10; background: #05090B;
display: grid; place-items: center; }
.shot img { width: 100%; height: 100%; object-fit: cover; display: block; }
.noshot { display: flex; flex-direction: column; align-items: center; gap: 10px;
color: var(--muted); font-size: 12.5px; text-align: center;
padding: 0 28px; line-height: 1.5; }
/* A lens, drawn rather than an emoji: an empty tile that still reads as a
camera, instead of a black hole with an apology in it. */
.lens { width: 34px; height: 34px; border-radius: 50%;
border: 2px solid var(--line); position: relative; }
.lens::after { content: ''; position: absolute; inset: 7px; border-radius: 50%;
border: 2px solid var(--line-soft); }
/* A gradient, so white text stays legible over a bright shop floor. */
.shot-over { position: absolute; inset: auto 0 0 0; display: flex;
align-items: flex-end; justify-content: space-between; gap: 10px;
padding: 26px 14px 12px;
background: linear-gradient(transparent, rgba(4,8,10,.85)); }
.shot-name b { display: block; font-size: 14.5px; font-weight: 600;
letter-spacing: -.01em; }
.shot-name span { display: block; font-size: 11.5px; color: #9FB0BA; margin-top: 1px; }
.status { display: flex; align-items: center; gap: 6px; flex: none;
font-size: 11px; font-weight: 550; letter-spacing: .01em;
padding: 4px 9px; border-radius: 20px; white-space: nowrap;
background: rgba(14,19,23,.72); backdrop-filter: blur(6px);
color: var(--ink-2); }
.status i { width: 6px; height: 6px; border-radius: 50%; background: var(--muted); }
.status.ok { color: #8FE0B4; } .status.ok i { background: var(--ok); }
.status.warn { color: #EFC77C; } .status.warn i { background: var(--warn); }
.status.bad { color: #F0A79E; } .status.bad i { background: var(--bad); }
.status.idle i { background: var(--muted); }
.shot-age { position: absolute; top: 10px; right: 12px; font-size: 10.5px;
color: #9FB0BA; background: rgba(4,8,10,.6); padding: 2px 7px;
border-radius: 20px; backdrop-filter: blur(6px); }
/* One line, and it is the line that matters. */
.verdict { display: flex; align-items: center; gap: 10px; padding: 12px 14px;
font-size: 13px; border-top: 1px solid var(--line-soft); }
.verdict .words { flex: 1; min-width: 0; }
.verdict .go { color: var(--muted); font-size: 14px; }
.verdict .mark { width: 18px; height: 18px; border-radius: 50%; flex: none;
display: grid; place-items: center; font-size: 10.5px; }
.verdict.ok { color: var(--ok); } .verdict.ok .mark { background: var(--ok-dim); }
.verdict.warn { color: var(--warn); } .verdict.warn .mark { background: var(--warn-dim); }
.verdict.bad { color: var(--bad); } .verdict.bad .mark { background: var(--bad-dim); }
.verdict.idle { color: var(--muted); }.verdict.idle .mark { background: var(--surface-2); }
/* Claiming a shop PC, inside the shop drawer. */
.claim { margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--line-soft); }
.claim h3 { font-size: 14px; font-weight: 600; margin-bottom: 6px; }
.claim .field { display: block; margin: 12px 0; }
.claim .field span { display: block; font-size: 12.5px; color: var(--muted); margin-bottom: 5px; }
.claim .field input { width: 100%; background: var(--ground); border: 1px solid var(--line);
border-radius: 7px; padding: 9px 12px; }
.claim .row { display: flex; gap: 8px; margin-top: 12px; }
/* Big, monospaced and selectable: this gets read down a phone line and typed
into another machine, so ambiguity between O and 0 is a support call. */
.claim .code { font-family: var(--mono); font-size: 19px; letter-spacing: .08em;
background: var(--ground); border: 1px solid var(--line);
border-radius: 8px; padding: 14px 12px; text-align: center;
margin: 12px 0 10px; user-select: all; word-break: break-all; }
/* ---------------------------------------------------------- assistant --- */
.with-assistant { display: flex; flex: 1; min-height: 0; }
.with-assistant .page { flex: 1; min-width: 0; }
.ask-btn { background: var(--accent-dim); color: var(--accent); border: 0;
border-radius: 7px; padding: 7px 13px; cursor: pointer;
font-size: 13.5px; font-weight: 550; display: flex; align-items: center; gap: 7px; }
.ask-btn:hover, .ask-btn.on { background: var(--accent); color: #04161B; }
.assistant { width: 360px; flex: none; border-left: 1px solid var(--line);
background: var(--surface); display: flex; flex-direction: column;
position: sticky; top: 60px; height: calc(100vh - 60px); }
.assistant-head { display: flex; align-items: flex-start; gap: 12px;
padding: 15px 16px; border-bottom: 1px solid var(--line-soft); }
.assistant-head > div { flex: 1; display: flex; flex-direction: column; gap: 2px; }
.assistant-body { flex: 1; overflow-y: auto; padding: 16px;
display: flex; flex-direction: column; gap: 12px; }
.assistant-ask { display: flex; gap: 8px; padding: 12px 14px;
border-top: 1px solid var(--line-soft); }
.assistant-ask input { flex: 1; min-width: 0; background: var(--ground);
border: 1px solid var(--line); border-radius: 8px;
padding: 9px 12px; }
.bubble { padding: 11px 13px; border-radius: 12px; font-size: 14px;
line-height: 1.55; white-space: pre-wrap; max-width: 100%; }
.bubble.user { background: var(--accent-dim); color: var(--ink);
align-self: flex-end; border-bottom-right-radius: 4px; }
.bubble.assistant { background: var(--ground); border: 1px solid var(--line-soft);
border-bottom-left-radius: 4px; }
.bubble.failed { border-color: #4A211D; color: var(--bad); }
.bubble.assistant-thinking { color: var(--muted); display: flex; align-items: center; gap: 9px; }
.used { display: block; margin-top: 8px; padding-top: 7px;
border-top: 1px solid var(--line-soft);
font-size: 11.5px; color: var(--muted); }
.suggest { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; }
.chip { background: var(--ground); border: 1px solid var(--line); border-radius: 20px;
padding: 7px 13px; color: var(--ink-2); cursor: pointer; font-size: 13px;
text-align: left; }
.chip:hover { border-color: var(--accent); color: var(--accent); }
@media (max-width: 900px) {
.with-assistant { flex-direction: column; }
.assistant { width: 100%; height: 60vh; position: static; border-left: 0;
border-top: 1px solid var(--line); }
}

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>
)
}