Commit Graph

309 Commits

Author SHA1 Message Date
julian 28bd838696 docs(wiki): merchant validations settled + as-built; scan input decided (camera paths postponed)
Build desktop / desktop (push) Successful in 5m5s
CI / check (push) Successful in 47s
Build & push images / images (push) Successful in 2m59s
validation-discounts: driving cases → the settled validation-only model (all
money/paper at the booth) → setup UX/storage/RBAC → full as-built record.
DECIDED: merchant stations scan with a USB/HID barcode scanner on the
web/desktop app (hand-keying + Luhn as fallback); POSTPONED with analysis:
web getUserMedia scanning (secure-context TLS prerequisite on the LAN +
Code128-via-camera weakness → QR-on-ticket first) and a Tauri v2 Android
merchant app (native ML Kit scanning; Android build/sideload overhead +
configurable-server-URL prerequisite). Also: wsl-dev-networking gains the
mirrored-mode gotcha where a Windows-side listener makes a port EADDRINUSE
inside WSL while invisible to ss — Vite auto-increments and tauri dev's fixed
devUrl waits on the wrong port.

Claude-Session: https://claude.ai/code/session_01YYkpEsLmoQPaize5ec3oUm
2026-07-13 19:50:09 +02:00
julian 692dff5f89 feat(validations): merchant (bar/lavazh) ticket validations end-to-end
In-park merchants discharge customers' parking: a merchant user scans the
ticket on their device (/validate; validation:create + program↔user binding)
and applies their program — comp / first-N-minutes free / amount-off (capped,
typed at scan) / percent. All money stays at the booth: the quote folds live
validations in a canonical order (timeCredit → percent → fixed → comp, net
floors at 0, Σ lines ≡ gross − net), the payment records gross/discount and
CONSUMES the validation ids (an overstay's fresh period never re-applies
them), the receipt prints the gross → lines → net story, and the Z/X-report
carries discountTotalMinor leakage. Every apply/void is a signed, attributed
ledger event (refId = append-only void); program config is /setup/site master
data (Bar/Lavazh checkboxes + right-column panel, tabs when both) whose saves
sign config_change. Migration 0024 + reset-db drift-guard entries; 8 route
integration tests + priceSession fold suite.

See wiki/concepts/validation-discounts.md for the full design record.

