From 1ee47df53ee4b58057e171c7fe4fe950bfd8ef46 Mon Sep 17 00:00:00 2001 From: Trey t Date: Thu, 8 Jan 2026 22:20:07 -0600 Subject: [PATCH] Add StadiumAlias CloudKit sync and offline-first data architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add CKStadiumAlias model for CloudKit record mapping - Add fetchStadiumAliases/fetchStadiumAliasChanges to CloudKitService - Add syncStadiumAliases to CanonicalSyncService for delta sync - Add subscribeToStadiumAliasUpdates for push notifications - Update cloudkit_import.py with --stadium-aliases-only option Data Architecture Updates: - Remove obsolete provider files (CanonicalDataProvider, CloudKitDataProvider, StubDataProvider) - AppDataProvider now reads exclusively from SwiftData - Add background CloudKit sync on app startup (non-blocking) - Document data architecture in CLAUDE.md 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 72 +++- Scripts/cloudkit_import.py | 87 ++-- SportsTime.xcodeproj/project.pbxproj | 2 + .../Core/Models/CloudKit/CKModels.swift | 50 +++ .../Core/Services/CanonicalDataProvider.swift | 234 ----------- .../Core/Services/CanonicalSyncService.swift | 82 +++- .../Core/Services/CloudKitDataProvider.swift | 108 ----- .../Core/Services/CloudKitService.swift | 54 +++ SportsTime/Core/Services/DataProvider.swift | 163 ++++++-- .../Core/Services/StubDataProvider.swift | 373 ------------------ SportsTime/Info.plist | 2 + SportsTime/SportsTimeApp.swift | 35 ++ 12 files changed, 482 insertions(+), 780 deletions(-) delete mode 100644 SportsTime/Core/Services/CanonicalDataProvider.swift delete mode 100644 SportsTime/Core/Services/CloudKitDataProvider.swift delete mode 100644 SportsTime/Core/Services/StubDataProvider.swift diff --git a/CLAUDE.md b/CLAUDE.md index 42bd781..f9955e3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,11 +50,75 @@ This is an iOS app for planning multi-stop sports road trips. It uses **Clean MV - `Services/POISearchService` - Finds nearby restaurants, attractions via MKLocalSearch - `Services/PDFAssetPrefetcher` - Parallel prefetching of all PDF assets -### Data Storage Strategy +### Data Architecture (Offline-First) -- **CloudKit Public DB**: Read-only schedules, stadiums, teams (shared across all users) -- **SwiftData Local**: User's saved trips, preferences, cached schedules -- **No network dependency** for trip planning once schedules are synced +**CRITICAL: `AppDataProvider.shared` is the ONLY source of truth for canonical data.** + +All code that reads stadiums, teams, games, or league structure MUST use `AppDataProvider.shared`. Never access CloudKit or SwiftData directly for this data. + +``` +┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ +│ Bundled JSON │ │ SwiftData │ │ CloudKit │ +│ (App Bundle) │ │ (Local Store) │ │ (Remote Sync) │ +└────────┬─────────┘ └────────▲─────────┘ └────────┬─────────┘ + │ │ │ + │ Bootstrap │ Read │ Background + │ (first launch) │ │ Sync + ▼ │ ▼ +┌─────────────────────────────────┴────────────────────────────────────┐ +│ AppDataProvider.shared │ +│ (Single Source of Truth) │ +└─────────────────────────────────┬────────────────────────────────────┘ + │ + ▼ + All Features, ViewModels, Services +``` + +**App Startup Flow:** +1. **Bootstrap** (first launch only): `BootstrapService` loads bundled JSON → SwiftData +2. **Configure**: `AppDataProvider.shared.configure(with: context)` +3. **Load**: `AppDataProvider.shared.loadInitialData()` reads SwiftData into memory +4. **App usable immediately** with local data +5. **Background sync**: `CanonicalSyncService.syncAll()` fetches CloudKit → updates SwiftData (non-blocking) +6. **Reload**: After sync completes, `loadInitialData()` refreshes in-memory cache + +**Offline Handling:** +- SwiftData always has data (from bootstrap or last successful CloudKit sync) +- If CloudKit unavailable, app continues with existing local data +- First launch + offline = bootstrap data used + +**Canonical Data Models** (in SwiftData, synced from CloudKit): +- `CanonicalStadium` → `Stadium` (domain) +- `CanonicalTeam` → `Team` (domain) +- `CanonicalGame` → `Game` (domain) +- `LeagueStructureModel` +- `TeamAlias`, `StadiumAlias` + +**User Data Models** (local only, not synced): +- `SavedTrip`, `StadiumVisit`, `UserPreferences`, `Achievement` + +**Correct Usage:** +```swift +// ✅ 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(...) + +// ❌ WRONG - Never access CloudKit directly for reads +let stadiums = try await CloudKitService.shared.fetchStadiums() // NO! + +// ❌ WRONG - Never fetch canonical data from SwiftData directly +let descriptor = FetchDescriptor() +let stadiums = try context.fetch(descriptor) // NO! (except in DataProvider/Sync/Bootstrap) +``` + +**Allowed Direct SwiftData Access:** +- `DataProvider.swift` - It IS the data provider +- `CanonicalSyncService.swift` - CloudKit → SwiftData sync +- `BootstrapService.swift` - Initial data population +- `StadiumIdentityService.swift` - Specialized identity resolution for stadium renames +- User data (e.g., `StadiumVisit`) - Not canonical data ### Key Data Flow diff --git a/Scripts/cloudkit_import.py b/Scripts/cloudkit_import.py index dc0e1b4..5108f3a 100755 --- a/Scripts/cloudkit_import.py +++ b/Scripts/cloudkit_import.py @@ -14,6 +14,7 @@ Usage: python cloudkit_import.py --key-id XX --key-file key.p8 # Import all python cloudkit_import.py --stadiums-only ... # Stadiums first python cloudkit_import.py --games-only ... # Games after + python cloudkit_import.py --stadium-aliases-only ... # Stadium aliases only python cloudkit_import.py --delete-all ... # Delete then import python cloudkit_import.py --delete-only ... # Delete only (no import) """ @@ -44,26 +45,27 @@ def show_menu(): print("\n" + "="*50) print("CloudKit Import - Select Action") print("="*50) - print("\n 1. Import all (stadiums, teams, games, league structure, team aliases)") + print("\n 1. Import all (stadiums, teams, games, league structure, team aliases, stadium aliases)") print(" 2. Stadiums only") print(" 3. Games only") print(" 4. League structure only") print(" 5. Team aliases only") - print(" 6. Canonical only (league structure + team aliases)") - print(" 7. Delete all then import") - print(" 8. Delete only (no import)") - print(" 9. Dry run (preview only)") + print(" 6. Stadium aliases only") + print(" 7. Canonical only (league structure + team aliases + stadium aliases)") + print(" 8. Delete all then import") + print(" 9. Delete only (no import)") + print(" 10. Dry run (preview only)") print(" 0. Exit") print() while True: try: - choice = input("Enter choice [1-9, 0 to exit]: ").strip() + choice = input("Enter choice [1-10, 0 to exit]: ").strip() if choice == '0': return None - if choice in ['1', '2', '3', '4', '5', '6', '7', '8', '9']: + if choice in ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10']: return int(choice) - print("Invalid choice. Please enter 1-9 or 0.") + print("Invalid choice. Please enter 1-10 or 0.") except (EOFError, KeyboardInterrupt): print("\nExiting.") return None @@ -257,7 +259,8 @@ def main(): p.add_argument('--games-only', action='store_true') p.add_argument('--league-structure-only', action='store_true', help='Import only league structure') p.add_argument('--team-aliases-only', action='store_true', help='Import only team aliases') - p.add_argument('--canonical-only', action='store_true', help='Import only canonical data (league structure + team aliases)') + p.add_argument('--stadium-aliases-only', action='store_true', help='Import only stadium aliases') + p.add_argument('--canonical-only', action='store_true', help='Import only canonical data (league structure + team aliases + stadium aliases)') p.add_argument('--delete-all', action='store_true', help='Delete all records before importing') p.add_argument('--delete-only', action='store_true', help='Only delete records, do not import') p.add_argument('--dry-run', action='store_true') @@ -268,8 +271,8 @@ def main(): # Show interactive menu if no action flags provided or --interactive has_action_flag = any([ args.stadiums_only, args.games_only, args.league_structure_only, - args.team_aliases_only, args.canonical_only, args.delete_all, - args.delete_only, args.dry_run + args.team_aliases_only, args.stadium_aliases_only, args.canonical_only, + args.delete_all, args.delete_only, args.dry_run ]) if args.interactive or not has_action_flag: @@ -288,13 +291,15 @@ def main(): args.league_structure_only = True elif choice == 5: # Team aliases only args.team_aliases_only = True - elif choice == 6: # Canonical only + elif choice == 6: # Stadium aliases only + args.stadium_aliases_only = True + elif choice == 7: # Canonical only args.canonical_only = True - elif choice == 7: # Delete all then import + elif choice == 8: # Delete all then import args.delete_all = True - elif choice == 8: # Delete only + elif choice == 9: # Delete only args.delete_only = True - elif choice == 9: # Dry run + elif choice == 10: # Dry run args.dry_run = True print(f"\n{'='*50}") @@ -308,7 +313,8 @@ def main(): games = json.load(open(data_dir / 'games.json')) if (data_dir / 'games.json').exists() else [] league_structure = json.load(open(data_dir / 'league_structure.json')) if (data_dir / 'league_structure.json').exists() else [] team_aliases = json.load(open(data_dir / 'team_aliases.json')) if (data_dir / 'team_aliases.json').exists() else [] - print(f"Loaded {len(stadiums)} stadiums, {len(games)} games, {len(league_structure)} league structures, {len(team_aliases)} team aliases\n") + stadium_aliases = json.load(open(data_dir / 'stadium_aliases.json')) if (data_dir / 'stadium_aliases.json').exists() else [] + print(f"Loaded {len(stadiums)} stadiums, {len(games)} games, {len(league_structure)} league structures, {len(team_aliases)} team aliases, {len(stadium_aliases)} stadium aliases\n") ck = None if not args.dry_run: @@ -325,7 +331,7 @@ def main(): print("--- Deleting Existing Records ---") # Delete in order: dependent records first, then base records - for record_type in ['Game', 'TeamAlias', 'Team', 'LeagueStructure', 'Stadium']: + for record_type in ['Game', 'TeamAlias', 'StadiumAlias', 'Team', 'LeagueStructure', 'Stadium']: print(f" Deleting {record_type} records...") deleted = ck.delete_all(record_type, verbose=args.verbose) print(f" Deleted {deleted} {record_type} records") @@ -336,15 +342,16 @@ def main(): print() return - stats = {'stadiums': 0, 'teams': 0, 'games': 0, 'league_structures': 0, 'team_aliases': 0} + stats = {'stadiums': 0, 'teams': 0, 'games': 0, 'league_structures': 0, 'team_aliases': 0, 'stadium_aliases': 0} team_map = {} # Determine what to import based on flags - import_stadiums = not args.games_only and not args.league_structure_only and not args.team_aliases_only and not args.canonical_only - import_teams = not args.games_only and not args.league_structure_only and not args.team_aliases_only and not args.canonical_only - import_games = not args.stadiums_only and not args.league_structure_only and not args.team_aliases_only and not args.canonical_only - import_league_structure = args.league_structure_only or args.canonical_only or (not args.stadiums_only and not args.games_only and not args.team_aliases_only) - import_team_aliases = args.team_aliases_only or args.canonical_only or (not args.stadiums_only and not args.games_only and not args.league_structure_only) + import_stadiums = not args.games_only and not args.league_structure_only and not args.team_aliases_only and not args.stadium_aliases_only and not args.canonical_only + import_teams = not args.games_only and not args.league_structure_only and not args.team_aliases_only and not args.stadium_aliases_only and not args.canonical_only + import_games = not args.stadiums_only and not args.league_structure_only and not args.team_aliases_only and not args.stadium_aliases_only and not args.canonical_only + import_league_structure = args.league_structure_only or args.canonical_only or (not args.stadiums_only and not args.games_only and not args.team_aliases_only and not args.stadium_aliases_only) + import_team_aliases = args.team_aliases_only or args.canonical_only or (not args.stadiums_only and not args.games_only and not args.league_structure_only and not args.stadium_aliases_only) + import_stadium_aliases = args.stadium_aliases_only or args.canonical_only or (not args.stadiums_only and not args.games_only and not args.league_structure_only and not args.team_aliases_only) # Build stadium UUID lookup (stadium string ID -> UUID) stadium_uuid_map = {s['id']: deterministic_uuid(s['id']) for s in stadiums} @@ -522,8 +529,40 @@ def main(): }) stats['team_aliases'] = import_data(ck, recs, 'team aliases', args.dry_run, args.verbose) + # Import stadium aliases + if import_stadium_aliases and stadium_aliases: + print("--- Stadium Aliases ---") + now_ms = int(datetime.now(timezone.utc).timestamp() * 1000) + recs = [] + for sa in stadium_aliases: + fields = { + 'aliasName': {'value': sa['alias_name'].lower()}, # Normalize to lowercase + 'stadiumCanonicalId': {'value': sa['stadium_canonical_id']}, + 'schemaVersion': {'value': 1}, + 'lastModified': {'value': now_ms, 'type': 'TIMESTAMP'}, + } + # Add optional date fields + if sa.get('valid_from'): + try: + dt = datetime.strptime(sa['valid_from'], '%Y-%m-%d') + fields['validFrom'] = {'value': int(dt.timestamp() * 1000), 'type': 'TIMESTAMP'} + except: + pass + if sa.get('valid_until'): + try: + dt = datetime.strptime(sa['valid_until'], '%Y-%m-%d') + fields['validUntil'] = {'value': int(dt.timestamp() * 1000), 'type': 'TIMESTAMP'} + except: + pass + recs.append({ + 'recordType': 'StadiumAlias', + 'recordName': sa['alias_name'].lower(), # Use alias_name as recordName (unique key) + 'fields': fields + }) + stats['stadium_aliases'] = import_data(ck, recs, 'stadium aliases', args.dry_run, args.verbose) + print(f"\n{'='*50}") - print(f"COMPLETE: {stats['stadiums']} stadiums, {stats['teams']} teams, {stats['games']} games, {stats['league_structures']} league structures, {stats['team_aliases']} team aliases") + print(f"COMPLETE: {stats['stadiums']} stadiums, {stats['teams']} teams, {stats['games']} games, {stats['league_structures']} league structures, {stats['team_aliases']} team aliases, {stats['stadium_aliases']} stadium aliases") if args.dry_run: print("[DRY RUN - nothing imported]") print() diff --git a/SportsTime.xcodeproj/project.pbxproj b/SportsTime.xcodeproj/project.pbxproj index 3761c7e..eb5df82 100644 --- a/SportsTime.xcodeproj/project.pbxproj +++ b/SportsTime.xcodeproj/project.pbxproj @@ -294,6 +294,7 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = SportsTime/Info.plist; + INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "SportsTime uses your photos to import stadium visits by reading GPS location and date from photo metadata."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -328,6 +329,7 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = SportsTime/Info.plist; + INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "SportsTime uses your photos to import stadium visits by reading GPS location and date from photo metadata."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; diff --git a/SportsTime/Core/Models/CloudKit/CKModels.swift b/SportsTime/Core/Models/CloudKit/CKModels.swift index e99a2b8..72aeee9 100644 --- a/SportsTime/Core/Models/CloudKit/CKModels.swift +++ b/SportsTime/Core/Models/CloudKit/CKModels.swift @@ -17,6 +17,7 @@ enum CKRecordType { static let sport = "Sport" static let leagueStructure = "LeagueStructure" static let teamAlias = "TeamAlias" + static let stadiumAlias = "StadiumAlias" } // MARK: - CKTeam @@ -273,6 +274,55 @@ struct CKLeagueStructure { } } +// MARK: - CKStadiumAlias + +struct CKStadiumAlias { + static let aliasNameKey = "aliasName" + static let stadiumCanonicalIdKey = "stadiumCanonicalId" + static let validFromKey = "validFrom" + static let validUntilKey = "validUntil" + static let schemaVersionKey = "schemaVersion" + static let lastModifiedKey = "lastModified" + + let record: CKRecord + + init(record: CKRecord) { + self.record = record + } + + init(model: StadiumAlias) { + let record = CKRecord(recordType: CKRecordType.stadiumAlias, recordID: CKRecord.ID(recordName: model.aliasName)) + record[CKStadiumAlias.aliasNameKey] = model.aliasName + record[CKStadiumAlias.stadiumCanonicalIdKey] = model.stadiumCanonicalId + record[CKStadiumAlias.validFromKey] = model.validFrom + record[CKStadiumAlias.validUntilKey] = model.validUntil + record[CKStadiumAlias.schemaVersionKey] = model.schemaVersion + record[CKStadiumAlias.lastModifiedKey] = model.lastModified + self.record = record + } + + /// Convert to SwiftData model for local storage + func toModel() -> StadiumAlias? { + guard let aliasName = record[CKStadiumAlias.aliasNameKey] as? String, + let stadiumCanonicalId = record[CKStadiumAlias.stadiumCanonicalIdKey] as? String + else { return nil } + + let validFrom = record[CKStadiumAlias.validFromKey] as? Date + let validUntil = record[CKStadiumAlias.validUntilKey] as? Date + let schemaVersion = record[CKStadiumAlias.schemaVersionKey] as? Int ?? SchemaVersion.current + let lastModified = record[CKStadiumAlias.lastModifiedKey] as? Date ?? record.modificationDate ?? Date() + + return StadiumAlias( + aliasName: aliasName, + stadiumCanonicalId: stadiumCanonicalId, + validFrom: validFrom, + validUntil: validUntil, + schemaVersion: schemaVersion, + lastModified: lastModified + ) + } +} + // MARK: - CKTeamAlias struct CKTeamAlias { diff --git a/SportsTime/Core/Services/CanonicalDataProvider.swift b/SportsTime/Core/Services/CanonicalDataProvider.swift deleted file mode 100644 index 35327bd..0000000 --- a/SportsTime/Core/Services/CanonicalDataProvider.swift +++ /dev/null @@ -1,234 +0,0 @@ -// -// CanonicalDataProvider.swift -// SportsTime -// -// DataProvider implementation that reads from SwiftData canonical models. -// This is the primary data source after bootstrap completes. -// - -import Foundation -import SwiftData - -actor CanonicalDataProvider: DataProvider { - - // MARK: - Properties - - private let modelContainer: ModelContainer - - // Caches for converted domain objects (rebuilt on first access) - private var cachedTeams: [Team]? - private var cachedStadiums: [Stadium]? - private var teamsByCanonicalId: [String: Team] = [:] - private var stadiumsByCanonicalId: [String: Stadium] = [:] - private var teamUUIDByCanonicalId: [String: UUID] = [:] - private var stadiumUUIDByCanonicalId: [String: UUID] = [:] - - // MARK: - Initialization - - init(modelContainer: ModelContainer) { - self.modelContainer = modelContainer - } - - // MARK: - DataProvider Protocol - - func fetchTeams(for sport: Sport) async throws -> [Team] { - try await loadCachesIfNeeded() - return cachedTeams?.filter { $0.sport == sport } ?? [] - } - - func fetchAllTeams() async throws -> [Team] { - try await loadCachesIfNeeded() - return cachedTeams ?? [] - } - - func fetchStadiums() async throws -> [Stadium] { - try await loadCachesIfNeeded() - return cachedStadiums ?? [] - } - - func fetchGames(sports: Set, startDate: Date, endDate: Date) async throws -> [Game] { - try await loadCachesIfNeeded() - - let context = ModelContext(modelContainer) - - // Fetch canonical games within date range - let sportStrings = sports.map { $0.rawValue } - let descriptor = FetchDescriptor( - predicate: #Predicate { game in - sportStrings.contains(game.sport) && - game.dateTime >= startDate && - game.dateTime <= endDate && - game.deprecatedAt == nil - }, - sortBy: [SortDescriptor(\.dateTime)] - ) - - let canonicalGames = try context.fetch(descriptor) - - // Convert to domain models - return canonicalGames.compactMap { canonical -> Game? in - guard let homeTeamUUID = teamUUIDByCanonicalId[canonical.homeTeamCanonicalId], - let awayTeamUUID = teamUUIDByCanonicalId[canonical.awayTeamCanonicalId], - let stadiumUUID = stadiumUUIDByCanonicalId[canonical.stadiumCanonicalId] else { - return nil - } - - return Game( - id: canonical.uuid, - homeTeamId: homeTeamUUID, - awayTeamId: awayTeamUUID, - stadiumId: stadiumUUID, - dateTime: canonical.dateTime, - sport: canonical.sportEnum ?? .mlb, - season: canonical.season, - isPlayoff: canonical.isPlayoff, - broadcastInfo: canonical.broadcastInfo - ) - } - } - - func fetchGame(by id: UUID) async throws -> Game? { - try await loadCachesIfNeeded() - - let context = ModelContext(modelContainer) - - // Search by UUID - let descriptor = FetchDescriptor( - predicate: #Predicate { game in - game.uuid == id && game.deprecatedAt == nil - } - ) - - guard let canonical = try context.fetch(descriptor).first else { - return nil - } - - guard let homeTeamUUID = teamUUIDByCanonicalId[canonical.homeTeamCanonicalId], - let awayTeamUUID = teamUUIDByCanonicalId[canonical.awayTeamCanonicalId], - let stadiumUUID = stadiumUUIDByCanonicalId[canonical.stadiumCanonicalId] else { - return nil - } - - return Game( - id: canonical.uuid, - homeTeamId: homeTeamUUID, - awayTeamId: awayTeamUUID, - stadiumId: stadiumUUID, - dateTime: canonical.dateTime, - sport: canonical.sportEnum ?? .mlb, - season: canonical.season, - isPlayoff: canonical.isPlayoff, - broadcastInfo: canonical.broadcastInfo - ) - } - - func fetchRichGames(sports: Set, startDate: Date, endDate: Date) async throws -> [RichGame] { - try await loadCachesIfNeeded() - - let games = try await fetchGames(sports: sports, startDate: startDate, endDate: endDate) - let teamsById = Dictionary(uniqueKeysWithValues: (cachedTeams ?? []).map { ($0.id, $0) }) - let stadiumsById = Dictionary(uniqueKeysWithValues: (cachedStadiums ?? []).map { ($0.id, $0) }) - - 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) - } - } - - // MARK: - Additional Queries - - /// Fetch stadium by canonical ID (useful for visit tracking) - func fetchStadium(byCanonicalId canonicalId: String) async throws -> Stadium? { - try await loadCachesIfNeeded() - return stadiumsByCanonicalId[canonicalId] - } - - /// Fetch team by canonical ID - func fetchTeam(byCanonicalId canonicalId: String) async throws -> Team? { - try await loadCachesIfNeeded() - return teamsByCanonicalId[canonicalId] - } - - /// Find stadium by name (matches aliases) - func findStadium(byName name: String) async throws -> Stadium? { - let context = ModelContext(modelContainer) - - // Precompute lowercased name outside the predicate - let lowercasedName = name.lowercased() - - // First try exact alias match - let aliasDescriptor = FetchDescriptor( - predicate: #Predicate { alias in - alias.aliasName == lowercasedName - } - ) - - if let alias = try context.fetch(aliasDescriptor).first, - let stadiumCanonicalId = Optional(alias.stadiumCanonicalId) { - return try await fetchStadium(byCanonicalId: stadiumCanonicalId) - } - - return nil - } - - /// Invalidate caches (call after sync completes) - func invalidateCaches() { - cachedTeams = nil - cachedStadiums = nil - teamsByCanonicalId.removeAll() - stadiumsByCanonicalId.removeAll() - teamUUIDByCanonicalId.removeAll() - stadiumUUIDByCanonicalId.removeAll() - } - - // MARK: - Private Helpers - - private func loadCachesIfNeeded() async throws { - guard cachedTeams == nil else { return } - - let context = ModelContext(modelContainer) - - // Load stadiums - let stadiumDescriptor = FetchDescriptor( - predicate: #Predicate { stadium in - stadium.deprecatedAt == nil - } - ) - let canonicalStadiums = try context.fetch(stadiumDescriptor) - - cachedStadiums = canonicalStadiums.map { canonical in - let stadium = canonical.toDomain() - stadiumsByCanonicalId[canonical.canonicalId] = stadium - stadiumUUIDByCanonicalId[canonical.canonicalId] = stadium.id - return stadium - } - - // Load teams - let teamDescriptor = FetchDescriptor( - predicate: #Predicate { team in - team.deprecatedAt == nil - } - ) - let canonicalTeams = try context.fetch(teamDescriptor) - - cachedTeams = canonicalTeams.compactMap { canonical -> Team? in - guard let stadiumUUID = stadiumUUIDByCanonicalId[canonical.stadiumCanonicalId] else { - // Generate a placeholder UUID for teams without known stadiums - let placeholderUUID = CanonicalStadium.deterministicUUID(from: canonical.stadiumCanonicalId) - let team = canonical.toDomain(stadiumUUID: placeholderUUID) - teamsByCanonicalId[canonical.canonicalId] = team - teamUUIDByCanonicalId[canonical.canonicalId] = team.id - return team - } - - let team = canonical.toDomain(stadiumUUID: stadiumUUID) - teamsByCanonicalId[canonical.canonicalId] = team - teamUUIDByCanonicalId[canonical.canonicalId] = team.id - return team - } - } -} diff --git a/SportsTime/Core/Services/CanonicalSyncService.swift b/SportsTime/Core/Services/CanonicalSyncService.swift index f40350f..ab2c349 100644 --- a/SportsTime/Core/Services/CanonicalSyncService.swift +++ b/SportsTime/Core/Services/CanonicalSyncService.swift @@ -42,12 +42,13 @@ actor CanonicalSyncService { let gamesUpdated: Int let leagueStructuresUpdated: Int let teamAliasesUpdated: Int + let stadiumAliasesUpdated: Int let skippedIncompatible: Int let skippedOlder: Int let duration: TimeInterval var totalUpdated: Int { - stadiumsUpdated + teamsUpdated + gamesUpdated + leagueStructuresUpdated + teamAliasesUpdated + stadiumsUpdated + teamsUpdated + gamesUpdated + leagueStructuresUpdated + teamAliasesUpdated + stadiumAliasesUpdated } var isEmpty: Bool { totalUpdated == 0 } @@ -81,7 +82,7 @@ actor CanonicalSyncService { guard syncState.syncEnabled else { return SyncResult( stadiumsUpdated: 0, teamsUpdated: 0, gamesUpdated: 0, - leagueStructuresUpdated: 0, teamAliasesUpdated: 0, + leagueStructuresUpdated: 0, teamAliasesUpdated: 0, stadiumAliasesUpdated: 0, skippedIncompatible: 0, skippedOlder: 0, duration: 0 ) @@ -101,6 +102,7 @@ actor CanonicalSyncService { var totalGames = 0 var totalLeagueStructures = 0 var totalTeamAliases = 0 + var totalStadiumAliases = 0 var totalSkippedIncompatible = 0 var totalSkippedOlder = 0 @@ -138,13 +140,21 @@ actor CanonicalSyncService { totalSkippedIncompatible += skipIncompat4 totalSkippedOlder += skipOlder4 - let (games, skipIncompat5, skipOlder5) = try await syncGames( + let (stadiumAliases, skipIncompat5, skipOlder5) = try await syncStadiumAliases( + context: context, + since: syncState.lastSuccessfulSync + ) + totalStadiumAliases = stadiumAliases + totalSkippedIncompatible += skipIncompat5 + totalSkippedOlder += skipOlder5 + + let (games, skipIncompat6, skipOlder6) = try await syncGames( context: context, since: syncState.lastSuccessfulSync ) totalGames = games - totalSkippedIncompatible += skipIncompat5 - totalSkippedOlder += skipOlder5 + totalSkippedIncompatible += skipIncompat6 + totalSkippedOlder += skipOlder6 // Mark sync successful syncState.syncInProgress = false @@ -176,6 +186,7 @@ actor CanonicalSyncService { gamesUpdated: totalGames, leagueStructuresUpdated: totalLeagueStructures, teamAliasesUpdated: totalTeamAliases, + stadiumAliasesUpdated: totalStadiumAliases, skippedIncompatible: totalSkippedIncompatible, skippedOlder: totalSkippedOlder, duration: Date().timeIntervalSince(startTime) @@ -346,6 +357,30 @@ actor CanonicalSyncService { return (updated, skippedIncompatible, skippedOlder) } + @MainActor + private func syncStadiumAliases( + context: ModelContext, + since lastSync: Date? + ) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) { + let remoteAliases = try await cloudKitService.fetchStadiumAliasChanges(since: lastSync) + + var updated = 0 + var skippedIncompatible = 0 + var skippedOlder = 0 + + for remoteAlias in remoteAliases { + let result = try mergeStadiumAlias(remoteAlias, context: context) + + switch result { + case .applied: updated += 1 + case .skippedIncompatible: skippedIncompatible += 1 + case .skippedOlder: skippedOlder += 1 + } + } + + return (updated, skippedIncompatible, skippedOlder) + } + // MARK: - Merge Logic private enum MergeResult { @@ -631,4 +666,41 @@ actor CanonicalSyncService { return .applied } } + + @MainActor + private func mergeStadiumAlias( + _ remote: StadiumAlias, + context: ModelContext + ) throws -> MergeResult { + // Schema version check + guard remote.schemaVersion <= SchemaVersion.current else { + return .skippedIncompatible + } + + let remoteAliasName = remote.aliasName + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.aliasName == remoteAliasName } + ) + let existing = try context.fetch(descriptor).first + + if let existing = existing { + // lastModified check + guard remote.lastModified > existing.lastModified else { + return .skippedOlder + } + + // Update all fields (no user fields on StadiumAlias) + existing.stadiumCanonicalId = remote.stadiumCanonicalId + existing.validFrom = remote.validFrom + existing.validUntil = remote.validUntil + existing.schemaVersion = remote.schemaVersion + existing.lastModified = remote.lastModified + + return .applied + } else { + // Insert new + context.insert(remote) + return .applied + } + } } diff --git a/SportsTime/Core/Services/CloudKitDataProvider.swift b/SportsTime/Core/Services/CloudKitDataProvider.swift deleted file mode 100644 index 456f316..0000000 --- a/SportsTime/Core/Services/CloudKitDataProvider.swift +++ /dev/null @@ -1,108 +0,0 @@ -// -// CloudKitDataProvider.swift -// SportsTime -// -// Wraps CloudKitService to conform to DataProvider protocol -// - -import Foundation - -actor CloudKitDataProvider: DataProvider { - - private let cloudKit = CloudKitService.shared - - // MARK: - Availability - - func checkAvailability() async throws { - try await cloudKit.checkAvailabilityWithError() - } - - // MARK: - DataProvider Protocol - - func fetchTeams(for sport: Sport) async throws -> [Team] { - do { - try await checkAvailability() - return try await cloudKit.fetchTeams(for: sport) - } catch { - throw CloudKitError.from(error) - } - } - - func fetchAllTeams() async throws -> [Team] { - do { - try await checkAvailability() - var allTeams: [Team] = [] - for sport in Sport.supported { - let teams = try await cloudKit.fetchTeams(for: sport) - allTeams.append(contentsOf: teams) - } - return allTeams - } catch { - throw CloudKitError.from(error) - } - } - - func fetchStadiums() async throws -> [Stadium] { - do { - try await checkAvailability() - return try await cloudKit.fetchStadiums() - } catch { - throw CloudKitError.from(error) - } - } - - func fetchGames(sports: Set, startDate: Date, endDate: Date) async throws -> [Game] { - do { - try await checkAvailability() - return try await cloudKit.fetchGames(sports: sports, startDate: startDate, endDate: endDate) - } catch { - throw CloudKitError.from(error) - } - } - - func fetchGame(by id: UUID) async throws -> Game? { - do { - try await checkAvailability() - return try await cloudKit.fetchGame(by: id) - } catch { - throw CloudKitError.from(error) - } - } - - func fetchRichGames(sports: Set, startDate: Date, endDate: Date) async throws -> [RichGame] { - do { - try await checkAvailability() - - // Fetch all required data - async let gamesTask = cloudKit.fetchGames(sports: sports, startDate: startDate, endDate: endDate) - async let teamsTask = fetchAllTeamsInternal() - async let stadiumsTask = cloudKit.fetchStadiums() - - let (games, teams, stadiums) = try await (gamesTask, teamsTask, stadiumsTask) - - let teamsById = Dictionary(uniqueKeysWithValues: teams.map { ($0.id, $0) }) - let stadiumsById = Dictionary(uniqueKeysWithValues: stadiums.map { ($0.id, $0) }) - - 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) - } - } catch { - throw CloudKitError.from(error) - } - } - - // Internal helper to avoid duplicate availability checks - private func fetchAllTeamsInternal() async throws -> [Team] { - var allTeams: [Team] = [] - for sport in Sport.supported { - let teams = try await cloudKit.fetchTeams(for: sport) - allTeams.append(contentsOf: teams) - } - return allTeams - } -} diff --git a/SportsTime/Core/Services/CloudKitService.swift b/SportsTime/Core/Services/CloudKitService.swift index f140fae..3b61584 100644 --- a/SportsTime/Core/Services/CloudKitService.swift +++ b/SportsTime/Core/Services/CloudKitService.swift @@ -228,6 +228,24 @@ actor CloudKitService { } } + func fetchStadiumAliases(for stadiumCanonicalId: String? = nil) async throws -> [StadiumAlias] { + let predicate: NSPredicate + if let stadiumId = stadiumCanonicalId { + predicate = NSPredicate(format: "stadiumCanonicalId == %@", stadiumId) + } else { + predicate = NSPredicate(value: true) + } + + let query = CKQuery(recordType: CKRecordType.stadiumAlias, predicate: predicate) + + let (results, _) = try await publicDatabase.records(matching: query) + + return results.compactMap { result in + guard case .success(let record) = result.1 else { return nil } + return CKStadiumAlias(record: record).toModel() + } + } + // MARK: - Delta Sync (Date-Based for Public Database) /// Fetch league structure records modified after the given date @@ -270,6 +288,26 @@ actor CloudKitService { } } + /// Fetch stadium alias records modified after the given date + func fetchStadiumAliasChanges(since lastSync: Date?) async throws -> [StadiumAlias] { + let predicate: NSPredicate + if let lastSync = lastSync { + predicate = NSPredicate(format: "lastModified > %@", lastSync as NSDate) + } else { + predicate = NSPredicate(value: true) + } + + let query = CKQuery(recordType: CKRecordType.stadiumAlias, predicate: predicate) + query.sortDescriptors = [NSSortDescriptor(key: CKStadiumAlias.lastModifiedKey, ascending: true)] + + let (results, _) = try await publicDatabase.records(matching: query) + + return results.compactMap { result in + guard case .success(let record) = result.1 else { return nil } + return CKStadiumAlias(record: record).toModel() + } + } + // MARK: - Sync Status func checkAccountStatus() async -> CKAccountStatus { @@ -327,10 +365,26 @@ actor CloudKitService { try await publicDatabase.save(subscription) } + func subscribeToStadiumAliasUpdates() async throws { + let subscription = CKQuerySubscription( + recordType: CKRecordType.stadiumAlias, + predicate: NSPredicate(value: true), + subscriptionID: "stadium-alias-updates", + options: [.firesOnRecordCreation, .firesOnRecordUpdate] + ) + + let notification = CKSubscription.NotificationInfo() + notification.shouldSendContentAvailable = true + subscription.notificationInfo = notification + + try await publicDatabase.save(subscription) + } + /// Subscribe to all canonical data updates func subscribeToAllUpdates() async throws { try await subscribeToScheduleUpdates() try await subscribeToLeagueStructureUpdates() try await subscribeToTeamAliasUpdates() + try await subscribeToStadiumAliasUpdates() } } diff --git a/SportsTime/Core/Services/DataProvider.swift b/SportsTime/Core/Services/DataProvider.swift index 74831a2..1c3f3ba 100644 --- a/SportsTime/Core/Services/DataProvider.swift +++ b/SportsTime/Core/Services/DataProvider.swift @@ -2,61 +2,90 @@ // DataProvider.swift // SportsTime // +// Unified data provider that reads from SwiftData for offline support. +// Data is bootstrapped from bundled JSON and can be synced via CloudKit. +// import Foundation +import SwiftData import Combine -/// Protocol defining data operations for teams, stadiums, and games -protocol DataProvider: Sendable { - func fetchTeams(for sport: Sport) async throws -> [Team] - func fetchAllTeams() async throws -> [Team] - func fetchStadiums() async throws -> [Stadium] - func fetchGames(sports: Set, startDate: Date, endDate: Date) async throws -> [Game] - func fetchGame(by id: UUID) async throws -> Game? - - // Resolved data (with team/stadium references) - func fetchRichGames(sports: Set, startDate: Date, endDate: Date) async throws -> [RichGame] -} - -/// Environment-aware data provider that switches between stub and CloudKit +/// Environment-aware data provider that reads from SwiftData (offline-first) @MainActor final class AppDataProvider: ObservableObject { static let shared = AppDataProvider() - private let provider: any DataProvider - @Published private(set) var teams: [Team] = [] @Published private(set) var stadiums: [Stadium] = [] @Published private(set) var isLoading = false @Published private(set) var error: Error? @Published private(set) var errorMessage: String? - @Published private(set) var isUsingStubData: Bool private var teamsById: [UUID: Team] = [:] private var stadiumsById: [UUID: Stadium] = [:] + private var stadiumsByCanonicalId: [String: Stadium] = [:] + private var teamsByCanonicalId: [String: Team] = [:] - private init() { - #if targetEnvironment(simulator) - self.provider = StubDataProvider() - self.isUsingStubData = true - #else - self.provider = CloudKitDataProvider() - self.isUsingStubData = false - #endif + // Canonical ID lookups for game conversion + private var canonicalTeamUUIDs: [String: UUID] = [:] + private var canonicalStadiumUUIDs: [String: UUID] = [:] + + private var modelContext: ModelContext? + + private init() {} + + // MARK: - Configuration + + /// Set the model context for SwiftData access + func configure(with context: ModelContext) { + self.modelContext = context } // MARK: - Data Loading + /// Load all data from SwiftData into memory for fast access func loadInitialData() async { + guard let context = modelContext else { + errorMessage = "Model context not configured" + return + } + isLoading = true error = nil errorMessage = nil do { - async let teamsTask = provider.fetchAllTeams() - async let stadiumsTask = provider.fetchStadiums() + // Fetch canonical stadiums from SwiftData + let stadiumDescriptor = FetchDescriptor( + predicate: #Predicate { $0.deprecatedAt == nil } + ) + let canonicalStadiums = try context.fetch(stadiumDescriptor) - let (loadedTeams, loadedStadiums) = try await (teamsTask, stadiumsTask) + // Convert to domain models and build lookups + var loadedStadiums: [Stadium] = [] + for canonical in canonicalStadiums { + let stadium = canonical.toDomain() + loadedStadiums.append(stadium) + stadiumsByCanonicalId[canonical.canonicalId] = stadium + canonicalStadiumUUIDs[canonical.canonicalId] = stadium.id + } + + // Fetch canonical teams from SwiftData + let teamDescriptor = FetchDescriptor( + predicate: #Predicate { $0.deprecatedAt == nil } + ) + let canonicalTeams = try context.fetch(teamDescriptor) + + // Convert to domain models + var loadedTeams: [Team] = [] + for canonical in canonicalTeams { + // Get stadium UUID for this team + let stadiumUUID = canonicalStadiumUUIDs[canonical.stadiumCanonicalId] ?? UUID() + let team = canonical.toDomain(stadiumUUID: stadiumUUID) + loadedTeams.append(team) + teamsByCanonicalId[canonical.canonicalId] = team + canonicalTeamUUIDs[canonical.canonicalId] = team.id + } self.teams = loadedTeams self.stadiums = loadedStadiums @@ -64,9 +93,7 @@ final class AppDataProvider: ObservableObject { // Build lookup dictionaries self.teamsById = Dictionary(uniqueKeysWithValues: loadedTeams.map { ($0.id, $0) }) self.stadiumsById = Dictionary(uniqueKeysWithValues: loadedStadiums.map { ($0.id, $0) }) - } catch let cloudKitError as CloudKitError { - self.error = cloudKitError - self.errorMessage = cloudKitError.errorDescription + } catch { self.error = error self.errorMessage = error.localizedDescription @@ -98,12 +125,71 @@ final class AppDataProvider: ObservableObject { teams.filter { $0.sport == sport } } + // MARK: - Game Fetching + + /// Fetch games from SwiftData within date range func fetchGames(sports: Set, startDate: Date, endDate: Date) async throws -> [Game] { - try await provider.fetchGames(sports: sports, startDate: startDate, endDate: endDate) + guard let context = modelContext else { + throw DataProviderError.contextNotConfigured + } + + let sportStrings = sports.map { $0.rawValue } + + let descriptor = FetchDescriptor( + predicate: #Predicate { game in + game.deprecatedAt == nil && + game.dateTime >= startDate && + game.dateTime <= endDate + } + ) + + let canonicalGames = try context.fetch(descriptor) + + // Filter by sport and convert to domain models + return canonicalGames.compactMap { canonical -> Game? in + guard sportStrings.contains(canonical.sport) else { return nil } + + let homeTeamUUID = canonicalTeamUUIDs[canonical.homeTeamCanonicalId] ?? UUID() + let awayTeamUUID = canonicalTeamUUIDs[canonical.awayTeamCanonicalId] ?? UUID() + let stadiumUUID = canonicalStadiumUUIDs[canonical.stadiumCanonicalId] ?? UUID() + + return canonical.toDomain( + homeTeamUUID: homeTeamUUID, + awayTeamUUID: awayTeamUUID, + stadiumUUID: stadiumUUID + ) + } } + /// Fetch a single game by ID + func fetchGame(by id: UUID) async throws -> Game? { + guard let context = modelContext else { + throw DataProviderError.contextNotConfigured + } + + let idString = id.uuidString + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.canonicalId == idString } + ) + + guard let canonical = try context.fetch(descriptor).first else { + return nil + } + + let homeTeamUUID = canonicalTeamUUIDs[canonical.homeTeamCanonicalId] ?? UUID() + let awayTeamUUID = canonicalTeamUUIDs[canonical.awayTeamCanonicalId] ?? UUID() + let stadiumUUID = canonicalStadiumUUIDs[canonical.stadiumCanonicalId] ?? UUID() + + return canonical.toDomain( + homeTeamUUID: homeTeamUUID, + awayTeamUUID: awayTeamUUID, + stadiumUUID: stadiumUUID + ) + } + + /// Fetch games with full team and stadium data func fetchRichGames(sports: Set, startDate: Date, endDate: Date) async throws -> [RichGame] { - let games = try await provider.fetchGames(sports: sports, startDate: startDate, endDate: endDate) + let games = try await fetchGames(sports: sports, startDate: startDate, endDate: endDate) return games.compactMap { game in guard let homeTeam = teamsById[game.homeTeamId], @@ -124,3 +210,16 @@ final class AppDataProvider: ObservableObject { return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium) } } + +// MARK: - Errors + +enum DataProviderError: Error, LocalizedError { + case contextNotConfigured + + var errorDescription: String? { + switch self { + case .contextNotConfigured: + return "Data provider not configured with model context" + } + } +} diff --git a/SportsTime/Core/Services/StubDataProvider.swift b/SportsTime/Core/Services/StubDataProvider.swift deleted file mode 100644 index 658928c..0000000 --- a/SportsTime/Core/Services/StubDataProvider.swift +++ /dev/null @@ -1,373 +0,0 @@ -// -// StubDataProvider.swift -// SportsTime -// -// Provides real data from bundled JSON files for Simulator testing -// - -import Foundation -import CryptoKit - -actor StubDataProvider: DataProvider { - - // MARK: - JSON Models - - private struct JSONGame: Codable { - let id: String - let sport: String - let season: String - let date: String - let time: String? - let home_team: String - let away_team: String - let home_team_abbrev: String - let away_team_abbrev: String - let venue: String - let source: String - let is_playoff: Bool - let broadcast: String? - } - - private struct JSONStadium: Codable { - let id: String - let name: String - let city: String - let state: String - let latitude: Double - let longitude: Double - let capacity: Int - let sport: String - let team_abbrevs: [String] - let source: String - let year_opened: Int? - } - - // MARK: - Cached Data - - private var cachedGames: [Game]? - private var cachedTeams: [Team]? - private var cachedStadiums: [Stadium]? - private var teamsByAbbrev: [String: Team] = [:] - private var stadiumsByVenue: [String: Stadium] = [:] - - // MARK: - DataProvider Protocol - - func fetchTeams(for sport: Sport) async throws -> [Team] { - try await loadAllDataIfNeeded() - return cachedTeams?.filter { $0.sport == sport } ?? [] - } - - func fetchAllTeams() async throws -> [Team] { - try await loadAllDataIfNeeded() - return cachedTeams ?? [] - } - - func fetchStadiums() async throws -> [Stadium] { - try await loadAllDataIfNeeded() - return cachedStadiums ?? [] - } - - func fetchGames(sports: Set, startDate: Date, endDate: Date) async throws -> [Game] { - try await loadAllDataIfNeeded() - - return (cachedGames ?? []).filter { game in - sports.contains(game.sport) && - game.dateTime >= startDate && - game.dateTime <= endDate - } - } - - func fetchGame(by id: UUID) async throws -> Game? { - try await loadAllDataIfNeeded() - return cachedGames?.first { $0.id == id } - } - - func fetchRichGames(sports: Set, startDate: Date, endDate: Date) async throws -> [RichGame] { - try await loadAllDataIfNeeded() - - let games = try await fetchGames(sports: sports, startDate: startDate, endDate: endDate) - let teamsById = Dictionary(uniqueKeysWithValues: (cachedTeams ?? []).map { ($0.id, $0) }) - let stadiumsById = Dictionary(uniqueKeysWithValues: (cachedStadiums ?? []).map { ($0.id, $0) }) - - 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) - } - } - - // MARK: - Data Loading - - private func loadAllDataIfNeeded() async throws { - guard cachedGames == nil else { return } - - // Load stadiums first - let jsonStadiums = try loadStadiumsJSON() - cachedStadiums = jsonStadiums.map { convertStadium($0) } - - // Build stadium lookup by venue name - for stadium in cachedStadiums ?? [] { - stadiumsByVenue[stadium.name.lowercased()] = stadium - } - - // Load games and extract teams - let jsonGames = try loadGamesJSON() - - // Build teams from games data - var teamsDict: [String: Team] = [:] - for jsonGame in jsonGames { - let sport = parseSport(jsonGame.sport) - - // Home team - let homeKey = "\(sport.rawValue)_\(jsonGame.home_team_abbrev)" - if teamsDict[homeKey] == nil { - let stadiumId = findStadiumId(venue: jsonGame.venue, sport: sport) - let team = Team( - id: deterministicUUID(from: homeKey), - name: extractTeamName(from: jsonGame.home_team), - abbreviation: jsonGame.home_team_abbrev, - sport: sport, - city: extractCity(from: jsonGame.home_team), - stadiumId: stadiumId - ) - teamsDict[homeKey] = team - teamsByAbbrev[homeKey] = team - } - - // Away team - let awayKey = "\(sport.rawValue)_\(jsonGame.away_team_abbrev)" - if teamsDict[awayKey] == nil { - // Away teams might not have a stadium in our data yet - let team = Team( - id: deterministicUUID(from: awayKey), - name: extractTeamName(from: jsonGame.away_team), - abbreviation: jsonGame.away_team_abbrev, - sport: sport, - city: extractCity(from: jsonGame.away_team), - stadiumId: UUID() // Placeholder, will be updated when they're home team - ) - teamsDict[awayKey] = team - teamsByAbbrev[awayKey] = team - } - } - cachedTeams = Array(teamsDict.values) - - // Convert games (deduplicate by ID - JSON may have duplicate entries) - var seenGameIds = Set() - let uniqueJsonGames = jsonGames.filter { game in - if seenGameIds.contains(game.id) { - return false - } - seenGameIds.insert(game.id) - return true - } - cachedGames = uniqueJsonGames.compactMap { convertGame($0) } - } - - private func loadGamesJSON() throws -> [JSONGame] { - guard let url = Bundle.main.url(forResource: "games", withExtension: "json") else { - return [] - } - let data = try Data(contentsOf: url) - return try JSONDecoder().decode([JSONGame].self, from: data) - } - - private func loadStadiumsJSON() throws -> [JSONStadium] { - guard let url = Bundle.main.url(forResource: "stadiums", withExtension: "json") else { - return [] - } - let data = try Data(contentsOf: url) - return try JSONDecoder().decode([JSONStadium].self, from: data) - } - - // MARK: - Conversion Helpers - - private func convertStadium(_ json: JSONStadium) -> Stadium { - Stadium( - id: deterministicUUID(from: json.id), - name: json.name, - city: json.city, - state: json.state.isEmpty ? stateFromCity(json.city) : json.state, - latitude: json.latitude, - longitude: json.longitude, - capacity: json.capacity, - sport: parseSport(json.sport), - yearOpened: json.year_opened - ) - } - - private func convertGame(_ json: JSONGame) -> Game? { - let sport = parseSport(json.sport) - - let homeKey = "\(sport.rawValue)_\(json.home_team_abbrev)" - let awayKey = "\(sport.rawValue)_\(json.away_team_abbrev)" - - guard let homeTeam = teamsByAbbrev[homeKey], - let awayTeam = teamsByAbbrev[awayKey] else { - return nil - } - - let stadiumId = findStadiumId(venue: json.venue, sport: sport) - - guard let dateTime = parseDateTime(date: json.date, time: json.time ?? "7:00p") else { - return nil - } - - return Game( - id: deterministicUUID(from: json.id), - homeTeamId: homeTeam.id, - awayTeamId: awayTeam.id, - stadiumId: stadiumId, - dateTime: dateTime, - sport: sport, - season: json.season, - isPlayoff: json.is_playoff, - broadcastInfo: json.broadcast - ) - } - - private func parseSport(_ sport: String) -> Sport { - switch sport.uppercased() { - case "MLB": return .mlb - case "NBA": return .nba - case "NHL": return .nhl - case "NFL": return .nfl - case "MLS": return .mls - default: return .mlb - } - } - - private func parseDateTime(date: String, time: String) -> Date? { - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - - // Parse date - formatter.dateFormat = "yyyy-MM-dd" - guard let dateOnly = formatter.date(from: date) else { return nil } - - // Parse time (e.g., "7:30p", "10:00p", "1:05p") - var hour = 12 - var minute = 0 - - let cleanTime = time.lowercased().replacingOccurrences(of: " ", with: "") - let isPM = cleanTime.contains("p") - let timeWithoutAMPM = cleanTime.replacingOccurrences(of: "p", with: "").replacingOccurrences(of: "a", with: "") - - let components = timeWithoutAMPM.split(separator: ":") - if !components.isEmpty, let h = Int(components[0]) { - hour = h - if isPM && hour != 12 { - hour += 12 - } else if !isPM && hour == 12 { - hour = 0 - } - } - if components.count > 1, let m = Int(components[1]) { - minute = m - } - - return Calendar.current.date(bySettingHour: hour, minute: minute, second: 0, of: dateOnly) - } - - // Venue name aliases for stadiums that changed names - private static let venueAliases: [String: String] = [ - "daikin park": "minute maid park", // Houston Astros (renamed 2024) - "rate field": "guaranteed rate field", // Chicago White Sox - "george m. steinbrenner field": "tropicana field", // Tampa Bay spring training → main stadium - "loandepot park": "loandepot park", // Miami - ensure case match - ] - - private func findStadiumId(venue: String, sport: Sport) -> UUID { - var venueLower = venue.lowercased() - - // Check for known aliases - if let aliasedName = Self.venueAliases[venueLower] { - venueLower = aliasedName - } - - // Try exact match - if let stadium = stadiumsByVenue[venueLower] { - return stadium.id - } - - // Try partial match - for (name, stadium) in stadiumsByVenue { - if name.contains(venueLower) || venueLower.contains(name) { - return stadium.id - } - } - - // Generate deterministic ID for unknown venues - return deterministicUUID(from: "venue_\(venue)") - } - - private func deterministicUUID(from string: String) -> UUID { - // Create a deterministic UUID using SHA256 (truly deterministic across launches) - let data = Data(string.utf8) - let hash = SHA256.hash(data: data) - let hashBytes = Array(hash) - - // Use first 16 bytes of SHA256 hash - var bytes = Array(hashBytes.prefix(16)) - - // Set UUID version (4) and variant bits - bytes[6] = (bytes[6] & 0x0F) | 0x40 - bytes[8] = (bytes[8] & 0x3F) | 0x80 - - return UUID(uuid: ( - bytes[0], bytes[1], bytes[2], bytes[3], - bytes[4], bytes[5], bytes[6], bytes[7], - bytes[8], bytes[9], bytes[10], bytes[11], - bytes[12], bytes[13], bytes[14], bytes[15] - )) - } - - private func extractTeamName(from fullName: String) -> String { - // "Boston Celtics" -> "Celtics" - let parts = fullName.split(separator: " ") - if parts.count > 1 { - return parts.dropFirst().joined(separator: " ") - } - return fullName - } - - private func extractCity(from fullName: String) -> String { - // "Boston Celtics" -> "Boston" - // "New York Knicks" -> "New York" - // "Los Angeles Lakers" -> "Los Angeles" - let knownCities = [ - "New York", "Los Angeles", "San Francisco", "San Diego", "San Antonio", - "New Orleans", "Oklahoma City", "Salt Lake City", "Kansas City", - "St. Louis", "St Louis" - ] - - for city in knownCities { - if fullName.hasPrefix(city) { - return city - } - } - - // Default: first word - return String(fullName.split(separator: " ").first ?? Substring(fullName)) - } - - private func stateFromCity(_ city: String) -> String { - let cityToState: [String: String] = [ - "Atlanta": "GA", "Boston": "MA", "Brooklyn": "NY", "Charlotte": "NC", - "Chicago": "IL", "Cleveland": "OH", "Dallas": "TX", "Denver": "CO", - "Detroit": "MI", "Houston": "TX", "Indianapolis": "IN", "Los Angeles": "CA", - "Memphis": "TN", "Miami": "FL", "Milwaukee": "WI", "Minneapolis": "MN", - "New Orleans": "LA", "New York": "NY", "Oklahoma City": "OK", "Orlando": "FL", - "Philadelphia": "PA", "Phoenix": "AZ", "Portland": "OR", "Sacramento": "CA", - "San Antonio": "TX", "San Francisco": "CA", "Seattle": "WA", "Toronto": "ON", - "Washington": "DC", "Las Vegas": "NV", "Tampa": "FL", "Pittsburgh": "PA", - "Baltimore": "MD", "Cincinnati": "OH", "St. Louis": "MO", "Kansas City": "MO", - "Arlington": "TX", "Anaheim": "CA", "Oakland": "CA", "San Diego": "CA", - "Tampa Bay": "FL", "St Petersburg": "FL", "Salt Lake City": "UT" - ] - return cityToState[city] ?? "" - } -} diff --git a/SportsTime/Info.plist b/SportsTime/Info.plist index ca9a074..442858f 100644 --- a/SportsTime/Info.plist +++ b/SportsTime/Info.plist @@ -5,6 +5,8 @@ UIBackgroundModes remote-notification + fetch + processing diff --git a/SportsTime/SportsTimeApp.swift b/SportsTime/SportsTimeApp.swift index 83da253..19282a1 100644 --- a/SportsTime/SportsTimeApp.swift +++ b/SportsTime/SportsTimeApp.swift @@ -90,13 +90,48 @@ struct BootstrappedContentView: View { let bootstrapService = BootstrapService() do { + // 1. Bootstrap from bundled JSON if first launch (no data exists) try await bootstrapService.bootstrapIfNeeded(context: context) + + // 2. Configure DataProvider with SwiftData context + AppDataProvider.shared.configure(with: context) + + // 3. Load data from SwiftData into memory + await AppDataProvider.shared.loadInitialData() + + // 4. App is now usable isBootstrapping = false + + // 5. Background: Try to refresh from CloudKit (non-blocking) + Task.detached(priority: .background) { + await self.performBackgroundSync(context: context) + } } catch { bootstrapError = error isBootstrapping = false } } + + @MainActor + private func performBackgroundSync(context: ModelContext) async { + let syncService = CanonicalSyncService() + + do { + let result = try await syncService.syncAll(context: context) + + // If any data was updated, reload the DataProvider + if !result.isEmpty { + await AppDataProvider.shared.loadInitialData() + print("CloudKit sync completed: \(result.totalUpdated) items updated") + } + } catch CanonicalSyncService.SyncError.cloudKitUnavailable { + // Offline or CloudKit not available - silently continue with local data + print("CloudKit unavailable, using local data") + } catch { + // Other sync errors - log but don't interrupt user + print("Background sync error: \(error.localizedDescription)") + } + } } // MARK: - Bootstrap Loading View