Skip to content

Architecture

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

Architecture

GoalTracker uses SwiftUI for screens, SwiftData for storage, and UserNotifications for reminders. Screens read saved goals directly. GoalService handles changes, and a separate reminder service updates notifications after those changes are saved.

Reviewed October 5, 2026, at commit 09cffb6. Source links point to this published revision. The draft-only editing API and button cleanup below also reflect local development commit a2bd99e, which has not yet been pushed. App Store availability may differ.

The main pieces

This diagram shows the path of a goal change. Each box represents a responsibility, rather than every type involved.

flowchart TD
    Screens["View"] --> Service["GoalService"]
    Service --> Storage["SwiftData"]
Loading
Piece Job Start reading here
App startup Load storage; show recovery UI if loading fails GoalTrackerApp.swift
Screens and navigation Show the list, selected goal, forms, and history GoalNavigationView.swift
Form state Hold drafts, validate input, and detect unsaved changes GoalFormState.swift
Goal operations Create, edit, delete, and update progress GoalService.swift
Storage Define models and configure local and iCloud persistence GoalTrackerSchema.swift
Reminders Schedule notifications and expose failures or retries GoalReminderCoordinator.swift

State and navigation

Views use SwiftData's @Query to read saved goals. Shared UI state uses @Observable classes, owned with @State and passed through bindings or the environment. Form drafts stay separate from saved models until Save. GoalFormState.makeGoalDraft() produces a GoalDraft containing only goal values and selected GoalTagDraft values. GoalService accepts these goal-owned inputs; picker selection, validation, and dirty-state tracking remain in the form. Editing uses one entry point, updateGoal(_:with:), with a complete GoalDraft. Start with GoalDraft(goal:) when changing only selected fields so unedited values are retained. The former field-by-field overload and recurrence-update flag have been removed.

GoalNavigationView hosts an adaptive NavigationSplitView. It keeps the selected goal separate from the detail's navigation path, so opening history does not change the sidebar selection. Create, edit, and progress-update forms appear as sheets.

The app uses Main Actor isolation for UI state and SwiftData operations. Progress calculations use separate value types, making the core rules easier to test.

What is stored

Only Goal and Tag are separate SwiftData records.

Data How it is stored
Goal Title, details, dates, progress, recurrence, reminder settings, and tag relationships
Tag A reusable name linked to one or more goals
Progress history Timestamped changes embedded inside the goal's progress value

Progress is either binary completion (OutcomeProgress) or a numeric target (MeasurableProgress). One-time goals use all their recorded changes; recurring goals use changes within the current calendar period. Streaks count consecutive completed periods.

Production stores data locally and syncs it through private CloudKit storage. Tests and screenshot previews use in-memory containers without iCloud. The app has a versioned schema (GoalTrackerSchemaV1) and a migration plan; no migration stages are needed yet.

Known storage limitation: progress history is one Codable value. Conflicting writes from different devices can replace the whole history array. Moving events into separate records would require a new schema and a migration that preserves existing data. Issue #22 tracks validating the conflict risk, migration, and cross-device testing; a real-device reproduction has not yet been verified.

Saving a change

  1. A view sends an action to GoalService.
  2. The service changes the model and explicitly saves it.
  3. On success, reminder scheduling runs separately using the latest saved goal.

If saving fails, the service rolls back and restores captured state where applicable; the view shows a save-failure alert. If notification scheduling fails, the goal stays saved and the app offers reminder feedback and retry. A retry does not repeat the progress change.

If storage cannot load at startup, the app shows a recovery screen with retry and diagnostic sharing.

Reminders

flowchart TD
    Saved["Saved goal settings"] --> Service["Reminder service"]
    Service --> Notifications["This device's notifications"]
Loading

GoalService has one required reminder dependency: GoalReminderUpdating. After a successful save, it reports the changed goal and why it changed, or the IDs of deleted goals. It does not construct notification requests or manage asynchronous work.