Claude-Session: https://claude.ai/code/session_01YYkpEsLmoQPaize5ec3oUm
2026-07-13 19:49:58 +02:00
julian ba7538aeb5 docs(wiki): capture cloud-service SaaS requirements (postponed)
Multi-tenant SaaS layered on the offline model: link-up monitoring of the
signed ledger, device status, financials; one admin → many sites; per-site
secret custody; recurring fee. Records the four tensions, the confirmed
secrets boundary (sync creds + device-password escrow + app identity, NOT
the signing key), and the two in-discussion corrections that stand (NetBird
already solves booth isolation; remote barrier-open is pulseOpen-and-signed,
driven by the unmanned future). status: open, postponed.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-13 14:51:01 +02:00
julian bb365b5d6e fix(booth-pay): entry/exit timestamps read alike (Sot 19:25:44)
The pay modal rendered entry via formatRelativeDateTime (relative day, no
seconds → "Sot 19:25") and exit/now via the legacy formatTime (raw
HH:MM:SS, no day → "19:25:44") — inconsistent on both day context and
seconds. Added a { seconds } option to formatRelativeDateTime and routed
all four call sites (entry, exit, live now, alreadyClosed toast) through
it, so every row reads "Sot 19:25:44". Removed formatTime — the last raw
toTimeString() helper and the source of the mismatch; BoothPayModal was
its only caller.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-11 10:38:12 +02:00
julian c52a42dad2 fix(resources): update TAG to stage-22544ec for deployment consistency
Build & push images / images (push) Successful in 2m52s
CI / check (push) Successful in 44s
2026-07-10 08:50:51 +02:00
julian 22544ecf63 docs(wiki): log-storm hardening + reset drift guard (2026-07-07 incident)
Build desktop / desktop (push) Successful in 4m37s
CI / check (push) Successful in 42s
Build & push images / images (push) Successful in 2m51s
button-light-indicator: failure backoff + rate-limited logging rationale;
app-logs: storm coalescing invariant + --diagnostics wipe; local-dev-workflow
and appliance-provisioning §7d: new reset flag table + drift guard; log entry
tying all three layers to the ENETUNREACH incident.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-10 08:29:59 +02:00
julian ba5b4b1f4e fix(reset-db): app_logs + tariff_drafts were uncategorized — add a drift guard
Both tables belonged to NO reset category and silently survived every
reset, --all included (the hand-maintained table list lagged the schema
twice). app_logs gets a new --diagnostics category; tariff_drafts joins
--config. A drift guard now compares the category union against
sqlite_master before doing anything and refuses on any uncategorized
table, so the next new table forces a deliberate one-line decision instead
of escaping by omission. Verified on a scratch DB: guard refuses a planted
table (exit 1), --all lists both new tables, --diagnostics wipes app_logs.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-10 08:29:52 +02:00
julian 51b160bfc9 feat(logs): coalesce repeated identical lines into one row (×N badge)
A line identical to the last persisted row (level+source+message+path)
within a 5-min refreshing window updates that row — context._repeat counts
the fold, _firstAt keeps the first occurrence, createdAt tracks the latest
so the storm stays at the top of the newest-first viewer. A continuous
storm stays ONE row however long it rages, so it can't evict unrelated
history via the 50k row cap or grind the appliance disk. LogsViewer badges
coalesced rows ×N (tooltip: count + first occurrence, sq/en). In-memory
last-row cache only; a pruned-under-us row falls through to a fresh insert.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-10 08:29:43 +02:00
julian e2d5105da2 fix(button-light): back off failed setAux sends — kill the ENETUNREACH hot loop
An unreachable controller rejects the UDP send instantly, and #pump's
failure re-pump retried inline: a tight loop logging hundreds of identical
errors per minute (park-buzi, 2026-07-07). Failed sends now arm a 1s→30s
exponential retry (reset on success); desiredOn keeps tracking the truth
table meanwhile and the armed retry converges to it. Logging is
rate-limited: first failure of a streak in full, then one summary/minute,
one info line on recovery. #finalOff waives the backoff so the last-gasp
OFF on drop/shutdown still gets an immediate try.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-10 08:29:33 +02:00
julian 5287be5278 docs(wiki): catch-up sweep — five pages lagging the log
Build & push images / images (push) Successful in 2m52s
CI / check (push) Successful in 43s
rongta-printer still named the cashino driver id (→ escpos + migration
0023 note); tariff-time-tiers listed the composer price preview as
deferred (→ delivered by the lab fee breakdown); tariff.md lab section
gained the breakdown + composer increment-guard paragraph; i18n.md now
records the "25 Qer 14:30" date standard + never-toLocaleString-for-
dates rule; fleet-deployment-komodo gained the park-lab stack + tier
table (the park-lab addition had also slipped the log — both fixed).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-07 13:08:55 +02:00
julian 3a85483e6c deploy(park-buzi): pin TAG=stage-6ceaadf (supersedes cd3b534)
Adds on top of the un-deployed cd3b534 pin: camera clock sync via ISAPI
(heals the 1970 power-cut reset at the offline→ready edge + daily
backstop). Everything since the deployed f9887c2 rides along: USB
printer close-cancel fix (hardware-verified at the lab), USB device
dropdown (lp1 shows by model name), printers addable without a
controller. No migrations.

Post-deploy validation: pull a camera's power, let it come back, then
docker logs | grep "clock synced" — expect a warn with a huge drift.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-07 13:08:55 +02:00
julian 6ceaadfbf2 feat(devices): camera clock sync via ISAPI — heal the 1970 power-cut reset
Build desktop / desktop (push) Successful in 4m18s
CI / check (push) Successful in 44s
Build & push images / images (push) Successful in 2m51s
park-buzi field observation: after a power cut the Hikvision cameras
reboot at the 1970 epoch (no/dead RTC battery, no NTP) and stay there
until a human logs into the web UI (which silently pushes the browser
clock) — corrupting the snapshot OSD timestamps (the evidence trail) and
ANPR push times meanwhile.

The host is the site's time authority (offline-first, no NTP infra):

- Device monitor triggers a sync at each camera's offline→ready edge —
  exactly the power-restored moment — plus a 24h backstop; the attempt
  is stamped before the async call so a failing camera retries at
  backstop cadence, never every poll.
- HikvisionCamera.syncClock: GET /ISAPI/System/time; drift ≤60s → leave
  alone; beyond (or unparseable = infinite drift) → PUT timeMode=manual
  with the site wall-clock now WITH explicit utc offset
  (localIsoWithOffset), echoing the camera's timeZone verbatim — correct
  the clock, never fight its tz/DST config.
- Jumps >1h (the power-cut signature) log warn (persisted to app_logs);
  small corrections info. Capability-guarded (isClockSyncable) —
  hikvision only; dahua's CGI has no such endpoint.
- http-digest generalised to digestRequest (GET/PUT/POST + body); the
  handshake was already method-aware. digestGet delegates unchanged.

