Production Web App

knockabase

A door-to-door canvassing app for an HVAC electrification crew, where a rep speaks a knock between houses and it becomes a schema-validated database record instead of a note typed at 9pm. Live on two hostnames: a marketing site on the apex and the app on app.knockabase.com.

Next.js
TypeScript
Supabase
PostGIS
Mapbox
Claude API
Live Code: Private repo
Territory map with coloured door pins along a Vancouver block, a drawn territory boundary, and a microphone dock

Employer signal

What This Project Shows

This is the project where I designed and built a whole system rather than a feature: the schema and its row-level security, the rule for when two doors are the same door, an LLM extraction pipeline whose output is treated as untrusted, an offline outbox, a hand-written service worker, and the design decisions that make it usable one-handed on a doorstep. 133 commits and 27 migrations over nine days, built for a door-to-door crew, AI-assisted throughout, one day's work ran six agents in parallel, partitioned by file ownership. Reviewing what an agent produced rather than accepting it is how, two days later, a migration a cloud agent had shipped without the credentials to apply it got caught, while production was quietly running the new code against the old schema. The part worth a reviewer's time is not the feature list: it is the two questions I answered wrongly first and then had to answer properly: when are two doors the same door, and what should a system do with a time that carries no timezone.

Problem

What Needed To Be Solved

Canvassing software assumes a person who can type. A rep works outdoors and one-handed for a five-hour shift, and residential streets and basements have no signal, so an app that needs a connection to accept a knock is an app that loses knocks. Underneath that sits a quieter problem that only surfaces once records accumulate: the address a door is filed under is a guess. It arrives from a reverse geocode or a spreadsheet, two neighbouring houses can be handed the same street line, and a spoken "unit 4B" has to find the right door among the ones already recorded at that address, or silently attach what a household said to a household that never said it.

Approach

How I Built The Solution

I made speech the primary input and treated everything downstream of it as untrusted. The phone's own recogniser runs first because it is free and instant, with a cloud transcription fallback for wind and traffic; the transcript is mapped onto a fixed schema by a model, validated against that schema on the server before it can reach the database, and shown back to the rep as a card to confirm. Below that, the guarantees that have to survive a knock arriving late (ordering, tenancy, and which door a knock belongs to) are enforced inside Postgres rather than in application code, because with an offline queue the order things arrive in is not the order they happened in.

Outcome

What It Demonstrates

What shipped is a product rather than a set of experiments: a clustered territory map with one-tap door placement, the voice-to-record pipeline, an offline outbox and territory cache, territories drawn on the map, a door list, a schedule, realtime messaging, per-rep and per-territory analytics, an org roster with email invitations, bulk CSV territory import with geocoding, and a marketing site on the apex with its own first-party analytics. Two things are unfinished and worth saying plainly: the voice path is tested end to end in a browser and has never been run through a full shift on real hardware in wind, and there is no test framework and no CI: verification is six hand-written scripts wired into one npm command, scoped on purpose to the logic that sits behind auth, behind a map, or behind a model and therefore cannot be checked by clicking, including a schedule test that runs a timezone at UTC+05:45 specifically to catch any implementation storing offsets in whole hours. The judgement the project demonstrates is knowing which parts had to be right the first time (the tenancy boundary, the ordering guarantee, and the rule for when two doors are the same door) and which could be learned by shipping and being corrected.

Screens

Territory map with coloured door pins along a Vancouver block inside a drawn boundary
Doors cluster by zoom and carry both a colour and a glyph. Colour is the only chromatic thing in the entire app, so a pin is always the most visible object on screen, and the map still reads under colour blindness and in direct sun.
Knock sheet showing known household data, tags, and a row of outcome buttons
What the rep sees at the door: what is already known about the household, and a one-tap outcome row. Dispositions are abbreviated to the shorthand this industry already uses. Voice is the upgrade, not the requirement.
Design reference page showing four field themes and the eight-value disposition scale
Four themes chosen by the light the screen is read in, direct sun through to a true-black night shift, and the disposition scale ordered so neighbouring outcomes differ in brightness as well as hue.

