diff --git a/docs/plans/2026-01-12-delta-sync-implementation.md b/docs/plans/2026-01-12-delta-sync-implementation.md new file mode 100644 index 0000000..63ac751 --- /dev/null +++ b/docs/plans/2026-01-12-delta-sync-implementation.md @@ -0,0 +1,680 @@ +# Delta Sync Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Remove arbitrary date restrictions from game browsing and implement proper delta sync using CloudKit modificationDate. + +**Architecture:** Update CloudKitService to query by `modificationDate` instead of game `dateTime`. First sync fetches all records, subsequent syncs fetch only modified records. Rename DataProvider methods to clarify local vs network semantics. + +**Tech Stack:** Swift, SwiftData, CloudKit, XCTest + +--- + +## Task 1: Update CloudKitService.fetchStadiumsForSync + +**Files:** +- Modify: `SportsTime/Core/Services/CloudKitService.swift:217-231` + +**Step 1: Update method signature and implementation** + +Change from: +```swift +func fetchStadiumsForSync() async throws -> [SyncStadium] +``` + +To: +```swift +/// Fetch stadiums for sync operations +/// - Parameter lastSync: If nil, fetches all stadiums. If provided, fetches only stadiums modified since that date. +func fetchStadiumsForSync(since lastSync: Date?) async throws -> [SyncStadium] { + let predicate: NSPredicate + if let lastSync = lastSync { + predicate = NSPredicate(format: "modificationDate >= %@", lastSync as NSDate) + } else { + predicate = NSPredicate(value: true) + } + let query = CKQuery(recordType: CKRecordType.stadium, predicate: predicate) + + let (results, _) = try await publicDatabase.records(matching: query) + + return results.compactMap { result -> SyncStadium? in + guard case .success(let record) = result.1 else { return nil } + let ckStadium = CKStadium(record: record) + guard let stadium = ckStadium.stadium, + let canonicalId = ckStadium.canonicalId + else { return nil } + return SyncStadium(stadium: stadium, canonicalId: canonicalId) + } +} +``` + +**Step 2: Commit** + +```bash +git add SportsTime/Core/Services/CloudKitService.swift +git commit -m "feat(sync): add lastSync parameter to fetchStadiumsForSync" +``` + +--- + +## Task 2: Update CloudKitService.fetchTeamsForSync + +**Files:** +- Modify: `SportsTime/Core/Services/CloudKitService.swift:233-249` + +**Step 1: Update method signature and implementation** + +Change from per-sport to all teams with delta sync: +```swift +/// Fetch teams for sync operations +/// - Parameter lastSync: If nil, fetches all teams. If provided, fetches only teams modified since that date. +func fetchTeamsForSync(since lastSync: Date?) async throws -> [SyncTeam] { + let predicate: NSPredicate + if let lastSync = lastSync { + predicate = NSPredicate(format: "modificationDate >= %@", lastSync as NSDate) + } else { + predicate = NSPredicate(value: true) + } + let query = CKQuery(recordType: CKRecordType.team, predicate: predicate) + + let (results, _) = try await publicDatabase.records(matching: query) + + return results.compactMap { result -> SyncTeam? in + guard case .success(let record) = result.1 else { return nil } + let ckTeam = CKTeam(record: record) + guard let team = ckTeam.team, + let canonicalId = ckTeam.canonicalId, + let stadiumCanonicalId = ckTeam.stadiumCanonicalId + else { return nil } + return SyncTeam(team: team, canonicalId: canonicalId, stadiumCanonicalId: stadiumCanonicalId) + } +} +``` + +**Step 2: Commit** + +```bash +git add SportsTime/Core/Services/CloudKitService.swift +git commit -m "feat(sync): change fetchTeamsForSync to delta sync (all teams, not per-sport)" +``` + +--- + +## Task 3: Update CloudKitService.fetchGamesForSync + +**Files:** +- Modify: `SportsTime/Core/Services/CloudKitService.swift:251-301` + +**Step 1: Update method signature and implementation** + +Change from date range to delta sync: +```swift +/// Fetch games for sync operations +/// - Parameter lastSync: If nil, fetches all games. If provided, fetches only games modified since that date. +func fetchGamesForSync(since lastSync: Date?) async throws -> [SyncGame] { + let predicate: NSPredicate + if let lastSync = lastSync { + predicate = NSPredicate(format: "modificationDate >= %@", lastSync as NSDate) + } else { + predicate = NSPredicate(value: true) + } + let query = CKQuery(recordType: CKRecordType.game, predicate: predicate) + + let (results, _) = try await publicDatabase.records(matching: query) + + return results.compactMap { result -> SyncGame? in + guard case .success(let record) = result.1 else { return nil } + let ckGame = CKGame(record: record) + + guard let canonicalId = ckGame.canonicalId, + let homeTeamCanonicalId = ckGame.homeTeamCanonicalId, + let awayTeamCanonicalId = ckGame.awayTeamCanonicalId, + let stadiumCanonicalId = ckGame.stadiumCanonicalId + else { return nil } + + guard let game = ckGame.game( + homeTeamId: homeTeamCanonicalId, + awayTeamId: awayTeamCanonicalId, + stadiumId: stadiumCanonicalId + ) else { return nil } + + return SyncGame( + game: game, + canonicalId: canonicalId, + homeTeamCanonicalId: homeTeamCanonicalId, + awayTeamCanonicalId: awayTeamCanonicalId, + stadiumCanonicalId: stadiumCanonicalId + ) + }.sorted { $0.game.dateTime < $1.game.dateTime } +} +``` + +**Step 2: Commit** + +```bash +git add SportsTime/Core/Services/CloudKitService.swift +git commit -m "feat(sync): change fetchGamesForSync to delta sync by modificationDate" +``` + +--- + +## Task 4: Update CanonicalSyncService.syncStadiums + +**Files:** +- Modify: `SportsTime/Core/Services/CanonicalSyncService.swift:208-236` + +**Step 1: Pass lastSync to CloudKit fetch** + +Change line 214 from: +```swift +let syncStadiums = try await cloudKitService.fetchStadiumsForSync() +``` + +To: +```swift +let syncStadiums = try await cloudKitService.fetchStadiumsForSync(since: lastSync) +``` + +**Step 2: Commit** + +```bash +git add SportsTime/Core/Services/CanonicalSyncService.swift +git commit -m "feat(sync): pass lastSync to stadium sync for delta updates" +``` + +--- + +## Task 5: Update CanonicalSyncService.syncTeams + +**Files:** +- Modify: `SportsTime/Core/Services/CanonicalSyncService.swift:238-271` + +**Step 1: Simplify to single CloudKit call** + +Replace the for-loop that calls per-sport: +```swift +@MainActor +private func syncTeams( + context: ModelContext, + since lastSync: Date? +) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) { + // Single call for all teams (no per-sport loop) + let allSyncTeams = try await cloudKitService.fetchTeamsForSync(since: lastSync) + + var updated = 0 + var skippedIncompatible = 0 + var skippedOlder = 0 + + for syncTeam in allSyncTeams { + let result = try mergeTeam( + syncTeam.team, + canonicalId: syncTeam.canonicalId, + stadiumCanonicalId: syncTeam.stadiumCanonicalId, + context: context + ) + + switch result { + case .applied: updated += 1 + case .skippedIncompatible: skippedIncompatible += 1 + case .skippedOlder: skippedOlder += 1 + } + } + + return (updated, skippedIncompatible, skippedOlder) +} +``` + +**Step 2: Commit** + +```bash +git add SportsTime/Core/Services/CanonicalSyncService.swift +git commit -m "feat(sync): simplify team sync to single CloudKit call with delta" +``` + +--- + +## Task 6: Update CanonicalSyncService.syncGames + +**Files:** +- Modify: `SportsTime/Core/Services/CanonicalSyncService.swift:273-311` + +**Step 1: Remove date range, use delta sync** + +Replace lines 278-286: +```swift +@MainActor +private func syncGames( + context: ModelContext, + since lastSync: Date? +) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) { + // Delta sync: nil = all games, Date = only modified since + let syncGames = try await cloudKitService.fetchGamesForSync(since: lastSync) + + var updated = 0 + var skippedIncompatible = 0 + var skippedOlder = 0 + + for syncGame in syncGames { + let result = try mergeGame( + syncGame.game, + canonicalId: syncGame.canonicalId, + homeTeamCanonicalId: syncGame.homeTeamCanonicalId, + awayTeamCanonicalId: syncGame.awayTeamCanonicalId, + stadiumCanonicalId: syncGame.stadiumCanonicalId, + context: context + ) + + switch result { + case .applied: updated += 1 + case .skippedIncompatible: skippedIncompatible += 1 + case .skippedOlder: skippedOlder += 1 + } + } + + return (updated, skippedIncompatible, skippedOlder) +} +``` + +**Step 2: Commit** + +```bash +git add SportsTime/Core/Services/CanonicalSyncService.swift +git commit -m "feat(sync): remove date range from game sync, use modificationDate delta" +``` + +--- + +## Task 7: Rename DataProvider.fetchGames to filterGames + +**Files:** +- Modify: `SportsTime/Core/Services/DataProvider.swift:121-144` + +**Step 1: Rename method** + +Change: +```swift +/// Fetch games from SwiftData within date range +func fetchGames(sports: Set, startDate: Date, endDate: Date) async throws -> [Game] +``` + +To: +```swift +/// Filter games from SwiftData within date range +func filterGames(sports: Set, startDate: Date, endDate: Date) async throws -> [Game] +``` + +**Step 2: Commit** + +```bash +git add SportsTime/Core/Services/DataProvider.swift +git commit -m "refactor: rename fetchGames to filterGames (clarify local query)" +``` + +--- + +## Task 8: Rename DataProvider.fetchRichGames to filterRichGames + +**Files:** +- Modify: `SportsTime/Core/Services/DataProvider.swift:163-175` + +**Step 1: Rename method and update internal call** + +Change: +```swift +/// Fetch games with full team and stadium data +func fetchRichGames(sports: Set, startDate: Date, endDate: Date) async throws -> [RichGame] { + let games = try await fetchGames(sports: sports, startDate: startDate, endDate: endDate) +``` + +To: +```swift +/// Filter games with full team and stadium data within date range +func filterRichGames(sports: Set, startDate: Date, endDate: Date) async throws -> [RichGame] { + let games = try await filterGames(sports: sports, startDate: startDate, endDate: endDate) +``` + +**Step 2: Commit** + +```bash +git add SportsTime/Core/Services/DataProvider.swift +git commit -m "refactor: rename fetchRichGames to filterRichGames" +``` + +--- + +## Task 9: Add DataProvider.allGames method + +**Files:** +- Modify: `SportsTime/Core/Services/DataProvider.swift` (add after filterGames, around line 145) + +**Step 1: Add new method** + +```swift +/// Get all games for specified sports (no date filtering) +func allGames(for sports: Set) async throws -> [Game] { + guard let context = modelContext else { + throw DataProviderError.contextNotConfigured + } + + let sportStrings = sports.map { $0.rawValue } + + let descriptor = FetchDescriptor( + predicate: #Predicate { game in + game.deprecatedAt == nil + }, + sortBy: [SortDescriptor(\.dateTime)] + ) + + let canonicalGames = try context.fetch(descriptor) + + return canonicalGames.compactMap { canonical -> Game? in + guard sportStrings.contains(canonical.sport) else { return nil } + return canonical.toDomain() + } +} +``` + +**Step 2: Commit** + +```bash +git add SportsTime/Core/Services/DataProvider.swift +git commit -m "feat: add allGames method for unfiltered game access" +``` + +--- + +## Task 10: Add DataProvider.allRichGames method + +**Files:** +- Modify: `SportsTime/Core/Services/DataProvider.swift` (add after allGames) + +**Step 1: Add new method** + +```swift +/// Get all games with full team and stadium data (no date filtering) +func allRichGames(for sports: Set) async throws -> [RichGame] { + let games = try await allGames(for: sports) + + return games.compactMap { game in + guard let homeTeam = teamsById[game.homeTeamId], + let awayTeam = teamsById[game.awayTeamId], + let stadium = stadiumsById[game.stadiumId] else { + return nil + } + return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium) + } +} +``` + +**Step 2: Commit** + +```bash +git add SportsTime/Core/Services/DataProvider.swift +git commit -m "feat: add allRichGames method for unfiltered rich game access" +``` + +--- + +## Task 11: Update TripCreationViewModel.loadGamesForBrowsing + +**Files:** +- Modify: `SportsTime/Features/Trip/ViewModels/TripCreationViewModel.swift:491-497` + +**Step 1: Remove 90-day limit, use allGames** + +Change from: +```swift +// Fetch games for next 90 days for browsing +let browseEndDate = Calendar.current.date(byAdding: .day, value: 90, to: Date()) ?? endDate +games = try await dataProvider.fetchGames( + sports: selectedSports, + startDate: Date(), + endDate: browseEndDate +) +``` + +To: +```swift +// Fetch ALL available games for browsing (no date restrictions) +games = try await dataProvider.allGames(for: selectedSports) +``` + +**Step 2: Commit** + +```bash +git add SportsTime/Features/Trip/ViewModels/TripCreationViewModel.swift +git commit -m "feat: remove 90-day limit from game browsing, show all games" +``` + +--- + +## Task 12: Update MockCloudKitService sync methods + +**Files:** +- Modify: `SportsTimeTests/Mocks/MockCloudKitService.swift:165-206` + +**Step 1: Update fetchStadiumsForSync** + +```swift +func fetchStadiumsForSync(since lastSync: Date?) async throws -> [CloudKitService.SyncStadium] { + try await simulateNetwork() + let filtered = lastSync == nil ? stadiums : stadiums // Mock doesn't track modificationDate + return filtered.map { stadium in + CloudKitService.SyncStadium( + stadium: stadium, + canonicalId: stadium.id + ) + } +} +``` + +**Step 2: Update fetchTeamsForSync** + +```swift +func fetchTeamsForSync(since lastSync: Date?) async throws -> [CloudKitService.SyncTeam] { + try await simulateNetwork() + return teams.map { team in + CloudKitService.SyncTeam( + team: team, + canonicalId: team.id, + stadiumCanonicalId: team.stadiumId + ) + } +} +``` + +**Step 3: Update fetchGamesForSync** + +```swift +func fetchGamesForSync(since lastSync: Date?) async throws -> [CloudKitService.SyncGame] { + try await simulateNetwork() + + return games.map { game in + CloudKitService.SyncGame( + game: game, + canonicalId: game.id, + homeTeamCanonicalId: game.homeTeamId, + awayTeamCanonicalId: game.awayTeamId, + stadiumCanonicalId: game.stadiumId + ) + } +} +``` + +**Step 4: Commit** + +```bash +git add SportsTimeTests/Mocks/MockCloudKitService.swift +git commit -m "test: update MockCloudKitService signatures for delta sync" +``` + +--- + +## Task 13: Update MockAppDataProvider methods + +**Files:** +- Modify: `SportsTimeTests/Mocks/MockAppDataProvider.swift:157-189` + +**Step 1: Rename fetchGames to filterGames** + +```swift +func filterGames(sports: Set, startDate: Date, endDate: Date) async throws -> [Game] { + fetchGamesCallCount += 1 + await simulateLatency() + + if config.shouldFailOnFetch { + throw DataProviderError.contextNotConfigured + } + + return games.filter { game in + sports.contains(game.sport) && + game.dateTime >= startDate && + game.dateTime <= endDate + }.sorted { $0.dateTime < $1.dateTime } +} +``` + +**Step 2: Rename fetchRichGames to filterRichGames** + +```swift +func filterRichGames(sports: Set, startDate: Date, endDate: Date) async throws -> [RichGame] { + fetchRichGamesCallCount += 1 + let filteredGames = try await filterGames(sports: sports, startDate: startDate, endDate: endDate) + + return filteredGames.compactMap { game in + richGame(from: game) + } +} +``` + +**Step 3: Add allGames method** + +```swift +func allGames(for sports: Set) async throws -> [Game] { + fetchGamesCallCount += 1 + await simulateLatency() + + if config.shouldFailOnFetch { + throw DataProviderError.contextNotConfigured + } + + return games.filter { game in + sports.contains(game.sport) + }.sorted { $0.dateTime < $1.dateTime } +} +``` + +**Step 4: Add allRichGames method** + +```swift +func allRichGames(for sports: Set) async throws -> [RichGame] { + fetchRichGamesCallCount += 1 + let allGames = try await allGames(for: sports) + + return allGames.compactMap { game in + richGame(from: game) + } +} +``` + +**Step 5: Commit** + +```bash +git add SportsTimeTests/Mocks/MockAppDataProvider.swift +git commit -m "test: update MockAppDataProvider with renamed and new methods" +``` + +--- + +## Task 14: Fix all callers of renamed methods + +**Files:** +- Search and update all files calling the old method names + +**Step 1: Find all usages** + +Run: +```bash +grep -r "fetchGames\|fetchRichGames" --include="*.swift" SportsTime/ SportsTimeTests/ | grep -v "Mock" +``` + +**Step 2: Update each caller** + +For each file found: +- `fetchGames(` → `filterGames(` (when using date parameters) +- `fetchRichGames(` → `filterRichGames(` (when using date parameters) + +Common files likely to need updates: +- `GameMatcher.swift` +- `ScheduleMatcher.swift` +- `TripPlanningEngine.swift` +- Various test files + +**Step 3: Commit** + +```bash +git add -A +git commit -m "refactor: update all callers to use renamed filter methods" +``` + +--- + +## Task 15: Run tests and fix any failures + +**Step 1: Run full test suite** + +```bash +xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' test +``` + +**Step 2: Fix any compilation errors or test failures** + +Most likely issues: +- Missing method implementations +- Signature mismatches between mock and real implementations +- Tests expecting old method names + +**Step 3: Commit fixes** + +```bash +git add -A +git commit -m "fix: resolve test failures from delta sync refactor" +``` + +--- + +## Task 16: Final verification + +**Step 1: Verify CloudKit sync logic** + +Review the sync flow: +1. First launch: `lastSync == nil` → fetches ALL records +2. Subsequent syncs: `lastSync == Date` → fetches only modified records + +**Step 2: Verify game browsing** + +1. Build and run app +2. Go to "By Games" mode +3. Select MLB +4. Verify Houston Astros shows full season (160+ games) + +**Step 3: Final commit** + +```bash +git add -A +git commit -m "feat: complete delta sync and unlimited game browsing implementation" +``` + +--- + +## Summary of Changes + +| File | Changes | +|------|---------| +| `CloudKitService.swift` | 3 methods updated to use `since: Date?` parameter | +| `CanonicalSyncService.swift` | 3 methods updated to pass `lastSync` | +| `DataProvider.swift` | 2 methods renamed, 2 methods added | +| `TripCreationViewModel.swift` | 1 method updated to use `allGames` | +| `MockCloudKitService.swift` | 3 methods updated to match new signatures | +| `MockAppDataProvider.swift` | 2 methods renamed, 2 methods added | +| Various callers | Updated to use new method names |