8 new tests: in-sync no-op, 1970 PUT shape (manual + host instant +
echoed tz), unparseable→sync, failed-set surfaces, dahua non-capability,
DST-both-sides pins on the offset formatter.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-07 12:56:51 +02:00
julian 7f42805e8d bump(resources): update TAG to stage-cd3b534 for deployment consistency
Build & push images / images (push) Successful in 2m51s
CI / check (push) Successful in 42s
2026-07-07 11:57:09 +02:00
julian cd3b534e51 feat(setup): USB printer discovery — pick a real /dev/usb device
Build desktop / desktop (push) Successful in 4m21s
CI / check (push) Successful in 50s
Build & push images / images (push) Successful in 2m54s
The kernel numbers usblp nodes by plug/boot order (park-buzi's printer
is lp1); the wizard hardcoded lp0 in labels/default and the admin had to
shell in and `ls /dev/usb`. Now:

- GET /api/setup/usb-printers enumerates /dev/usb/lpN (visible via the
  compose bind-mount) and enriches each with the printer's self-reported
  make/model from sysfs ieee1284_id (readable through Docker's ro /sys).
- The wizard's devicePath becomes a SELECT of printers actually present
  ("/dev/usb/lp1 — Xprinter XP-K200L"): a fresh form preselects the
  first real device; a saved-but-unplugged path stays selectable,
  flagged "saved — not present now"; zero found falls back to free text
  + a check-the-cable hint.
- Transport option label no longer hardcodes lp0.

Wiki: printer-usb-transport marked HARDWARE-VERIFIED (lab 2026-07-07:
full slip + feed + cut over USB — parity with TCP).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-07 11:35:25 +02:00
julian 011fe5a4c4 fix(devices): USB truncation mode 2 — close() kills the in-flight usblp URB
Build desktop / desktop (push) Successful in 4m16s
CI / check (push) Successful in 43s
Build & push images / images (push) Successful in 2m51s
The chunked-write fix (81bc2e3) still truncated on hardware: the lab
test slip stopped mid-sentence with no feed and no cut (text hidden
until the feed button). Verified against drivers/usb/class/usblp.c:

- write() returns at URB SUBMISSION, not completion;
- only ONE write URB is in flight (the next write EAGAINs until it
  completes);
- usblp_release() — our close() — KILLS in-flight URBs.

The printer drains bulk data at PRINT speed (tiny internal buffer on
these clones), so closing right after the last accepted write cancels
the still-transferring tail — exactly where the feed + GS V cut bytes
live. Kernel-accepted ≠ printer-received.

Fix: the one-URB rule makes acceptance of write N a completion
certificate for write N−1. writeAllUsb now writes the payload's FINAL
BYTE alone — its acceptance proves everything before it is physically
in the printer — then drains 300 ms for that single packet before the
caller closes. New test pins the final-byte-alone chunking; wiki
printer-usb-transport.md carries the kernel-level account.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-07 10:50:47 +02:00
julian 6f3f6ca596 fix(fleet): park-lab stack points at server "park-lab"
Build & push images / images (push) Successful in 2m52s
CI / check (push) Successful in 43s
The lab box's Periphery onboarded as park-lab (the earlier park-test
name was from the first, discarded install attempt).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-07 10:06:47 +02:00
julian 5443b910c6 feat(fleet): add park-lab stack (lab bench, dev tier)
Build desktop / desktop (push) Successful in 4m15s
Build & push images / images (push) Successful in 2m52s
CI / check (push) Successful in 43s
Second [[stack]] block: server park-test (the lab box's Periphery
connect_as), compose files from the dev branch, MOVING TAG=dev (a lab
may float; real booths pin), its own park_lab_* secret refs (per-box
blast radius — never shared with a real booth). park-buzi is untouched
on stage + pinned.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-07 10:04:08 +02:00
julian a02957034d fix(web): setup allows adding a printer with no controller configured
Second half of the printer/relay decoupling: the category section's
add-button gate ("add a controller first — a printer points at one of
its relays") blocked every non-access category while zero controllers
existed — hit on the lab bench (USB printer test, no relays on hand).
Printers don't bind (role + failoverRank route jobs), so the gate now
exempts them like the form's requirement already does.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-07 09:54:59 +02:00
julian ee61c24bb9 docs(wiki): Periphery v2.2.0 --user installer defaults root_directory=/etc/komodo
Lab box (park-test) crash-looped: panic writing the agent key to
/etc/komodo/keys/periphery.key (Permission denied). Gotcha #9 was framed
as a hand-config hazard; v2.2.0's installer now writes the system-style
default even with --user. §7a: verify root_directory after every
install + sed fix + reset-failed/restart; user-unit vs sudo note; the
onboarding key survives a pre-connect crash.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-07 09:34:00 +02:00
julian 3a186d29df docs(wiki): runbook §5c uses gpasswd -d — deluser rejects hyphenated users
Build & push images / images (push) Successful in 3m14s
CI / check (push) Successful in 43s
Demoting the operator on park-buzi failed with "sanitize_string: invalid
characters in 'park-operator'" — Ubuntu's perl adduser/deluser tooling
rejects the hyphenated username. §5c now prescribes gpasswd -d for
sudo/lxd/lpadmin (shadow-suite, no perl sanitize) and documents that
group removal lands at NEXT login: the auto-login operator session keeps
its old memberships until reboot/relog, so verify `groups` from inside
the session afterwards.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-07 08:55:51 +02:00
julian 827445d514 deploy(park-buzi): pin TAG=stage-f9887c2 (supersedes d905dd1)
Carries since the deployed d905dd1: reports dashboard (occupancy curve,
hour×dow heatmap, stay histogram, fraud KPIs), USB printer chunked-write
fix (barcode + cut over usblp), driver rename cashino→escpos (migration
0023 rewrites device rows on boot), setup wizard printer-binding fix,
composer published-versions sidebar + increment-unit guards + currency-
scaled examples, lab fee breakdown, UI-wide "25 Qer" date standard,
camera health-check log bucketing, seed-admin role self-heal + signed
ledger event, Z-report label wording.

Post-deploy on-site: switch the ICS printer's driver to the generic
ESC/POS entry if still on rongta; USB print test (barcode + cut).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-07 08:55:51 +02:00
julian f9887c2a76 fix(server): seed-admin self-heals the admin role + signs a ledger event
Build desktop / desktop (push) Successful in 4m28s
CI / check (push) Successful in 44s
Build & push images / images (push) Successful in 2m59s
Field failure on park-buzi: reset-db --users wipes the roles table and
points to seed-admin — which inserted the user with roleId "admin"
without recreating the role row (migration 0007 never re-runs), dying on
the role_id FOREIGN KEY. The script now upserts the built-in admin role
first (the row alone suffices — admin permissions resolve in code).

It also appends a SIGNED config_change (admin.passwordReset /
admin.seeded, operator console:seed-admin) via the server's compiled
EventLog + signer: a console seed/reset by the Linux admin can't be
gated by the app, but it stays attributable in the chain. Best-effort —
no build/signing key warns loudly and proceeds (locking an admin out to
protect an audit line would invert the priority). Both paths verified
against a scratch DB reproducing the post-reset state.

Runbook: appliance-provisioning §7e — lost app-admin password reset via
FORCE=1 (interactive preferred; sessions not revoked → rotate JWT_SECRET
if theft suspected); §7d notes the FK failure + self-heal.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-06 15:41:40 +02:00
julian 7649b897c4 feat(tariff): lab explains the sum — fee breakdown from the engine walk
"ALL 740 / 3h 2m" gave no derivation. explainFee in @parking/shared runs
the EXACT computeFee walk with an optional trace collector — one code
path, so Σ line items ≡ the amount by construction (golden V1 regression
byte-identical; instrumentation changes no fee). Items: contiguous
same-price increment runs (time window · N × unit · tier-card name),
window-package occurrences, stepped day totals (top-tier repeat
flagged), daily-cap clamps as NEGATIVE adjustments, entry grace.

/api/tariff/simulate returns `breakdown` (null when settled); the lab's
Outcome panel renders the lined table with a rounding note (raw min →
billed min at the increment — answers "why does 3h 2m bill as 4h") and
a total row. Works against active/historical versions and drafts alike,
so a night-package draft can be verified line by line before publish.
Largely delivers the wiki's open "composer price preview" item.

4 new engine tests pin the sum invariant + item shapes (97 shared green).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-06 15:41:40 +02:00
julian ab968eb25e feat(web): composer states the billing unit — the 60→10 price trap closed
Ladder/flat prices are PER BILLING INCREMENT, but the form said only
"Çmimi / interval" — so changing the increment 60→10 silently multiplied
every price ×6 (operator walked into it). Now:

- Price headers name the real unit live: "Çmimi / orë" at 60,
  "Çmimi / N min" otherwise (flat-mode radio label likewise).
- Amber warning whenever the increment ≠ 60: every price below is
  charged per started N minutes, NOT per hour.
- Per-row "= X / orë" equivalence next to each ladder/flat price when
  the tick isn't an hour — the multiplication nobody should do mentally.
- Example defaults are currency-scaled: ALL gets 200/100 ladder, 200/500
  up-to, 2000 lost ticket (the old "2.00/1.00" euro-scale examples read
  as 2 lekë/hour); EUR/USD keep 2/1/5/20. Threaded through empty forms,
  new tier rows, and mode-switch templates alike.

Band DURATIONS stay in hours — real wall time, increment-independent.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-06 15:41:40 +02:00
julian 5e1a885dcb feat(web): one date standard across the UI — "25 Qer 14:30"
Dates were a mix: catalog-formatted "25 Qershor 20:01" where screens used
formatRelativeDateTime, and browser-locale "7/6/2026, 9:34 AM" in ~20
places that called raw toLocaleString/-Date-/-Time-String. Unified:

- common.monthsShort in both catalogs (Jan/Shk/…/Qer/Korr/…/Dhj);
  formatDate ("25 Qer", year only when not current), formatDateTime
  ("25 Qer 14:30", optional seconds), formatClock ("HH:mm", 24h) in
  lib/format.ts. formatRelativeDateTime keeps Sot/Dje and switches its
  older-dates branch to the same short months.
- Every raw toLocale* DATE call swept: shifts X-report line, plan
  effective dates, sub version labels, drawer today feed, snapshot
  tooltips, device footer checkedAt, event-detail timestamp (keeps
  seconds — chain evidence), tariff composer active-since + version
  sidebar. Number toLocaleString (thousand separators) untouched.

The catalogs in this commit also carry the keys for the two follow-up
commits (fee breakdown, composer increment labels).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-06 15:41:40 +02:00
julian 6cf3492bff chore(shift): Z-report slip label wording (Albanian)
Operator-adjusted labels on the printed Z-report: "Gjëndje fillestare"
for the opening float, aligned "Abonime"/"Jashtë orarit" rows.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-06 12:36:14 +02:00
julian ffe8c13a1c fix(web): setup wizard no longer forces printers to bind to a barrier
"Cilën barrierë shërben kjo pajisje?" is load-bearing for readers and
cameras (which barrier a scan opens + inherited direction) but nothing
consumes it on a printer — print routing is role + failoverRank
(printer-routing.ts). The wizard applied the requirement to every
non-controller device, so adding a printer demanded a meaningless relay
pick that got stored as dead config.

Printers are now exempt: no requirement, the binding panel is hidden,
the binding is not persisted (a stale pre-fix one drops off on next
edit), and the device list shows the printer's ROLE instead of a bogus
amber "unbound". Server never validated it — no API change.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-06 12:36:14 +02:00
julian fcea992e1e refactor(devices): rename driver "cashino" → "escpos" (generic ESC/POS)
The reachability-only clone driver carried its first unit's vendor name,
which read as misleading in the setup UI once other clones (ICS/Xprinter
XP-K200L, verified 2026-07-06: no /prn_stat.htm) used it. It was always
the generic ESC/POS driver — now named so:

- printer-cashino.ts → printer-generic.ts; GenericEscposPrinter;
  id "escpos", label "Generic ESC/POS 80mm printer (Cashino,
  ICS/Xprinter…)".
