expo-notifications for two different things.
- Remote push from the server: coach messages, form and check-in nudges, plan updates, subscription notices.
- Local notifications scheduled by the app itself: the rest-over alert and two workout reminders.
src/lib/notifications.ts, with tap routing in src/lib/notificationRouting.ts.
Foreground presentation
At module load the file sets one handler for every notification that arrives while the app is open:Android channels
Channels are created lazily, each guarded by a module-level flag.ensureCoachChannel() runs before the push token is fetched. A push that arrives before the channel exists would land in the silent default channel and stay there.
The Android cardio notification has its own channel, created by the native module. See Live Activity and widgets.
Push registration
Endpoints
The token is an Expo push token from
getExpoPushTokenAsync, which needs the EAS project id from Constants.expoConfig.extra.eas.projectId. With no project id the upload is skipped.
When it runs
pushRegistered is a module-level latch that makes these no-ops after the first successful upload. It lives for the JS process, and sign out does not reload the bundle, so logout() and switchStudio() call resetPushRegistration(). Without that, the next trainee to sign in on the same install would never register.
retryPushRegistrationSilently() exists because the startup upload can fail on a dead network or a rate-limited server. It makes at most one attempt per 60 seconds and only when permission is already granted.
isPushGranted() counts iOS provisional authorization as granted.
Failures are logged with console.warn('[push] token registration failed', error) and otherwise ignored. Push is best effort.
Sign out
unregisterPushToken() runs during logout() while the bearer token is still set, so a phone that changes hands stops receiving the previous trainee’s pushes. It is raced against a 3 second timeout.
Simulators and Expo Go
Fetching a token throws where no push token exists. The error is caught and logged, and the app continues.Notifications off banner
features/home/components/NotificationsOffBanner.tsx renders on the home screen while the trainee is signed in and push permission is not granted.
- If the OS still allows the permission sheet (
canAskAgain), the button requests permission in place and registers the token. - Otherwise the button opens system settings with
Linking.openSettings(). - The close button hides it for 7 days. The dismissal time is stored under
perform_push_banner_dismissed_at, and a module-level flag keeps it hidden for the rest of the process. - It re-reads the permission on every return to the foreground, so it clears after the trainee flips the toggle in settings.
Tap routing
useNotificationRouting(enabled) is mounted in (app)/_layout.tsx and is enabled only when the trainee is signed in and not locked out.
It handles two cases:
- Cold start.
getLastNotificationResponseAsync()is read once per process, guarded by the module-levelcoldStartHandledflag. - While running.
addNotificationResponseReceivedListener.
target string in the notification’s data:
Local notifications use the same mechanism. The rest alert and both workout reminders carry
target: 'activeWorkout'.
Rest-over alert
When a rest timer starts, the app schedules a local notification for the moment it ends, so the trainee is told even with the phone locked.
The content sets
interruptionLevel: 'timeSensitive' and data: { kind: 'rest-timer', target: 'activeWorkout' }. The iOS entitlement com.apple.developer.usernotifications.time-sensitive is declared in app.json.
A received-notification listener removes older rest alerts whenever a new one arrives, so the tray never holds a stack of them. isRestAlert() recognises one by its kind, by the time-sensitive level, or by its channel id.
restAlertBlockReason() lets the workout UI explain a silent rest timer. 'permission' means notifications are off. 'channel' means the trainee muted the rest-timer channel in Android settings (importance NONE or MIN).
presentRestAlertNow covers Android timing. The scheduled alarm is inexact unless the app holds the exact-alarm permission, and JS timers pause in the background. When the app comes back and finds the rest has ended with no alert shown, it posts one immediately. android.permission.SCHEDULE_EXACT_ALARM is declared in app.json.
How the rest timer drives these calls is covered in Workout session engine.
Workout reminders
useWorkoutReminders() in features/workouts/hooks/useWorkoutReminders.ts runs for the whole signed-in session. Both reminders are scheduled on the device. The server only supplies their text and whether they are on.
Configuration
GET /v1/trainee/notification-configs returns a list of configs, cached for 10 minutes under the query key ['notification-configs']. The hook reads two types:
Each config supplies
enabled, titleTemplate, bodyTemplate and sound.
Scheduling
- The idle reminder is cancelled and rescheduled every time the completed set count changes. In effect it fires 60 minutes after the last logged set.
- The unfinished reminder is scheduled once when the last set is completed.
- Both are cancelled when the workout ends, when the config is disabled, or when the other condition takes over.
- Each effect uses a
cancelledflag so a reminder that finishes scheduling after its effect was cleaned up is cancelled straight away.
scheduleWorkoutAlert() on the workout-reminders channel, with data of { type, target: 'activeWorkout' }.
When the trainee turns off the Workout reminders group in notification preferences, the save invalidates ['notification-configs'] so the hook picks up the change without a restart.