Commit graph

3 commits

Author SHA1 Message Date
c8da9daf94 fix(scan): speed up QR recognition and keep scanning 2026-10-05 09:43:53 +02:00
80608602ad fix(scan): the QR was being downscaled below its own resolution
Scanning felt like it never resolved. It was not slow — it was being
downscaled into unreadability.

The printed QR is 28pt on a 210mm page: 4.7% of the page width. The loop
down-scaled the camera frame to 640px wide before decoding, so a phone
pointed at a full A4 sheet rendered that QR at ~30px total, about 1 pixel
per module. Measured against the real rendered page, jsQR cannot read it
at any crop size:

    cap    px/module   whole frame        bottom-45% ROI
     640      1.04      MISS               MISS
    1280      2.08      miss               miss
    1600      2.60      HIT  69ms          HIT  28ms
    1920      3.12      HIT  98ms          HIT  39ms
    2560      4.16      HIT 171ms          HIT  39ms

Above ~2.5px per module it is reliable, so the cap is now 1920. No amount
of speeding up the loop could have fixed this; the pixels were not there.

  - requestAnimationFrame instead of a 120ms setInterval. rAF fires
    straight after a painted frame, so the decoder gets a fresh one
    rather than whichever frame happened to be current at the tick; it
    also removes the 120ms floor on every detection and lets a
    readyState stall recover on the next frame instead of a timer tick.
  - Two regions instead of one. The delivery-note QR sits in the bottom
    band of the page, so that band is scanned every frame at roughly a
    third of the cost of the full frame (39ms vs 98ms). The full frame
    runs every 6th as a safety net for a tilted page. It is an
    accelerator, not a restriction.
  - Native BarcodeDetector is now EXCLUSIVE when present. It used to
    fall through to jsQR on a miss "because jsQR sometimes succeeds
    where native blurs", which doubled the cost on every miss — the
    common case. The native detector is Google's own ML stack; when the
    browser ships it, use it alone.
  - Screen wake lock while scanning, re-taken on visibilitychange
    because the browser drops it whenever the tab hides. Otherwise the
    phone locks mid-queue.
  - 3s debounce per code, and the camera keeps running after a hit, so a
    stack of notes can be worked through without a tap per sheet.

The viewfinder is now ONE <video> mounted for the life of the page,
resized when a note loads. A second copy in the note branch would have
unmounted the first — and since a MediaStream is attached to the
ELEMENT, the replacement video would have come up blank while the stream
kept running into nothing.

Four new tests pin the resolution floor, including one that documents
that a full A4 page framed at 640px is UNREADABLE, so nobody lowers
MAX_SCAN_EDGE back to 640 thinking it is just a performance knob.
2026-10-04 13:51:44 +02:00
d98c263f45 feat(scan): standalone delivery-note scanner PWA at /scan
Scan the QR printed on a delivery note, see what it is, and act on it:
reprint, photograph the signed copy, or walk it toward invoicing. It is its
own route group outside `(app)` so it gets a clean full-bleed layout and its
own manifest — "add to home screen" should give the operator a scanner, not
the CRM. Mirrors the pattern `(counter)` already uses.

Two detectors, not one. `BarcodeDetector` is fast and allocation-free but is
Chrome-only: Safari/iOS still has it "Under Consideration" (WebKit bug
#254573) and Firefox does not ship it. A scanner that only used it would
simply not work on an iPhone. So native where it exists, jsQR everywhere
else — pure JS, so no 2 MB WASM payload on a weak warehouse signal, and the
delivery-note QR is always a QR code. Both run against the same canvas frame
so the caller never branches. Also requires HTTPS, which the page says
plainly when it isn't.

The QR carries the human number, so the first call is a new resolve endpoint
mapping number -> PB id. Everything after that speaks ids, which is what every
existing route already takes. The number is shape-validated before any network
call, so a supplier's packing label can't fire a lookup.

STATUS LADDER. mark-invoiced hard-refuses anything that isn't po_received, and
customer-po refuses a note that isn't sent/loaded — so rather than offering a
button that 500s, each action only appears when the server would accept it, and
the card shows which step comes next. It routes draft -> loaded -> po_received
-> invoiced via the existing load-dn, NOT draft -> sent, because draft -> sent
means "create a quote and email it" and a warehouse scan must never trigger
that silently.

The signed-copy upload deliberately uses the upload-only endpoint, not
/api/v1/deliveries/mark-signed-copy — that one flips the note to `completed`
in the same write, which would jump a note straight past invoicing.

Manual entry is always available, so the page works on a device with no camera
or a browser that won't scan.

Tests decode the REAL delivery-note QR through the production fallback path at
print size, and assert the shape check rejects a supplier QR, a random string
and an over-long number.
2026-10-04 13:15:56 +02:00