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.
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 said | This says | Why |
|---|---|---|
| A custom zone from day one, ready for sharing | Default zone via automatic sync; the household is the logical seam | The 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 creator | An explicit caregiver field written at log time | The framework never surfaces the underlying record from inside a query. |
| One calendar trigger per dose, scheduled N days ahead | Two-day window, capped at 60 requests | The 64-request cap. A fourteen-day pre-schedule blows the budget on the first multi-pet household. |
| A fourteen-day pre-generated window | Fourteen days for dose rows, two days for notifications | The 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.
Get updates
Occasional notes on what's happening at Enginery. No spam, no marketing.