Skip to main content

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:

TriggerWhere
App startRemindersInit
App foreground / resume, reconnectregisterRevalidation in sync.ts (in a Capacitor WebView, visibilitychange is app resume)
A completed check-inthe submitted branch of wellness-check-in-sheet.tsx
The toggle flippingTrackingSettingsDrawer
Language change / translation catalogue loadRemindersInit
The app settings arriving or changingRemindersInit
Logoutlogout() 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":

StateWindow
Signed out · notification permission missing · device toggle offcleared — a definite answer
Profile or app settings not loadedleft 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 through translate at 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:

  1. the device is native and the OS permission is granted;
  2. the athlete opted in (device-local toggle in Settings);
  3. the shared eligibility rule passes (below);
  4. 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:

#ConditionSource
1userCheckinEnabled === trueuser_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.
2effectively 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
3a non-empty emailUserReturnDto

Per entry point, on top of that:

Entry pointExtra conditions
Pre-workout gate — before a training startstraining 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 notificationdevice opt-in · OS notification permission · not checked in that day
Dashboard card — Training Schedulenot 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 rowthat 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. closeCheckIn in apps/tracking/src/app/workouts/today/page.tsx is 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:

  • from and to are mandatory and inclusive, epoch ms. There is no unbounded query and no pagination — the window is the limit.
  • metric on the returned rows is always null (no eager loading), so the match is on metricID. The ids come from the metrics store via getWellnessMetricIds() (@enode/core/metrics/wellness).
  • An empty wellness id set means the catalogue hasn't loaded, not that no check-in exists. hasWellnessMeasurement returns 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 markCheckInSubmitted is called in the submitted branch and not in onCompleted.

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_COMPLETED plus a boot receiver that re-arms alarms after a reboot, and WAKE_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=+1h0m0s on 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, not USE_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 a warning saying 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 default channel — nothing to create.
  • Cold-start taps work: both platforms retain localNotificationActionPerformed until a listener consumes it, which is why the listener is attached from a root-level init component rather than a screen.
  • No iOS Info.plist key 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 regenerates ios/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​

ConcernFile
Pure policy — window, ids, datespackages/core/src/reminders/schedule.ts
Types (ReminderRule, ReminderContext)packages/core/src/reminders/types.ts
OS wrapper — permission, schedule, tapspackages/core/src/reminders/plugin.ts
Device-local opt-inpackages/core/src/reminders/preference.ts
The rulesapps/tracking/src/app/reminders/rules.ts
Orchestrationapps/tracking/src/app/reminders/sync.ts
Tap handler + startupapps/tracking/src/app/reminders/RemindersInit.tsx
Shared eligibility ruleapps/tracking/src/app/workouts/today/wellness/eligibility.ts
"Checked in today" lookupapps/tracking/src/app/workouts/today/wellness/check-in-status.ts
Dashboard card + dev overrideapps/tracking/src/app/workouts/today/today-workouts-pane.tsx · wellness/check-in-card-dev-flag.ts
Participant badge + its lazy storeapps/tracking/src/app/workouts/today/wellness/participant-chip.tsx · wellness/check-in-store.ts
The userCheckinEnabled togglepackages/ui/src/settings/settings-drawer.tsx (privilege-gated)
Measurements read clientpackages/core/src/api/measurements.ts
Wellness metric keys → idspackages/core/src/metrics/wellness.ts
Deep-link constantsapps/tracking/src/app/workouts/today/wellness/check-in-route.ts
Settings toggleapps/tracking/src/app/workouts/today/tracking-settings-drawer.tsx
Debug harnessapps/tracking/src/app/debug/reminders/