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>
18 KiB
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:
func fetchStadiumsForSync() async throws -> [SyncStadium]
To:
/// 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
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:
/// 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
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:
/// 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
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:
let syncStadiums = try await cloudKitService.fetchStadiumsForSync()
To:
let syncStadiums = try await cloudKitService.fetchStadiumsForSync(since: lastSync)
Step 2: Commit
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:
@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
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:
@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
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:
/// Fetch games from SwiftData within date range
func fetchGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [Game]
To:
/// Filter games from SwiftData within date range
func filterGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [Game]
Step 2: Commit
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:
/// 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:
/// 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
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
/// 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
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
/// 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
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:
// 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:
// Fetch ALL available games for browsing (no date restrictions)
games = try await dataProvider.allGames(for: selectedSports)
Step 2: Commit
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
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
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
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
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
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
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
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
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
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:
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.swiftScheduleMatcher.swiftTripPlanningEngine.swift- Various test files
Step 3: Commit
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
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
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:
- First launch:
lastSync == nil→ fetches ALL records - Subsequent syncs:
lastSync == Date→ fetches only modified records
Step 2: Verify game browsing
- Build and run app
- Go to "By Games" mode
- Select MLB
- Verify Houston Astros shows full season (160+ games)
Step 3: Final commit
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 |