Evidence From Source

The two ways of being wrong are not symmetric

Sizing the merge radius is not a tuning exercise, and the code argues the direction explicitly. Too large silently fuses two real houses into one pin and is discovered weeks later, in data nobody can reconstruct; too small splits one house into two pins, which the rep can see on the map and drag together. That asymmetry is why the migration that introduced the radius was itself corrected by a later one: the first answer, 20m, was wider than a residential lot, so a deliberate human placement now overrides a guessed address at 3m while guessed inputs keep the wide radius.

A debug screen that immediately earned itself

The first transcript put through a debug view for the extraction pipeline exposed a timezone corruption that was invisible from inside the app: JavaScript parses an offset-less date-time as local, so the confirmation card the rep read was right while the row in the database was seven hours early. The fix reads the naive string as UTC, looks up the zone's offset at that instant and applies it twice, because the first shift can cross a DST boundary and change the answer, and returns strings that already carry an offset untouched, since re-anchoring those would invent the exact error the function exists to prevent.

The measurement inverted the obvious action

Before enabling prompt caching on the extraction route I measured it, and the measurement said not to. The system prompt interpolated the knock's millisecond timestamp about a third of the way in, and the cache is an exact prefix match, so caching the prompt as written would have paid the write premium on every single call and never collected a read: roughly 18% more expensive per extraction, measured as two consecutive calls producing two writes and zero reads. Split into a static block and a per-request block, the same measurement on a larger model reads back every one of the 3,142 tokens it wrote, about 66% cheaper warm. On the default model it does nothing at all, and the entry says so: that model's minimum cacheable prefix is 4,096 tokens against this prefix's ~2,675, and a prefix under the minimum silently does not cache: no error, just zeros. The check suite therefore asserts that the static block is byte-identical across two different knocks and that no per-request value appears in it, because the failure mode is a silent zero in a ledger column rather than something that throws.

Type-checks, lint and build are not verification

Three real bugs passed tsc, eslint and next build together, and all three were found by opening the app and clicking. A constant exported from a "use server" module became a server reference, so a client-side equality check was permanently false and every form rendered an empty message box on first paint. An update ran unfiltered under a comment arguing that row-level security would pick the row, but PostgREST refuses unfiltered updates outright, before policies are ever consulted, so every save on that screen failed. And ten spinners sat at zero opacity running CSS animations forever. Nothing threw.

A test that passes in the broken case is not evidence

Production sign-in failed for a day because a byte-order mark had been welded to the front of an API key: PowerShell 5.1 prepends U+FEFF to anything piped into a native command's stdin, and the key was pushed that way. Only the key broke: the identically-corrupted URL healed itself, because the client library trims it and JavaScript's trim() treats U+FEFF as whitespace, so the symptom pointed away from the cause. Reproducing it took a deliberately expired session, because a session check with no cookie short-circuits in process and never reaches the network. Which meant the all-green smoke suite from the previous deploy, every route answering exactly as expected, was indistinguishable from what a completely broken auth stack produces. That is the rule I took from it, and it is why the checks that exist assert properties rather than absence of errors.

Design decided by measurement

I ran the marketing site against the platform-agnostic HIG foundations (accessibility, colour, layout, typography) and measured every finding in a browser at 320, 390 and 1280px, in both themes, at 100% and 200% text. The finding worth repeating: a max-width of 68ch was making line length worse rather than better, 99 characters, because Atkinson Hyperlegible's zero is 11.67px against an 8.0px average character, so the ch unit lies for that face; measured directly, 36rem gives about 72. The same pass found nine footer links at 78×17px with an 11px gap that were the only route to Sign in on a phone, and FAQ rows whose padding sat on the details element instead of the summary, so a row that looked 60px tall only opened from its middle 28px.