GoalReminderCoordinator implements that interface and owns scheduling tasks, cancellation, and feedback. GoalNavigationView owns one coordinator for its lifetime and injects it into goal screens. The coordinator keeps the SwiftData container alive until scheduling finishes. Its context, notification center, scheduler, calendar, and clock are configured once; saves, retries, and reconciliation use the same dependencies.

GoalReminderCoordinator manages errors, retries, and the order of scheduling operations. GoalNotificationScheduler builds and adds or cancels notification requests. When the app becomes active or relevant goal data changes, the coordinator checks that pending reminders match saved settings.

The reminder feedback banner asks GoalService to save a disabled reminder. Once that save succeeds, the coordinator cancels pending notifications and clears feedback. The coordinator never calls back into goal persistence.

iCloud syncs goal settings; each device schedules its own notifications. See Notifications and Reminders for timing, permissions, and reconciliation details.

Opening a notification

flowchart TD
    Tap["Tap notification"] --> Router["Resolve goal ID"]
    Router --> Detail["Open goal detail"]
Loading

GoalNotificationRouter passes the goal UUID to the UI. The list selects that goal when it is available, clears the previous detail path, and consumes the pending notification intent.

Finding code and tests

Files are grouped by feature. App-wide navigation and keyboard support live under App; storage setup lives under Persistence.

GoalTracker/
├── App/
│   ├── Navigation/
│   └── Keyboard/
├── Features/
│   ├── Goals/
│   │   ├── GoalService.swift
│   │   ├── GoalDraft.swift
│   │   ├── GoalTagDraft.swift
│   │   ├── GoalReminderUpdating.swift
│   │   ├── GoalReminderChange.swift
│   │   ├── Models/Progress/
│   │   ├── List/
│   │   ├── Detail/
│   │   ├── Form/
│   │   │   ├── Tags/
│   │   │   └── Units/
│   │   ├── ProgressHistory/
│   │   └── Shared/
│   └── Reminders/
│       ├── GoalReminderCoordinator.swift
│       ├── Scheduling/
│       ├── Notifications/
│       └── Feedback/
├── Persistence/
├── Resources/
│   ├── Localization/
│   ├── Assets.xcassets/
│   └── GoalTrackerAppIcon.icon/
└── PreviewSupport/

Keep screen-specific state, formatting, and helpers beside the screen they support. Simple, single-use controls can stay inline in their owning view: the completion and custom-progress buttons now live in GoalDetailBottomView. Separate components should provide shared styling or meaningful behavior. Goals/Shared contains components used by several goal screens. Persisted goal models stay in Goals/Models; the schema, container loader, and startup recovery UI belong in Persistence. PreviewSupport holds preview containers and DEBUG-only screenshot seeds. Signing entitlements and Info.plist remain at the target root.

GoalTrackerTests mirrors the app's folder names:

GoalTrackerTests/
├── App/
│   ├── Navigation/
│   └── Keyboard/
├── Features/
│   ├── Goals/
│   │   ├── Models/Progress/
│   │   ├── List/
│   │   ├── Detail/
│   │   ├── Form/Tags/
│   │   └── ProgressHistory/
│   └── Reminders/
│       ├── Scheduling/
│       └── Notifications/
├── Persistence/
└── Support/

Place a test in the same relative folder as the source it verifies. Coordinator-level reminder tests live directly under Features/Reminders; shared fixtures and notification stubs live in Support. Add matching test folders as new code needs coverage rather than creating empty folders in advance.

The published revision and the local draft-only editing change each passed 250 tests on the iOS 26.5 simulator. The subsequent button cleanup also built successfully, and formatter checks passed. Live cross-device sync and a full accessibility walkthrough were not verified by that run.

Beyond the history conflict risk, follow-up checks include recurrence-period UI refresh, unsaved changes during sheet dismissal, and performance with large histories.

Update this page and its source revision when storage, navigation, or service responsibilities change. For the product view, see App Overview.

Clone this wiki locally