Client workPWAOffline
A field app that works with no signal.
Technicians fill in their report on the roof, in airplane mode. The office gets it the same day and sends it to the client as a PDF.
Stack
- PWA
- JavaScript
- Supabase
- PostgreSQL
- Python
- FastAPI
- Claude
- 69 invariants
- Tests each born from a production bug, reading the shipped code.
- Same day
- the office sees the job, instead of three days of re-typing before.
- Airplane mode
- for the whole report. Only the sync needs a network.
- 1 minute
- between two mailbox reads by the agent. Every write waits for a human to approve it.
Interfaces
The phone on the roof, the dashboard at the office.
Both screens read the same database. What the technician validates shows up on the office map as soon as their phone is back online.
Review
The agent proposes, a person approves.
Signed quotes, invoices and claims arrive by email. The agent extracts the fields, each one stays editable, and nothing is written to the database until someone clicks the button on the right. Reconstructed interface, fictional data.
Signed quote — Oakwood HOA
- Client
- Oakwood HOA ▾
- Quote no.
- DV 2845
- Amount
- $5,078.50 excl. tax
- Address
- 1420 Cedar Ridge Dr, Austin
- ZIP
- 78701
- Unit
- APT 42
- Text to copy
- DV 2845 - OAK / BC-883603
Features
From the roof survey to the client report.
Fifteen steps at most per report, and only the ones that apply to that type of job.
-
Offline first
Every entry is written to the phone first and joins a local queue. Saving never waits for the network.
-
Field-level merge
The offline technician and the live office edit the same record. A written rule decides who wins, and a conflict is always flagged.
-
Built-in camera
Photos are taken in the app, through the browser. On the field's rugged Android phones, the system camera often refused to open.
-
Client report as PDF
The office generates the report in one step, from the fields and photos of the survey.
-
Per-company isolation
Every database row belongs to one company (PostgreSQL Row Level Security). A check verifies it with a real technician account.
-
Encrypted backups
Encrypted off-machine copies, with a restore verified byte for byte. The app installs from the browser, no app store needed.
Architecture
The path of a report.
Only the fourth step touches the network. Everything before it works in airplane mode.
- 01 On the roof The technician fills in, photographs and validates the steps for that type of job.
- 02 Written to the phone Immediately: the record in local storage, the photos in a dedicated local database.
- 03 The queue The change waits its turn, with a revision number that changes on every edit.
- 04 Back online The queue replays in order. Before each write, it re-reads the server's row and merges field by field.
- 05 The office The map, today's counters and the planner update, and the client report comes out as a PDF.
Email agent
A read-only agent on the client database.
It reads two mailboxes every minute and prepares proposals. Its only allowed write: setting a job's status to “scheduled”.
What the agent does on its own
- Read two mailboxes every minute and sort each message
- Open attached PDFs and extract client, references, amounts and dates
- Query the client's database, read-only
- Draft a reply, left unsent
What it is not allowed to do
- Write to the client's database
- Send an email
- Create a record nobody has reviewed
- Delete or overwrite existing data
Production incidents
Four failures, and what they changed.
Each one left a written check that can be replayed.
01 · Lost draft
A technician lost a report they had already filled in
July, back from a job site
- The assumption
- The draft was saved on every entry and every photo. Coming back to the app should find it.
- What happened
- They left the app during a report. On return they were on the home screen, and the draft's photos were gone.
- Why
- Five defects chained together: a cache serving a stale version, photo storage that had become private, a race between two operations, expired image URLs and a fragile sync. The draft existed, but reopening it required a button they never saw, and the photo was still being written to the local database when they left the app.
- What changed
- Reopening the app on an active draft reopens the report at the saved step. The draft waits for its photos to finish writing before it is saved, and it is also saved when the app is left.
- The proof
- Invariant tests cover the chain by reading the shipped source. The live version is checked by downloading the deployed service worker.
02 · Invisible photos
Every photo disappeared, with no data lost
July, reported as “no photos on a job”
- The assumption
- Photo storage is private: the app signs temporary URLs to display them, and keeps a public URL as a fallback.
- What happened
- Every photo disappeared at once, for every user, with no error message.
- Why
- On private storage, a public URL returns an error: the fallback had never worked. All URLs were signed in one batched call whose failure was swallowed. A single transient network error emptied the whole display, while every file and every row was intact.
- What changed
- Valid signed URLs are kept across reloads, the display rebuilds from them at startup, and signing runs in small isolated batches: a failed batch only blocks its own photos.
- The proof
- A three-layer diagnosis, written and replayable: does the row exist, does the file exist, does signing work. Three yeses mean nothing is lost and the defect is in the display.
03 · Sync queue
An entry typed during a sync disappeared
July, found by an adversarial audit
- The assumption
- Send pending records one by one, then rewrite what remains in the queue. The code had run that way for months.
- What happened
- Anything typed during a sync could be lost. The server had one version, the screen another, and no send was left to close the gap.
- Why
- Read the queue, send, rewrite the queue: the network separates those three moments, and the rewrite erased entries added in between. The existing timestamp could not detect it, because it is deliberately kept on re-queue for conflict detection.
- What changed
- Every entry carries a revision counter that changes on every edit. On acknowledgement the queue is re-read, and a send's result is only applied if the revision has not changed. Whatever was added during the send stays in the queue.
- The proof
- The merge rule lives between two markers in the source, and a script tests exactly that zone: moving the code without moving the test makes the script fail.
04 · False success
A button said “done” and deleted nothing
Found while writing a test against the real database
- The assumption
- The database isolates data per company, row by row. The rules were written and reviewed.
- What happened
- A reset button reported success every time and removed no rows, for weeks. Nothing failed, so nobody noticed.
- Why
- When a delete matches no security policy, PostgreSQL returns no error: the query succeeds and deletes zero rows. From the app, a silent refusal looks exactly like success.
- What changed
- Isolation is checked with a real technician account on the real database: the check creates a throwaway record and tests what that account can and cannot reach.
- The proof
- The check runs with a technician's rights and fails if a rule is missing, the very case it was written for.
Product decisions
Two diagnoses settled by a measurement.
Before changing the code, measure what is actually happening.
Duplicate jobs
Measured
0 of 55
duplicate IDs, and zero shared IDs across 181 calendar events. Deduplication was working.
Decided
Upstream
The duplicates came from calendars where an appointment deleted and re-created gets a new ID. The fix was made at the source; the merge did not change.
Test coverage
Measured
3 areas
tested: startup, service worker, offline. A total block at step 5 went unnoticed for weeks.
Decided
Input = output
A check verifies that what is entered comes out in the report. When it was added, it found five defects in one pass.
- Role
- Product design, field UX, full-stack development
- Context
- Client work · anonymized · 2026
- Result
- 69 invariant tests, each one born from a production bug. The office sees the job the same day instead of three days later.