From 3978429716344d95c89f1adf1d1abeb4ca05a216 Mon Sep 17 00:00:00 2001 From: Trey t Date: Mon, 12 Jan 2026 11:04:52 -0600 Subject: [PATCH] feat: complete delta sync implementation - add allGames, update callers - Add allRichGames method to DataProvider - Update TripCreationViewModel.loadGamesForBrowsing to use allGames (removes 90-day limit) - Update MockCloudKitService sync methods to use new delta sync signatures - Update MockAppDataProvider with renamed methods and new allGames/allRichGames - Fix all callers: ScheduleViewModel, TripCreationViewModel, SuggestedTripsGenerator, GameMatcher - Update CLAUDE.md documentation with new method names This completes the delta sync implementation: - CloudKit sync now uses modificationDate for proper delta sync - First sync fetches ALL data, subsequent syncs only fetch modified records - "By Games" mode now shows all available games (not just 90 days) - All data types (stadiums, teams, games) use consistent delta sync pattern Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 5 +- SportsTime/Core/Services/DataProvider.swift | 14 ++++++ SportsTime/Core/Services/GameMatcher.swift | 2 +- .../Services/SuggestedTripsGenerator.swift | 4 +- .../ViewModels/ScheduleViewModel.swift | 2 +- .../ViewModels/TripCreationViewModel.swift | 13 ++--- .../Mocks/MockAppDataProvider.swift | 50 ++++++++++++++----- .../Mocks/MockCloudKitService.swift | 30 +++++------ 8 files changed, 78 insertions(+), 42 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3e62a48..e004980 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -102,8 +102,9 @@ All code that reads stadiums, teams, games, or league structure MUST use `AppDat // ✅ CORRECT - Use AppDataProvider let stadiums = AppDataProvider.shared.stadiums let teams = AppDataProvider.shared.teams -let games = try await AppDataProvider.shared.fetchGames(sports: sports, startDate: start, endDate: end) -let richGames = try await AppDataProvider.shared.fetchRichGames(...) +let games = try await AppDataProvider.shared.filterGames(sports: sports, startDate: start, endDate: end) +let richGames = try await AppDataProvider.shared.filterRichGames(...) +let allGames = try await AppDataProvider.shared.allGames(for: sports) // ❌ WRONG - Never access CloudKit directly for reads let stadiums = try await CloudKitService.shared.fetchStadiums() // NO! diff --git a/SportsTime/Core/Services/DataProvider.swift b/SportsTime/Core/Services/DataProvider.swift index 504526f..c2f35a1 100644 --- a/SportsTime/Core/Services/DataProvider.swift +++ b/SportsTime/Core/Services/DataProvider.swift @@ -197,6 +197,20 @@ final class AppDataProvider: ObservableObject { } } + /// 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) + } + } + func richGame(from game: Game) -> RichGame? { guard let homeTeam = teamsById[game.homeTeamId], let awayTeam = teamsById[game.awayTeamId], diff --git a/SportsTime/Core/Services/GameMatcher.swift b/SportsTime/Core/Services/GameMatcher.swift index b6b102b..b3d982c 100644 --- a/SportsTime/Core/Services/GameMatcher.swift +++ b/SportsTime/Core/Services/GameMatcher.swift @@ -398,7 +398,7 @@ final class GameMatcher { let sports: Set = sport != nil ? [sport!] : Set(Sport.allCases) do { - let allGames = try await dataProvider.fetchGames(sports: sports, startDate: startDate, endDate: endDate) + let allGames = try await dataProvider.filterGames(sports: sports, startDate: startDate, endDate: endDate) // Filter by stadium let games = allGames.filter { $0.stadiumId == stadium.id } diff --git a/SportsTime/Core/Services/SuggestedTripsGenerator.swift b/SportsTime/Core/Services/SuggestedTripsGenerator.swift index d3c0ba4..545b352 100644 --- a/SportsTime/Core/Services/SuggestedTripsGenerator.swift +++ b/SportsTime/Core/Services/SuggestedTripsGenerator.swift @@ -104,9 +104,9 @@ final class SuggestedTripsGenerator { } do { - // Fetch all games in the window + // Filter all games in the window let allSports = Set(Sport.supported) - let games = try await dataProvider.fetchGames( + let games = try await dataProvider.filterGames( sports: allSports, startDate: startDate, endDate: endDate diff --git a/SportsTime/Features/Schedule/ViewModels/ScheduleViewModel.swift b/SportsTime/Features/Schedule/ViewModels/ScheduleViewModel.swift index 02270fe..8a505b1 100644 --- a/SportsTime/Features/Schedule/ViewModels/ScheduleViewModel.swift +++ b/SportsTime/Features/Schedule/ViewModels/ScheduleViewModel.swift @@ -95,7 +95,7 @@ final class ScheduleViewModel { return } - games = try await dataProvider.fetchRichGames( + games = try await dataProvider.filterRichGames( sports: selectedSports, startDate: startDate, endDate: endDate diff --git a/SportsTime/Features/Trip/ViewModels/TripCreationViewModel.swift b/SportsTime/Features/Trip/ViewModels/TripCreationViewModel.swift index c479484..25b440d 100644 --- a/SportsTime/Features/Trip/ViewModels/TripCreationViewModel.swift +++ b/SportsTime/Features/Trip/ViewModels/TripCreationViewModel.swift @@ -260,8 +260,8 @@ final class TripCreationViewModel { stadiums[stadium.id] = stadium } - // Fetch games - games = try await dataProvider.fetchGames( + // Filter games within date range + games = try await dataProvider.filterGames( sports: selectedSports, startDate: startDate, endDate: endDate @@ -488,13 +488,8 @@ final class TripCreationViewModel { stadiums[stadium.id] = stadium } - // 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 - ) + // Fetch all games for browsing (no date filter) + games = try await dataProvider.allGames(for: selectedSports) // Build rich games for display availableGames = games.compactMap { game -> RichGame? in diff --git a/SportsTimeTests/Mocks/MockAppDataProvider.swift b/SportsTimeTests/Mocks/MockAppDataProvider.swift index 0650c4a..f2087d7 100644 --- a/SportsTimeTests/Mocks/MockAppDataProvider.swift +++ b/SportsTimeTests/Mocks/MockAppDataProvider.swift @@ -48,8 +48,10 @@ final class MockAppDataProvider: ObservableObject { // MARK: - Call Tracking private(set) var loadInitialDataCallCount = 0 - private(set) var fetchGamesCallCount = 0 - private(set) var fetchRichGamesCallCount = 0 + private(set) var filterGamesCallCount = 0 + private(set) var filterRichGamesCallCount = 0 + private(set) var allGamesCallCount = 0 + private(set) var allRichGamesCallCount = 0 // MARK: - Initialization @@ -89,8 +91,10 @@ final class MockAppDataProvider: ObservableObject { error = nil errorMessage = nil loadInitialDataCallCount = 0 - fetchGamesCallCount = 0 - fetchRichGamesCallCount = 0 + filterGamesCallCount = 0 + filterRichGamesCallCount = 0 + allGamesCallCount = 0 + allRichGamesCallCount = 0 config = .default } @@ -152,10 +156,10 @@ final class MockAppDataProvider: ObservableObject { teams.filter { $0.sport == sport } } - // MARK: - Game Fetching + // MARK: - Game Filtering (Local Queries) - func fetchGames(sports: Set, startDate: Date, endDate: Date) async throws -> [Game] { - fetchGamesCallCount += 1 + func filterGames(sports: Set, startDate: Date, endDate: Date) async throws -> [Game] { + filterGamesCallCount += 1 await simulateLatency() if config.shouldFailOnFetch { @@ -169,6 +173,19 @@ final class MockAppDataProvider: ObservableObject { }.sorted { $0.dateTime < $1.dateTime } } + func allGames(for sports: Set) async throws -> [Game] { + allGamesCallCount += 1 + await simulateLatency() + + if config.shouldFailOnFetch { + throw DataProviderError.contextNotConfigured + } + + return games.filter { game in + sports.contains(game.sport) + }.sorted { $0.dateTime < $1.dateTime } + } + func fetchGame(by id: String) async throws -> Game? { await simulateLatency() @@ -179,15 +196,24 @@ final class MockAppDataProvider: ObservableObject { return gamesById[id] } - func fetchRichGames(sports: Set, startDate: Date, endDate: Date) async throws -> [RichGame] { - fetchRichGamesCallCount += 1 - let filteredGames = try await fetchGames(sports: sports, startDate: startDate, endDate: endDate) + func filterRichGames(sports: Set, startDate: Date, endDate: Date) async throws -> [RichGame] { + filterRichGamesCallCount += 1 + let filteredGames = try await filterGames(sports: sports, startDate: startDate, endDate: endDate) return filteredGames.compactMap { game in richGame(from: game) } } + func allRichGames(for sports: Set) async throws -> [RichGame] { + allRichGamesCallCount += 1 + let allFilteredGames = try await allGames(for: sports) + + return allFilteredGames.compactMap { game in + richGame(from: game) + } + } + func richGame(from game: Game) -> RichGame? { guard let homeTeam = teamsById[game.homeTeamId], let awayTeam = teamsById[game.awayTeamId], @@ -248,8 +274,8 @@ extension MockAppDataProvider { stadiumsById[stadium.id] = stadium } - /// Get all games (for test verification) - func allGames() -> [Game] { + /// Get all stored games (for test verification) + func getAllStoredGames() -> [Game] { games } diff --git a/SportsTimeTests/Mocks/MockCloudKitService.swift b/SportsTimeTests/Mocks/MockCloudKitService.swift index 385ee50..6149c4c 100644 --- a/SportsTimeTests/Mocks/MockCloudKitService.swift +++ b/SportsTimeTests/Mocks/MockCloudKitService.swift @@ -160,10 +160,13 @@ actor MockCloudKitService { return games.first { $0.id == id } } - // MARK: - Sync Fetch Methods + // MARK: - Sync Fetch Methods (Delta Sync Pattern) - func fetchStadiumsForSync() async throws -> [CloudKitService.SyncStadium] { + /// Fetch stadiums for sync - returns all if lastSync is nil, otherwise filters by modification date + func fetchStadiumsForSync(since lastSync: Date?) async throws -> [CloudKitService.SyncStadium] { try await simulateNetwork() + // Mock doesn't track modification dates, so return all stadiums + // (In production, CloudKit filters by modificationDate) return stadiums.map { stadium in CloudKitService.SyncStadium( stadium: stadium, @@ -172,9 +175,12 @@ actor MockCloudKitService { } } - func fetchTeamsForSync(for sport: Sport) async throws -> [CloudKitService.SyncTeam] { + /// Fetch teams for sync - returns all if lastSync is nil, otherwise filters by modification date + func fetchTeamsForSync(since lastSync: Date?) async throws -> [CloudKitService.SyncTeam] { try await simulateNetwork() - return teams.filter { $0.sport == sport }.map { team in + // Mock doesn't track modification dates, so return all teams + // (In production, CloudKit filters by modificationDate) + return teams.map { team in CloudKitService.SyncTeam( team: team, canonicalId: team.id, @@ -183,18 +189,12 @@ actor MockCloudKitService { } } - func fetchGamesForSync( - sports: Set, - startDate: Date, - endDate: Date - ) async throws -> [CloudKitService.SyncGame] { + /// Fetch games for sync - returns all if lastSync is nil, otherwise filters by modification date + func fetchGamesForSync(since lastSync: Date?) async throws -> [CloudKitService.SyncGame] { try await simulateNetwork() - - return games.filter { game in - sports.contains(game.sport) && - game.dateTime >= startDate && - game.dateTime <= endDate - }.map { game in + // Mock doesn't track modification dates, so return all games + // (In production, CloudKit filters by modificationDate) + return games.map { game in CloudKitService.SyncGame( game: game, canonicalId: game.id,