Skip to content

Notifications and Reminders

Samuel Yanez edited this page Oct 5, 2026 · 6 revisions

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.

Supported notifications

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.

Eligibility and completion

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.

Synced configuration and device-local requests

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.

Where the code lives

Reminder code is grouped under GoalTracker/Features/Reminders:

  • GoalReminderCoordinator.swift coordinates 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.

Saving and scheduling

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:

  1. Save the goal or progress change.
  2. Start asynchronous reminder synchronization using the latest goal state.
  3. 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.

Permission handling and feedback

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.

Reconciliation

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:

  1. Fetches this device's pending notification requests.
  2. Reads current notification authorization and checks cancellation or supersession.
  3. Fetches the latest goals and applies the existing scheduling rules.
  4. Removes feedback for goals that are no longer eligible.
  5. Cancels recognized goal-reminder requests for deleted or ineligible goals. When permission is unavailable, it cancels recognized pending goal reminders without modifying goal data.
  6. 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.

Implementation map

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.

Known limitations and deliberate scope

  • 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 willPresent to 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.

Tests and manual verification

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:

  1. On one device, create a reminder-enabled goal and confirm its local notification.
  2. On another device with notification permission, let the goal sync while the app is active and confirm its reminder is scheduled.
  3. Edit the name or schedule on the first device and verify the receiving device updates its request.
  4. Complete a one-time goal or delete it and verify pending requests are removed after sync and reconciliation.
  5. Deny notification permission on one device, verify reminder intent remains configured, then restore permission and verify recovery.
  6. Complete a recurring goal and verify its repeating reminder remains scheduled.
  7. Tap a delivered notification for an existing goal and verify navigation to its detail screen.

The original reconciliation requirement is tracked in issue #17.