Appearance
Push Notifications
LucidPal uses two distinct push notification systems:
- Local notifications — trial lifecycle and re-engagement prompts, scheduled on-device via
UNUserNotificationCenterand fired byTrialNotificationScheduler. - APNs silent push —
content-available: 1payloads sent by the sync API to wake the app and trigger an incremental sync pull when another device pushes data. No visible banner — purely a background wake signal.
Architecture
TrialNotificationScheduler (singleton, @MainActor)
├── scheduleIfNeeded(appTrialManager:) ← called on scene active + trial start
├── scheduleDormantCalendarIfNeeded() ← called on onboarding completion
├── cancelAll() ← called on Pro upgrade
└── scheduleForTesting(type:afterSeconds:) ← DevPushTestView onlyNotification taps are handled in AppDelegate.userNotificationCenter(_:didReceive:). Each type either:
- Sets
UserDefaultsKeys.siriPendingQuery→ consumed byconsumePendingSiriQuery()inLucidPalApp→ chat pre-seeded on next foreground - Sets
UserDefaultsKeys.pendingTrialExpiredSheet→ consumed byconsumePendingTrialExpiredSheet()→TrialExpiredSheetpresented as a modal
Four Notification Tracks
1. Day 2 — First Nudge (lucidpal.trial.nudge)
Fires: 2 days after trial start
Audience: users who haven't opened the app since installing
Psychology: Fogg Prompt + Reciprocity. Motivation is low at day 2 — the notification must reactivate curiosity, not demand. Passive interruption level.
| Field | Value |
|---|---|
| Title | "Your AI is still waiting" |
| Body | "You haven't asked LucidPal anything yet. Try asking about your schedule, writing a message, or anything else." |
| Tap → | Chat opens, pre-seeded: "What can you help me with today?" |
The promise: "your AI is waiting" implies the user can act immediately with zero friction.
The delivery: AI responds before the user types a single character.
2. Day 5 — Trial Expiring (lucidpal.trial.expiring)
Fires: 5 days after trial start (2 days remaining)
Psychology: Loss Aversion (Kahneman 2×). The user has had cloud AI for 5 days. Losing it in 2 days must feel painful. Copy leads with loss, not upgrade pitch. Price never mentioned in the notification.
| Field | Value |
|---|---|
| Title | "2 days of AI left" |
| Body | "Your cloud AI trial ends in 2 days. There are things you haven't tried yet — voice notes, calendar prep, smart search." |
| Tap → | Chat opens, pre-seeded: "Show me everything I can do with LucidPal AI" |
The promise: "things you haven't tried yet" implies the app will show specific features, not a paywall.
The delivery: AI demos capabilities live. Upgrade CTA appears naturally at response end, never as a gate.
3. Day 7 — Trial Expired (lucidpal.trial.expired)
Fires: 7 days after trial start
Psychology: Transparent off-ramp. The worst possible experience is landing on the home screen with no explanation. This notification must deliver the clearest possible status breakdown.
| Field | Value |
|---|---|
| Title | "Your cloud AI trial ended" |
| Body | "On-device AI is still yours, free forever. Cloud AI and integrations are available on Pro." |
| Tap → | TrialExpiredSheet presented immediately |
The promise: "your cloud AI just turned off" is a factual statement. User expects exact status, not a generic screen.
The delivery: TrialExpiredSheet shows three rows — Cloud AI (red, Turned off), On-device AI (green, Free forever), Live Notes (purple, Still available). One orange upgrade CTA. One dismiss link. Nothing else.
4. Day 30 — Dormant Re-engagement (lucidpal.reengagement.calendar)
Fires: 30 days after onboarding completion
Audience: free users who never engaged after the trial
Psychology: Specificity converts (Nielsen Norman Group). "Stay productive" = ignored. "3 events this week" = tapped. Body is built at schedule time using real EventKit data if access was granted.
| Field | Value |
|---|---|
| Title | "LucidPal can prep you for this week" |
| Body (calendar access) | "You have N events this week. I can help you prepare for all of them." |
| Body (no calendar access) | "Ask about your schedule, prep for meetings, set reminders — all from one place." |
| Tap → | Chat opens, pre-seeded: "Help me prepare for my upcoming calendar events this week" |
The promise: "I can prep you for all of them" implies the app immediately starts prep.
The delivery: AI fetches events and starts preparing. If calendar not granted, AI explains what it can do and offers to enable it.
Notification Scheduling
swift
// On app active + after trial start
await TrialNotificationScheduler.shared.scheduleIfNeeded(appTrialManager: appTrialManager)
// On onboarding completion
await TrialNotificationScheduler.shared.scheduleDormantCalendarIfNeeded()
// On Pro upgrade — cancel all pending trial notifications
TrialNotificationScheduler.shared.cancelAll()scheduleIfNeeded is idempotent — it checks UNUserNotificationCenter.pendingNotificationRequests() before scheduling to avoid duplicates. All production notifications use UNCalendarNotificationTrigger.
Deep Link Flow
User taps notification banner
↓
AppDelegate.userNotificationCenter(_:didReceive:)
↓
Sets UserDefaults key (siriPendingQuery OR pendingTrialExpiredSheet)
↓
App becomes active → LucidPalApp.consumePending*()
↓
Chat pre-seeded OR TrialExpiredSheet presentedAppDelegate tap handler cases (in userNotificationCenter(_:didReceive:)):
swift
} else if identifier.hasPrefix("lucidpal.trial.nudge") {
UserDefaults.standard.set("What can you help me with today?", forKey: UserDefaultsKeys.siriPendingQuery)
} else if identifier.hasPrefix("lucidpal.trial.expiring") {
UserDefaults.standard.set("Show me everything I can do with LucidPal AI", forKey: UserDefaultsKeys.siriPendingQuery)
} else if identifier.hasPrefix("lucidpal.trial.expired") {
UserDefaults.standard.set(true, forKey: UserDefaultsKeys.pendingTrialExpiredSheet)
} else if identifier.hasPrefix("lucidpal.reengagement.calendar") {
UserDefaults.standard.set("Help me prepare for my upcoming calendar events this week", forKey: UserDefaultsKeys.siriPendingQuery)
}TrialExpiredSheet
Views/TrialExpiredSheet.swift — presented as a .sheet from RootView when pendingTrialExpiredSheet is true.
Callbacks:
onUpgrade: () -> Void— opens upgrade sheet (Pro paywall)onDismiss: () -> Void— closes the sheet, user continues with on-device AI
Testing (Dev + TestFlight)
Access via the Dev Tools drawer (left-edge handle → "Test Deep Links").
DevPushTestView (Views/DevPushTestView.swift) — opened as a sheet from DevTierDrawer. Visible in DEBUG and TestFlight builds only (BuildEnvironment.showDeveloperSection).
Each of the 4 rows displays:
| Label | Color | Content |
|---|---|---|
| Concept | Blue | The psychological mechanic driving this notification |
| Promise | Orange | What the notification copy implies the user will see |
| Expectation | Purple | What the app must deliver immediately when tapped |
How to test:
- Open dev drawer → "Test Deep Links"
- Tap "Fire in 3 seconds" on any row
- Immediately background the app (Home gesture or swipe up)
- Notification fires as a banner — tap it
- Verify the deep link delivers on the promise
The fire button disables after first tap (green checkmark). Re-open the sheet to reset.
swift
// Called from DevPushTestView
await TrialNotificationScheduler.shared.scheduleForTesting(type: .nudge, afterSeconds: 3)scheduleForTesting uses UNTimeIntervalNotificationTrigger (not calendar-based) for reliable sub-minute delivery in development.
APNs Silent Sync Push
Overview
When a device pushes a sync batch (PUT /sync/batch), the API immediately sends a content-available: 1 silent push to all other registered devices for that user. The receiving app wakes in the background, pulls the delta, and becomes up-to-date within seconds — no user action required.
Flow
Device A edits a note
↓
SyncManager.syncNowDebounced() fires after 2 s
↓
PUT /sync/batch → lucidpal-api
↓
ApnsService.sendSilentSync(tokens) [via c.executionCtx.waitUntil]
↓
APNs HTTP/2 → content-available: 1 → Device B
↓
AppDelegate.didReceiveRemoteNotification(fetchCompletionHandler:)
↓
AppDelegate.onSilentPush() → SyncManager.syncNow()
↓
Pull delta → note appears on Device BDevice Token Registration
iOS registers for remote notifications at launch (UIApplication.shared.registerForRemoteNotifications()). On success, AppDelegate.didRegisterForRemoteNotificationsWithDeviceToken fires:
swift
AppDelegate.onDeviceTokenRegistered = { token in
Task { await syncManager.registerDeviceToken(token) }
}SyncManager.registerDeviceToken(_:) calls PUT /sync/device-token with the hex token string and environment (sandbox in DEBUG builds, production in release). Tokens are stored in the device_push_tokens table keyed by (user_id, token).
API Worker Side
ApnsService (in src/services/apns.service.ts) generates an ES256 JWT signed with the .p8 key and fires POST https://api[.sandbox].push.apple.com/3/device/{token} with:
json
{ "aps": { "content-available": 1 } }The push fires via waitUntil — non-blocking, does not delay the PUT /sync/batch response. Failed tokens (expired, invalid) are ignored individually; other tokens still receive the push.
Required Worker Secrets
APNS_KEY_ID, APNS_TEAM_ID, APNS_PRIVATE_KEY_BASE64, APNS_BUNDLE_ID — see secrets.md. All four are optional; omitting them disables silent push but does not break sync (background refresh and foreground trigger still work).
Key Files
| File | Role |
|---|---|
Services/SyncManager.swift | registerDeviceToken, unregisterDeviceToken |
App/AppDelegate.swift | didRegisterForRemoteNotificationsWithDeviceToken, didReceiveRemoteNotification |
src/services/apns.service.ts | ES256 JWT generation + APNs HTTP/2 push |
src/repositories/device-push-token.repository.ts | device_push_tokens table CRUD |
src/routes/sync.ts | PUT /sync/device-token, DELETE /sync/device-token |
Local Notification Key Files
| File | Role |
|---|---|
Services/TrialNotificationScheduler.swift | Schedules, deduplicates, and cancels all 4 tracks |
Views/TrialExpiredSheet.swift | Modal for trial-expired tap — status breakdown + upgrade CTA |
Views/DevPushTestView.swift | Dev/TF test panel — concept/promise/expectation per notification |
Views/DevTierDrawer.swift | Houses the "Test Deep Links" entry point |
App/AppDelegate.swift | Local notification tap handler + remote notification handler |
App/LucidPalApp.swift | Consumes pending flags on scene active |
ViewModels/UserDefaultsKeys.swift | pendingTrialExpiredSheet key |
Rules
- Never send a push notification that asks for money. The notification delivers value (briefing, status, feature reveal). The upgrade prompt lives inside the app, after the user taps.
- Every notification must deliver on its exact promise within 1 second of the app foregrounding. If the tap lands on a generic screen, the notification series is dead — users stop tapping.
scheduleDormantCalendarIfNeededmust be called only once (idempotent via pending-ID check). Do not call it on every app open.- Cancel all trial notifications immediately on Pro upgrade via
cancelAll(). A trial-expiry notification firing after upgrade is brand damage.