feat: harden the offline-first behaviour of the field client #1

Merged
yami merged 1 commit from feat/offline-first-hardening into main 2026-07-31 09:11:10 -04:00
Owner

Four gaps that made the PWA fall over in exactly the conditions it was built for. The upload queue itself is unchanged in substance — its design (single drain loop, stable clientUploadId, reconcile-before-resend, blob released only after storagePersisted) is what everything here is built around.

1. A lapsed session locked the technician out

The API has no refresh endpoint, so expiry is handled by degrading rather than renewing.

Previously: AuthContext dropped the session on expiry or on any 401, App renders the login screen when user is null, and UploadContext stopped the queue. An expired token in a basement therefore meant no capture, no access to the evidence already on the phone, and a queue that would not drain until someone signed in — with no way to sign in offline.

Now the token is dropped but the technician is kept. authenticated is split from user; the shell, camera and queue stay reachable while lapsed. The queue gates the wire on holding a token rather than gating the whole queue on being started, so captures still enqueue during the lapse without firing uploads that can only come back 401. A /signin route handles re-auth and bounces back once a token exists. A banner two hours before expiry is the only preventive lever available without a refresh endpoint.

2. The service worker reloaded the page under a running capture

registerType was autoUpdate, which bakes skipWaiting/clientsClaim into the worker and reloads on controlling. That directly contradicted the comment in main.tsx ("Shell updates apply on the next launch"), and a deploy mid-capture destroyed the live camera stream plus any capture reviewed but not yet written to IndexedDB.

Now prompt, with a restart chip that hides on the capture screen and while the queue is draining.

3. No media was cached, so a workspace opened offline empty

Both halves were missing, and either alone is inert — bytes without a list are unreachable, a list without bytes renders broken tiles.

  • Thumbnail and poster bytes via a Workbox CacheFirst route (jeh-media).
  • Media lists via a new IndexedDB store (schema v2, upgrade keyed on oldVersion so existing queued originals survive).

Full previews and /videos/{id}/content are deliberately excluded: large and range-requested, and a partial response in the cache is worse than none. Cached media is purged on sign-out and when a different technician signs in — but not on a mere expiry, which is the same person on the same device.

4. The app never asked to be installed

Storage persistence and the screen wake lock that keeps an upload alive are both materially weaker in a browser tab, and a tab can be discarded with queued originals in it. The client was already built on the assumption of a Home Screen install without ever requesting one. Added beforeinstallprompt handling for Chromium and spelled-out Share → Add to Home Screen instructions for iOS, which never fires that event.

Verification

npm run build (runs tsc -b first) passes clean. The generated worker was checked directly: skipWaiting now appears only inside a SKIP_WAITING message listener, the location.reload() in the bundle sits inside the update function rather than on every deploy, and the jeh-media route serialized correctly with its sameOrigin guard.

Not verified in a browser. The repo has no test framework, so offline capture → lapse → reconnect → confirm is still unexercised end to end. That path is worth a manual run before merge.

Not addressed

Remaining findings from the same audit, in rough priority order: the workspace cache still uses localStorage (ITP-evictable on iOS) rather than IndexedDB; cachedAt is written but never surfaced, so cached data carries no staleness indicator; requestPersistentStorage() is still only requested on fresh sign-in, never on session restore; navigator.onLine remains the only connectivity signal (it lies true on a captive portal); no Background Sync; no detection of silent IndexedDB eviction; and several manifest gaps (id, screenshots, shortcuts, share_target, iOS splash screens).

🤖 Generated with Claude Code

