From 9ec644811a7e6503b9c2798407505bb5982513fa Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Fri, 19 Jun 2026 17:23:32 +0200 Subject: [PATCH] feat(vision): surface the recognized plate in the booth UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ANPR plate was saved (device_events kind:"read") but had no UI. Extend GET /api/snapshots/by-identity/:identity to also return plates[] (plate, confidence, region, direction, snapshotId, at) for that session, and render each as a cyan "Plate: AA558EE 100%" chip in the SnapshotStrip — so it shows in both the booth event-detail modal and the pay modal, beside the evidence photo, no separate screen. Deduped by plate+direction; session:read gated; i18n sq+en. Verified: by-identity returns plates[] for a seeded read (200, AA558EE 0.999 Albania entry). Build + lint green. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V --- apps/server/src/routes/snapshots.ts | 39 ++++++++++++++++++++++++++- apps/web/src/api.ts | 15 +++++++++-- apps/web/src/lib/i18n/en.ts | 1 + apps/web/src/lib/i18n/sq.ts | 1 + apps/web/src/ui/SnapshotStrip.tsx | 40 ++++++++++++++++++++++++++-- wiki/entities/opencv-anpr-service.md | 5 ++++ wiki/log.md | 4 +++ 7 files changed, 100 insertions(+), 5 deletions(-) diff --git a/apps/server/src/routes/snapshots.ts b/apps/server/src/routes/snapshots.ts index da9bf21..ec413cc 100644 --- a/apps/server/src/routes/snapshots.ts +++ b/apps/server/src/routes/snapshots.ts @@ -68,7 +68,44 @@ export async function snapshotRoutes(app: FastifyInstance, db: Db): Promise. */ + * attempts PLUS any recognized plates. Image bytes are at `/api/snapshots/:id`. */ export function fetchSnapshots( identity: string, -): Promise<{ snapshots: SnapshotMeta[]; failures?: SnapshotFailure[] }> { +): Promise<{ snapshots: SnapshotMeta[]; failures?: SnapshotFailure[]; plates?: PlateRead[] }> { return apiFetch(`/api/snapshots/by-identity/${encodeURIComponent(identity)}`); } diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index 97bb73c..852d0c7 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -558,5 +558,6 @@ export const en: Catalog = { snapEntry: "entry", snapExit: "exit", snapFailed: "camera unreachable", + plate: "Plate", }, }; diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index 765739d..5160df2 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -573,6 +573,7 @@ export const sq = { snapEntry: "hyrje", snapExit: "dalje", snapFailed: "kamera e paarritshme", + plate: "Targa", }, }; diff --git a/apps/web/src/ui/SnapshotStrip.tsx b/apps/web/src/ui/SnapshotStrip.tsx index f637b0d..0bdacf3 100644 --- a/apps/web/src/ui/SnapshotStrip.tsx +++ b/apps/web/src/ui/SnapshotStrip.tsx @@ -1,7 +1,20 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery } from "@tanstack/react-query"; -import { fetchSnapshots, snapshotImageUrl } from "../api.js"; +import { fetchSnapshots, snapshotImageUrl, type PlateRead } from "../api.js"; + +/** Keep one entry per (plate, direction) — newest wins (the list is newest-first). */ +function dedupePlates(plates: PlateRead[]): PlateRead[] { + const seen = new Set(); + const out: PlateRead[] = []; + for (const p of plates) { + const key = `${p.plate}|${p.direction}`; + if (seen.has(key)) continue; + seen.add(key); + out.push(p); + } + return out; +} // Entry/exit evidence images for a session. Lets the operator verify the car at the // booth against the ticket. Thumbnails load from /api/snapshots/:id (cookie-authed, @@ -18,17 +31,40 @@ export function SnapshotStrip({ identity }: { identity: string }) { const shots = data?.snapshots ?? []; const failures = data?.failures ?? []; + const plates = data?.plates ?? []; /** Localized direction label for a snapshot/failure tile. */ const dirLabel = (dir: "entry" | "exit" | null): string => dir === "entry" ? t("pay.snapEntry") : dir === "exit" ? t("pay.snapExit") : "—"; if (isLoading) return
{t("pay.loadingSnapshots")}
; - if (shots.length === 0 && failures.length === 0) + if (shots.length === 0 && failures.length === 0 && plates.length === 0) return
{t("pay.noSnapshots")}
; return ( <> + {/* Recognized plate(s) (ANPR) — advisory. Dedup by plate+direction so an + entry+exit read of the same plate shows once per direction. */} + {plates.length > 0 && ( +
+ {dedupePlates(plates).map((p, i) => ( + + {t("pay.plate")} + {p.plate} + {typeof p.confidence === "number" && ( + {(p.confidence * 100).toFixed(0)}% + )} + + ))} +
+ )} +
{shots.map((s) => (