Device reminders (tracking app)
Not to be confused with Push notifications — also OS banners in the tracking app, but raised by the server and delivered via APNs/FCM. These are scheduled by the phone itself. The two share an OS permission and nothing else; see that page for which is which.
The tracking app can raise an OS-level notification on the phone itself — a banner in the notification centre that arrives whether or not the app is open, and opens a specific screen when tapped. Today there is one: a daily nudge at 07:00 device-local time for athletes who haven't done their wellness check-in yet.
This is a different system from Notifications, the in-app feed of server-sent events. The two share no code and no backend: that one is server-generated and read inside the app; this one is scheduled by the device and delivered by iOS/Android.
They do meet in one place, without merging: the tracking app's notifications
drawer pins an "Open" card for a check-in that is still outstanding, worded
by wellnessCheckInRule.content() — the same rule that words the banner. It is
a rendering of the live condition, not a notification, and nothing about it
touches the notifications store. The sidebar bell badges the same condition, and
the Today screen's own check-in card was removed so the offer isn't made twice.
See Device reminders in the feed.
Why local notifications and not push
07:00 is a device-local wall clock, and the server does not reliably know where the athlete is. Local notifications sidestep that entirely, and with it the whole push stack: no FCM/APNs setup, no device-token registration endpoint (the backend has none), no per-user scheduler, no network at fire time.
The trade is the constraint everything below is shaped around:
The OS fires a payload we registered earlier. It never runs our code at 07:00. Both the condition and the wording are frozen when we schedule.
The model: a rolling window, re-materialised on every foreground
Because we cannot evaluate anything at fire time, the app instead keeps a 7-day window of pending notifications in the OS, and rebuilds it whenever something that feeds the decision changes:
| Trigger | Where |
|---|---|
| App start | RemindersInit |
| App foreground / resume, reconnect | registerRevalidation in sync.ts (in a Capacitor WebView, visibilitychange is app resume) |
| A completed check-in | the submitted branch of wellness-check-in-sheet.tsx |
| The toggle flipping | TrackingSettingsDrawer |
| Language change / translation catalogue load | RemindersInit |
| The app settings arriving or changing | RemindersInit |
| Logout | logout() in @enode/core/api/auth |
Each occurrence's id is derived from (rule, day) — see occurrenceId — so
re-scheduling replaces rather than duplicates, and the sync needs no bookkeeping
of its own: it is a plain "schedule the window, then cancel my ids that aren't
in it".
That order is a reliability property, not a detail. Cancelling first left a
gap in which the device held no reminders at all, and anything throwing in
between — a permission revoked since the check, a plugin call rejecting — ended
the run with the week wiped and nothing to restore it before the next
foreground. Scheduling first means a failure leaves the previous window
standing, the same trade the early returns below already make. It is safe
because both platforms replace a pending notification by id: iOS's
UNUserNotificationCenter.add overwrites a request with the same identifier,
and the Android plugin builds its PendingIntent with FLAG_CANCEL_CURRENT on
the id as request code.
Seven days means a reminder keeps arriving for a week without the app being opened, and stays far below iOS's cap of 64 pending notifications.
Cancelling is not the default — which states clear the window
applyReminderWindow is the only path out of the OS, so a state that never
reaches it leaves the existing week scheduled. Seven days of head start makes
that failure long-lived, and the sync therefore distinguishes two kinds of
"no":
| State | Window |
|---|---|
| Signed out · notification permission missing · device toggle off | cleared — a definite answer |
| Profile or app settings not loaded | left alone — absent is "can't tell", not "no" |
The device toggle belongs in the first group even though it is read late in
run(): it is a synchronous localStorage value the user set themselves, so it
needs no network and cannot be "unknown". It is therefore resolved before
the two profile reads — otherwise switching the reminder off while offline (or
before the settings arrive) would return early and strand the scheduled week.
The second group is why the app settings are a sync trigger of their own. A run that bailed out on an unloaded record is not retried by anything else, and their arrival is also how an account-level switch-off reaches the device: eligibility drops, the planned window comes out empty, and the pending reminders go with it.
It is silent, on purpose
No sound is set on the scheduled notification, so the reminder arrives as a
banner and nothing more on both platforms. A 07:00 nudge is not an alarm: it
asks for thirty seconds of the athlete's morning, and something that wakes a
household to do that is asking for the toggle to be switched off. The banner
waits in the notification centre, which is the right amount of insistence for a
prompt whose whole design elsewhere is "always cancellable, never a barrier".
Worth knowing because it is one line away from being wrong in either direction:
adding sound to applyReminderWindow makes every reminder audible, and there
is no per-rule override today.
Arm by default, disarm on the user's action
We cannot know whether tomorrow's check-in will happen, so future days are armed optimistically and today's occurrence is cancelled the moment a check-in is recorded. That works precisely because a check-in can only happen inside the app — so the app always gets its chance to cancel.
What the timezone guarantee actually is
Both platforms turn a dated at into an absolute instant (iOS builds a
UNTimeIntervalNotificationTrigger, Android an RTC alarm), so a pending
notification does not follow the device across a timezone change on its own.
The rolling re-sync is what makes it right: every foreground recomputes 07:00 in
the timezone the device is in now.
The bounded worst case: fly to another timezone and don't open the app before
the next morning, and that one reminder arrives at the old zone's 07:00. Opening
the app corrects the rest of the window. A repeating calendar trigger
(schedule.on) would be wall-clock-correct without the re-sync, but it cannot
skip individual days — which would cost us the "unless you already checked in"
condition, the more valuable half.
The rules
A rule is data (ReminderRule in @enode/core/reminders/types), so a second
reminder is an object appended to REMINDER_RULES in
apps/tracking/src/app/reminders/rules.ts — no new branches anywhere. Each rule
answers, ahead of time:
isEligible(context)— does this user get this reminder at all;armsOn(dateKey, context)— is it armed on this particular day;content()— the title and body, resolved throughtranslateat schedule time.
ReminderContext is a plain snapshot (user, checkInFeatureEnabled,
enabled, lastCheckInDateKey, now), which keeps every rule a pure function
and the whole policy testable in plain Node — see plannedOccurrences and its
suite.
The wellness check-in rule
Fires at 07:00 when all of the following hold:
- the device is native and the OS permission is granted;
- the athlete opted in (device-local toggle in Settings);
- the shared eligibility rule passes (below);
- no check-in is recorded for that local date.
Tapping it opens /workouts/today?checkin=1, which opens the check-in sheet
standalone — no workout start attached. The URL is the state: closing the
sheet strips the parameter, and a later tap puts it back, so no "already
dismissed" flag can get out of sync.
Who gets a check-in, and where from
The check-in has three entry points. They share one eligibility rule —
isCheckInEligible in
apps/tracking/src/app/workouts/today/wellness/eligibility.ts — because when
they each had their own, a reminder could fire for someone the dashboard card was
hiding itself from.
Shared eligibility — all three require all three:
| # | Condition | Source |
|---|---|---|
| 1 | userCheckinEnabled === true | user_app_settings; writable only with the canWriteCheckInSetting privilege. Absent reads as false — it is opt-in, and a backend predating the field is not consent. |
| 2 | effectively trainable — trainable === true or the Athlete role (isEffectivelyTrainable in packages/core/src/users/trainable.ts). An athlete can carry trainable: false until the profile is saved. | UserReturnDto |
| 3 | a non-empty email | UserReturnDto |
Per entry point, on top of that:
| Entry point | Extra conditions |
|---|---|
| Pre-workout gate — before a training starts | training alone · not checked in today. Fails toward asking while the answer is in flight: being asked twice costs seconds, skipping costs the measurement. |
| Daily reminder — 07:00 notification | device opt-in · OS notification permission · not checked in that day |
| Dashboard card — Training Schedule | not checked in today. Hidden while the answer is unknown — offering a check-in that turns out to be done is worse than showing the card late. |
| Participant badge — the training view's athlete row | that athlete has no check-in today. Only the account switch applies; email and trainable deliberately do not. |
Why a group start asks nobody
Starting a workout with other participants goes straight into training — no prompt for the starter, and no up-front check of anyone else.
An earlier version did check: it looked every participant up before the training could begin and named those missing in a dialog. That does one request per athlete, and sessions run with a couple of hundred of them — hundreds of concurrent requests standing between a coach and starting, capped by a deadline they would usually exceed, so it showed nothing anyway.
The badge replaces it. Each athlete is looked up as their chip renders,
deduped and cached for the session by wellness/check-in-store.ts — the same
shape @enode/core/profile-images already uses for the avatar in that very chip.
Nothing blocks the start, and answers arrive as the row draws.
Tapping a badge opens the questionnaire for that athlete — see Questionnaire service, "Answering for someone else". That is how athletes without a login get check-ins at all.
The badge is drawn only for a settled no. Loading and error draw nothing: it names a specific person, so "we haven't asked yet" must not look like "they didn't do it".
The Reminders section in tracking's Settings follows condition 1 too: there is no arming a reminder for a feature the account doesn't have.
It is always cancellable. Dismissing the check-in — X, Escape, hardware Back, or the sheet's own fail-open timeout — has no consequence: a workout waiting on it starts anyway. It is a prompt, never a barrier.
closeCheckIninapps/tracking/src/app/workouts/today/page.tsxis the single place all four exits converge.
Testing it: /debug/reminders prints userCheckinEnabled, trainable,
email and the resolved eligibility, so a silent "nothing is showing" is
diagnosable in one screen. "Force check-in card on dashboard" bypasses the whole
question — dev builds only.
Where "checked in today" comes from
The server. fetchLastCheckInDateKey() in check-in-status.ts asks
GET /measurements/user/{id}?from=<local midnight>&to=<now> and looks for a
measurement whose metricID is one of the wellness metrics. A check-in writes
one measurement per measure against nothing but the metric and the user (see
Questionnaire service), so a single wellness
measurement in today's window proves one happened — on any device, including
one this app has never run on.
Three properties of that endpoint shape the client:
fromandtoare mandatory and inclusive, epoch ms. There is no unbounded query and no pagination — the window is the limit.metricon the returned rows is always null (no eager loading), so the match is onmetricID. The ids come from the metrics store viagetWellnessMetricIds()(@enode/core/metrics/wellness).- An empty wellness id set means the catalogue hasn't loaded, not that no
check-in exists.
hasWellnessMeasurementreturns false for it, which lands on the fail-open branch below rather than silently cancelling everyone's reminder.
Fail-open on error
A failed read — offline, a 403, a backend blip — resolves to "not checked in", which arms the reminder. The failure modes are not symmetric: arming costs one redundant nudge on a day the athlete did check in, while the alternative costs the reminder entirely on every offline morning. It is the same trade the check-in sheet makes when its service won't load. The error is swallowed deliberately: it is not actionable by the athlete and must never surface as a toast.
The write-then-read window
The submit is fire-and-forget through the service's outbox, and the host
re-syncs the moment it sees submitted — so the read usually runs before the
write lands, and someone checking in at 06:30 would still be nudged at 07:00.
A module-level { userId, dateKey } closes that window: markCheckInSubmitted()
sets it, and the sync uses serverKey ?? sessionKey.
It is in memory only — not localStorage, gone on restart, by which point
the outbox has drained and the server answers for itself. Scoped by user id so a
second athlete on a shared device can't inherit it, which is also why it needs no
logout hook.
The fail-open path must never count as a check-in. The sheet also completes itself after a 4-second load timeout so a dead service can't block training. Marking that as a check-in would suppress a reminder for one that never happened, which is why
markCheckInSubmittedis called in thesubmittedbranch and not inonCompleted.
Permission
Requested contextually, when the athlete switches the reminder on — never at launch. iOS grants exactly one prompt, and a denial taken out of context can only be undone in system Settings, which this app has no deep link to. A denial leaves the switch off (it must never claim a reminder the OS will swallow) and shows an explanatory line, matching the dead-end-text convention the BLE and camera permission paths already use.
The setting is hidden entirely on web and for users the reminder can't apply to, rather than shown disabled — a switch that can never be flipped is a question the user cannot answer.
Platform notes
- Android permissions come from the plugin's own manifest and are merged in:
POST_NOTIFICATIONS(runtime, API 33+),RECEIVE_BOOT_COMPLETEDplus a boot receiver that re-arms alarms after a reboot, andWAKE_LOCK. - Exact alarms are required, and user-granted. Without them Android delivers
each reminder inside a window of its own choosing — measured on an Android 15
device as
window=+1h0m0son the 07:00 alarms, before Doze adds more overnight. A reminder that lands anywhere in an hour is not a routine cue, so the app asks for the "Alarms & reminders" special access.SCHEDULE_EXACT_ALARM, notUSE_EXACT_ALARM: the first is granted by the user, the second is automatic but Play reserves it for apps whose core function is alarms or calendars.- Both halves are needed. The plugin computes
isExactNotification && canScheduleExactAlarms(), so leaving the flag false opts out of exactness even where the permission is held. That was the original bug: the flag alone forced every alarm onto the inexact path. - Granted from Android 12–13, denied by default from Android 14 for apps
targeting SDK 33+ — which is this app. There is no prompt; the only route is
a full-screen system settings page, opened by
requestExactAlarmSetting(). - Declining is not fatal. The reminder still fires, just inside the window,
and
schedule()returns awarningsaying so. The settings row drops its "at 7:00" promise to "every morning" in that state rather than claiming a precision it can't deliver. - Revoking it later force-restarts the app and deletes its exact alarms; the foreground sync rebuilds the window on next launch.
- The channel is the plugin's own
defaultchannel — nothing to create. - Cold-start taps work: both platforms retain
localNotificationActionPerformeduntil a listener consumes it, which is why the listener is attached from a root-level init component rather than a screen. - No iOS
Info.plistkey is needed; notification permission is runtime-only. - OEM battery managers (Xiaomi, Huawei, some Samsung) can drop alarms for apps the user force-stopped. Nothing to do about it — worth knowing before it is filed as a bug.
- Adding the plugin needs
npx cap sync(iOS regeneratesios/App/CapApp-SPM/Package.swift, Android its gradle includes). The Android CI job already runs it.
Copy is frozen at schedule time
The title and body are handed to the OS when the notification is registered, so
they are baked in the language that was active then. A language switch or the
translation catalogue arriving after a cold start therefore re-runs the sync —
RemindersInit subscribes to both. Until the catalogue loads, translate serves
the bundled English baseline, as everywhere else.
Verifying
Unit tests cover the parts that can be pure: plannedOccurrences (eligibility,
the already-checked-in day, past instants, DST), localDateKey, occurrenceId
determinism and range, startOfLocalDay, hasWellnessMeasurement (including the
unloaded-catalogue case) and the session hint —
packages/core/src/reminders/schedule.test.ts and
apps/tracking/src/app/workouts/today/wellness/check-in-status.test.ts.
Everything else needs a device, because only the OS can tell you whether it kept
the alarm. /debug/reminders (dev builds only) shows the permission state, what
the rules would schedule, and what the OS actually holds. It reports the
server-derived and session check-in keys separately, so a device test can see
which of the two answered — the server key reading today on its own, after a
restart clears the session hint, is the proof the read endpoint works.
Two test schedules avoid waiting for 07:00. Both are unconditional — no rule,
no eligibility, no check-in record — and both use production's schedule options
(allowWhileIdle, inexact alarms), so they test how the OS treats our alarms
rather than just the plugin:
- Fire in 10s — one notification, for the tap → deep-link path.
- Every 15 min × 6 — 90 minutes of reminders, for the "does it fire while the app is closed" question. The spacing is deliberate: Android throttles allow-while-idle alarms to roughly one delivery per app per 9 minutes, whether or not they are exact, so a tighter cadence measures the throttle rather than the feature and makes a missing notification meaningless.
Their ids sit outside every rule's range (TEST_ID_BASE), so opening the app
mid-test doesn't sweep them away when the foreground sync replaces the real
window. "Cancel test series" clears them.
If a test notification is late or missing, check the Exact alarms row first —
anything but granted means the OS is delivering inside a window and the timing
tells you nothing. adb shell dumpsys alarm | grep -A2 <package> shows the
truth: an exact alarm has window=0, an inexact one shows its slack.
Code map
| Concern | File |
|---|---|
| Pure policy — window, ids, dates | packages/core/src/reminders/schedule.ts |
Types (ReminderRule, ReminderContext) | packages/core/src/reminders/types.ts |
| OS wrapper — permission, schedule, taps | packages/core/src/reminders/plugin.ts |
| Device-local opt-in | packages/core/src/reminders/preference.ts |
| The rules | apps/tracking/src/app/reminders/rules.ts |
| Orchestration | apps/tracking/src/app/reminders/sync.ts |
| Tap handler + startup | apps/tracking/src/app/reminders/RemindersInit.tsx |
| Shared eligibility rule | apps/tracking/src/app/workouts/today/wellness/eligibility.ts |
| "Checked in today" lookup | apps/tracking/src/app/workouts/today/wellness/check-in-status.ts |
| Dashboard card + dev override | apps/tracking/src/app/workouts/today/today-workouts-pane.tsx · wellness/check-in-card-dev-flag.ts |
| Participant badge + its lazy store | apps/tracking/src/app/workouts/today/wellness/participant-chip.tsx · wellness/check-in-store.ts |
The userCheckinEnabled toggle | packages/ui/src/settings/settings-drawer.tsx (privilege-gated) |
| Measurements read client | packages/core/src/api/measurements.ts |
| Wellness metric keys → ids | packages/core/src/metrics/wellness.ts |
| Deep-link constants | apps/tracking/src/app/workouts/today/wellness/check-in-route.ts |
| Settings toggle | apps/tracking/src/app/workouts/today/tracking-settings-drawer.tsx |
| Debug harness | apps/tracking/src/app/debug/reminders/ |