- Migration 0023 rewrites stored devices.driver_id rows.
- The registry keeps a PERMANENT cashino→escpos alias so restored
  pre-rename backups still resolve instead of "unknown driver".

Prose mentions of the Cashino as physical hardware stay — it's a real,
verified-fit printer; only the driver identity stopped being vendor-named.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-06 12:36:14 +02:00
julian 81bc2e357c fix(devices): USB printing dropped the job tail — chunked write loop
Field bug (ICS XP-K200L over USB): text printed, barcode + cut missing;
same bytes over TCP fine. sendRawUsb did ONE write() on an O_NONBLOCK
usblp fd and never checked bytesWritten — the kernel accepts only what
fits the printer's ~8 KB USB buffer and returns a short write, so the
tail of any job bigger than one buffer (the barcode mid-payload, the cut
at the end) was silently discarded. The regular-file test stand-in can't
short-write, which is why tests never caught it.

writeAllUsb now pushes 4 KB chunks until every byte is accepted,
continues after partial writes, retries EAGAIN/zero-byte with a short
pause, and fails at the deadline with an (N/M bytes) diagnostic. Driven
by fake-handle tests (short writes, EAGAIN interleave, wedged-printer
timeout, non-EAGAIN passthrough).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-06 12:36:14 +02:00
julian 7ef332999e feat(reports): occupancy curve, hour×dow heatmap, stay histogram, fraud KPIs
The dashboard had generic BI views but nothing parking-shaped. Added:

- Occupancy step-area over the range with the configured capacity as a
  red reference line. occupancyStart folds the ENTIRE prior ledger
  (voided entries excluded, clamped ≥0); each series point carries
  occupancyEnd. Answers "when are we near full".
- Entries heatmap hour × day-of-week (7×24, row 0 = Monday, site tz) as
  a pure CSS-grid intensity map — weekday-vs-weekend at a glance, the
  direct evidence for tariff windows. Replaces the flat hour histogram
  (strictly contains it).
- Stay-duration histogram at tariff-shaped edges (30m/1h/2h/4h/8h/24h/
  tail): where ladder/up-to breakpoints should sit.
- Voids + anomalies KPIs (accented when >0) — the look-closer counters
  the signed chain exists for; peak-occupancy KPI (peak / capacity).
- Revenue bars stacked cash vs card (the drawer's money vs the bank's);
  CSV export gains cash, card, occupancy_end columns.

Internals: localParts caches its Intl formatter per tz (was one new
formatter per ledger row); @parking/db re-exports lt/gt. 5 new tests.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-06 12:35:54 +02:00
julian a2e102f3dd bump(resources): update TAG to stage-14638c2 for deployment consistency
Build & push images / images (push) Successful in 2m53s
CI / check (push) Successful in 42s
2026-07-05 16:53:32 +02:00
julian 14638c2e13 docs(wiki): industry survey of parking tariff systems + session log
Build desktop / desktop (push) Successful in 4m14s
CI / check (push) Successful in 43s
Build & push images / images (push) Successful in 2m49s
New reference page tariff-industry-survey.md (2026-07 web research):
field taxonomy — per-started-increment hourly (per-minute tried and
rolled back in practice), degressive ladders, day caps, up-to matrices,
day tickets, evening/overnight packages, event rates, early bird
(entry-time-conditioned), day/night + weekend/holiday/seasonal windows,
category pricing, contracts, merchant validations (amount/percent/
time-credit/re-rate), SFpark-style dynamic pricing. Coverage map: our
engine expresses everything a staffed single lot advertises; real gaps =
early bird (the pick-table-by-entry-time future design, same mechanism
as weekend menus) and validation overlays; anti-features = per-minute
billing + dynamic pricing. Indexed + logged.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-05 16:37:53 +02:00
julian 4f902d869e feat(web): published-versions sidebar on the composer page
The lab redesign gave only the lab tab the published-history sidebar;
the composer page was expected to have it too. /setup/tariff now lists
every published version (name or effective date, active badge, currency)
on the right; clicking one loads it into the editor as the SEED for the
next publish — which always creates a new immutable version (the sidebar
hint states this), making "roll back to last month's prices" a two-click
republish while the history stays append-only.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-05 16:37:53 +02:00
julian a5e54a8b93 fix(devices): bucket camera health-check detail — stop per-frame log/status churn
The device monitor logs + re-emits a status only when state OR detail
changes, but the camera probe's detail was the exact snapshot byte count,
which differs on every JPEG frame — so healthy cameras "changed" on
nearly every poll, writing a log line + websocket event each time
(inflating the freshly budgeted container logs). The detail is now a
stable power-of-two bucket ("snapshot ≈16 KB" / "≈256 KB") that moves
only on a real shift (stream/resolution change); an empty-ish 200 body
is flagged as "<1 KB" rather than bucketed away. Failure details
(auth/HTTP/timeout) unchanged. 3 tests pin the no-flap behavior.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-05 16:37:53 +02:00
julian 0180394c45 bump(resources): update TAG to stage-d905dd1 for deployment consistency
Build & push images / images (push) Successful in 2m53s
CI / check (push) Successful in 43s
2026-07-05 16:09:35 +02:00
julian d905dd19b4 Merge branch 'dev' into stage
Build & push images / images (push) Successful in 2m52s
CI / check (push) Successful in 42s
2026-07-05 15:56:39 +02:00
julian c5ed3f1308 feat(drawer): drawer hub — balance now, this-shift figure, daily activity, shift history; busy spinners
Build desktop / desktop (push) Successful in 4m12s
Build & push images / images (push) Successful in 2m53s
CI / check (push) Successful in 41s
/drawer was record + review only: no current balance, no sight of the open
shift's incomings, no daily activity, no shift history. Rebuilt as a hub:

