Case study / pawscriptions
Building pawscriptions: a med tracker that can never double-remind
pawscriptions tracks a dog's medications for a two-person household: define meds and schedules, log or skip each dose, forecast the supply, and get push reminders in the right timezone. The v1 core went from empty repo to running in two days, and the data model it shipped with survived two later feature waves without a single new table. The hard problem is small but sharp: an external scheduler hits the reminder endpoint every minute, runs can overlap or retry, and a double reminder risks a double-dosed dog, so deduplication has to be guaranteed by construction.
- role
- Solo: product, design, build, ship
- timeline
- 2 days to v1, two feature waves, June 2026
- status
- Live, behind the household passphrase
The stakes fit in one sentence
A missed reminder is an annoyance. A doubled reminder is a double-dosed dog. That asymmetry drove every hard choice in this build: when correctness and convenience argued, correctness won, and the database got the deciding vote.
The rest of the product is deliberately small. No accounts, one shared passphrase, six tables, eight runtime dependencies. The v1 core shipped in two days, and two feature waves later the schema has the same six tables it started with: every new capability landed as columns on a model that held.

The decisions
decision 01 / 05
Claim the slot before you send
Reminders are driven by an external scheduler hitting the endpoint every minute, so overlapping and retried runs are a fact of life, not an edge case. Before sending anything, the run inserts a claim row keyed on medication, time slot, and notification kind, into a table with a unique constraint on exactly that key.
Two concurrent runs cannot both insert the same key; the loser gets a constraint error and skips. Zero duplicate sends is not a property of careful code, it is a property of the index. The in-memory check that runs first is just an optimization; correctness never depends on it.
Tradeoff taken: the guarantee is at-most-once, not exactly-once. A send that crashes after its claim is written stays marked as sent and never retries. I accepted that because the failure modes are not symmetric: a lost ping gets caught by the separate overdue escalation, while a doubled ping risks a doubled dose.
decision 02 / 05
Compute the supply, never store a counter
On-hand quantity is a derived number: units at last refill minus every dose given since. Logging a dose, editing it, undoing it, or skipping it can never desynchronize a counter, because there is no counter.
The forecast walks the schedule forward honoring cadence and per-day dosing, and handles meds stocked by volume but dosed in units, like U-40 insulin where a 10 ml bottle is 400 units. When the forecast dips below the threshold, the Running Low banner and its own deduplicated notification kind take over.
Tradeoff taken: every supply read recomputes over the dose history since the last refill, and the days-remaining forecast assumes the schedule is actually followed. More work per read, bought with zero risk of drift.
decision 03 / 05
Scan labels on the device, not on a server
The label scanner lazy-loads Tesseract.js in the browser, reads the photo in memory, and discards it. Nothing is uploaded, ever. A conservative parser lifts the med name, strength, and frequency into the form, translating pharmacy shorthand like twice-daily into concrete dose times.
Tradeoff taken: on-device OCR means a heavy lazy-loaded library and best-effort accuracy, so the parser deliberately only prefills a form the human must review. Worse extraction, in exchange for photos that never leave the phone and a vision-API bill of zero.
decision 04 / 05
Rent a corner of a shared database, then lock the door
The app shares a free-tier Postgres with another project, so isolation is structural: a dedicated schema, row-level security enabled on every table with zero policies granting anything, privileges granted only to the service role, and a client pinned to the schema. The public API key can see nothing at all.
Tradeoff taken: with row-level security set to deny-all, every read and write funnels through the server holding the service-role key, so there is no second line of defense below a server-side bug. For a single-household app I took the simpler trust model over defense-in-depth.
decision 05 / 05
Size the auth to the household
One shared passphrase unlocks a signed httpOnly session cookie, and a request-level guard verifies it on every route except the login page and the cron endpoint, which carries its own secret. It is the minimum viable gate, chosen on purpose and documented as such.
Tradeoff taken: no accounts means no per-user identity: who gave a dose is a free-text field, sessions last a year, and a leaked passphrase is full access. For two known users and one dog, an account system would have been ceremony.
The numbers
| Measure | day 2 | day 24 |
|---|---|---|
| Postgres tables | 6 | 6 |
| Runtime dependencies | 8 | 8 |
| Notification kinds, deduped independently | 1 | 3 |
| Schedule vocabulary | weekly days | intervals and phases |
| Supply tracking | none | derived forecast |
| Automated tests | 0 | 0 |
The first two rows are the ones I am proud of: the feature waves that added phased schedules like "daily for three weeks, then every other day" and the whole supply system landed as columns and functions on the day-two model. The last row is the one I am not, and it is in the next section.
What I would do differently
- Test the sharp parts. The dedup claim, the schedule expansion, and the supply forecast are exactly the kind of pure-ish logic unit tests are cheapest for, and the test count is zero. The claim-first pattern is safe by construction, but "the index saves me" deserves a test that proves it.
- Retry the lost sends. At-most-once was the right call, but the overdue escalation is a coarse backstop. Recording send failures on the claim row would allow a targeted retry without reopening the double-send door.
- Keep secrets out of query strings. The cron endpoint accepts its secret as a URL parameter as well as a header, which means it can end up in request logs. Header-only is one deleted line of tolerance away.