Enginery
Artifacts / Architecture

Pet Med Reminder: the v1.0 architecture

iOS allows 64 pending local notifications per app. Two pets with three twice-daily items each over fourteen days is 168, so the dose rows and the notifications need different horizons.

Made

20 May 2026

Format

Architecture proposal, written 2026-05-20

Written before the first line of the rewrite. Local data with the platform’s own sync, no backend, no accounts. The forcing function: “the dog, and a dental treat every seventh day” has to read naturally end to end.

Five entities

A household, pets, care items, dose entries, and frequency as an embedded value rather than a stored row.

The household exists in v1.0 as a single invisible row with no interface anywhere. Every pet belongs to it. That is the seam for partner sharing later, and without it, adding a second caregiver would need a data migration in a shipped app with real users’ history in it. It costs one table and nothing else.

A care item carries a name, a free-text dose label, a start date, an optional end date, a frequency, and an active flag. A dose entry carries the time it was due, a status, the time it was actually given, and who gave it.

The end date is the field that four months later turned out to be unreachable from the screen. The engine below was written to respect it from day one; the form never grew a control for it. See let a course end.

Sync, and the thing the platform will not do yet

The honest version: the platform’s automatic sync writes into the private database’s default zone, and it does not expose custom-zone configuration. Sharing requires a custom zone. So automatic sync cannot be wrapped in a share today.

The decision is to ship on automatic sync anyway and treat the household as the future sharing seam, so the schema is logically share-ready even though the zone is not. If the platform ships custom zones and sharing later, migrate. If it does not, the fallback is a hand-rolled sync for the household subtree, leaving the rest alone. The data model does not change either way. That is the real cost of using the convenient sync path, written down rather than discovered later.

Caregiver identity is written by hand rather than read from the record’s creator, because the framework abstracts the underlying records away and never exposes it. An explicit field is queryable, lives in our own schema, and behaves identically with one user and with two.

Dose generation

Pre-generate a rolling fourteen-day window of dose rows. Short enough to fit, long enough that the Today screen, the History screen and the notification scheduler all read the same source of truth instead of each recomputing the schedule.

Regenerated on launch, on any schedule edit, and on a daily background refresh. Idempotent by set membership on the scheduled time, so running it twice over the same window does nothing.

Past pending rows are never touched by this. They stay pending and surface as missed until somebody backfills or skips them. A generator that tidies up history is a generator that loses it.

Notifications, and the number that shapes them

iOS allows 64 pending local notifications per app. Two pets, three items each, twice daily, over the fourteen-day dose window is 168. It does not fit.

So the notification window is two days, not fourteen, capped at 60 requests to leave headroom, cleared and rebuilt on every foreground and on the background refresh. The dose rows and the notifications have different cost curves and different limits, and conflating them is what breaks on the first multi-pet household rather than in testing.

Each notification carries the dose id and three actions: mark given, skip, snooze an hour. Snooze schedules a fresh request an hour out with the same category and the same dose id, so a second snooze re-snoozes from there, and the dose stays pending until somebody picks given or skipped.

Repeating triggers were considered and rejected. A repeating calendar trigger matches one recurring pattern, which is fine for “daily at 8am” but fragments for “every 7 days” and “Mondays, Wednesdays and Fridays,” and the fragments lose the link back to a specific dose. One request per dose over a short window keeps that lookup unambiguous.

Frequency

One value type with three cases: daily at a set of times, every N days at a set of times, and specific weekdays at a set of times. Stored as JSON on the care item rather than as its own table.

The “every N days” case is anchored on the care item’s start date, not on today. The generator computes ticks from the anchor, so re-running it never shifts the cadence, and editing an item next month does not silently move a weekly treat to a different day.

Photos

The pet photo is the disambiguator at six in the morning, so it has to sync, but it does not have to be instant on first launch.

Two representations: a thumbnail of about 30 KB stored inline on the pet record and synced with it, which is what every list and card renders, and the full-resolution photo synced lazily as an asset, used only on the pet detail screen. If the asset path proves unreliable during the build, v1.0 ships thumbnail-only, because the disambiguation job is fully served by the thumbnail.

Where this diverged from the brief that preceded it

The brief saidThis saysWhy
A custom zone from day one, ready for sharingDefault zone via automatic sync; the household is the logical seamThe framework does not expose custom zones. Forcing one means dropping out of automatic sync for the entire schema.
Read the caregiver from the record’s creatorAn explicit caregiver field written at log timeThe framework never surfaces the underlying record from inside a query.
One calendar trigger per dose, scheduled N days aheadTwo-day window, capped at 60 requestsThe 64-request cap. A fourteen-day pre-schedule blows the budget on the first multi-pet household.
A fourteen-day pre-generated windowFourteen days for dose rows, two days for notificationsThe brief conflated two things with different limits.

Questions this left open

  • Is the custom-zone limitation still true? Worth thirty minutes to confirm rather than inherit from the docs. If the platform shipped it without fanfare, the future migration disappears and the right move is a custom zone today.
  • Full-resolution photos in v1.0, or thumbnail only? The recommendation is to try the asset path and fall back, but that is a decision, not a default.
The rest of the argument

Get updates

Occasional notes on what's happening at Enginery. No spam, no marketing.