Files
Reflect/CLAUDE.md
2026-02-13 22:49:23 -06:00

23 KiB

Feels

iOS mood tracking app. Users rate their day on a 5-point scale (Horrible to Great) and view patterns via Day, Month, Year, and Insights views. Includes watchOS companion, widgets, Live Activities, and AI-powered insights.

Build & Run Commands

# Build
xcodebuild -project Feels.xcodeproj -scheme "Feels (iOS)" -destination 'platform=iOS Simulator,name=iPhone 16 Pro' build

# Run all tests
xcodebuild -project Feels.xcodeproj -scheme "Feels (iOS)" -destination 'platform=iOS Simulator,name=iPhone 16 Pro' test

# Run a single test suite
xcodebuild -project Feels.xcodeproj -scheme "Feels (iOS)" -destination 'platform=iOS Simulator,name=iPhone 16 Pro' -only-testing:"Tests iOS/Tests_iOS" test

# Run a single test
xcodebuild -project Feels.xcodeproj -scheme "Feels (iOS)" -destination 'platform=iOS Simulator,name=iPhone 16 Pro' -only-testing:"Tests iOS/Tests_iOS/testDatesBetween" test

Architecture Overview

  • Pattern: MVVM with SwiftUI
  • Language/Framework: Swift / SwiftUI
  • Data: SwiftData with CloudKit sync (migrated from Core Data)
  • Test Framework: XCTest (minimal coverage currently)

Layers

  1. Models (Shared/Models/): Data types and domain logic

    • MoodEntryModel — SwiftData @Model for mood entries
    • Mood — Enum for mood values (horrible/bad/average/good/great/missing/placeholder)
    • EntryType — Enum for entry source (listView/widget/watch/shortcut/siri/controlCenter/liveActivity/etc.)
    • Customization protocols: MoodTintable, MoodImagable, PersonalityPackable, Themeable
  2. Persistence (Shared/Persisence/): SwiftData operations (note: directory has a typo — "Persisence" not "Persistence")

    • DataController — Singleton @MainActor data access layer
    • DataControllerGET — Read/fetch operations
    • DataControllerADD — Create operations
    • DataControllerUPDATE — Update operations
    • DataControllerDELETE — Delete operations
    • DataControllerHelper — Utility methods
    • DataControllerProtocol — Protocol definitions for testability
    • SharedModelContainer — Factory for ModelContainer (shared App Group storage)
    • ExtensionDataProvider — Data provider for widget and watch extensions
  3. ViewModels (colocated with views):

    • DayViewViewModel — Day/Month grid data, mood logging
    • YearViewModel — Year view filtering and chart data
    • InsightsViewModel — AI-powered insights via Apple Foundation Models
    • IconViewModel — Custom app icon selection
    • CustomWidgetStateViewModel — Widget customization state
  4. Views (Shared/Views/): SwiftUI views organized by feature

    • DayView/, MonthView/, YearView/, InsightsView/
    • SettingsView/, CustomizeView/, CustomWidget/
    • Sharing/, SharingTemplates/
  5. Services (Shared/Services/ and Shared/): Business logic singletons

    • MoodLogger — Centralized mood logging with side effects
    • IAPManager — StoreKit 2 subscriptions
    • AnalyticsManager — PostHog analytics
    • HealthKitManager — HealthKit mood sync
    • BiometricAuthManager — Face ID / Touch ID
    • PhotoManager — Photo attachment handling
    • WatchConnectivityManager — Watch-phone communication
    • LiveActivityScheduler — Live Activity lifecycle
    • ReviewRequestManager — App Store review prompts
    • FoundationModelsInsightService — Apple Foundation Models AI

Key Components

Component Type Responsibility
DataController.shared @MainActor singleton All SwiftData CRUD — single source of truth for data access
MoodLogger.shared @MainActor singleton Centralized mood logging with all side effects (HealthKit, streak, widget, watch, Live Activity)
IAPManager.shared @MainActor ObservableObject StoreKit 2 subscription state, trial tracking, paywall gating
AnalyticsManager.shared @MainActor singleton PostHog event tracking — all analytics go through this
ExtensionDataProvider.shared @MainActor singleton Widget/Watch data access via App Group container
HealthKitManager.shared ObservableObject HealthKit read/write for mood data
WatchConnectivityManager.shared ObservableObject (NSObject) WCSession for phone-watch UI updates
LiveActivityScheduler.shared ObservableObject Manages mood streak Live Activity start/stop timing

