fix(frontend): wire up the Scan a receipt capture flow #74

Merged
TimCane merged 3 commits from fix/landing-scan-receipt into main 2026-07-06 22:40:59 +01:00
TimCane commented 2026-07-06 22:21:31 +01:00 (Migrated from github.com)

Summary

  • Wire the Landing Scan a receipt button to a file picker (accept="image/*" capture="environment"), photo preview (Use photo / Retake), and multipart upload.
  • Add preprocess() (lib/image.ts): downscale to 2000px longest edge, re-encode JPEG 0.85, strip EXIF/HEIC.
  • Add createSession() + CreateSessionResponse schema; upload runs over XHR for a determinate progress bar.
  • On success store the host token via storeIdentity and navigate to /s/:id; 429 shows the rate-limit copy, other errors stay on the preview.

Test plan

  • pnpm test (44 pass, incl. new Landing preview/upload/error cases)
  • pnpm typecheck, pnpm lint, pnpm format

Closes #73

## Summary - Wire the Landing **Scan a receipt** button to a file picker (`accept="image/*" capture="environment"`), photo preview (`Use photo` / `Retake`), and multipart upload. - Add `preprocess()` (`lib/image.ts`): downscale to 2000px longest edge, re-encode JPEG 0.85, strip EXIF/HEIC. - Add `createSession()` + `CreateSessionResponse` schema; upload runs over XHR for a determinate progress bar. - On success store the host token via `storeIdentity` and navigate to `/s/:id`; 429 shows the rate-limit copy, other errors stay on the preview. ## Test plan - `pnpm test` (44 pass, incl. new Landing preview/upload/error cases) - `pnpm typecheck`, `pnpm lint`, `pnpm format` Closes #73
TimCane commented 2026-07-06 22:32:36 +01:00 (Migrated from github.com)

Code review — fixes applied in b505f89

Reviewed the capture flow (8 finder angles + verify). Fixed the following in this PR:

Correctness

  • Object URL leak. The preview blob: URL was only revoked on successful upload or Retake, so navigating away mid-preview/upload leaked it for the page's lifetime. The URL is now derived from the chosen file and revoked on change/unmount.
  • Same-photo re-pick did nothing. The file input's value was never reset, so selecting the same file again after Retake fired no change event and left the screen stuck. It now resets after each pick.
  • EXIF-rotated receipts reached OCR sideways. createImageBitmap ran without imageOrientation: 'from-image'; re-encoding then stripped the orientation tag, so a portrait photo could arrive rotated. Orientation is now baked into the pixels before re-encode.
  • Decode failures reported as network errors. preprocess() and createSession() shared one catch, so a bad/undecodable image (e.g. HEIC on a browser with no HEIC decoder) told the user to "check your connection." Decode failures now get their own message.
  • Dead error branch. The xhr.response parse-failure fallback built an ApiError with a 2xx status that could never be reached (a Zod parse failure is always an Error). Replaced with a clear invalid-response error.

Robustness / cleanup

  • Stalled uploads hung forever with both buttons disabled and no recovery — added a 120s XHR timeout.
  • Duplicated problem+json parsing. createSession hand-rolled the same type ?? 'unknown' mapping as toApiError; extracted a shared problemToApiError so the fetch and XHR paths can't drift.

pnpm test (45 pass, +1 decode-failure case), typecheck, lint, format all green.

Not fixed here (out of scope for this frontend PR)

  • 429 is overloaded server-side. POST /api/v1/sessions returns 429 rate-limited both for IP rate-limiting and OCR-queue-full backpressure (SessionsController.cs:82). The client can't tell them apart, so a busy-queue rejection shows "Too many sessions from this network." Distinguishing them needs a separate backend error code (+ docs/04 update).
  • 429 copy is duplicated between Landing.tsx and JoinByCode.tsx; a shared is429 helper would be nice but touches an unrelated route.
## Code review — fixes applied in b505f89 Reviewed the capture flow (8 finder angles + verify). Fixed the following in this PR: **Correctness** - **Object URL leak.** The preview `blob:` URL was only revoked on successful upload or Retake, so navigating away mid-preview/upload leaked it for the page's lifetime. The URL is now derived from the chosen file and revoked on change/unmount. - **Same-photo re-pick did nothing.** The file input's value was never reset, so selecting the same file again after Retake fired no `change` event and left the screen stuck. It now resets after each pick. - **EXIF-rotated receipts reached OCR sideways.** `createImageBitmap` ran without `imageOrientation: 'from-image'`; re-encoding then stripped the orientation tag, so a portrait photo could arrive rotated. Orientation is now baked into the pixels before re-encode. - **Decode failures reported as network errors.** `preprocess()` and `createSession()` shared one `catch`, so a bad/undecodable image (e.g. HEIC on a browser with no HEIC decoder) told the user to "check your connection." Decode failures now get their own message. - **Dead error branch.** The `xhr.response` parse-failure fallback built an `ApiError` with a 2xx status that could never be reached (a Zod parse failure is always an `Error`). Replaced with a clear `invalid-response` error. **Robustness / cleanup** - **Stalled uploads hung forever** with both buttons disabled and no recovery — added a 120s XHR timeout. - **Duplicated problem+json parsing.** `createSession` hand-rolled the same `type ?? 'unknown'` mapping as `toApiError`; extracted a shared `problemToApiError` so the fetch and XHR paths can't drift. `pnpm test` (45 pass, +1 decode-failure case), `typecheck`, `lint`, `format` all green. ### Not fixed here (out of scope for this frontend PR) - **429 is overloaded server-side.** `POST /api/v1/sessions` returns `429 rate-limited` both for IP rate-limiting *and* OCR-queue-full backpressure (`SessionsController.cs:82`). The client can't tell them apart, so a busy-queue rejection shows "Too many sessions from this network." Distinguishing them needs a separate backend error code (+ docs/04 update). - **429 copy is duplicated** between `Landing.tsx` and `JoinByCode.tsx`; a shared `is429` helper would be nice but touches an unrelated route.
Sign in to join this conversation.
No description provided.