Skip to main content

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 the text-scale / training/flags localStorage 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. SCHEDULE_EXACT_ALARM is stripped from the merged Android manifest and every notification carries isExactNotification: false, avoiding the Play Store exact-alarm justification for a nudge that does not need to-the-second delivery. Reversed 2026-08-20 — measurement showed the slack is an hour, not minutes. See the amendment.

Consequences​

  • A second reminder is an object appended to REMINDER_RULES — the policy is data, and plannedOccurrences is 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 returned metricIDs against the wellness metric ids. metric is always null on that endpoint, so the id match is the whole test.
  • check-in-record.ts and its localStorage key 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 isExactNotification with canScheduleExactAlarms(), 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.