Data Flow

User taps mood → DayViewViewModel.add()
                       ↓
               MoodLogger.shared.logMood()     ← ALL mood entry points use this
                       ↓
               DataController.shared.add()     ← SwiftData insert + save
                       ↓
               Side effects (all in MoodLogger):
                 → HealthKit sync (if enabled + subscribed)
                 → Streak calculation
                 → Live Activity update
                 → Widget reload (WidgetCenter.shared.reloadAllTimelines())
                 → Watch notification (WatchConnectivityManager)
                 → TipKit parameter update
                 → Analytics event

Widget/Watch → ExtensionDataProvider.shared    ← Separate container via App Group
                       ↓
               MoodLogger.applySideEffects()   ← Catch-up when app opens

CloudKit ←→ DataController.container           ← Automatic sync via SwiftData CloudKit integration

Data Access Rules

Source of Truth: DataController.shared — all data reads and writes go through this singleton.

Correct Usage

// ✅ CORRECT — Log a mood through MoodLogger (handles all side effects)
MoodLogger.shared.logMood(.great, for: Date(), entryType: .listView)

// ✅ CORRECT — Read data through DataController
let entry = DataController.shared.getEntry(byDate: date)
let entries = DataController.shared.getData(startDate: start, endDate: end, includedDays: [1,2,3,4,5,6,7])

// ✅ CORRECT — Track analytics through AnalyticsManager
AnalyticsManager.shared.track(.moodLogged(mood: mood.rawValue, entryType: "listView"))

Wrong Usage

// ❌ WRONG — Never insert into modelContext directly (bypasses side effects and analytics)
let entry = MoodEntryModel(forDate: date, mood: .great, entryType: .listView)
modelContext.insert(entry)

// ❌ WRONG — Never call PostHogSDK directly (bypasses opt-out checks and config)
PostHogSDK.shared.capture("mood_logged")

// ❌ WRONG — Never create a separate ModelContainer (breaks shared state)
let container = try ModelContainer(for: MoodEntryModel.self)

