docs(wiki): activity-log explainability, dates/i18n, KP-300H barcode fix
Record this session's work across the affected pages + three log entries. - ticket-encoding: id 13→11 digits (guess-resistance rationale, legacy-safe validation) + a barcode-geometry rule (symbol dots must fit the narrowest deployed printer's line — the KP-300H 72mm overflow). - rongta-printer: KP-300H raster-garbage root cause (line overflow, not corruption), sendRaw graceful-close fix, Albanian human dates (formatStampSq). - i18n: localized ledger reason codes, relative/human dates + the "browser ICU lacks Albanian" gotcha, toggle stale-router-context fix. - shift: Albanian Z-report, shift-history UI + permission scoping. - booth-console: explainable activity log (inline reasons/badges, event-detail modal with snapshots + audit disclosure, subscriber names, failed-snapshot tiles). - index/log updated; all added wikilinks resolve. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
type: concept
|
||||
tags: [parking, frontend, booth, realtime, ui]
|
||||
sources: []
|
||||
updated: 2026-06-18
|
||||
updated: 2026-06-19
|
||||
status: open
|
||||
---
|
||||
|
||||
@@ -68,6 +68,29 @@ a right column with the **live event ticker**. Submitting/clicking a ticket open
|
||||
modal** (entry/duration/total, tender, voucher checkbox, entry/exit snapshots). All live-refreshed via
|
||||
the WS.
|
||||
|
||||
### Explainable activity log (2026-06-19)
|
||||
The ticker used to flag an **anomaly** with no explanation — a red row with just an id, "nobody knows
|
||||
what happened." Now every event is **self-describing and clickable**:
|
||||
- **Inline reason + badges.** Each row surfaces the localized reason (from the signed `reasonCode` —
|
||||
see [[i18n]]) and computed classification badges (entry/exit-refused, lot-full, barrier-failed,
|
||||
manual-open…). Anomalies always show a reason line (or "no reason recorded") so a red flag is never
|
||||
silent.
|
||||
- **Clickable → event-detail modal.** A read-only modal with humanized labelled fields (not raw JSON),
|
||||
the session's **entry/exit snapshots**, and the **signed-chain provenance** (signature / keyId /
|
||||
prevHash) collapsed behind an "audit data" disclosure — operator sees the story, an auditor expands
|
||||
for the crypto. *Why show signatures at all:* makes the [[append-only-event-chain|tamper-evidence]]
|
||||
visible against the [[threat-model|booth-operator]] threat, and survives a signer swap (`keyId`).
|
||||
- **Subscriber names, not opaque keys.** A [[subscription]] occurrence's `SUBSESS-…` id now renders as
|
||||
the holder's name ("Aqif Kopertoni", fallback "Abonent"/"Subscriber"). Resolved **read-time
|
||||
server-side** (events API + WS push attach a non-signed `subscriberLabel` from `permitId →
|
||||
holder_name`; cached, invalidated on subscription edit/delete) so it needs no extra client
|
||||
permission and the signed event stays minimal.
|
||||
- **Failed-snapshot visibility.** A camera that was *attempted but unreachable* now shows a "⚠ camera
|
||||
unreachable" tile (from snapshot [[device-events|telemetry]]) instead of a silent gap — so the
|
||||
operator can tell "no camera" from "camera failed". (Surfaced a real incident: a subscriber's entry
|
||||
snapshot failed `EHOSTUNREACH` while the exit one succeeded — by design a snapshot is *evidence, not
|
||||
a gate*, so the open proceeded and only the image was missing.)
|
||||
|
||||
## The shift control (header) + the booth gate
|
||||
|
||||
The header carries a single **shift button** that expresses the [[shift|site-wide single-open
|
||||
|
||||
+44
-8
@@ -2,7 +2,7 @@
|
||||
type: concept
|
||||
tags: [parking, frontend, i18n, localization]
|
||||
sources: []
|
||||
updated: 2026-06-18
|
||||
updated: 2026-06-19
|
||||
status: open
|
||||
---
|
||||
|
||||
@@ -40,16 +40,52 @@ any booth. (Decided + built 2026-06-18.)
|
||||
key). Keys are dot-namespaced by area (`common`, `nav`, `status`, `auth`, `booth`, `pay`, `shift`,
|
||||
`site`, `permits`, `tariff`).
|
||||
- **Translated screens:** the booth ([[booth-console]] — screen, pay/exit modal, active sessions,
|
||||
snapshots, status), Login, ShiftControl, SiteSettings, PermitManager, TariffComposer.
|
||||
snapshots, status), Login, ShiftControl, SiteSettings, PermitManager, TariffComposer,
|
||||
**SetupWizard devices tab** (2026-06-19 — the static chrome; driver/config-field labels still come
|
||||
from the backend catalog), Users/Roles managers, and the **shift-history** screen.
|
||||
|
||||
## Localized ledger reasons & relative dates (2026-06-19)
|
||||
|
||||
Two patterns added on top of the catalog approach:
|
||||
|
||||
- **Reason codes (backend i18n for the signed ledger).** Anomaly/payment/override reasons used to be
|
||||
free-text **English baked into the signed `payload.reason`** — unlocalizable at render time (the
|
||||
bytes are immutable). Now the ledger signs a **stable `reasonCode` + `reasonParams`** (plus the
|
||||
English `reason` as a fallback) from a closed set `REASON_CODES` in `@parking/shared`. The UI
|
||||
translates `reason.<code>` via the sq/en catalogs, so an Albanian operator reads Albanian **from the
|
||||
same immutable event**, and adding a language is a catalog change with **no re-signing** of past
|
||||
events. Legacy events (no code) show the signed English fallback. This narrows — but doesn't close —
|
||||
the "server API error strings are English" gap noted below: the *ledger* reasons are localized; raw
|
||||
HTTP error strings still aren't. See [[append-only-event-chain]], [[booth-console]].
|
||||
- **Relative + human dates (`formatRelativeDateTime`).** Sessions/logs/history show **`Sot/Today
|
||||
10:48` · `Dje/Yesterday 17:33` · `17 Qershor/June 10:48`** instead of a bare time (a 2-day-old
|
||||
session previously showed only `10:48`). today/yesterday come from `common.today`/`yesterday`.
|
||||
> **Gotcha — the appliance browser's ICU has NO Albanian locale data.** `Intl.DateTimeFormat("sq",
|
||||
> {month:"long"})` silently returns **English** ("June", not "Qershor") on this hardware. So month
|
||||
> names come from a **`common.months` catalog array**, NOT `Intl`. Any future date formatting on
|
||||
> this box must avoid relying on `Intl` for Albanian or it leaks English. (Printed slips already
|
||||
> solved this with the `SQ_MONTHS` table in [[rongta-printer]].)
|
||||
|
||||
## Open / deferred
|
||||
|
||||
- **SetupWizard is NOT translated** (deliberate). Its content is mostly **server-provided** — driver
|
||||
labels and config-field labels/help come from the backend device-catalog API ([[device-registry]],
|
||||
[[first-run-setup]]). Translating only its static chrome would leave a half-English screen; it's
|
||||
deferred until **backend catalog i18n** is scoped, then chrome + catalog localize together.
|
||||
- **Server API error strings** are still English (surfaced raw in the UI). v1 relies on the
|
||||
client mapping known errors; a fuller approach would translate by error *code*, not message.
|
||||
- **SetupWizard chrome is now translated (2026-06-19)**; the remaining gap is the **backend
|
||||
device-catalog** (driver + config-field labels/help from [[device-registry]], [[first-run-setup]]),
|
||||
still English. Localizing the catalog is the open item.
|
||||
- **Server API error strings** are still English (surfaced raw in the UI). The **ledger reason codes**
|
||||
are now localized (above), but raw HTTP error messages aren't. A fuller approach translates by error
|
||||
*code*, not message — the reason-code pattern is the template to follow.
|
||||
- **Behaviour note (not a bug):** a *hard navigation* (new URL) re-bootstraps the language from the
|
||||
user's stored preference via `/me` — so an un-persisted toggle resets. Correct: the stored pref
|
||||
wins. The toggle persists via the PUT, so it survives once saved.
|
||||
|
||||
## Gotcha — language/theme toggle stale router-context (fixed 2026-06-19)
|
||||
|
||||
The header SQ/EN (and dark/light) toggles read the active value from `user`, which comes from the
|
||||
**TanStack Router context** (`rootRoute.useRouteContext()`). Router context is **captured at
|
||||
route-resolution time and does NOT re-render on `setUser`** — so after *one* switch the toggle's
|
||||
`user.language` froze, the active-button highlight stuck, and the equality guard blocked switching
|
||||
*back* until a page refresh (which re-resolved the context). Fix: drive the toggles off **live**
|
||||
state, not the stale context — language reads `i18n.language` (`useTranslation()` subscribes to
|
||||
i18next's `languageChanged`), theme uses local `useState`. Both still call `setUser` to keep the
|
||||
context eventually-consistent + persisted, but no longer *depend* on it re-rendering. General lesson:
|
||||
**router context is not reactive React state** — never read frequently-changing UI state from it.
|
||||
|
||||
+16
-1
@@ -2,7 +2,7 @@
|
||||
type: concept
|
||||
tags: [parking, domain, business, shifts, anti-fraud]
|
||||
sources: []
|
||||
updated: 2026-06-18
|
||||
updated: 2026-06-19
|
||||
status: open
|
||||
---
|
||||
|
||||
@@ -77,6 +77,21 @@ login ————————————————————————
|
||||
That's the whole human-side requirement: **print the cash and the POS (if any).** No blind count,
|
||||
no variance gate, no manager override.
|
||||
|
||||
> **Z-report is now Albanian (2026-06-19).** The printed Z-report labels were hardcoded English
|
||||
> (`Operator:`/`From:`/`Cash:`) with raw ISO timestamps; now fully Albanian (`Operatori`/`Nga`/`Deri`/
|
||||
> `Para në dorë`/`-- Arka --`/`Arka e pritur`…) with the human date format `19 Qershor 2026 10:48:25`,
|
||||
> shared via `formatStampSq` from [[rongta-printer]]. Consistent with the "[[i18n|printed paper is
|
||||
> always Albanian]]" rule — independent of the operator's UI language.
|
||||
|
||||
### Shift history UI + permission scoping (2026-06-19)
|
||||
A read-only **shift-history screen** (`GET /api/shifts`) lists completed shifts — each a signed
|
||||
`shift_z_report` folded for its figures (no re-summing), newest-first, expandable to the drawer
|
||||
reconciliation. **Scoped server-side by permission** (dynamic [[local-jwt-auth|RBAC]]): an operator
|
||||
with `shift:read` sees **only their own** shifts (operator/date filters ignored); an admin-grade role
|
||||
(`shift:cash`) sees **all** operators with an operator + date-window filter. The server hard-scopes
|
||||
non-admins to `req.user.username` regardless of any `?operator=` param (verified: an operator passing
|
||||
another's name returns `scope:self`, 0 rows). One screen, behaviour driven by the returned `scope`.
|
||||
|
||||
### As-built (2026-06-16)
|
||||
|
||||
- A shift is **two signed ledger events**, no mutable table (decision): `shift_open` (new event
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
type: concept
|
||||
tags: [parking, domain, business, devices, entry-flow]
|
||||
sources: []
|
||||
updated: 2026-06-15
|
||||
updated: 2026-06-19
|
||||
status: open
|
||||
---
|
||||
|
||||
@@ -23,11 +23,19 @@ must have:
|
||||
reconciliation aid ([[reconciliation]] pre-numbered stock), not the scan key.
|
||||
- **All-numeric** (as-built 2026-06-17) — so ANY legacy 1D barcode scanner reads it and an operator
|
||||
can hand-key it. Random (not sequential), so "all-numeric" does not weaken the unguessable
|
||||
property. Format: **13 digits = 12 cryptographically-random digits + 1 Luhn check digit**
|
||||
(10^12 space → negligible collisions at lot scale; the Luhn digit lets manual entry reject a typo
|
||||
rather than fail as "session not found"). `newTicketId()` in `apps/server/src/entry-flow.ts`;
|
||||
validate with `validateTicketCode()` (gate MANUAL entry only — a scanned/looked-up id already in
|
||||
the ledger is authoritative regardless of format).
|
||||
property. Format (**shortened 13→11 on 2026-06-19**): **11 digits = 10 cryptographically-random
|
||||
digits + 1 Luhn check digit**. **Length is driven by guess-resistance, not volume** — with 10^10
|
||||
valid ids and the Luhn digit rejecting 9/10 malformed guesses, a blind attempt at a currently-OPEN
|
||||
ticket lands at ~1-in-10^7 even with thousands parked (the [[threat-model|booth-operator]] threat),
|
||||
which is the property that matters; raw count (a billion) was never the constraint. Collisions stay
|
||||
negligible at lot scale. `newTicketId()` in `apps/server/src/entry-flow.ts`; validate with
|
||||
`validateTicketCode()` — now **length-agnostic** (`\d{10,14}` + Luhn) so legacy 13-digit tickets in
|
||||
circulation keep validating. Gate MANUAL entry only — a scanned/looked-up id already in the ledger
|
||||
is authoritative regardless of format.
|
||||
> **Why 11, not the requested 9 (2026-06-19):** 9 digits (10^8 space) against ~1000 live tickets
|
||||
> gives ~1-in-10^5 per blind guess — an operator scripting guesses could find a valid open ticket
|
||||
> in minutes. 11 keeps a strong anti-forgery margin AND was the actual fix for a printer overflow
|
||||
> (below). The shorter id is *why* the width-3 barcode now fits the 72mm Cashino line.
|
||||
- **Format is a property of minting, not the schema** — `identity` / `sessions.id` are free-form
|
||||
`text`, so changing the id format is a code change with **no migration**. Legacy `T-<uuid>` ids
|
||||
(pre-2026-06-17) remain valid keys and coexist with numeric ones.
|
||||
@@ -50,6 +58,20 @@ must have:
|
||||
> uncertain, so the id is carried in two independently-readable forms (1D barcode / printed digits).
|
||||
> The "operator scans with a phone" path reuses the
|
||||
> existing dispatch flow ([[entry-exit-readers]]) and is tracked separately (not yet built).
|
||||
|
||||
> **Barcode GEOMETRY must fit the paper width — root cause of a real garbage-print incident
|
||||
> (2026-06-19).** The Cashino [[rongta-printer|KP-300H]] entry dispenser printed tickets as **raster
|
||||
> garbage** (solid black bars / banding) while the [[rongta-printer|Rongta]] printed the *identical*
|
||||
> byte stream fine. Not data corruption: the **Code128 symbol overflowed the print line.** Math: a
|
||||
> Code128-B symbol is `(11·chars + 35) · moduleWidth` dots. At 203 dpi the KP-300H prints **72mm =
|
||||
> 512 usable dots** (the Rongta runs 80mm = 576). The old **13-digit** id at `GS w 3` = ~534 dots
|
||||
> **overran 72mm**, and the firmware rendered the overflow as pixels; the Rongta's 80mm had just
|
||||
> enough room — which is why only the Cashino failed. The **11-digit** id at width 3 = **~468 dots**,
|
||||
> fits both widths, and scanned the full value at the exit reader (verified on hardware). Lessons:
|
||||
> (1) keep `(11·len+35)·moduleWidth` under the **narrowest** deployed printer's usable dots; (2) a
|
||||
> too-NARROW module (`GS w 2`) is also bad — it scanned but returned **truncated** values (partial
|
||||
> reads logged as `exit.refused.noSession` anomalies). Width 3 + 11 digits is the verified sweet
|
||||
> spot. `code128()` in `packages/devices/src/drivers/printer-escpos.ts`.
|
||||
- **Scan points** (both host-side reads — [[entry-exit-readers]]):
|
||||
- **Pay station** — customer scans the ticket → host finds the session → shows fee → takes
|
||||
payment ([[tariff]], pay-on-foot) → appends `payment`.
|
||||
|
||||
Reference in New Issue
Block a user