Push notifications (tracking app)
The tracking app can receive server-sent push — a notification raised by the backend and delivered by the operating system, whether or not the app is running, and even if it has been killed.
Status: one category routed, the rest logged. Registration, the identifying headers and both native platforms are wired. A
requestSessionRpeEventpush opens the session-RPE questionnaire; every other push is silent and recorded to an in-memory log visible only under/debug. Nothing is marked read yet. See What it does when a push arrives.
Three notification systems, and which is which
This is the third, and the names are close enough to be genuinely confusing:
| Raised by | Reaches | Where | |
|---|---|---|---|
| Device reminders | the phone itself, on a schedule the app registered earlier | the OS notification centre | tracking app |
| Notifications | the server, over SSE | an in-app feed with a bell and a badge | portal |
| Push (this page) | the server, via APNs / FCM | the OS notification centre | tracking app |
The distinctions that matter:
- Reminders vs push. A reminder's content is baked into the OS ahead of time — the app decides what and when, then the OS fires it with no further involvement. A push is composed by the server at the moment it happens. So a reminder can nudge at 07:00 with no network; only a push can say "your upload finished".
- The portal's feed vs push. The feed is foreground-only and portal-only. ADR 0007 named the gap explicitly: "Waking a backgrounded / killed app still needs FCM / APNs." This is that.
- Reminders and push share an OS permission. One app-wide notification permission covers both. Enabling either enables the other, and there is no way to hold one without the other. This is load-bearing for the UX below.
When the permission is asked
Once, on the first authenticated launch —
push/first-run.ts, driven by
PushInit. Not from a settings row.
- Authenticated, not literally first open. Push needs a session to register against, and a dialog over the login screen asks for something the app cannot yet act on.
- Once, ever. Guarded by
localStorageenode.push.permissionAskedand by the OS state — a permission already granted or denied is settled and is never re-asked. The flag is what stops Android, which unlike iOS may re-show the dialog, from nagging on every launch. - A decline is final in-app. Neither platform lets the app re-open that dialog, so the Settings drawer's notification row is a status row only: it reports the OS's answer and, when it is "no", points at device settings. There is deliberately no "Turn on notifications" button — on iOS it would visibly do nothing.
This replaced a contextual ask behind a button in Settings, which most users never found: the account stayed unreachable and the server had nowhere to send a check-in or session-RPE prompt.
The portal is untouched by any of this, and its channel wire format stays
portal|mobile.
Transport
iOS talks to APNs directly, Android to FCM, both behind
@capacitor/push-notifications, which wraps them in one JS API. Nothing above
packages/core/src/push/plugin.ts knows
or needs to know which is in play — the only visible difference is the shape of
the token (hex on iOS, an opaque string on Android).
The contract
Fixed by the backend's v2 spec. Header names and payload keys are not ours to rename.
Two request headers, native only
| Header | Value | Set by |
|---|---|---|
bundle-id | App.getInfo().id | ApiConfigInit.tsx, during device-identity refinement |
device-token | the plugin's registration token | push/plugin.ts, when registration completes |
Both ride in the cfg.isNative branch of standardApiHeaders()
(api/client.ts). Unlike their neighbours
there they are CORS-allow-listed server-side; they live in that branch because
only a native build can have them.
Two things worth holding on to:
- Android receives nothing without
bundle-id. The backend maps it through a registry to decide targeting and transport, and its legacy fallback covers only iOS/watchOS. - Both are upserted only-when-present. Absence never erases a stored value, which is what makes it safe that every request between launch and registration carries neither. A regression here shows up as a second app start nulling the session's token.
The bundle id is read from the native bundle, not from capacitor.config.ts's
appId — the two differ. The real ids are com.enode.enodeone /
com.enode.enodeone.dev on Android and com.BMSportsTechnology.Vmaxpro on
iOS; appId is ai.enode.tracking and is registered with nobody.
The payload
Arrives in notification.data — APNs custom top-level keys, the FCM data
block. Every value is a string, and nil keys are omitted entirely.
| Key | Meaning |
|---|---|
notificationId | the Event.id — may be stale, see below |
category | e.g. sessionUploadReadyEvent, licenseUpdateEvent |
actionType | openSession / openCompetition / openLicense / openExportDownload / openSessionRpeQuestionnaire / none |
linkUrl | absolute URL, for an export download |
referenceId | the entity the action refers to, e.g. a session id |
actionType is a closed set of six. category is not enumerated at all —
the backend documents two by example and has never published the full list.
That asymmetry is why
push/payload.ts parses category as an
open string while narrowing actionType to a known value or null: an
unrecognised or future action must never drop an otherwise-valid push.
The 404 rule
The server's replaceExistingWithSameCategory deletes older Events of the same
category. So a tap on an older banner can name an Event that no longer exists.
- Route on
category/actionType/linkUrl/referenceIdonly. - Use
notificationIdsolely for a best-effortPOST /events/<id>/read— for whichmarkReadRequestalready exists — and swallow every failure, a 404 above all.
Nothing marks read yet, so nothing is exposed to this today. It is written down for whoever adds it.
The OS may also coalesce banners of one category (the server sets collapse keys). No client action needed.
What it does when a push arrives
requestSessionRpeEvent — the one category it acts on
A tapped push with category === "requestSessionRpeEvent" opens the
session-RPE questionnaire, via the query-param deep link in
session-rpe-route.ts
(/workouts/today?sessionrpe=1). Same pattern as the wellness reminder's
check-in-route.ts, and for the same reason: the thing that navigates and the
page that consumes it live far apart, so the string is shared rather than
written twice. It also survives a cold start, which matters — this notification
arrives about thirty minutes after a workout, when the app is almost certainly
closed.
This closes the gap that
questionnaire-service.md recorded: that prompt
was always meant to come from the server, not from the tracking app.
Whether the push is sent at all is a server decision, taken from the account's
sessionRpeEnabled user app setting — the session-RPE counterpart to
userCheckinEnabled (see device-reminders.md). Its
switch lives in Settings → App settings, behind the canWriteSessionRPESetting
privilege. The client only reads and writes the flag: nothing here gates on it,
so a requestSessionRpeEvent that arrives is always acted on.
The push is the second entrance, not the only one. The same prompt also lands
in the in-app feed as a notification with actionType: "openSessionRpeQuestionnaire", and tapping that row opens the same sheet — which
is what the dismissed-notification case now relies on, since there is no
dashboard card any more. Both entrances converge on one exit: completing the
questionnaire marks the unread prompt read, resolved by lookup, so a push-opened
rating clears the feed row too. See
notifications.md.
Three deliberate choices:
- Routed on
category, notactionType. The action was added to the set later than the routing, and the push and the feed row could still diverge. Never onnotificationId, which can name an event the server has already deleted. - On tap only. A push that merely arrives while the athlete is mid-set must not throw a questionnaire over their workout. The OS banner is the invitation; opening it is their decision.
- Closing returns to the entry route, not to a stripped URL — a push
launched the app into this screen and there is nothing behind it. Mirrors
closeCheckIn.
Everything else — silent, and logged
Recorded to a bounded in-memory log
(push/log.ts, 20 entries, newest first)
and nothing else happens. The OS banner has already told the athlete; a
second in-app surface on top of it is noise.
The log is read by /debug/server-notifications, which shows each entry's
category, arrival path, whether the app acted on it, and the whole raw object.
Recording is unconditional; only the SURFACE is debug-gated. That is deliberate — it means the screen shows pushes that arrived before it was opened, which is the normal case for something that fires while the app is closed. The cost is a handful of objects in memory.
A payload that fails to parse is still logged, with its raw object intact. A push we cannot read is the most interesting thing there is to see.
This replaced a drawer that opened over the app on every push. That made unhandled categories visible, but interrupted the app for a notification nobody had written handling for — the wrong trade in any build someone else might run.
Still unimplemented, per the handoff: openExportDownload opening linkUrl via
@capacitor/browser, and the best-effort mark-as-read. openSession also needs
somewhere to land — the tracking app has no session-detail screen, and its
static export forbids dynamic route segments, so that target would follow the
query-param pattern in
check-in-route.ts.
Seeing the debug area at all
isDebugViewEnabled() is NODE_ENV !== "production" unless
NEXT_PUBLIC_ENABLE_DEBUG_VIEW says otherwise — and npm run build is a
production build. So a device build has the debug area compiled out by
default: /debug 404s and the bug icon doesn't render. Build with the flag:
NEXT_PUBLIC_ENABLE_DEBUG_VIEW=true npm run build tracking
Automated
The pure parts are covered:
payload.test.ts pins the leniency
(unknown actionType → null without dropping; unknown category accepted;
omitted keys → null), and client.test.ts pins that both headers are sent on
native, absent on web, and absent before registration.
The plugin layer is deliberately untested, following reminders/, which has no
plugin.test.ts either: everything worth asserting lives in the pure modules.
End to end needs the backend deployed, an APNs key for Sandbox and the Firebase
service-account JSON. Until then, what is verifiable on-device is that
registration reaches the OS at all — the settings row reflects the real
permission, and granting it produces a token visible in the Xcode console or
logcat. A token means APNs/FCM accepted us; whether anyone can send to it is
the backend's half.
Out of scope
Silent/background push (needs an iOS background mode; the backend deleted its
silent path anyway), a notification inbox in the tracking app, any portal
change, and Web Push. Logout hygiene relies on the backend's session teardown —
if logout() deletes the backend device session, the token dies with it.
Open
- Nothing invokes the session-RPE questionnaire. No action and no category
covers it, but
questionnaire-service.mdrecords that prompt as explicitly not the tracking app's to own — "an external backend will send a notification once a workout has been complete for thirty minutes". That needs a category, an action, and a routing target. Raise with the backend. - The full category list is unpublished.
openCompetitionandopenLicenseimply categories nobody has named.