Skip to main content

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 requestSessionRpeEvent push 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 byReachesWhere
Device remindersthe phone itself, on a schedule the app registered earlierthe OS notification centretracking app
Notificationsthe server, over SSEan in-app feed with a bell and a badgeportal
Push (this page)the server, via APNs / FCMthe OS notification centretracking 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 localStorage enode.push.permissionAsked and 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​

HeaderValueSet by
bundle-idApp.getInfo().idApiConfigInit.tsx, during device-identity refinement
device-tokenthe plugin's registration tokenpush/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.

KeyMeaning
notificationIdthe Event.id — may be stale, see below
categorye.g. sessionUploadReadyEvent, licenseUpdateEvent
actionTypeopenSession / openCompetition / openLicense / openExportDownload / openSessionRpeQuestionnaire / none
linkUrlabsolute URL, for an export download
referenceIdthe 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 / referenceId only.
  • Use notificationId solely for a best-effort POST /events/<id>/read — for which markReadRequest already 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, not actionType. The action was added to the set later than the routing, and the push and the feed row could still diverge. Never on notificationId, 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.md records 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. openCompetition and openLicense imply categories nobody has named.