Repository navigation
Notifications and Reminders
Moku uses local notifications to remind users about their goals. Reminder configuration is stored with the goal and synced through SwiftData + CloudKit. Scheduled notification requests belong to the current device and are rebuilt from that configuration when needed.
This page describes the implementation as of commit 09cffb6. It documents development behavior, not necessarily the currently published App Store version.
The app supports calendar-based goal reminders through UNCalendarNotificationTrigger.
| Goal type | Reminder schedule | Repeats? |
|---|---|---|
| One-time goal with a target date | Target date at 9:00 AM by default | No |
| Daily recurring goal | Every day at 9:00 AM | Yes |
| Weekly recurring goal | First weekday of the current calendar at 9:00 AM | Yes |
| Monthly recurring goal | First day of each month at 9:00 AM | Yes |
| Yearly recurring goal | January 1 at 9:00 AM | Yes |
The scheduling rules use the device's current calendar. Weekly reminders follow calendar.firstWeekday, so the first day is not hard-coded to Monday or Sunday.
The form offers an on/off reminder toggle, rather than a time picker. GoalReminder stores a settingHour, which one-time scheduling uses, but the current UI creates the default value of 9. Recurring scheduling uses the fixed default hour.
Each notification contains:
- The goal's name as its title.
- A localized reminder body appropriate to the date or recurrence cadence.
- The default notification sound.
- The goal's UUID in
userInfo["goalId"]for navigation.
The app requests alert and sound authorization. It does not currently implement remote push notifications, notification badges, location triggers, snooze, or notification actions for completing a goal.
A goal needs a non-nil GoalReminder to produce a reminder request.
For one-time goals, the goal must also be incomplete, have a target date, and have a calculated reminder fire time in the future. A target date without a reminder does not schedule a notification. A reminder whose fire time has already passed is not rescheduled as an immediate catch-up alert.
For recurring goals, the reminder remains scheduled even when the goal is completed for the current period. This preserves reminders for future periods. Completion does not cancel the repeating request.
These rules live in GoalReminderSchedule and GoalRecurrenceCadence.
Goal.reminder represents the user's reminder configuration. CloudKit syncs that configuration along with the rest of the goal. It does not sync the device's UNNotificationRequest objects.
The app treats pending notifications as derived state:
Saved goal + reminder configuration
|
v
Scheduling eligibility rules
|
v
Pending notification on this device
For example, a reminder-enabled goal created on an iPad can sync to an iPhone. Once the iPhone receives the goal and reconciliation runs, it schedules its own reminder if notification permission is available.
Each request has a stable identifier:
goal-reminder-<goal UUID>
That identifier lets the app update or cancel a goal's pending reminder on the current device. It does not deduplicate reminders across devices. If an iPhone and iPad both schedule the goal, both can notify the user. There is no device-specific delivery preference or preferred-device routing yet.
Reminder code is grouped under GoalTracker/Features/Reminders:
-
GoalReminderCoordinator.swiftcoordinates scheduling, permissions, and retries. -
Scheduling/contains schedules, outcomes, refresh triggers, and the per-goal operation queue. -
Notifications/contains the notification scheduler, router, payload, and content localization. -
Feedback/contains issue state and recovery UI.
The persisted GoalReminder and recurrence values live in GoalTracker/Features/Goals/Models. GoalService lives in GoalTracker/Features/Goals.
Tests mirror these locations under GoalTrackerTests/Features. Shared test stubs live in GoalTrackerTests/Support. Source links are pinned to the documented commit and match this folder layout.
GoalService owns goal persistence and reports successful creates, edits, progress changes, disabling, and deletion through one required GoalReminderUpdating dependency. GoalReminderCoordinator implements that interface and translates those changes into notification work. The navigation root owns and injects one coordinator, configured with its context, notification center, scheduler, calendar, and clock. Saving, retrying, cancellation, and reconciliation share that configuration; the service does not create fallback coordinators.
Persistence and notification scheduling are separate outcomes:
- Save the goal or progress change.
- Start asynchronous reminder synchronization using the latest goal state.
- If scheduling fails, retain the saved goal and report a reminder issue.
A notification failure does not turn a successful goal save into a save failure. Retrying a reminder does not repeat the goal save or progress change.
Deletion and disabling a reminder cancel pending requests only after persistence succeeds. Disabling clears the saved reminder configuration, which subsequently syncs to other devices. The feedback banner calls GoalService to save this change; the coordinator cancels requests and clears the issue only after the save succeeds. The coordinator never calls the goal service.
Post-save work keeps the SwiftData container alive while asynchronous reads are in progress. It reads the latest goal before scheduling and checks for deletion or a disabled reminder after scheduling returns.
The scheduler maps system authorization into three app-level states:
| State | Behavior |
|---|---|
| Not determined | Create/edit synchronization may request alert and sound permission. Automatic reconciliation never prompts. |
| Denied | Do not schedule locally; cancel pending goal reminders during reconciliation and show issues for eligible goals. |
| Authorized | Schedule or reconcile eligible reminders. Provisional and ephemeral authorization are treated as authorized. |
A denial on this device does not clear or modify the synced reminder configuration. Users can restore notification permission in Settings or explicitly disable a goal's reminder.
Reminder issues are held in memory, keyed by goal ID, by GoalReminderCoordinator. They appear as an orange warning icon in the goal's list row and an animated banner near the top of the detail screen.
| Issue | Banner actions |
|---|---|
| Permission denied | Open Settings; Disable Reminder |
| Scheduling failure | Try Again; Disable Reminder |
The banner uses localized explanatory text. It does not expose raw system error messages. Scheduling errors are logged privately for diagnostics. Save failures when disabling a reminder use the app's existing save-failure alert.
Returning from Settings triggers reconciliation. Permission issues are rediscovered on foreground entry; scheduling failures are retried when reconciliation finds a missing or outdated request. Successful repair clears the affected issue.
The existing permission-check path also remembers the last observed denial in UserDefaults so it can recover after permission changes across launches. Full reconciliation checks pending requests regardless of that recovery flag.
GoalListView owns a shared GoalReminderCoordinator and injects it through the SwiftUI environment. Its task runs when the scene is active and restarts when its GoalReminderRefreshTrigger changes.
The trigger contains the active state and immutable GoalReminderSyncState snapshots. This covers:
- Launching into an active scene.
- Returning to the foreground, including returning from notification Settings.
- Changes to goal reminder data while active, including CloudKit updates arriving after launch.
A reconciliation pass:
- Fetches this device's pending notification requests.
- Reads current notification authorization and checks cancellation or supersession.
- Fetches the latest goals and applies the existing scheduling rules.
- Removes feedback for goals that are no longer eligible.
- Cancels recognized goal-reminder requests for deleted or ineligible goals. When permission is unavailable, it cancels recognized pending goal reminders without modifying goal data.
- When authorized, compares eligible goals with their pending requests. Matching requests remain untouched; missing or outdated requests are synchronized without requesting permission.
The comparison checks the identifier, calendar trigger date components, repeating behavior, title, localized body, sound, and goal ID payload. Unrelated notification identifiers are left alone.
Goals are fetched after the asynchronous notification-center reads, and again before each comparison. Per-goal scheduling operations are queued within the shared coordinator so an older suspended write cannot finish after a newer write and replace it. Different goals can proceed independently. Cancellation checks and pass IDs stop canceled or superseded passes from continuing through the remaining goals.
Reconciliation does not save goals or change their progress.
| Component | Responsibility |
|---|---|
| GoalReminder | Codable reminder configuration stored with a goal. |
| GoalReminderSchedule | Eligibility and validated calendar schedules. |
| GoalNotificationScheduler | Authorization, request construction, comparison, stable identifiers, and pending-request cancellation. Its narrow notification-center protocol allows test doubles. |
GoalReminderSyncState |
Immutable snapshot defined alongside the scheduler and used across asynchronous operations. |
| GoalReminderCoordinator | Implements GoalReminderUpdating; owns post-save tasks, cancellation, permission checks, reconciliation, issue state, and retry. Uses @MainActor and @Observable. |
| GoalReminderOperationQueue | Serializes notification operations for each goal within a coordinator. |
| GoalReminderRefreshTrigger | Equatable input used to restart the SwiftUI reconciliation task. |
| GoalReminderIssue | Transient issue metadata and localized feedback. |
| GoalReminderFeedbackView | Detail-screen banner and recovery actions. |
| GoalNotificationRouter | Notification delegate that passes a tapped goal ID to SwiftUI navigation. |
| GoalNotificationPayload | Shared payload key and UUID parsing. |
The router is installed during app initialization. The list navigates to a goal once it can resolve the ID in its current query. A notification whose goal is no longer available cannot immediately navigate to that goal.
- Foreground reconciliation only. There is no background repair job or server-driven scheduling. A device can keep an outdated reminder until it receives synced data and the active app processes it. Already-scheduled local notifications can still fire while the app is closed; repair does not run continuously.
- Multiple devices can alert. Reminder intent syncs, but local scheduling and authorization are independent. Device-specific delivery preferences and automatic routing are deferred.
- Delivered notifications are retained. Cleanup applies to pending requests only. Existing notifications may remain in Notification Center after deletion, disabling, or one-time completion. This is an accepted product decision for the current scope, not planned work in this implementation.
- No custom-time UI. The form only toggles reminders. Recurring schedules use fixed period boundaries and the default hour.
-
No explicit foreground presentation. The delegate handles notification responses but does not implement
willPresentto request a banner or sound while Moku is foregrounded. Apple's default foreground behavior silences notification presentation. Apple's presentation guidance - Calendar settings are captured at initialization. There is no dedicated time-zone or locale change observer. Reconciliation compares content and triggers using the coordinator’s configured calendar; recreating navigation or relaunching the app picks up changed calendar settings.
- Issue state is transient. Feedback is not persisted as goal data. Permission checks and reconciliation rediscover relevant problems; there is no persistent reminder-error history.
- No explicit quota prioritization. Reconciliation attempts to maintain a request for each eligible goal; there is no app-level policy for selecting a subset when system scheduling limits are reached.
Swift Testing coverage includes schedule eligibility and recurrence, authorization, request construction, stable identifiers, payload parsing, persistence failures, feedback, and reconciliation.
The reconciliation suite covers missing and outdated requests, unchanged requests, obsolete-request cleanup, unrelated notifications, permission denial and recovery, scheduling failures, goals or permissions changing during asynchronous reads, overlapping writes, and cancellation. See GoalReminderReconciliationTests.
At the documented implementation commit, all 250 tests passed. The tests use in-memory SwiftData and notification-center doubles; they do not prove real CloudKit propagation or system presentation on physical devices.
Recommended smoke test:
- On one device, create a reminder-enabled goal and confirm its local notification.
- On another device with notification permission, let the goal sync while the app is active and confirm its reminder is scheduled.
- Edit the name or schedule on the first device and verify the receiving device updates its request.
- Complete a one-time goal or delete it and verify pending requests are removed after sync and reconciliation.
- Deny notification permission on one device, verify reminder intent remains configured, then restore permission and verify recovery.
- Complete a recurring goal and verify its repeating reminder remains scheduled.
- Tap a delivered notification for an existing goal and verify navigation to its detail screen.
The original reconciliation requirement is tracked in issue #17.