Allowed Exceptions

  • DataControllerADD.swift — Directly inserts into modelContext (this IS the data layer)
  • ExtensionDataProvider — Creates its own ModelContainer for widget/watch (can't share process)
  • Widget extensions — May call DataController.add() directly when MoodLogger isn't available

Architecture Rules

  • All mood logging MUST go through MoodLogger.shared.logMood(). This ensures HealthKit sync, streak calculation, widget refresh, watch notification, and analytics all fire.
  • All data reads MUST go through DataController.shared methods (getEntry, getData, splitIntoYearMonth). Views MUST NOT use @Query or direct modelContext access.
  • All analytics MUST go through AnalyticsManager.shared. NEVER call PostHogSDK directly.
  • Any code that modifies mood data MUST call WidgetCenter.shared.reloadAllTimelines() (handled automatically by MoodLogger).
  • NEVER access DataController.shared from a background thread — it is @MainActor isolated.
  • NEVER hardcode App Group identifiers — use Constants.groupShareId / Constants.groupShareIdDebug or SharedModelContainer.appGroupID.
  • NEVER hardcode CloudKit container IDs — use SharedModelContainer.cloudKitContainerID.
  • New SwiftData fetch operations MUST be added to DataControllerGET.swift.
  • New create operations MUST be added to DataControllerADD.swift.
  • New update operations MUST be added to DataControllerUPDATE.swift.
  • New delete operations MUST be added to DataControllerDELETE.swift.
  • ViewModels MUST be @MainActor class conforming to ObservableObject.
  • ALWAYS use @StateObject (not @ObservedObject) when a view owns its ViewModel.
  • NEVER bypass IAPManager subscription checks — use IAPManager.shared.shouldShowPaywall to gate premium features.

Mutation / Write Patterns

Create:  MoodLogger.shared.logMood(mood, for: date, entryType: type)
           → DataController.shared.add(mood:forDate:entryType:)
              → Deletes existing entries for that date first (prevents duplicates)
              → Inserts new MoodEntryModel
              → saveAndRunDataListeners()

Update:  DataController.shared.update(entryDate:withMood:)
         DataController.shared.updateNotes(forDate:notes:)
         DataController.shared.updatePhoto(forDate:photoID:)

Delete:  DataController.shared.clearDB()
         DataController.shared.deleteLast(numberOfEntries:)

Fill:    DataController.shared.fillInMissingDates()
           → Called by BGTask to backfill missing days with .missing entries

Correct Mutation

// ✅ CORRECT — Create a mood entry (full flow with side effects)
MoodLogger.shared.logMood(.good, for: selectedDate, entryType: .listView)

// ✅ CORRECT — Update notes on an existing entry
DataController.shared.updateNotes(forDate: date, notes: "Had a great day")
DataController.shared.saveAndRunDataListeners()

Wrong Mutation

// ❌ WRONG — Calling DataController.add() directly skips side effects
DataController.shared.add(mood: .good, forDate: date, entryType: .listView)
// Missing: HealthKit sync, streak calc, widget reload, watch notify, analytics

Concurrency Patterns

  • DataController is @MainActor final class — all SwiftData operations run on main actor.
  • MoodLogger is @MainActor final class — mood logging and side effects run on main actor.
  • AnalyticsManager is @MainActor final class — analytics calls run on main actor.
  • IAPManager is @MainActor class ObservableObject — StoreKit state is main-actor bound.
  • ExtensionDataProvider is @MainActor final class — extension data access on main actor.
  • HealthKitManager is class ObservableObject (NOT @MainActor) — HealthKit calls may be async.
  • WatchConnectivityManager is NSObject, ObservableObject — WCSession delegate callbacks.
  • All ViewModels (DayViewViewModel, YearViewModel, InsightsViewModel) are @MainActor class ObservableObject.
  • Background work (HealthKit sync) MUST use Task { } from @MainActor context.
  • CloudKit sync happens automatically via SwiftData's built-in CloudKit integration — no manual threading.
  • BGTask.swift runs fillInMissingDates() which accesses DataController.shared on the main actor.

Swift 6 / Sendable Notes

  • Color gets @retroactive Codable and @retroactive RawRepresentable conformance in Color+Codable.swift.
  • Date gets @retroactive RawRepresentable conformance in Date+Extensions.swift.
  • Widget Provider uses @preconcurrency IntentTimelineProvider to suppress Sendable warnings in WidgetProviders.swift.
  • AppDelegate uses @preconcurrency UNUserNotificationCenterDelegate for notification delegate callbacks.
  • WatchConnectivityManager is NSObject, ObservableObject — WCSession delegate callbacks arrive on arbitrary threads. Ensure UI updates dispatch to main actor.
  • HealthKitManager is NOT @MainActor — its async methods may cross isolation boundaries when called from @MainActor ViewModels.

State Management

Cache / Offline Behavior

  • SwiftData persists all mood data locally in the App Group shared container.
  • CloudKit sync is automatic — works offline and syncs when connectivity returns.
  • No explicit cache layer — SwiftData IS the cache and persistence.
  • Widget reads from the same shared App Group container (local file, not CloudKit).
  • Watch uses CloudKit for data sync (separate from WCSession which is for UI updates only).
  • If CloudKit sync fails, local data is always available.

Startup Flow

  1. FeelsApp.init() — Configure AnalyticsManager, register BGTaskScheduler, reset FeelsTipsManager, initialize LiveActivityScheduler, initialize WatchConnectivityManager
  2. MainTabView receives DataController.shared.container as modelContainer
  3. IAPManager, BiometricAuthManager, HealthKitManager injected as @EnvironmentObject
  4. On scenePhase change to .activeDataController.shared.refreshFromDisk() picks up widget/watch changes
  5. On scenePhase change to .background — schedule BGTask for missing date backfill

Test Conventions

Framework & Location

  • Framework: XCTest
  • Test directory: Tests iOS/ (iOS), Tests macOS/ (macOS — template only)
  • File naming: {SuiteName}Tests.swift

Existing Test Suites

Suite Test Count Covers
Tests_iOS 2 Date.dates(from:toDate:) utility — basic date range generation
Tests_iOSLaunchTests 1 Default launch test (template)

Note: Test coverage is minimal. Most of the app is untested. Priority areas for new tests: DataController CRUD operations, MoodLogger side effects, IAPManager subscription state transitions, MoodEntryModel initialization edge cases.

Naming Convention

test{Component}_{Behavior}

Example: testDatesBetween, testDatesIncluding

Mocking Strategy

  • SwiftData: Use ModelContainer with isStoredInMemoryOnly: true for test isolation
  • DataController: The DataControllerProtocol.swift defines MoodDataReading, MoodDataWriting, MoodDataDeleting, MoodDataPersisting protocols — use these for protocol-based mocking
  • Analytics: No mock needed — AnalyticsManager can be stubbed or ignored in tests
  • HealthKit: Mock HKHealthStore or skip — not critical for unit tests
  • StoreKit: Use StoreKit Testing in Xcode for IAPManager tests

Example:

// Test setup with in-memory SwiftData
let schema = Schema([MoodEntryModel.self])
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
let container = try ModelContainer(for: schema, configurations: [config])
let context = container.mainContext

Bug Fix Protocol

When fixing a bug:

  1. Write a regression test that reproduces the bug BEFORE fixing it
  2. Include edge cases — test boundary conditions, nil/empty inputs, related scenarios
  3. Confirm all tests pass after the fix
  4. Name tests descriptively: test{Component}_{WhatWasBroken}

Known Edge Cases & Gotchas

Platform Gotchas

  • Widget extensions cannot access CloudKit — they use local App Group storage via ExtensionDataProvider
  • Widget extensions cannot access HealthKit or TipKit — MoodLogger.logMood() has syncHealthKit and updateTips flags for this
  • watchOS uses CloudKit for data sync (automatic), WCSession only for UI update notifications

Framework Gotchas

  • SwiftData with CloudKit requires all @Model properties to have default values (CloudKit requirement)
  • SwiftData @Model stores enums as raw Int values — moodValue: Int not mood: Mood directly
  • DataController.add() deletes ALL existing entries for a date before inserting — this prevents duplicates but means "update" is really "delete + insert"
  • modelContext.rollback() is used in refreshFromDisk() to pick up extension changes — this discards any unsaved in-process changes
  • SharedModelContainer.createWithFallback() silently falls back to in-memory storage if App Group container fails — data loss risk if App Group is misconfigured

Data Gotchas

  • Two entries for same date: Handled by add() which deletes existing entries first, but could occur via CloudKit sync conflict
  • Mood value outside enum range: Mood(rawValue:) returns nil, caught by ?? .missing fallback in MoodEntryModel.mood
  • Timezone change crossing midnight: forDate is stored as DateCalendar.current.startOfDay(for:) used for comparisons, but timezone changes could shift which "day" an entry belongs to
  • Missing dates backfill: fillInMissingDates() creates .missing entries for gaps — if it runs during timezone change, could create entries for wrong dates
  • Widget timeline with 0 entries: ExtensionDataProvider returns empty array — widget must handle empty state
  • CloudKit sync conflict: SwiftData/CloudKit uses last-writer-wins — no custom conflict resolution

Common Developer Mistakes

  • Calling DataController.shared.add() directly instead of MoodLogger.shared.logMood() — skips all side effects
  • Creating a new ModelContainer instead of using DataController.shared.container — breaks shared state
  • Accessing DataController from a background thread — it's @MainActor, will crash or produce undefined behavior
  • Hardcoding App Group ID strings instead of using Constants or SharedModelContainer.appGroupID
  • Forgetting to call saveAndRunDataListeners() after mutations — listeners won't fire, UI won't update
  • Using @ObservedObject instead of @StateObject for ViewModels the view owns — causes recreation on every view update

External Boundaries

Boundary Handler Class Input Source What Could Go Wrong
CloudKit sync SharedModelContainer (automatic) iCloud Sync conflicts (last-writer-wins), offline delays, iCloud account not signed in
StoreKit 2 IAPManager App Store Network timeout during purchase, receipt validation failure, subscription state mismatch
HealthKit HealthKitManager Health app Permission denied, Health data unavailable, write conflicts
App Group storage SharedModelContainer, ExtensionDataProvider File system Container URL nil (entitlement misconfigured), file corruption
Watch Connectivity WatchConnectivityManager WCSession Watch not paired, session not reachable, message delivery failure
Background Tasks BGTask System scheduler System may not run task, task killed by system, insufficient time
Apple Foundation Models FoundationModelsInsightService On-device AI Model not available on device, generation failure, unexpected output
PostHog AnalyticsManager Network analytics.88oakapps.com unreachable, event queue overflow
Deep Links FeelsApp.onOpenURL URL scheme feels:// Malformed URL, unknown host/path
App Shortcuts / Siri AppShortcuts.swift SiriKit Intent not recognized, missing parameter

Analytics

  • SDK: PostHog (self-hosted at analytics.88oakapps.com)
  • Manager: AnalyticsManager.shared in Shared/Analytics.swift — NEVER call PostHogSDK directly
  • Events: Defined as AnalyticsManager.Event enum in Analytics.swift
  • Screens: Defined as AnalyticsManager.Screen enum in Analytics.swift
  • Opt-out: User can opt out via settings — AnalyticsManager.isOptedOut
  • Session Replay: Supported, toggled via AnalyticsManager.sessionReplayEnabled

Adding new analytics:

// 1. Add case to AnalyticsManager.Event enum in Analytics.swift
case myNewEvent(param: String)

// 2. Add name and properties in the `payload` computed property switch
case .myNewEvent(let param):
    return ("my_new_event", ["param": param])

// 3. Call from anywhere:
AnalyticsManager.shared.track(.myNewEvent(param: "value"))

// For screen tracking:
AnalyticsManager.shared.trackScreen(.day)

Environment Configuration

  • Debug: Uses iCloud.com.88oakapps.feels.debug CloudKit container, group.com.88oakapps.feels.debug App Group, Feels-Debug.store filename
  • Production: Uses iCloud.com.88oakapps.feels CloudKit container, group.com.88oakapps.feels App Group, Feels.store filename
  • Toggle: #if DEBUG preprocessor directive in SharedModelContainer and throughout codebase
  • StoreKit Testing: IAPManager.bypassSubscription flag (DEBUG only) — set to true to bypass paywall during development
  • Subscription Group ID: 21914363
  • Product IDs: com.88oakapps.feels.IAP.subscriptions.monthly, com.88oakapps.feels.IAP.subscriptions.yearly
  • Trial: 30-day free trial tracked via firstLaunchDate in GroupUserDefaults

Directory Conventions

When adding new files:

  • New views: Shared/Views/{FeatureName}/
  • New view models: Colocated with views in Shared/Views/{FeatureName}/
  • New models: Shared/Models/
  • New services/managers: Shared/Services/
  • New persistence operations: Shared/Persisence/DataController{OPERATION}.swift (note directory typo)
  • New widget code: FeelsWidget2/
  • New watch code: Feels Watch App/
  • New tests: Tests iOS/

Naming Conventions

  • ViewModels: {Feature}ViewModel.swift — e.g., DayViewViewModel.swift, YearViewModel.swift, InsightsViewModel.swift
  • Views: {Feature}View.swift — e.g., DayView.swift, MonthView.swift, YearView.swift
  • Models: {ModelName}Model.swift for SwiftData models — e.g., MoodEntryModel.swift
  • Services/Managers: {Purpose}Manager.swift — e.g., IAPManager.swift, HealthKitManager.swift, PhotoManager.swift
  • DataController operations: DataController{OPERATION}.swift — e.g., DataControllerGET.swift, DataControllerADD.swift
  • Tests: {SuiteName}Tests.swift — e.g., Tests_iOS.swift

Localization

  • English: en.lproj/Localizable.strings
  • Spanish: es.lproj/Localizable.strings
  • Use String(localized:) for all user-facing strings

Dependencies

Package Manager

  • No external SPM packages (pure Apple frameworks)
  • Single external dependency: PostHog iOS SDK (added via SPM or directly)

Key Frameworks

Framework Purpose
SwiftData Persistence and CloudKit sync
CloudKit Automatic cloud sync via SwiftData
StoreKit 2 Subscriptions and in-app purchases
HealthKit Mood data sync to Health app
WidgetKit Home screen and Lock Screen widgets
ActivityKit Live Activities for mood streaks
BackgroundTasks BGProcessingTask for missing date backfill
WatchConnectivity Phone-watch communication
LocalAuthentication Biometric auth (Face ID / Touch ID)
PostHog Analytics and session replay
Foundation Models On-device AI for mood insights

Mood Values

enum Mood: Int {
    case horrible = 0
    case bad = 1
    case average = 2
    case good = 3
    case great = 4
    case missing = 5      // Unfilled day (system-generated)
    case placeholder = 6  // Calendar padding (system-generated)
}

Entry Types

enum EntryType: Int, Codable {
    case listView = 0       // Main app day view
    case widget = 1         // Home screen widget
    case watch = 2          // watchOS app
    case shortcut = 3       // Shortcuts app
    case filledInMissing = 4 // BGTask backfill
    case notification = 5   // Push notification
    case header = 6         // Header quick-entry
    case siri = 7           // Siri / App Intent
    case controlCenter = 8  // Control Center widget
    case liveActivity = 9   // Live Activity
}