0009 — Local device reminders instead of server push
Status: Implemented (tracking app) · Date: 2026-08-19
Amended 2026-08-26 by 0010. The "no push infrastructure at all" premise below no longer holds, and the revisit this ADR anticipated has happened: OS push now exists alongside these local reminders. Everything else here stands — local notifications keep the wall-clock, condition-bearing, offline-capable case.
Context
Product wants the tracking app to nudge an athlete at 07:00 their local time
when a condition holds — first case: the account is trainable, has an email,
and has not completed a wellness check-in that day. Tapping the nudge should
open the check-in.
Nothing in the monorepo could do this. The only notification system was the
portal's in-app feed (packages/core/src/notifications/, SSE + REST), which is
read-only on the client, portal-scoped, and never leaves the web UI. There was
no push infrastructure at all: no FCM or APNs configuration, no notification
entitlement, no POST_NOTIFICATIONS, no device-token endpoint in the API, and
no service worker for the web fallback.
The deciding constraint is that 07:00 is a wall-clock time on the athlete's
own device. A server-side scheduler would have to know each user's timezone
and re-derive their local 07:00 across DST. The backend does receive a
timezone header on every request (api/client.ts), but that is a snapshot
from whenever the app last called — a weak foundation for a daily job.
Decision
Schedule the reminders on the device, with
@capacitor/local-notifications, and keep the whole feature client-side.
Consequences that follow directly:
- No backend work, no push services, no network at fire time. The OS holds the alarm; it fires in airplane mode and while the app is force-quit.
- The OS never runs our code at fire time. Both the condition and the copy are frozen when we schedule. This is the single constraint the design is built around.
- Therefore: keep a rolling 7-day window of pending notifications with
deterministic
(rule, day)ids, and re-materialise it on every foreground — plus on check-in, opt-in, locale change and logout. - Therefore: arm future days optimistically and disarm on the user's action. A check-in can only happen inside the app, so the app always gets the chance to cancel that day's occurrence.
Rejected: a repeating calendar trigger
schedule.on = { hour: 7, minute: 0 } maps to a true wall-clock trigger
(UNCalendarNotificationTrigger; Android recomputes from the local calendar
each time it re-arms) and would follow the device across timezones with no
re-sync. It cannot skip individual days, so it cannot express "unless you
already checked in" — the condition is the point of the feature. Dated
occurrences are absolute instants, so the timezone correctness comes from the
re-sync instead. The bounded failure — travel, then don't open the app before
morning — costs one reminder at the old zone's 07:00.
Rejected for now: waiting for a server-side answer
Superseded 2026-08-20 — see the amendment at the end. The read endpoints landed and the seam below was used as designed.
"Has this athlete checked in today?" cannot be asked of the backend:
@enode/core/api/questionnaire is submit-only and the /api/measurements/*
read side is unbuilt. The questionnaire service's own outbox is on a different
origin and unreadable from the host app.
So the host records the fact locally on the service's submitted message, behind
getLastCheckInDateKey() as a seam: when the read endpoints land, that one
function changes. Accepted cost — a check-in on another device won't suppress
this device's reminder.
Also decided
- Device-local opt-in, not
user_app_settings: the reminder is raised by this phone, so wanting it is a property of the device. Follows thetext-scale/training/flagslocalStorage pattern. - Contextual permission prompt, at the toggle. iOS grants exactly one, and this app has no deep link to system Settings to recover a denial.
Inexact alarms.Reversed 2026-08-20 — measurement showed the slack is an hour, not minutes. See the amendment.SCHEDULE_EXACT_ALARMis stripped from the merged Android manifest and every notification carriesisExactNotification: false, avoiding the Play Store exact-alarm justification for a nudge that does not need to-the-second delivery.
Consequences
- A second reminder is an object appended to
REMINDER_RULES— the policy is data, andplannedOccurrencesis a pure function with a unit-test suite. - The feature is native-only by construction; every entry point no-ops on web, so the portal is untouched.
- It cannot be verified in a browser.
/debug/reminders(dev builds) exposes the permission state, the planned window, what the OS actually holds, and a ten-second test notification. - If a future condition depends on someone else's overnight action (a coach assigning a workout), that case genuinely needs push, and this decision would be revisited rather than stretched.
See Device reminders for the implementation.
Amendment — 2026-08-20: the check-in state moved to the server
The /api/measurements/* read side shipped one day after this decision, so the
seam was taken:
GET /measurements/user/{id}?from=<local midnight>&to=<now>(both bounds inclusive, mandatory, no pagination), matching returnedmetricIDs against the wellness metric ids.metricis always null on that endpoint, so the id match is the whole test.check-in-record.tsand itslocalStoragekey are gone. The accepted cost named above — a check-in on another device not suppressing this device's reminder — is gone with it.
Two parts of the original reasoning survive unchanged, because they are about the scheduling model rather than the data source:
- Fail-open. A read that fails (offline, 403, backend down) arms the reminder. Same asymmetry as before: one redundant nudge beats no reminder on every offline morning.
- A small amount of client state is still needed — but in memory, not
storage. The submit is fire-and-forget through the service's outbox, so the
read usually runs before the write lands; a session-scoped
{ userId, dateKey }covers that window and is gone on restart.
The rest of this ADR — local notifications over push, the rolling window, device-local opt-in, contextual permission — is unaffected.
Amendment — 2026-08-20: exact alarms, reversing the "inexact is fine" call
The original decision claimed a nudge "may land a few minutes late, which is the
right trade". Measurement on an Android 15 device disproved the premise: the
07:00 alarms carried window=+1h0m0s in dumpsys alarm while the device was
awake and the app in the ACTIVE standby bucket — the friendliest conditions
available, and before overnight Doze. An hour of slack makes the reminder useless
as a routine cue, and makes the feature untestable: late and broken look
identical.
So the app now asks for SCHEDULE_EXACT_ALARM and schedules with
isExactNotification: true.
- Still not
USE_EXACT_ALARM. That one is auto-granted but Play reserves it for apps whose core function is alarms or calendars; a training app claiming it is a rejection risk. The user-granted permission carries no such policy requirement, which was the original reason for avoiding exact alarms and is the one concern that survives. - Both halves were needed. The plugin ANDs
isExactNotificationwithcanScheduleExactAlarms(), so the flag alone had already forced every alarm onto the inexact path — the permission was never even consulted. - Declining degrades, it does not break. The reminder still fires inside the
window,
schedule()reports the downgrade, and the settings copy drops from "at 7:00" to "every morning" rather than promising precision it cannot deliver.
A related correction to the test harness: the debug series was spaced 5 minutes apart, under Android's ~9-minute floor for allow-while-idle deliveries — which applies to exact alarms too. It measured the throttle, not the feature. Now 15.