Four gaps that made the PWA fall over in exactly the conditions it was built for. The upload queue itself is unchanged in substance — its design (single drain loop, stable `clientUploadId`, reconcile-before-resend, blob released only after `storagePersisted`) is what everything here is built around. ## 1. A lapsed session locked the technician out The API has no refresh endpoint, so expiry is handled by degrading rather than renewing. Previously: `AuthContext` dropped the session on expiry or on any 401, `App` renders the login screen when `user` is null, and `UploadContext` stopped the queue. An expired token in a basement therefore meant no capture, no access to the evidence already on the phone, and a queue that would not drain until someone signed in — with no way to sign in offline. Now the token is dropped but the technician is kept. `authenticated` is split from `user`; the shell, camera and queue stay reachable while lapsed. The queue gates the *wire* on holding a token rather than gating the whole queue on being started, so captures still enqueue during the lapse without firing uploads that can only come back 401. A `/signin` route handles re-auth and bounces back once a token exists. A banner two hours before expiry is the only preventive lever available without a refresh endpoint. ## 2. The service worker reloaded the page under a running capture `registerType` was `autoUpdate`, which bakes `skipWaiting`/`clientsClaim` into the worker and reloads on `controlling`. That directly contradicted the comment in `main.tsx` ("Shell updates apply on the next launch"), and a deploy mid-capture destroyed the live camera stream plus any capture reviewed but not yet written to IndexedDB. Now `prompt`, with a restart chip that hides on the capture screen and while the queue is draining. ## 3. No media was cached, so a workspace opened offline empty Both halves were missing, and either alone is inert — bytes without a list are unreachable, a list without bytes renders broken tiles. - Thumbnail and poster bytes via a Workbox `CacheFirst` route (`jeh-media`). - Media lists via a new IndexedDB store (schema v2, upgrade keyed on `oldVersion` so existing queued originals survive). Full previews and `/videos/{id}/content` are deliberately excluded: large and range-requested, and a partial response in the cache is worse than none. Cached media is purged on sign-out and when a *different* technician signs in — but not on a mere expiry, which is the same person on the same device. ## 4. The app never asked to be installed Storage persistence and the screen wake lock that keeps an upload alive are both materially weaker in a browser tab, and a tab can be discarded with queued originals in it. The client was already built on the assumption of a Home Screen install without ever requesting one. Added `beforeinstallprompt` handling for Chromium and spelled-out Share → Add to Home Screen instructions for iOS, which never fires that event. ## Verification `npm run build` (runs `tsc -b` first) passes clean. The generated worker was checked directly: `skipWaiting` now appears only inside a `SKIP_WAITING` message listener, the `location.reload()` in the bundle sits inside the update function rather than on every deploy, and the `jeh-media` route serialized correctly with its `sameOrigin` guard. **Not verified in a browser.** The repo has no test framework, so offline capture → lapse → reconnect → confirm is still unexercised end to end. That path is worth a manual run before merge. ## Not addressed Remaining findings from the same audit, in rough priority order: the workspace cache still uses `localStorage` (ITP-evictable on iOS) rather than IndexedDB; `cachedAt` is written but never surfaced, so cached data carries no staleness indicator; `requestPersistentStorage()` is still only requested on fresh sign-in, never on session restore; `navigator.onLine` remains the only connectivity signal (it lies `true` on a captive portal); no Background Sync; no detection of silent IndexedDB eviction; and several manifest gaps (`id`, `screenshots`, `shortcuts`, `share_target`, iOS splash screens). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Four gaps that made the PWA fall over in exactly the conditions it was
built for. The upload queue itself is unchanged in substance.

Session lapse no longer locks the technician out. The API has no refresh
endpoint, so expiry is handled by degrading rather than renewing: the
token is dropped but the technician is kept, and the shell, camera and
queue stay reachable. Previously an expired token in a basement meant no
capture, no access to the evidence already on the phone, and a queue that
would not drain until someone signed in. The queue now gates the wire on
holding a token instead of on being started, so captures still enqueue
during the lapse without firing uploads that can only come back 401. A
banner two hours before expiry is the only preventive lever available.

The service worker no longer reloads the page under a running capture.
registerType was 'autoUpdate', which bakes skipWaiting/clientsClaim into
the worker and reloads on 'controlling' — directly contradicting the
comment in main.tsx, and destroying the live camera stream plus any
capture reviewed but not yet written to IndexedDB. It is now 'prompt',
with a restart chip that hides on the capture screen and while draining.

Media is cached, so a workspace opens offline with its photos rather
than an empty grid. Both halves were missing and either alone is inert:
thumbnail and poster bytes via a Workbox CacheFirst route, and the media
lists via a new IndexedDB store. Full previews and video content are
deliberately excluded — large and range-requested. The cached media is
purged on sign-out and when a different technician signs in, but not on
a mere expiry, which is the same person on the same device.

The app now asks to be installed. Storage persistence and the wake lock
that keeps an upload alive are both materially weaker in a browser tab,
and the client was already built on the assumption of a Home Screen
install without ever requesting one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
yami merged commit b6b23f6f7a into main 2026-07-31 09:11:10 -04:00
yami deleted branch feat/offline-first-hardening 2026-07-31 09:11:17 -04:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
yami/jeh-field!1
No description provided.