diff --git a/docs/plans/2026-01-12-delta-sync-design.md b/docs/plans/2026-01-12-delta-sync-design.md new file mode 100644 index 0000000..23c2631 --- /dev/null +++ b/docs/plans/2026-01-12-delta-sync-design.md @@ -0,0 +1,147 @@ +# Delta Sync & Game Browsing Design + +**Date:** 2026-01-12 +**Status:** Approved +**Problem:** Houston Astros only shows 7 games in "By Games" mode + +## Problem Analysis + +### Root Causes + +1. **90-day browsing window** in `TripCreationViewModel.loadGamesForBrowsing()` arbitrarily limits games shown in "By Games" mode + +2. **6-month CloudKit sync window** in `CanonicalSyncService.syncGames()` only syncs games within 6 months of today + +3. **Wrong query field** - CloudKit queries filter by game `dateTime` (when game is scheduled) instead of `modificationDate` (when record was last modified) + +### Impact + +- Users cannot plan trips around games outside the 90-day window +- New schedules added to CloudKit may not sync if games fall outside the window +- Delta sync doesn't work correctly - modified historical games never sync + +## Solution + +### 1. Unified Delta Sync Pattern + +All CloudKit data types follow the same pattern: + +```swift +func fetchForSync(since lastSync: Date?) async throws -> [T] { + let predicate: NSPredicate + + if let lastSync = lastSync { + // Delta: only modified records + predicate = NSPredicate(format: "modificationDate >= %@", lastSync as NSDate) + } else { + // First sync: ALL records + predicate = NSPredicate(value: true) + } + + // ... execute query +} +``` + +**First sync (lastSync == nil):** Fetch all records from CloudKit. This handles the edge case where CloudKit is updated after app build but before user installs. + +**Delta sync (lastSync has value):** Fetch only records with `modificationDate >= lastSync`. + +### 2. CloudKitService Changes + +| Method | Before | After | +|--------|--------|-------| +| `fetchStadiumsForSync` | `()` | `(since: Date?)` | +| `fetchTeamsForSync` | `(for: Sport)` | `(since: Date?)` | +| `fetchGamesForSync` | `(sports:, startDate:, endDate:)` | `(since: Date?)` | +| `fetchLeagueStructureChanges` | `(since: Date?)` | No change | +| `fetchTeamAliasChanges` | `(since: Date?)` | No change | +| `fetchStadiumAliasChanges` | `(since: Date?)` | No change | + +**Teams optimization:** Fetch all teams in one call instead of 7 separate per-sport calls. + +### 3. CanonicalSyncService Changes + +Remove date range calculations from `syncGames()`: + +```swift +// BEFORE +let startDate = lastSync ?? Date() +let endDate = Calendar.current.date(byAdding: .month, value: 6, to: Date()) ?? Date() +let syncGames = try await cloudKitService.fetchGamesForSync( + sports: Set(Sport.allCases), + startDate: startDate, + endDate: endDate +) + +// AFTER +let syncGames = try await cloudKitService.fetchGamesForSync(since: lastSync) +``` + +### 4. DataProvider Naming Convention + +Rename methods to clarify local vs network operations: + +| Before | After | Purpose | +|--------|-------|---------| +| `fetchGames(sports:, startDate:, endDate:)` | `filterGames(sports:, startDate:, endDate:)` | Local query with date filter | +| `fetchRichGames(...)` | `filterRichGames(...)` | Local query with date filter | +| New | `allGames(for sports:)` | Local query, no date filter | +| New | `allRichGames(for sports:)` | Local query, no date filter | + +Convention: `filter*` = local query with constraints, `all*` = local query without date filter. + +### 5. TripCreationViewModel Changes + +Remove 90-day browsing limit: + +```swift +// BEFORE +let browseEndDate = Calendar.current.date(byAdding: .day, value: 90, to: Date()) ?? endDate +games = try await dataProvider.fetchGames( + sports: selectedSports, + startDate: Date(), + endDate: browseEndDate +) + +// AFTER +games = try await dataProvider.allGames(for: selectedSports) +``` + +## Files to Modify + +| File | Changes | +|------|---------| +| `SportsTime/Core/Services/CloudKitService.swift` | Update fetch signatures, query by modificationDate | +| `SportsTime/Core/Services/CanonicalSyncService.swift` | Remove date range logic, pass lastSync directly | +| `SportsTime/Core/Services/DataProvider.swift` | Rename methods, add `allGames`/`allRichGames` | +| `SportsTime/Features/Trip/ViewModels/TripCreationViewModel.swift` | Use `allGames` instead of date-filtered fetch | +| `SportsTimeTests/Mocks/MockCloudKitService.swift` | Update mock signatures | +| `SportsTimeTests/Mocks/MockAppDataProvider.swift` | Update mock signatures, add new methods | + +## Edge Cases + +### App built before CloudKit update + +| Date | Event | +|------|-------| +| 1/12 | App published with bundled JSON | +| 1/13 | Developer adds NWSL 2026 to CloudKit | +| 1/14 | User installs app | + +**Handled by:** First sync (lastSync == nil) fetches ALL records from CloudKit, including the 1/13 additions. + +### New league added + +When a new league (e.g., NWSL) is added to CloudKit: +- New records have `modificationDate` = creation timestamp +- Delta sync picks them up if `modificationDate >= lastSync` +- Works for games, teams, stadiums, and all other data types + +## Testing + +Existing tests should pass with method renames. Key scenarios to verify: + +1. First launch: Full sync fetches all data +2. Subsequent launch: Delta sync only fetches modified records +3. "By Games" mode shows all available games +4. New CloudKit data syncs correctly after app update