docs: add delta sync implementation plan

16-task TDD implementation plan for:
- CloudKit delta sync using modificationDate
- Remove 90-day game browsing limit
- Rename fetch* to filter* for clarity
- Add allGames/allRichGames methods

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-12 10:44:42 -06:00
parent ffe5c0b6f7
commit b514d2119c

View File

@@ -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<Sport>, startDate: Date, endDate: Date) async throws -> [Game]
```
To:
```swift
/// Filter games from SwiftData within date range
func filterGames(sports: Set<Sport>, 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<Sport>, 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<Sport>, 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<Sport>) async throws -> [Game] {
guard let context = modelContext else {
throw DataProviderError.contextNotConfigured
}
let sportStrings = sports.map { $0.rawValue }
let descriptor = FetchDescriptor<CanonicalGame>(
predicate: #Predicate<CanonicalGame> { 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<Sport>) 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<Sport>, 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<Sport>, 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<Sport>) 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<Sport>) 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 |