Skip to main content

Notifications — Feature Spec & Implementation Plan

Status: Approved — ready to implement · Author: Wes / Claude pair · Last updated: 2026-06-14

Locked decisions (2026-06-14):

  1. Reminder delivery: hybrid local + server backfill.
  2. Permission prompt: soft-ask first, OS prompt only after user accepts in-app.
  3. Web push (Phase 4): deferred to a later release. v2.x is mobile-only.
  4. 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

  1. Reliable push delivery for direct messages (Socket.io chat already in backend) on iOS, Android, and Web.
  2. Reliable scheduled trick reminders ("Hey, you haven't landed kickflip on your street tricks list this week — go session it.") on iOS and Android. Web reminders are a stretch goal in this round.
  3. 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.
  4. Backend that respects OS-level state (cleans up dead tokens, never spams users whose OS permission is denied).
  5. 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 (active interruption 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-preferences endpoint).
  • 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)

  1. User signs up / logs in. No OS prompt yet.
  2. 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".
  3. If "Yes" → call Notifications.requestPermissionsAsync() → OS prompt → on grant, register push token with backend.
  4. If "Not now" → store softAskDeferredAt locally; 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).
  5. If OS-denied → record osPermission: denied in 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 = conversationId so 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 (the fromReminder query 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 by token (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 the notificationPreferences subdocument.
  • 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)plans scheduledNotifications rows 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 when transport='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 current pushNotifications.js does 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:

  1. Server pre-plans the next 30 days of scheduledNotifications rows when cadence changes or a new trick is added to a list. Each row has a UTC scheduledFor computed from user's quiet-hours window + cadence + tz.
  2. Mobile app on launch queries GET /users/me/scheduled-notifications?within=14d, then uses Notifications.scheduleNotificationAsync() to register them as local notifications on-device. Local notifications survive backgrounding, kill, and offline.
  3. Server cron fires any rows that local scheduling missed (user uninstalled and reinstalled, multi-device user where one device is offline).
  4. De-dup: every fire path uses the idempotencyKey — local notifications include it as data.idempotencyKey, and the server marks the row sent only 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-push npm 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 from pushTokens, calls webPush.sendNotification(subscription, payload, { TTL, urgency, topic }).
  • On 410 Gone response: mark token dead (subscription expired/revoked).
  • TTL: 60 seconds for messages (only relevant if delivered fresh), 86400 for 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: .p8 file + 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 use critical (requires Apple entitlement we don't have).
  • threadId → set to conversationId for messages so iOS stacks them. For reminders, set to reminder-{listId} so per-list reminders stack.
  • Badge counts: backend tracks unread conversation count per user; send badge: count on each message push and on every read-receipt push (with _displayInForeground: false).
  • Silent notifications: not used in v1. (content-available: 1 requires aps-environment entitlement 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 credentials flow handles uploading the google-services.json and configuring FCM v1 service-account credentials. Action item: confirm eas credentials shows "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-up
    sound: 'default',
    enableVibrate: true,
    lockscreenVisibility: Notifications.AndroidNotificationVisibility.PRIVATE,
    });
    await Notifications.setNotificationChannelAsync('reminders', {
    name: 'Trick Reminders',
    importance: Notifications.AndroidImportance.DEFAULT, // no heads-up
    sound: 'default',
    });
  • Android 13+ runtime permission: call Notifications.requestPermissionsAsync() from our soft-ask flow. Pre-check via Notifications.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: conversationId for messages so multiple messages in one chat collapse into a summary notification.

7.3 Web (NextJS)

  • VAPID keys generated once with web-push library, 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.js registers in app/layout.tsx (or _app.tsx). The SW handles push and notificationclick events, deep-links via clients.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 Gone from 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 by expo-router (mobile) or next/router (web).
  • Always include data.category so analytics can attribute taps.

8. Edge Cases & Open Decisions

#Edge caseDecision
1OS permission denied, in-app toggle still ONToken registration is skipped; in-app banner prompts Open Settings. We do not call requestPermissionsAsync() repeatedly.
2User toggles OFF in-app, then back ONWe re-query OS permission. If still granted, just flip server pref. If revoked, run soft-ask again.
3Sender = receivernotificationSender short-circuits if data.fromUserId === userId.
4User reads message in another device firstRead-receipt event also fires a low-priority "update badge" push to the other devices with _displayInForeground: false.
5Reminder fires but trick has been completednotificationSender re-checks trick status at send time. If complete, mark scheduled row cancelled.
6User in air mode / no connectionLocal-scheduled fires on-device. Server backfill on next reconnect — idempotency key dedupes.
7Quiet hours span midnightinQuietHours() handles wrap (e.g. 21:00–09:00). Stored as two HH:mm strings + IANA timezone.
8Timezone change while travellingpushTokens.timezone updates on each app foreground; reminder rescheduling on tz-change is a stretch (acceptable to fire at "wrong" local time until next foreground).
9Multiple devices, badge synciOS / Android badges are best-effort; we send badge updates but don't guarantee synchronization (no Apple "shared badge state" API).
10Same trick in two lists, both remindingIdempotency 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 credentials is on FCM v1, not legacy. Run migration if needed.
  • Verify APNs .p8 key is registered in EAS for com.thetrickbook.trickbook (re-use existing or generate via eas credentials).
  • Generate VAPID keypair (one time): npx web-push generate-vapid-keys. Store in Backend .env and Amplify env. Add to .env.example placeholders.
  • Delete legacy Backend/store/users.js references from routes/expoPushTokens.js and 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 pushTokens collection + indexes (Section 4.2).
  • notificationPreferences subdoc added to users (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, isExpoPushToken validation, ticket persistence.
  • Receipts poller cron (15 min interval) that flips pushTokens.deadAt for DeviceNotRegistered.
  • Delete legacy utilities/pushNotifications.js and the broken-by-design store/users.js-based expoPushTokens.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 softAskDeferredAt and osPermission state.
  • Token registration on permission grant + on every foreground (refresh staleness).
  • Foreground notification handler (suppress for current chat; show for others).
  • expo-router deep-link handler reads data.url.
  • Backend messages.js POST handler invokes notificationSender.send() after persisting the message — fire-and-forget (no awaiting send for socket emit responsiveness).
  • iOS payload includes threadId; Android payload includes channelId: 'messages' and group: 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:

  • scheduledNotifications collection (Section 4.3).
  • reminderPlanner.planNext30Days(userId, listId, cadence) — called on cadence change or trick-list mutation.
  • reminderSender cron (every 5 min) fires pending rows where scheduledFor <= now AND idempotencyKey not already sent.
  • 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 off and 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.js based notification code remains in the backend.
  • eas credentials reports 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 --staged review 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.md with screenshots and known issues.
  • Mention "first release of push notifications" in TestFlight / Play Store release notes.

12. Risks & Mitigations

RiskLikelihoodImpactMitigation
FCM credentials still on legacyMH — Android push silently brokenPhase 0 verifies; trivial fix via eas credentials
Existing expoPushTokens route has callers in unknown placesLM — breaks something on renamegit grep expoPushToken before deletion; the route writes to a no-op store so callers are already broken
Soft-ask fatigue (users ignore it twice)MM — install→token conversion drops7-day cooldown between soft-asks; contextual triggers
Doze delays reminders on AndroidML — slightly late remindersAcceptable; copy uses "around" instead of exact time
Safari iOS PWA install frictionHL — fewer web push opt-ins on iOSThis is the platform reality; we surface the install callout but don't block on it
Notification spam complaintsLM — bad reviewsDefault cadence = weekly; auto-pause after 60d list inactivity
Cross-device idempotency edgeMM — duplicate remindersidempotencyKey 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.

  1. Apple — UNNotificationInterruptionLevelhttps://developer.apple.com/documentation/usernotifications/unnotificationinterruptionlevel
  2. Apple — Establishing a token-based connection to APNshttps://developer.apple.com/documentation/usernotifications/establishing-a-token-based-connection-to-apns
  3. Google — Android notification permission (Android 13+)https://developer.android.com/develop/ui/views/notifications/notification-permission
  4. Google — Android 14 behavior changeshttps://developer.android.com/about/versions/14/behavior-changes-14
  5. Google — Android 15 behavior changeshttps://developer.android.com/about/versions/15/behavior-changes-15
  6. Google — FCM HTTP v1 migrationhttps://firebase.google.com/docs/cloud-messaging/migrate-v1
  7. Expo — Push notifications setuphttps://docs.expo.dev/push-notifications/push-notifications-setup/
  8. Expo — FCM v1 credentialshttps://docs.expo.dev/push-notifications/fcm-credentials
  9. Expo — Sending notifications (Expo proxy)https://docs.expo.dev/push-notifications/sending-notifications/
  10. Expo — Sending notifications direct via FCM/APNshttps://docs.expo.dev/push-notifications/sending-notifications-custom/
  11. Expo — Notifications SDK referencehttps://docs.expo.dev/versions/latest/sdk/notifications/
  12. Expo — Push FAQhttps://docs.expo.dev/push-notifications/faq/
  13. Expo — expo-server-sdk-nodehttps://github.com/expo/expo-server-sdk-node
  14. MDN — PushManager.subscribe()https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe
  15. MDN — Notification.requestPermission()https://developer.mozilla.org/en-US/docs/Web/API/Notification/requestPermission_static
  16. MDN — Re-engageable Notifications + Push (PWA tutorial)https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Tutorials/js13kGames/Re-engageable_Notifications_Push
  17. RFC 8292 — VAPID for Web Pushhttps://datatracker.ietf.org/doc/html/rfc8292
  18. GitHub — web-push-libs/web-pushhttps://github.com/web-push-libs/web-push

Blog corroboration (used for context, not as sole source for any decision):

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)

  1. Reminder delivery: Hybrid local + server backfill (Section 6.3).
  2. Permission prompt: Soft-ask first; no OS prompt on signup.
  3. Web push (Phase 4): Deferred — not in v2.x.
  4. Reminder defaults: Weekly cadence per list, quiet hours 21:00–09:00 local. (Open to override per-user; defaults applied at user creation.)
  5. v2.x phase scope: Phases 0, 1, 2, 3, and 5 — messages + reminders in the same release.