import { useState } from 'react'
import { api } from '../api.js'
import { usePolled } from '../hooks.js'
import Shot from './Shot.jsx'
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
if (error) return
if (!sites.length) return
// 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 (
<>
Shops
{sites.length} {sites.length === 1 ? 'shop' : 'shops'}
{broken > 0 && <> · {broken} not working>}
{watch > 0 && <> · {watch} needing attention>}
{fresh > 0 && <> · {fresh} not set up yet>}
{!broken && !watch && !fresh && <> · all working>}
{rows.map(r => (
setChecking(r.site)} />
))}
{/* 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 && 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 (
e.key === 'Enter' && onCheck()}>
{/* 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. */}
)
}
function Metric({ label, value, tone }) {
return (
{value}{label}
)
}
// 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
}
// The WHOLE snapshot object, not just its url. It carries `auth`, which says
// whether the picture has to be fetched with the session or can be handed
// straight to an - and rebuilding a partial copy here is how that flag
// gets silently dropped on one screen and not another.
if (best) return { ...best.snapshot, 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 (
)
}
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`
}
// How long until a moment in the future.
//
// `ago` clamps at zero and reads a future timestamp as "just now", which is
// right for a heartbeat whose clock is a little ahead and completely wrong for
// an expiry: a code valid for a week rendered as "expires just now", which
// tells the operator not to bother handing it over.
export function until(iso) {
if (!iso) return 'never'
const then = new Date(iso).getTime()
if (Number.isNaN(then)) return '—'
const secs = (then - Date.now()) / 1000
if (secs <= 0) return 'expired'
const mins = Math.round(secs / 60)
if (mins < 60) return `in ${mins} min`
const hrs = Math.round(mins / 60)
if (hrs < 48) return `in ${hrs} h`
return `in ${Math.round(hrs / 24)} days`
}
export function Loading() {
return
Loading…
}
export function Problem({ error }) {
return (
{error.message}
)
}
function Empty() {
return (
No shops yet
A shop appears here once its PC has been claimed with an enrolment code.