Notifications — Feature Spec & Implementation Plan
Status: Approved — ready to implement · Author: Wes / Claude pair · Last updated: 2026-06-14
Locked decisions (2026-06-14):
- Reminder delivery: hybrid local + server backfill.
- Permission prompt: soft-ask first, OS prompt only after user accepts in-app.
- Web push (Phase 4): deferred to a later release. v2.x is mobile-only.
- v2.x scope: Phases 0 + 1 + 2 + 3 + 5 — messages AND reminders ship together.
Cross-platform push & in-app notifications for the TrickBook ecosystem (iOS, Android, Web). First two categories shipping in v2.x: Messages and Trick reminders. Per-category opt-in/out toggles in user preferences on every platform.
This document is the source of truth for what we are building, why, and how — written after a deep research pass over current (2025-2026) Apple, Google, MDN, and Expo guidance. Citations live at the bottom.
1. Goals & Non-Goals
Goals
- Reliable push delivery for direct messages (Socket.io chat already in backend) on iOS, Android, and Web.
- Reliable scheduled trick reminders ("Hey, you haven't landed
kickflipon yourstreet trickslist this week — go session it.") on iOS and Android. Web reminders are a stretch goal in this round. - Per-category preferences (Messages on/off, Reminders on/off, plus per-list reminder cadence) editable from both the mobile profile screen and the web account settings page.
- Backend that respects OS-level state (cleans up dead tokens, never spams users whose OS permission is denied).
- Foundation that lets us add new categories later (likes, comments, friend requests, weekly digest) without re-architecting.
Non-Goals (this round)
- Marketing/broadcast notifications (no campaign tool, no segmentation engine).
- Rich-media notifications (images, action buttons) — passive payloads only for v1.
- iOS Live Activities / Dynamic Island / Lock Screen Widgets.
- Notification history / inbox UI inside the app.
- A/B testing of copy or cadence.
- Server-Sent Events / web push for messages (web messages will still use the existing Socket.io connection while the page is open; web push is reserved for reminders and out-of-tab events in a later phase).
2. User Stories
Messages
- US-M1 As a logged-in user, when another user sends me a DM and the TrickBook app is backgrounded or closed, I receive a push notification with the sender's display name and a one-line message preview, so I can tap to open the conversation.
- US-M2 As a logged-in user, when I have the app in the foreground, I do not see a system notification banner for a message in the chat I'm already viewing; I see the in-app inline indicator instead.
- US-M3 As a logged-in user, I can mute message notifications entirely from my profile preferences without affecting other categories.
- US-M4 As a logged-in user signed in on multiple devices, message pushes are delivered to all of them; tapping one and reading the message clears the badge/banner on the others (best-effort; badge sync is OS-permitting).
- US-M5 As an iOS user with Focus mode on, message notifications respect Focus by default (
activeinterruption level) unless I have explicitly marked the sender as a VIP (deferred — out of scope this round). - US-M6 As a sender, my own messages never push back to my own devices.
Trick reminders
- US-R1 As a user with a trick list (e.g. "Street goals"), I can opt into periodic reminders to land the unfinished tricks on that list.
- US-R2 Reminder cadence is per-list, user-chosen: off / daily / 3× week / weekly. Default for new lists: weekly.
- US-R3 Reminders fire in my local timezone between a configurable quiet-hours window (default 9:00–20:00 local).
- US-R4 A reminder names one trick at a time (the longest-outstanding unfinished trick on that list) and deep-links me into the trick's detail / video page.
- US-R5 From a reminder I can "snooze 1 week" or "mark this trick as done" directly (action buttons — stretch; falls back to deep link).
- US-R6 As a user, I can globally disable reminders from profile preferences regardless of per-list cadence.
- US-R7 Reminders do not fire for lists I haven't opened in 60+ days (auto-pause to prevent decay-fatigue), and the app prompts me on next open: "Resume reminders for [list]?"
Preferences
- US-P1 As a user, on iOS, Android, and Web, my profile preferences page exposes one toggle per category (Messages, Reminders) plus the per-list reminder cadence selector.
- US-P2 As a user who has denied the OS-level notification permission, the in-app toggles still work but render a banner: "Notifications are turned off in your phone settings — [Open Settings]". Tapping deep-links to the system app-settings page.
- US-P3 As a user, preference changes propagate across devices within seconds (preferences live server-side, mobile and web read from the same
/users/me/notification-preferencesendpoint). - US-P4 As a user, when I log out, my push tokens for that device are invalidated server-side so a future user on the same device doesn't receive my notifications.
- US-P5 As a brand-new user just signing up, I am not prompted for notification permission immediately. We use the soft-ask pattern: ask in-app first ("Want a heads-up when friends message you?"), then trigger the OS prompt only if they say yes.
3. UX Flows
3.1 First-run permission flow (mobile)
- User signs up / logs in. No OS prompt yet.
- After they reach the home tab (or after their first trick interaction — whichever comes first), a non-blocking in-app sheet appears: "Get a heads-up when your homies message you?" with "Yes, notify me" and "Not now".
- If "Yes" → call
Notifications.requestPermissionsAsync()→ OS prompt → on grant, register push token with backend. - If "Not now" → store
softAskDeferredAtlocally; offer again no sooner than 7 days later, and only on a contextually relevant moment (e.g., "you got a new message" toast in-app). - If OS-denied → record
osPermission: deniedin user prefs; do not re-prompt programmatically. Profile screen instead shows the "Open Settings" banner.
Why this matters: Both Apple and Google explicitly recommend the soft-ask pattern. Asking on app launch results in ~50% denial rates and the OS won't let you re-ask. (See Sources §1, §3.)
3.2 Notification preferences screen (mobile)
┌──────────────────────────────────────────────┐
│ Notifications [⨯] │
├──────────────────────────────────────────────┤
│ [ ⚠ Notifications are off in iOS Settings ] │← shown only if OS-denied
│ [ Open Settings → ] │
│ │
│ Push │
│ ────────────────────────────────────────── │
│ Direct messages [ ●━━━ on ] │
│ Trick reminders [ ●━━━ on ] │
│ │
│ Quiet hours │
│ ────────────────────────────────────────── │
│ Don't notify between 9:00 PM – 9:00 AM
│ │
│ Per-list reminder cadence │
│ ────────────────────────────────────────── │
│ Street goals Weekly › │
│ Tranny tricks Daily › │
│ Snowboard 25/26 Off › │
└──────────────────────────────────────────────┘
- The "Open Settings" banner appears only when OS permission is denied and the user toggled at least one category on. Behavior matches Slack / WhatsApp.
- Quiet hours apply to reminders only, not messages (people expect real-time messaging).
- Per-list cadence list is paginated/scrollable; only lists the user owns or has saved are shown.
3.3 Notification preferences (web)
Mirror of mobile, lives at /account/notifications in TrickBookWebsite. The "Open Settings" banner is replaced with: "Allow notifications in this browser" (button calls Notification.requestPermission()). On iOS Safari, render an install-as-PWA prompt because Safari refuses to even surface the permission prompt unless the site is installed to home screen (Sources §7).
3.4 Receiving a message push (iOS)
- Payload includes
threadId = conversationIdso iOS groups all messages from the same conversation under one stack. (Sources §2) - Tap → deep links into
/messages/[conversationId]. - Badge count = unread-conversations count (server tracks it; we update on each send and on read receipts).
3.5 Receiving a reminder
- Title:
Time to send it 🛹 - Body:
You've still got [trickName] on [listName] — go land it. - Tap →
/spots-or-trick-detail/[trickId]?listId=[listId]&fromReminder=1(thefromReminderquery param lets analytics attribute completions). - Action buttons (stretch):
[Mark landed][Snooze 1 week].
4. Data Model
4.1 MongoDB — users collection additions
Existing users currently store expoPushToken as a single string in a legacy in-memory store that is not wired to the real Mongo users collection — this stub will be removed. Replace with:
// users document — new fields
{
// ... existing fields
notificationPreferences: {
messages: { push: true, inApp: true, email: false },
reminders: { push: true, inApp: false, email: false },
quietHours: { start: '21:00', end: '09:00', timezone: 'America/New_York' },
osPermission: { ios: 'unknown', android: 'unknown', web: 'unknown' }, // 'granted'|'denied'|'unknown'|'provisional'
updatedAt: ISODate
}
}
4.2 New collection — pushTokens
One document per (user, device). Keeping these in their own collection (not embedded on users) so a single user with 5 devices doesn't bloat the user doc and so token cleanup is a simple delete.
{
_id: ObjectId,
userId: ObjectId, // indexed
platform: 'ios'|'android'|'web',
transport: 'expo'|'fcm'|'apns'|'webpush',
token: String, // ExpoPushToken[…], FCM registration id, or web push subscription JSON
endpoint: String, // web-push only — endpoint URL
keys: { p256dh: String, auth: String }, // web-push only
appVersion: String,
deviceModel: String,
locale: String,
timezone: String, // IANA, e.g. "America/Los_Angeles"
lastSeenAt: Date,
createdAt: Date,
// marked dead by receipt poller — kept ~30 days for debugging then purged
deadReason: 'DeviceNotRegistered'|'410-Gone'|null,
deadAt: Date|null
}
// Indexes
db.pushTokens.createIndex({ userId: 1, platform: 1 })
db.pushTokens.createIndex({ token: 1 }, { unique: true })
db.pushTokens.createIndex({ deadAt: 1 }, { expireAfterSeconds: 2592000 }) // 30d TTL on dead tokens
4.3 New collection — scheduledNotifications
Lightweight queue for server-scheduled reminders. (Local scheduled notifications on the device are still preferred for the next-firing-soon case; this collection handles long-horizon scheduling so a user who reinstalls the app doesn't lose their reminder.)
{
_id: ObjectId,
userId: ObjectId,
category: 'reminder',
listId: ObjectId,
trickId: ObjectId,
scheduledFor: Date, // UTC, indexed
status: 'pending'|'sent'|'cancelled'|'failed',
idempotencyKey: String, // hash(userId+listId+trickId+scheduledFor) — prevents dup sends
attemptCount: Number,
lastAttemptAt: Date,
createdAt: Date
}
db.scheduledNotifications.createIndex({ scheduledFor: 1, status: 1 })
db.scheduledNotifications.createIndex({ idempotencyKey: 1 }, { unique: true })
db.scheduledNotifications.createIndex({ userId: 1, listId: 1 })
4.4 New collection — notificationDeliveryLog (optional, debug)
Append-only audit log keyed by expoTicketId. Useful when debugging "I didn't get the message" complaints. 30-day TTL.
5. API Endpoints
All under /api. All require auth middleware unless noted.
Tokens
POST /push-tokens— register/upsert. Body:{ token, platform, transport, appVersion, deviceModel, timezone, locale }. Server upserts bytoken(unique). Returns 201.DELETE /push-tokens/:token— explicit logout / device removal.DELETE /push-tokens?platform=web&endpoint=...— web-push unsubscribe.
Preferences
GET /users/me/notification-preferences— returns thenotificationPreferencessubdocument.PATCH /users/me/notification-preferences— partial update. Body:{ messages: { push: false }, quietHours: { start: '22:00' } }. Server merges, validates with Joi.GET /users/me/reminder-cadence— returns[{ listId, cadence }]per saved list.PUT /users/me/reminder-cadence/:listId—{ cadence: 'off'|'daily'|'3x-week'|'weekly' }. Server (re)plansscheduledNotificationsrows for the next 30 days.
Web push
GET /push/vapid-public-key— returns{ publicKey }. Skip-auth (public, needed before subscribe).POST /push-tokens(same endpoint as above) handles the web push subscription JSON whentransport='webpush'.
Admin / debug (auth-admin only)
POST /admin/notifications/test—{ userId, category, body }sends a test push to all of that user's live tokens. Useful for support tickets and QA.
6. Backend Architecture
6.1 Sender service
A single services/notificationSender.js module is the only thing that calls expo-server-sdk. Every other caller (messages route, reminder cron, admin test endpoint) goes through it.
// pseudocode
async function send({ userId, category, title, body, data, threadId, channelId, interruptionLevel }) {
const prefs = await getPrefs(userId);
if (!prefs[category]?.push) return { skipped: 'in-app-pref-off' };
if (category === 'reminder' && inQuietHours(prefs)) return { skipped: 'quiet-hours' };
const tokens = await getLiveTokens(userId);
if (tokens.length === 0) return { skipped: 'no-tokens' };
const messages = tokens.map(t => buildMessageForPlatform(t, { title, body, data, threadId, channelId, interruptionLevel }));
const chunks = expo.chunkPushNotifications(messages); // ≤100 per chunk per Expo docs
const tickets = await sendChunksWithRetry(chunks);
await persistTickets(tickets, userId, category);
}
Key correctness points (all verified in research):
- Always chunk via
expo.chunkPushNotifications()— Expo enforces a hard 100-per-request cap. - Always run
Expo.isExpoPushToken(t)first — drop malformed tokens with a warn-log. - Never await in a
forEach(the currentpushNotifications.jsdoes this — bug to fix; tickets are silently dropped). - Persist ticket IDs so the receipts worker can look them up.
6.2 Receipts worker
A cron (every 15 min) that fetches receipts for tickets older than 30 min:
const receiptChunks = expo.chunkPushNotificationReceiptIds(pendingIds);
for (const chunk of receiptChunks) {
const receipts = await expo.getPushNotificationReceiptsAsync(chunk);
for (const [id, r] of Object.entries(receipts)) {
if (r.status === 'error' && r.details?.error === 'DeviceNotRegistered') {
await pushTokens.updateOne({ /* match ticket→token */ }, { $set: { deadAt: new Date(), deadReason: 'DeviceNotRegistered' } });
}
// log MessageRateExceeded, MessageTooBig, MismatchSenderId for ops visibility
}
}
Expo retains receipts at least 24h; we poll well within that window.
6.3 Reminder scheduler
Hybrid local + server approach — for each list with cadence ≠ off:
- Server pre-plans the next 30 days of
scheduledNotificationsrows when cadence changes or a new trick is added to a list. Each row has a UTCscheduledForcomputed from user's quiet-hours window + cadence + tz. - Mobile app on launch queries
GET /users/me/scheduled-notifications?within=14d, then usesNotifications.scheduleNotificationAsync()to register them as local notifications on-device. Local notifications survive backgrounding, kill, and offline. - Server cron fires any rows that local scheduling missed (user uninstalled and reinstalled, multi-device user where one device is offline).
- De-dup: every fire path uses the
idempotencyKey— local notifications include it asdata.idempotencyKey, and the server marks the rowsentonly after one path confirms delivery.
Why hybrid: local-only loses the reminder on uninstall and doesn't sync across devices. Server-only is subject to Doze/App Standby on Android (delivery delayed up to ~2h on aggressive battery) and silently fails on Web until the next foreground. Hybrid covers both.
6.4 Web push backend
- Add
web-pushnpm package. - Generate VAPID keys once with
web-push.generateVAPIDKeys(), store both in env vars (VAPID_PUBLIC_KEY,VAPID_PRIVATE_KEY) per project secrets policy. sendWebPush()reads the subscription JSON frompushTokens, callswebPush.sendNotification(subscription, payload, { TTL, urgency, topic }).- On
410 Goneresponse: mark token dead (subscription expired/revoked). - TTL:
60seconds for messages (only relevant if delivered fresh),86400for reminders (today's reminder OK to deliver up to a day late). urgency: 'high'for messages,'normal'for reminders.topic: 'msg-{conversationId}'for messages — push service collapses queued duplicates per RFC 8030.
7. Platform-specific Implementation Notes
7.1 iOS (Expo)
- APNs setup: provision via EAS — Expo CLI generates the .p8 key or uploads our existing one. Required:
.p8file + Key ID + Team ID. EAS stores all three; no manual JWT signing needed when sending through Expo's proxy. - Provisional authorization (
UNAuthorizationOptionProvisional): NOT used in v1. Provisional sends quiet, non-interruptive notifications without asking — useful for a "trial" period — but we have a clear soft-ask, and provisional reminders that go to Notification Center silently aren't useful. - Interruption levels:
- Messages →
active(default; respects Focus / Sleep). - Reminders →
active. - We do not use
timeSensitive(breaks Focus — reserved for actually-urgent stuff). We do not usecritical(requires Apple entitlement we don't have).
- Messages →
- threadId → set to
conversationIdfor messages so iOS stacks them. For reminders, set toreminder-{listId}so per-list reminders stack. - Badge counts: backend tracks unread conversation count per user; send
badge: counton each message push and on every read-receipt push (with_displayInForeground: false). - Silent notifications: not used in v1. (
content-available: 1requiresaps-environmententitlement and is throttled by iOS.) - iOS 18: Apple Intelligence may auto-summarize stacked notifications on iPhone 15 Pro+; our message payloads should keep the sender name in the title (not the body) so summaries make sense. No code change required, but copy guideline: title = sender name, body = message preview.
- Expo Go limitation: remote push works in Expo Go on iOS, not on Android (since SDK 53). All notification dev/test on Android must use a development build.
7.2 Android (Expo)
- FCM v1 migration is mandatory — legacy FCM was sunset September 2024. Expo's
eas credentialsflow handles uploading thegoogle-services.jsonand configuring FCM v1 service-account credentials. Action item: confirmeas credentialsshows "FCM V1" not "FCM (Legacy)" for the project; if legacy, run the migration before the next Android build. - Notification channels must exist BEFORE the OS permission prompt is shown, and before
getDevicePushTokenAsync()/getExpoPushTokenAsync()is called. Two channels for v1:await Notifications.setNotificationChannelAsync('messages', {name: 'Direct Messages',importance: Notifications.AndroidImportance.HIGH, // heads-upsound: 'default',enableVibrate: true,lockscreenVisibility: Notifications.AndroidNotificationVisibility.PRIVATE,});await Notifications.setNotificationChannelAsync('reminders', {name: 'Trick Reminders',importance: Notifications.AndroidImportance.DEFAULT, // no heads-upsound: 'default',}); - Android 13+ runtime permission: call
Notifications.requestPermissionsAsync()from our soft-ask flow. Pre-check viaNotifications.getPermissionsAsync()and respect "denied + don't ask again" — second-chance prompt shows a rationale screen, then the system Settings deep-link. - Android 14 foreground service types: not relevant to us (we don't run FGS).
- Android 14 USE_FULL_SCREEN_INTENT: not used (we're not a calling/alarms app — Play would revoke it anyway).
- Doze / App Standby: server-sent push delivery to a Dozing device may delay by ~15 min. Acceptable for both categories — we are not an emergency-alert app.
- Notification grouping: Android groups by channel by default. Add
group: conversationIdfor messages so multiple messages in one chat collapse into a summary notification.
7.3 Web (NextJS)
- VAPID keys generated once with
web-pushlibrary, stored as env vars on the Amplify-hosted site (public key bundled to client) and on the backend (private key, server-only). - Service worker at
/sw.jsregisters inapp/layout.tsx(or_app.tsx). The SW handlespushandnotificationclickevents, deep-links viaclients.openWindow(). - Permission prompt: only fired from a click handler on the "Enable notifications" button in
/account/notifications. Never on page load — Chrome's "quieter UI" will hide the prompt entirely. - Browser support reality:
- Chrome / Firefox / Edge desktop & Android — fully supported.
- Safari macOS 16+ — supported.
- Safari iOS 16.4+ — only after the site is added to the home screen as a PWA. Render an "Install to home screen" callout in our settings page on iOS Safari.
- Subscription endpoint goes stale: on
410 Gonefrom web-push send, mark the token dead. Common after browser clears site data, device reset, etc.
7.4 Cross-platform copy guidelines
- Title: subject of the notification (sender name, list name). 30 chars max.
- Body: one-line preview, no emoji-only payloads. 80 chars max.
- Deep-link via
data.url = '/<route>?...'consumed byexpo-router(mobile) ornext/router(web). - Always include
data.categoryso analytics can attribute taps.
8. Edge Cases & Open Decisions
| # | Edge case | Decision |
|---|---|---|
| 1 | OS permission denied, in-app toggle still ON | Token registration is skipped; in-app banner prompts Open Settings. We do not call requestPermissionsAsync() repeatedly. |
| 2 | User toggles OFF in-app, then back ON | We re-query OS permission. If still granted, just flip server pref. If revoked, run soft-ask again. |
| 3 | Sender = receiver | notificationSender short-circuits if data.fromUserId === userId. |
| 4 | User reads message in another device first | Read-receipt event also fires a low-priority "update badge" push to the other devices with _displayInForeground: false. |
| 5 | Reminder fires but trick has been completed | notificationSender re-checks trick status at send time. If complete, mark scheduled row cancelled. |
| 6 | User in air mode / no connection | Local-scheduled fires on-device. Server backfill on next reconnect — idempotency key dedupes. |
| 7 | Quiet hours span midnight | inQuietHours() handles wrap (e.g. 21:00–09:00). Stored as two HH:mm strings + IANA timezone. |
| 8 | Timezone change while travelling | pushTokens.timezone updates on each app foreground; reminder rescheduling on tz-change is a stretch (acceptable to fire at "wrong" local time until next foreground). |
| 9 | Multiple devices, badge sync | iOS / Android badges are best-effort; we send badge updates but don't guarantee synchronization (no Apple "shared badge state" API). |
| 10 | Same trick in two lists, both reminding | Idempotency key includes listId; user gets two reminders. Acceptable v1; deferred dedup. |
9. Phased Implementation Plan
Each phase is independently shippable. Phases 1 + 2 + 5 are the MVP (messages + preferences). Reminders (3 + 4) can ship in a follow-up build if needed.
Phase 0 — Pre-work (no app code yet)
- Confirm
eas credentialsis on FCM v1, not legacy. Run migration if needed. - Verify APNs
.p8key is registered in EAS forcom.thetrickbook.trickbook(re-use existing or generate viaeas credentials). - Generate VAPID keypair (one time):
npx web-push generate-vapid-keys. Store in Backend.envand Amplify env. Add to.env.exampleplaceholders. - Delete legacy
Backend/store/users.jsreferences fromroutes/expoPushTokens.jsand replace with real Mongo wiring (Phase 1 absorbs this). - Done when: EAS shows FCM V1 + valid APNs key; VAPID keys present in all three env stores.
Phase 1 — Backend foundation
Files touched: Backend/routes/pushTokens.js (renamed from expoPushTokens.js), Backend/routes/notificationPreferences.js (new), Backend/services/notificationSender.js (new, replaces utilities/pushNotifications.js), Backend/workers/receiptsPoller.js (new), Backend/index.js (mount new routes).
Scope:
- New
pushTokenscollection + indexes (Section 4.2). notificationPreferencessubdoc added tousers(Section 4.1) with safe defaults backfilled for existing users via one-time migration script.POST /push-tokens,DELETE /push-tokens/:token.GET/PATCH /users/me/notification-preferences.notificationSender.send()with chunking,isExpoPushTokenvalidation, ticket persistence.- Receipts poller cron (15 min interval) that flips
pushTokens.deadAtforDeviceNotRegistered. - Delete legacy
utilities/pushNotifications.jsand the broken-by-designstore/users.js-basedexpoPushTokens.js.
Acceptance: Manual curl POST to /push-tokens writes to Mongo; admin test endpoint sends to a real device; killing the app and re-installing → next push returns DeviceNotRegistered → token gets deadAt set within 30 min.
Risk: existing expoPushTokens.js is mounted in index.js:81 but writes to a no-op in-memory store. Removing it is safe (no real consumers). Sanity-check git grep expoPushToken in mobile app before deletion.
Phase 2 — Mobile push (messages)
Files touched: TrickList/src/lib/notifications/ (new dir with index.ts, permissions.ts, channels.ts, tokens.ts), TrickList/app/_layout.tsx (wire init), TrickList/src/lib/api/notifications.ts (new client), TrickList/app/(tabs)/profile/notifications.tsx (new screen), backend routes/messages.js (call sender on new message), TrickList/app.config.js (expo-notifications plugin config).
Scope:
npx expo install expo-notifications.- Android channels created on app start (before any token call).
- Soft-ask sheet component, gated on
softAskDeferredAtandosPermissionstate. - Token registration on permission grant + on every foreground (refresh staleness).
- Foreground notification handler (suppress for current chat; show for others).
expo-routerdeep-link handler readsdata.url.- Backend
messages.jsPOST handler invokesnotificationSender.send()after persisting the message — fire-and-forget (no awaiting send for socket emit responsiveness). - iOS payload includes
threadId; Android payload includeschannelId: 'messages'andgroup: conversationId.
Acceptance: Two physical devices logged in as two users; send DM; receiver gets banner within 5s; tap deep-links to conversation; badge increments. App in foreground viewing the chat → no banner. Profile → Notifications → toggle Messages off → next push is skipped (verify via server log).
Risk: badge math races (read receipts vs new messages arriving). Mitigate with last-write-wins and a fallback "clear badge on app foreground" sweep.
Phase 3 — Reminder engine
Files touched: Backend/services/reminderPlanner.js, Backend/workers/reminderSender.js, Backend/routes/reminderCadence.js, mobile TrickList/src/lib/notifications/scheduledLocal.ts, mobile profile reminder cadence picker UI.
Scope:
scheduledNotificationscollection (Section 4.3).reminderPlanner.planNext30Days(userId, listId, cadence)— called on cadence change or trick-list mutation.reminderSendercron (every 5 min) firespendingrows wherescheduledFor <= nowANDidempotencyKeynot alreadysent.- Local pre-scheduling on mobile via
Notifications.scheduleNotificationAsync()for next 14 days, refreshed on every app foreground. - Idempotency dedup between local and server paths.
- Auto-pause: lists with no opens in 60d set cadence to
offand surface re-engagement prompt on next list open.
Acceptance: Set a list to "daily" at 10:00 local; reminder fires at 10:00 local in user's tz, on at least the device where they last opened the list. Mark trick complete → next reminder skips that trick. Toggle Reminders off globally → no reminders fire even if cadence is on.
Risk: Doze on Android delays delivery up to ~15 min — surface that in copy ("approximate time") if it becomes a complaint.
Phase 4 — Web push (DEFERRED to a later release)
Not in v2.x scope. The web /account/notifications preferences page will still ship in v2.x (Phase 5) — toggles work, the only thing missing is delivery to a browser when the user is offline. While the web app is open, the existing Socket.io connection continues to deliver messages in real time.
When this phase is reactivated, files touched will be: TrickBookWebsite/public/sw.js, TrickBookWebsite/app/account/notifications/page.tsx, TrickBookWebsite/lib/pushClient.ts, backend notificationSender web-push branch — VAPID flow, service worker registration, iOS PWA install callout, 410-Gone cleanup. All web-push design from Sections 4.2 / 6.4 / 7.3 is already locked so the future phase is a straight implement.
Phase 5 — Preferences UX polish & QA
Scope:
- "Open Settings" deep-link banner on mobile. On web, the same prefs page renders but the "Allow notifications in this browser" button is hidden behind a "Coming soon" pill (Phase 4 reactivates it).
- Quiet-hours picker (mobile + web — web is read-only display until Phase 4? No — web can edit it; the value is server-side and applies to mobile delivery, so web users editing it from desktop is valid).
- Logout invalidates this device's token server-side.
- Analytics: track open-rate and toggle-rate per category.
- Empty states, loading states, error states for the prefs screen.
- Release notes page in Docusaurus (
docs/docs/releases/) per project convention.
Acceptance: Every flow in Section 3 works end-to-end on iOS, Android, Chrome, Safari (PWA). QA checklist (Section 11) is all green.
10. Acceptance Criteria — Master Checklist
Tied to user stories:
- US-M1 Receiving DM while backgrounded shows push within 5s on iOS + Android.
- US-M2 Receiving DM in current chat foreground shows in-app indicator, not OS banner.
- US-M3 Toggling Messages off stops pushes within 1 request cycle of the change.
- US-M4 Multi-device receives on both; badges roughly sync (best-effort).
- US-M6 Sender never gets own-message push.
- US-R1..R7 Reminders fire per per-list cadence, in local tz, within quiet hours, deep-link to trick.
- US-P1 Per-category toggles render on mobile and web prefs screens.
- US-P2 OS-denied state shows Open Settings banner; tap opens system app settings.
- US-P3 Toggle change on mobile reflects on web within 10s (manual refresh OK).
- US-P4 Logout deletes the token row server-side.
- US-P5 No OS permission prompt on first launch; soft-ask happens in-context.
Plus:
- No legacy
store/users.jsbased notification code remains in the backend. -
eas credentialsreports FCM V1 (not Legacy). - DeviceNotRegistered receipts cause token cleanup within 30 min.
- No secrets (VAPID private key, APNs key) committed to git — checked by
git diff --stagedreview pre-push.
11. QA / Rollout Checklist
Before submitting to TestFlight / Play Store:
Device matrix (minimum):
- iOS 17 device, iOS 18 device.
- Android 13 device, Android 14 device, Android 15 emulator.
- Chrome 130+, Firefox 130+, Safari 17+ desktop, Safari 18 iOS as PWA.
Functional:
- Soft-ask sheet shows after first home-tab reach, not on cold start.
- Granting OS permission registers token within 5s.
- Denying OS permission does not re-prompt.
- Killing the app, then sending a DM → wakes app, deep-links correctly.
- Quiet hours suppress reminders but not messages.
- Logging out of one device does not break notifications on other devices.
Regression:
- Existing Socket.io message flow still works when app is foregrounded.
- No crash on iOS when entering app from a notification cold start.
- Existing Google Maps API key + EAS secrets path untouched.
Privacy / security:
- Push payloads do NOT include message body for "preview off" users (future setting; for v1 we always include preview).
- No PII in notification logs beyond user id and ticket id.
Documentation:
- Update
docs/docs/releases/vX.Y.Z.mdwith screenshots and known issues. - Mention "first release of push notifications" in TestFlight / Play Store release notes.
12. Risks & Mitigations
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| FCM credentials still on legacy | M | H — Android push silently broken | Phase 0 verifies; trivial fix via eas credentials |
Existing expoPushTokens route has callers in unknown places | L | M — breaks something on rename | git grep expoPushToken before deletion; the route writes to a no-op store so callers are already broken |
| Soft-ask fatigue (users ignore it twice) | M | M — install→token conversion drops | 7-day cooldown between soft-asks; contextual triggers |
| Doze delays reminders on Android | M | L — slightly late reminders | Acceptable; copy uses "around" instead of exact time |
| Safari iOS PWA install friction | H | L — fewer web push opt-ins on iOS | This is the platform reality; we surface the install callout but don't block on it |
| Notification spam complaints | L | M — bad reviews | Default cadence = weekly; auto-pause after 60d list inactivity |
| Cross-device idempotency edge | M | M — duplicate reminders | idempotencyKey unique index; local + server paths both write it |
13. Out of scope (revisit in v2 of this feature)
- Comments / likes / follows pushes.
- Rich notifications (images, action buttons beyond snooze).
- Per-conversation mute (mute specific person).
- VIP / Time-Sensitive overrides on iOS.
- Notification inbox / history inside the app.
- Email and SMS fallbacks.
- Reminder content personalization via the trick library (e.g. "this is the easiest unfinished trick on your list").
14. Sources
Verified during deep research (primary sources unless noted). All accessed June 2026.
- Apple — UNNotificationInterruptionLevel — https://developer.apple.com/documentation/usernotifications/unnotificationinterruptionlevel
- Apple — Establishing a token-based connection to APNs — https://developer.apple.com/documentation/usernotifications/establishing-a-token-based-connection-to-apns
- Google — Android notification permission (Android 13+) — https://developer.android.com/develop/ui/views/notifications/notification-permission
- Google — Android 14 behavior changes — https://developer.android.com/about/versions/14/behavior-changes-14
- Google — Android 15 behavior changes — https://developer.android.com/about/versions/15/behavior-changes-15
- Google — FCM HTTP v1 migration — https://firebase.google.com/docs/cloud-messaging/migrate-v1
- Expo — Push notifications setup — https://docs.expo.dev/push-notifications/push-notifications-setup/
- Expo — FCM v1 credentials — https://docs.expo.dev/push-notifications/fcm-credentials
- Expo — Sending notifications (Expo proxy) — https://docs.expo.dev/push-notifications/sending-notifications/
- Expo — Sending notifications direct via FCM/APNs — https://docs.expo.dev/push-notifications/sending-notifications-custom/
- Expo — Notifications SDK reference — https://docs.expo.dev/versions/latest/sdk/notifications/
- Expo — Push FAQ — https://docs.expo.dev/push-notifications/faq/
- Expo — expo-server-sdk-node — https://github.com/expo/expo-server-sdk-node
- MDN — PushManager.subscribe() — https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe
- MDN — Notification.requestPermission() — https://developer.mozilla.org/en-US/docs/Web/API/Notification/requestPermission_static
- MDN — Re-engageable Notifications + Push (PWA tutorial) — https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Tutorials/js13kGames/Re-engageable_Notifications_Push
- RFC 8292 — VAPID for Web Push — https://datatracker.ietf.org/doc/html/rfc8292
- GitHub — web-push-libs/web-push — https://github.com/web-push-libs/web-push
Blog corroboration (used for context, not as sole source for any decision):
- Batch — iOS 18 / Apple Intelligence push impact: https://batch.com/blog/posts/ios18-apple-intelligence-push-notifications-email-marketing
- ProAndroidDev — Full-screen-intent changes Android 14/15: https://proandroiddev.com/full-screen-intent-fsi-notifications-in-android-14-15-what-changed-why-its-breaking-and-e5e862a75936
- MagicBell — PWA iOS limitations: https://www.magicbell.com/blog/pwa-ios-limitations-safari-support-complete-guide
- OneSignal — iOS Focus modes & interruption levels: https://documentation.onesignal.com/docs/en/ios-focus-modes-and-interruption-levels
- OneSignal — Deliver by timezone: https://onesignal.com/blog/deliver-by-timezone-push-notification/
Items flagged as best-current-knowledge (not fully verified in research pass)
- iOS 18 specific behavior changes beyond Apple Intelligence summaries — confirm against Apple's What's New in iOS 18 release notes before shipping.
- Exact Safari iOS PWA install requirements (manifest fields, gesture timing) — confirm against current WebKit docs before Phase 4.
- Doze delivery delay numbers (~15 min, up to 2h on aggressive battery) — derived from older Android docs and field reports; treat as approximate.
15. Decisions (locked 2026-06-14)
- Reminder delivery: Hybrid local + server backfill (Section 6.3).
- Permission prompt: Soft-ask first; no OS prompt on signup.
- Web push (Phase 4): Deferred — not in v2.x.
- Reminder defaults: Weekly cadence per list, quiet hours 21:00–09:00 local. (Open to override per-user; defaults applied at user creation.)
- v2.x phase scope: Phases 0, 1, 2, 3, and 5 — messages + reminders in the same release.