- Drawer now: the till's running balance (new GET /api/drawer/balance,
  shift:read — exposes the service's existing drawerBalance(); the drawer
  is one site-wide till, same exposure the X-report already had) with the
  open shift's X-report breakdown alongside (float + takings + vouchers =
  expected = balance) and a "This shift: ±X" figure (expected − opening
  float — the shift's own contribution vs what it inherited).
- Today's cash activity: every cash payment + voucher since local
  midnight from the signed chain, live, with day totals (card never
  enters the till).
- Record + movements/review: the 2026-07-01 flow, unchanged.
- Closed shifts: drawer-focused history via the scope-aware /api/shifts
  (float → takings ± vouchers → expected per shift).

Also: every shift open/close button (header, /shifts, pay modal, end-
shift confirm) now shows an animated spinner + dims while busy — the old
label-swap-only feedback read as a dead click when a shift open ran slow.
The slowness itself (drawer/shift reads fold the WHOLE chain, O(chain))
is recorded as an open item in wiki/concepts/shift.md with the fix
sketch: fold from the last z-report's signed expectedDrawerMinor forward.

No new ledger surface — one read-only endpoint; RBAC test added.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-05 15:56:30 +02:00
julian 0b7eb28dfa Merge branch 'dev' into stage
Build & push images / images (push) Successful in 2m50s
2026-07-05 15:24:09 +02:00
julian d5ff2097bd feat(web): currency becomes a closed select (ALL / EUR / USD)
Build desktop / desktop (push) Successful in 4m13s
Build & push images / images (push) Successful in 2m51s
CI / check (push) Successful in 41s
Currency was free text in the tariff editor (composer page + lab draft
modal — shared form) and the subscription plan editor; a typo could
publish an unknown code onto immutable versions. Both now offer a closed
select from lib/currencies.ts. An out-of-set code already stored on an
old record is appended as an extra option so it displays + round-trips
unchanged. Blank tariff form defaults to ALL (was EUR) — the site's
actual currency.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-05 15:23:29 +02:00
julian 1de209be48 fix(shifts): operator filter — select over real operators, no more focus loss
The admin operator filter was a free-text input that broke three ways at
once: its visibility hangs off the query response (scope === "all") and
its value is part of the query key, so every keystroke started a new
query, data went undefined for the round-trip, and the input UNMOUNTED
mid-keystroke (lost focus, list blanking that read as a page reload).
Filtering also silently failed — the server matches the operator by
exact username, so partial text matched nothing.

- keepPreviousData on the shifts query: previous data (and scope) stays
  live during refetch, so filter controls never unmount and the list
  never blanks on preset/filter changes.
- The filter is now a <select> of operators that HAVE shifts: the server
  returns the distinct list (signed z-reports + the open shift's holder)
  on GET /api/shifts, admin scope only — operators still can't see other
  names. Exact match by construction.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-05 15:23:29 +02:00
julian dc2cdc0a91 feat(web): self-host Chakra Petch as the app's primary face
The booth is an offline appliance — no webfont CDN — so the font ships
from public/fonts/chakra-petch: latin subset (covers en + sq ë/ç), the
weights the UI actually uses (400/600/700 + 400 italic, ~40 KB total),
SIL OFL license alongside the files. Chakra Petch leads all four family
tokens (mono/display/ui/body) with the previous stacks kept as fallback;
index.html preloads the two everywhere-weights so first paint doesn't
flash the fallback. Not a true monospace — .num/.tabular still request
tabular figures and columns verified aligned in the built app.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-05 15:23:15 +02:00
julian fd9885e9ec feat(tariff-lab): DB-backed draft tariffs + named published versions
Experimenting used to mean publishing — churning the immutable version
history and risking real tickets pricing against a half-baked card while
the admin iterated. The lab is now a true sandbox:

- tariff_drafts table (migration 0021): MUTABLE by design — the one
  exception to "editing publishes a version"; a draft prices nothing and
  signs nothing. Drafts are validated + tz-stamped on save exactly like a
  publish, so a saved draft always simulates and never fails at publish.
- CRUD under /api/tariff/drafts (list tariff:read, mutations
  tariff:update); publishing a draft goes through the normal immutable
  POST /api/tariff/versions path.
- Lab UI rebuilt: sidebar lists lab drafts AND the full published history
  (click any to price against it); main pane cut to pure entry/exit
  (ticket loader, payment, category inputs dropped); the composer form is
  extracted to TariffEditorForm.tsx and reused in a modal (new drafts
  prefill from the active card); per-draft Publish with confirm.
- tariff_versions.name (migration 0022): optional label stamped at
  publish — carried from the lab draft, or typed in the composer's new
  optional field — so history reads "Winter 2027", not UUID prefixes.
- Includes the composer UI + sq/en labels for the package mode (engine
  landed in d9e6c13) and the "Flat price / hour" relabel.

5 new server integration tests (RBAC, roundtrip, validation, tz-stamp +
simulate + publish w/ name); server suite 288 green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-05 14:31:42 +02:00
julian 52a89bfa56 feat(web): move tariff lab under /setup/tariff as a sub-tab
The lab lived at /subscriptions/tariff-lab — the wrong neighborhood for a
tool that tests the rate card. /setup/tariff is now a small layout with
two sub-tabs (composer at the index, lab at /setup/tariff/lab) behind the
existing tariff:read gate. Old URLs (/subscriptions/tariff-lab and the
original /setup/tariff-lab) redirect, and the tariff-read-only redirect
branch on /subscriptions is gone with the tab.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-05 14:31:32 +02:00
julian d9e6c13831 feat(tariff): whole-window package pricing mode (packageMinor)
A windowed card can now charge ONE total for any presence in its window —
the real night rate ("20:00–07:00 = 400, leave earlier and it's still
400"), which the per-increment flatMinor could not express (park-buzi's
"night 400" card billed 400/HOUR). Engine charges once per contiguous run
of increments the card wins, tracked across rolling-day segments so a
night crossing the 24h boundary charges once; out-of-window increments
price by the base card as usual.

Operator decisions (2026-07-05): per-occurrence repeat (two nights = two
charges), any-touch-pays-full, windowed cards only (a base "price per
day" is a 1-row up-to table). Validator: mutually exclusive with
flat/blocks/steps, no per-card cap, forbidden on the defaultCard.
flatMinor docs clarified as PER INCREMENT. 6 new engine tests.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-05 14:31:25 +02:00
julian 493210bbb0 Merge branch 'dev' into stage
Build & push images / images (push) Successful in 2m48s
2026-07-05 10:41:04 +02:00
julian a9f18be700 feat(logging): extend log retention to 60 days and update log level options
Build & push images / images (push) Successful in 2m51s
CI / check (push) Successful in 40s
2026-07-05 10:40:42 +02:00
julian 72ad504b8d deploy(park-buzi): pin TAG=stage-365b648 (supersedes 93f9ebe, adds anpr do-while fix)
Build & push images / images (push) Successful in 2m50s
Same payload as the 93f9ebe pin (camera press-gate + cooldown backstop +
duplicate-plate anomaly, reader channel tagging + phantom feed filter, log
rotation/format) plus the anpr poll-loop do-while fix (at least one analyze
attempt per detection). Code-only — no migration.

Manual Komodo step: refresh ResourceSync → Execute → Deploy. Then the
vendor-tool reader session (prefixes Q:/K:, Card Input format 8H, symbology
cut) — server first, readers second.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
(cherry picked from commit 5cdf8f227b)
2026-07-05 09:21:00 +02:00
julian 5cdf8f227b deploy(park-buzi): pin TAG=stage-365b648 (supersedes 93f9ebe, adds anpr do-while fix)
Build & push images / images (push) Successful in 3m7s
CI / check (push) Successful in 42s
Same payload as the 93f9ebe pin (camera press-gate + cooldown backstop +
duplicate-plate anomaly, reader channel tagging + phantom feed filter, log
rotation/format) plus the anpr poll-loop do-while fix (at least one analyze
attempt per detection). Code-only — no migration.

Manual Komodo step: refresh ResourceSync → Execute → Deploy. Then the
vendor-tool reader session (prefixes Q:/K:, Card Input format 8H, symbology
cut) — server first, readers second.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-05 09:20:58 +02:00
julian 365b648282 Merge branch 'dev' into stage
Build & push images / images (push) Successful in 2m50s
2026-07-04 20:17:00 +02:00
julian c03ef2a34b fix(anpr): guarantee at least one analyze attempt per vehicle detection
Build & push images / images (push) Successful in 2m49s
CI / check (push) Successful in 40s
CI flake root cause (Gitea runner, anpr-entry.test.ts "records an advisory
anpr-skip"): the poll-until-confident loop was a plain
`while (Date.now() < deadline)` — zero iterations were possible when the
window elapsed between deadline-set and loop-entry (the tests run a 5ms
window; a slow runner loses that race). Zero attempts → no frame analyzed →
"gave up" → no anpr-skip row → assertion fails. Not a regression: nothing in
the recent merges touched this path; the race existed since the poll loop
was built.

The invariant is real beyond tests: on a sufficiently loaded booth the old
loop could silently drop a real car's detection the same way. The loop is
now do-while (exit via the existing breaks: confident read, or next tick
past the slid deadline/hard cap), so a detection ALWAYS analyzes at least
one frame.

New regression test forces ANPR_POLL_WINDOW_MS=0 (the CI scenario, made
deterministic) and asserts exactly one capture attempt + the recorded skip.
Suite 283 green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-04 20:16:52 +02:00