# Dynamic Sports via CloudKit Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Add new sports leagues without shipping app updates by syncing sport definitions from CloudKit. **Architecture:** Hybrid model - keep `Sport` enum for existing 7 sports (compile-time safety), add `DynamicSport` struct for CloudKit-defined sports, unify via `AnySport` protocol so both work interchangeably in UI and planning engine. **Tech Stack:** Swift 6, SwiftUI, SwiftData, CloudKit (public database), @Observable pattern --- ## Task 1: Create AnySport Protocol **Files:** - Create: `SportsTime/Core/Models/Domain/AnySport.swift` **Step 1: Create the protocol file** Create `SportsTime/Core/Models/Domain/AnySport.swift`: ```swift // // AnySport.swift // SportsTime // // Protocol unifying Sport enum and DynamicSport for interchangeable use. // import SwiftUI /// Protocol that unifies Sport enum and DynamicSport /// Allows both to be used interchangeably in UI and planning engine. protocol AnySport: Identifiable, Hashable, Sendable { /// Unique identifier string (e.g., "MLB", "xfl") var sportId: String { get } /// Display name for UI (e.g., "Major League Baseball", "XFL Football") var displayName: String { get } /// SF Symbol name for sport icon (e.g., "baseball.fill") var iconName: String { get } /// Theme color for this sport var color: Color { get } /// Season months (start and end, 1-12). End may be less than start for wrap-around seasons. var seasonMonths: (start: Int, end: Int) { get } } extension AnySport { /// Check if sport is in season for a given date func isInSeason(for date: Date) -> Bool { let calendar = Calendar.current let month = calendar.component(.month, from: date) let (start, end) = seasonMonths if start <= end { // Normal range (e.g., March to October) return month >= start && month <= end } else { // Season wraps around year boundary (e.g., October to June) return month >= start || month <= end } } } ``` **Step 2: Verify the file compiles** Run: ```bash xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -20 ``` Expected: BUILD SUCCEEDED **Step 3: Commit** ```bash git add SportsTime/Core/Models/Domain/AnySport.swift git commit -m "$(cat <<'EOF' feat(domain): add AnySport protocol for unified sport handling Defines protocol that both Sport enum and DynamicSport will conform to, enabling interchangeable use in UI and planning engine. Co-Authored-By: Claude Opus 4.5 EOF )" ``` --- ## Task 2: Add AnySport Conformance to Sport Enum **Files:** - Modify: `SportsTime/Core/Models/Domain/Sport.swift` - Test: `SportsTimeTests/Domain/SportTests.swift` (create) **Step 1: Write the failing test** Create `SportsTimeTests/Domain/SportTests.swift`: ```swift // // SportTests.swift // SportsTimeTests // import Testing @testable import SportsTime @Suite("Sport AnySport Conformance") struct SportAnySportTests { @Test("Sport conforms to AnySport protocol") func sportConformsToAnySport() { let sport: any AnySport = Sport.mlb #expect(sport.sportId == "MLB") #expect(sport.displayName == "Major League Baseball") #expect(sport.iconName == "baseball.fill") } @Test("Sport.id equals Sport.sportId") func sportIdEqualsSportId() { for sport in Sport.allCases { #expect(sport.id == sport.sportId) } } @Test("Sport isInSeason works correctly") func sportIsInSeason() { let mlb = Sport.mlb // April is in MLB season (March-October) let april = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 15))! #expect(mlb.isInSeason(for: april)) // January is not in MLB season let january = Calendar.current.date(from: DateComponents(year: 2026, month: 1, day: 15))! #expect(!mlb.isInSeason(for: january)) } @Test("Sport with wrap-around season works correctly") func sportWrapAroundSeason() { let nba = Sport.nba // December is in NBA season (October-June wraps) let december = Calendar.current.date(from: DateComponents(year: 2026, month: 12, day: 15))! #expect(nba.isInSeason(for: december)) // July is not in NBA season let july = Calendar.current.date(from: DateComponents(year: 2026, month: 7, day: 15))! #expect(!nba.isInSeason(for: july)) } } ``` **Step 2: Run test to verify it fails** Run: ```bash xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/SportAnySportTests test 2>&1 | tail -30 ``` Expected: FAIL with "cannot find type 'AnySport'" or similar **Step 3: Add AnySport conformance to Sport enum** Modify `SportsTime/Core/Models/Domain/Sport.swift`. Add at end of file before closing brace: ```swift // MARK: - AnySport Conformance extension Sport: AnySport { var sportId: String { rawValue } // Note: displayName, iconName, seasonMonths already exist on Sport // color is computed from themeColor in ViewModifiers.swift } ``` **Step 4: Run test to verify it passes** Run: ```bash xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/SportAnySportTests test 2>&1 | tail -30 ``` Expected: Test passed **Step 5: Commit** ```bash git add SportsTime/Core/Models/Domain/Sport.swift SportsTimeTests/Domain/SportTests.swift git commit -m "$(cat <<'EOF' feat(domain): add AnySport conformance to Sport enum Existing Sport enum now conforms to AnySport protocol, enabling unified handling with future DynamicSport types. Co-Authored-By: Claude Opus 4.5 EOF )" ``` --- ## Task 3: Create DynamicSport Domain Model **Files:** - Create: `SportsTime/Core/Models/Domain/DynamicSport.swift` - Test: `SportsTimeTests/Domain/DynamicSportTests.swift` **Step 1: Write the failing test** Create `SportsTimeTests/Domain/DynamicSportTests.swift`: ```swift // // DynamicSportTests.swift // SportsTimeTests // import Testing import SwiftUI @testable import SportsTime @Suite("DynamicSport") struct DynamicSportTests { @Test("DynamicSport conforms to AnySport protocol") func dynamicSportConformsToAnySport() { let xfl = DynamicSport( id: "xfl", abbreviation: "XFL", displayName: "XFL Football", iconName: "football.fill", colorHex: "#E31837", seasonStartMonth: 2, seasonEndMonth: 5 ) let sport: any AnySport = xfl #expect(sport.sportId == "xfl") #expect(sport.displayName == "XFL Football") #expect(sport.iconName == "football.fill") } @Test("DynamicSport color parses from hex") func dynamicSportColorParsesFromHex() { let sport = DynamicSport( id: "test", abbreviation: "TST", displayName: "Test Sport", iconName: "star.fill", colorHex: "#FF0000", seasonStartMonth: 1, seasonEndMonth: 12 ) // Color should be red #expect(sport.color != Color.clear) } @Test("DynamicSport isInSeason works correctly") func dynamicSportIsInSeason() { let xfl = DynamicSport( id: "xfl", abbreviation: "XFL", displayName: "XFL Football", iconName: "football.fill", colorHex: "#E31837", seasonStartMonth: 2, seasonEndMonth: 5 ) // March is in XFL season (Feb-May) let march = Calendar.current.date(from: DateComponents(year: 2026, month: 3, day: 15))! #expect(xfl.isInSeason(for: march)) // September is not in XFL season let september = Calendar.current.date(from: DateComponents(year: 2026, month: 9, day: 15))! #expect(!xfl.isInSeason(for: september)) } @Test("DynamicSport is Hashable") func dynamicSportIsHashable() { let sport1 = DynamicSport( id: "xfl", abbreviation: "XFL", displayName: "XFL Football", iconName: "football.fill", colorHex: "#E31837", seasonStartMonth: 2, seasonEndMonth: 5 ) let sport2 = DynamicSport( id: "xfl", abbreviation: "XFL", displayName: "XFL Football", iconName: "football.fill", colorHex: "#E31837", seasonStartMonth: 2, seasonEndMonth: 5 ) let set: Set = [sport1, sport2] #expect(set.count == 1) } @Test("DynamicSport is Codable") func dynamicSportIsCodable() throws { let original = DynamicSport( id: "xfl", abbreviation: "XFL", displayName: "XFL Football", iconName: "football.fill", colorHex: "#E31837", seasonStartMonth: 2, seasonEndMonth: 5 ) let encoded = try JSONEncoder().encode(original) let decoded = try JSONDecoder().decode(DynamicSport.self, from: encoded) #expect(decoded.id == original.id) #expect(decoded.abbreviation == original.abbreviation) #expect(decoded.displayName == original.displayName) } } ``` **Step 2: Run test to verify it fails** Run: ```bash xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/DynamicSportTests test 2>&1 | tail -30 ``` Expected: FAIL with "cannot find type 'DynamicSport'" **Step 3: Create DynamicSport struct** Create `SportsTime/Core/Models/Domain/DynamicSport.swift`: ```swift // // DynamicSport.swift // SportsTime // // Domain model for CloudKit-defined sports. // import SwiftUI /// A sport defined via CloudKit, as opposed to the hardcoded Sport enum. /// Conforms to AnySport for interchangeable use with Sport enum. struct DynamicSport: Identifiable, Hashable, Codable, Sendable { let id: String let abbreviation: String let displayName: String let iconName: String let colorHex: String let seasonStartMonth: Int let seasonEndMonth: Int init( id: String, abbreviation: String, displayName: String, iconName: String, colorHex: String, seasonStartMonth: Int, seasonEndMonth: Int ) { self.id = id self.abbreviation = abbreviation self.displayName = displayName self.iconName = iconName self.colorHex = colorHex self.seasonStartMonth = seasonStartMonth self.seasonEndMonth = seasonEndMonth } } // MARK: - AnySport Conformance extension DynamicSport: AnySport { var sportId: String { id } var color: Color { Color(hex: colorHex) } var seasonMonths: (start: Int, end: Int) { (seasonStartMonth, seasonEndMonth) } } ``` **Step 4: Run test to verify it passes** Run: ```bash xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/DynamicSportTests test 2>&1 | tail -30 ``` Expected: All tests pass **Step 5: Commit** ```bash git add SportsTime/Core/Models/Domain/DynamicSport.swift SportsTimeTests/Domain/DynamicSportTests.swift git commit -m "$(cat <<'EOF' feat(domain): add DynamicSport model for CloudKit-defined sports Struct representing sports synced from CloudKit. Conforms to AnySport protocol for interchangeable use with Sport enum in UI and planning. Co-Authored-By: Claude Opus 4.5 EOF )" ``` --- ## Task 4: Create CanonicalSport SwiftData Model **Files:** - Modify: `SportsTime/Core/Models/Local/CanonicalModels.swift` - Test: `SportsTimeTests/Data/CanonicalSportTests.swift` **Step 1: Write the failing test** Create `SportsTimeTests/Data/CanonicalSportTests.swift`: ```swift // // CanonicalSportTests.swift // SportsTimeTests // import Testing import SwiftData @testable import SportsTime @Suite("CanonicalSport") struct CanonicalSportTests { @Test("CanonicalSport converts to DynamicSport domain model") @MainActor func canonicalSportToDomain() throws { let config = ModelConfiguration(isStoredInMemoryOnly: true) let container = try ModelContainer(for: CanonicalSport.self, configurations: config) let context = container.mainContext let canonical = CanonicalSport( id: "xfl", abbreviation: "XFL", displayName: "XFL Football", iconName: "football.fill", colorHex: "#E31837", seasonStartMonth: 2, seasonEndMonth: 5, isActive: true ) context.insert(canonical) let domain = canonical.toDomain() #expect(domain.id == "xfl") #expect(domain.abbreviation == "XFL") #expect(domain.displayName == "XFL Football") #expect(domain.iconName == "football.fill") #expect(domain.colorHex == "#E31837") #expect(domain.seasonStartMonth == 2) #expect(domain.seasonEndMonth == 5) } @Test("CanonicalSport id is unique") @MainActor func canonicalSportIdIsUnique() throws { let config = ModelConfiguration(isStoredInMemoryOnly: true) let container = try ModelContainer(for: CanonicalSport.self, configurations: config) let context = container.mainContext let sport1 = CanonicalSport( id: "xfl", abbreviation: "XFL", displayName: "XFL Football", iconName: "football.fill", colorHex: "#E31837", seasonStartMonth: 2, seasonEndMonth: 5, isActive: true ) context.insert(sport1) try context.save() // Fetching by id should work let descriptor = FetchDescriptor( predicate: #Predicate { $0.id == "xfl" } ) let fetched = try context.fetch(descriptor) #expect(fetched.count == 1) #expect(fetched.first?.displayName == "XFL Football") } } ``` **Step 2: Run test to verify it fails** Run: ```bash xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/CanonicalSportTests test 2>&1 | tail -30 ``` Expected: FAIL with "cannot find type 'CanonicalSport'" **Step 3: Add CanonicalSport to CanonicalModels.swift** Add to `SportsTime/Core/Models/Local/CanonicalModels.swift` after the SyncState model (around line 86): ```swift // MARK: - Canonical Sport @Model final class CanonicalSport { @Attribute(.unique) var id: String var abbreviation: String var displayName: String var iconName: String var colorHex: String var seasonStartMonth: Int var seasonEndMonth: Int var isActive: Bool var lastModified: Date var schemaVersion: Int var sourceRaw: String init( id: String, abbreviation: String, displayName: String, iconName: String, colorHex: String, seasonStartMonth: Int, seasonEndMonth: Int, isActive: Bool = true, lastModified: Date = Date(), schemaVersion: Int = SchemaVersion.current, source: DataSource = .cloudKit ) { self.id = id self.abbreviation = abbreviation self.displayName = displayName self.iconName = iconName self.colorHex = colorHex self.seasonStartMonth = seasonStartMonth self.seasonEndMonth = seasonEndMonth self.isActive = isActive self.lastModified = lastModified self.schemaVersion = schemaVersion self.sourceRaw = source.rawValue } var source: DataSource { get { DataSource(rawValue: sourceRaw) ?? .cloudKit } set { sourceRaw = newValue.rawValue } } func toDomain() -> DynamicSport { DynamicSport( id: id, abbreviation: abbreviation, displayName: displayName, iconName: iconName, colorHex: colorHex, seasonStartMonth: seasonStartMonth, seasonEndMonth: seasonEndMonth ) } } ``` **Step 4: Run test to verify it passes** Run: ```bash xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/CanonicalSportTests test 2>&1 | tail -30 ``` Expected: All tests pass **Step 5: Commit** ```bash git add SportsTime/Core/Models/Local/CanonicalModels.swift SportsTimeTests/Data/CanonicalSportTests.swift git commit -m "$(cat <<'EOF' feat(data): add CanonicalSport SwiftData model SwiftData model for sports synced from CloudKit. Converts to DynamicSport domain model via toDomain() method. Co-Authored-By: Claude Opus 4.5 EOF )" ``` --- ## Task 5: Create CKSport CloudKit Wrapper **Files:** - Modify: `SportsTime/Core/Models/CloudKit/CKModels.swift` **Step 1: Add CKSport struct to CKModels.swift** Add after the `CKRecordType` enum (around line 21): ```swift // MARK: - CKSport struct CKSport { static let idKey = "sportId" static let abbreviationKey = "abbreviation" static let displayNameKey = "displayName" static let iconNameKey = "iconName" static let colorHexKey = "colorHex" static let seasonStartMonthKey = "seasonStartMonth" static let seasonEndMonthKey = "seasonEndMonth" static let isActiveKey = "isActive" static let schemaVersionKey = "schemaVersion" static let lastModifiedKey = "lastModified" let record: CKRecord init(record: CKRecord) { self.record = record } /// Convert to CanonicalSport for local storage func toCanonical() -> CanonicalSport? { guard let id = record[CKSport.idKey] as? String, let abbreviation = record[CKSport.abbreviationKey] as? String, let displayName = record[CKSport.displayNameKey] as? String, let iconName = record[CKSport.iconNameKey] as? String, let colorHex = record[CKSport.colorHexKey] as? String, let seasonStartMonth = record[CKSport.seasonStartMonthKey] as? Int, let seasonEndMonth = record[CKSport.seasonEndMonthKey] as? Int else { return nil } let isActive = (record[CKSport.isActiveKey] as? Int ?? 1) == 1 let schemaVersion = record[CKSport.schemaVersionKey] as? Int ?? SchemaVersion.current let lastModified = record[CKSport.lastModifiedKey] as? Date ?? record.modificationDate ?? Date() return CanonicalSport( id: id, abbreviation: abbreviation, displayName: displayName, iconName: iconName, colorHex: colorHex, seasonStartMonth: seasonStartMonth, seasonEndMonth: seasonEndMonth, isActive: isActive, lastModified: lastModified, schemaVersion: schemaVersion, source: .cloudKit ) } } ``` **Step 2: Verify the file compiles** Run: ```bash xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -20 ``` Expected: BUILD SUCCEEDED **Step 3: Commit** ```bash git add SportsTime/Core/Models/CloudKit/CKModels.swift git commit -m "$(cat <<'EOF' feat(cloudkit): add CKSport wrapper for Sport records CloudKit record wrapper for Sport type. Converts CloudKit records to CanonicalSport for local SwiftData storage. Co-Authored-By: Claude Opus 4.5 EOF )" ``` --- ## Task 6: Add Sport Fetch Methods to CloudKitService **Files:** - Modify: `SportsTime/Core/Services/CloudKitService.swift` **Step 1: Add fetchSportsForSync method** Add after the existing `fetchStadiumAliasChanges` method (around line 419): ```swift // MARK: - Sport Sync /// Fetch sports for sync operations /// - Parameter lastSync: If nil, fetches all sports. If provided, fetches only sports modified since that date. func fetchSportsForSync(since lastSync: Date?) async throws -> [CanonicalSport] { let predicate: NSPredicate if let lastSync = lastSync { predicate = NSPredicate(format: "modificationDate >= %@", lastSync as NSDate) } else { predicate = NSPredicate(value: true) } let query = CKQuery(recordType: CKRecordType.sport, predicate: predicate) let (results, _) = try await publicDatabase.records(matching: query) return results.compactMap { result -> CanonicalSport? in guard case .success(let record) = result.1 else { return nil } return CKSport(record: record).toCanonical() } } ``` **Step 2: Verify the file compiles** Run: ```bash xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -20 ``` Expected: BUILD SUCCEEDED **Step 3: Commit** ```bash git add SportsTime/Core/Services/CloudKitService.swift git commit -m "$(cat <<'EOF' feat(cloudkit): add fetchSportsForSync method Fetches Sport records from CloudKit for sync operations. Supports delta sync via lastSync parameter. Co-Authored-By: Claude Opus 4.5 EOF )" ``` --- ## Task 7: Add Sport Sync to CanonicalSyncService **Files:** - Modify: `SportsTime/Core/Services/CanonicalSyncService.swift` **Step 1: Add syncSports method** Add after the existing `syncStadiumAliases` method (around line 372): ```swift @MainActor private func syncSports( context: ModelContext, since lastSync: Date? ) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) { let remoteSports = try await cloudKitService.fetchSportsForSync(since: lastSync) var updated = 0 var skippedIncompatible = 0 var skippedOlder = 0 for remoteSport in remoteSports { let result = try mergeSport(remoteSport, context: context) switch result { case .applied: updated += 1 case .skippedIncompatible: skippedIncompatible += 1 case .skippedOlder: skippedOlder += 1 } } return (updated, skippedIncompatible, skippedOlder) } @MainActor private func mergeSport( _ remote: CanonicalSport, context: ModelContext ) throws -> MergeResult { // Schema version check guard remote.schemaVersion <= SchemaVersion.current else { return .skippedIncompatible } let remoteId = remote.id let descriptor = FetchDescriptor( predicate: #Predicate { $0.id == remoteId } ) 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 sports) existing.abbreviation = remote.abbreviation existing.displayName = remote.displayName existing.iconName = remote.iconName existing.colorHex = remote.colorHex existing.seasonStartMonth = remote.seasonStartMonth existing.seasonEndMonth = remote.seasonEndMonth existing.isActive = remote.isActive existing.schemaVersion = remote.schemaVersion existing.lastModified = remote.lastModified existing.source = .cloudKit return .applied } else { // Insert new context.insert(remote) return .applied } } ``` **Step 2: Add sportsUpdated to SyncResult** Modify the `SyncResult` struct (around line 39) to add the new field: ```swift struct SyncResult { let stadiumsUpdated: Int let teamsUpdated: Int let gamesUpdated: Int let leagueStructuresUpdated: Int let teamAliasesUpdated: Int let stadiumAliasesUpdated: Int let sportsUpdated: Int // Add this line let skippedIncompatible: Int let skippedOlder: Int let duration: TimeInterval var totalUpdated: Int { stadiumsUpdated + teamsUpdated + gamesUpdated + leagueStructuresUpdated + teamAliasesUpdated + stadiumAliasesUpdated + sportsUpdated // Update this line } var isEmpty: Bool { totalUpdated == 0 } } ``` **Step 3: Call syncSports in syncAll method** In the `syncAll` method, add after the stadium sync (around line 117): ```swift // After syncStadiums call, add: let (sports, skipIncompat0, skipOlder0) = try await syncSports( context: context, since: syncState.lastSuccessfulSync ) var totalSports = sports totalSkippedIncompatible += skipIncompat0 totalSkippedOlder += skipOlder0 ``` And update the return statement (around line 183): ```swift return SyncResult( stadiumsUpdated: totalStadiums, teamsUpdated: totalTeams, gamesUpdated: totalGames, leagueStructuresUpdated: totalLeagueStructures, teamAliasesUpdated: totalTeamAliases, stadiumAliasesUpdated: totalStadiumAliases, sportsUpdated: totalSports, // Add this line skippedIncompatible: totalSkippedIncompatible, skippedOlder: totalSkippedOlder, duration: Date().timeIntervalSince(startTime) ) ``` Also update the early return for disabled sync (around line 84): ```swift return SyncResult( stadiumsUpdated: 0, teamsUpdated: 0, gamesUpdated: 0, leagueStructuresUpdated: 0, teamAliasesUpdated: 0, stadiumAliasesUpdated: 0, sportsUpdated: 0, // Add this line skippedIncompatible: 0, skippedOlder: 0, duration: 0 ) ``` **Step 4: Verify the file compiles** Run: ```bash xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -20 ``` Expected: BUILD SUCCEEDED **Step 5: Commit** ```bash git add SportsTime/Core/Services/CanonicalSyncService.swift git commit -m "$(cat <<'EOF' feat(sync): add sport sync to CanonicalSyncService Sports are now synced from CloudKit as part of the syncAll flow. Syncs first in dependency order (sports define what teams/stadiums belong to). Co-Authored-By: Claude Opus 4.5 EOF )" ``` --- ## Task 8: Add Dynamic Sports to AppDataProvider **Files:** - Modify: `SportsTime/Core/Services/DataProvider.swift` - Test: `SportsTimeTests/Data/DataProviderDynamicSportTests.swift` **Step 1: Write the failing test** Create `SportsTimeTests/Data/DataProviderDynamicSportTests.swift`: ```swift // // DataProviderDynamicSportTests.swift // SportsTimeTests // import Testing import SwiftData @testable import SportsTime @Suite("AppDataProvider Dynamic Sports") struct DataProviderDynamicSportTests { @Test("allSports returns enum sports first, then dynamic") @MainActor func allSportsOrdersCorrectly() async throws { let config = ModelConfiguration(isStoredInMemoryOnly: true) let container = try ModelContainer( for: CanonicalStadium.self, CanonicalTeam.self, CanonicalGame.self, CanonicalSport.self, SyncState.self, configurations: config ) let context = container.mainContext // Add a dynamic sport let xfl = CanonicalSport( id: "xfl", abbreviation: "XFL", displayName: "XFL Football", iconName: "football.fill", colorHex: "#E31837", seasonStartMonth: 2, seasonEndMonth: 5, isActive: true ) context.insert(xfl) try context.save() let provider = AppDataProvider.shared provider.configure(with: context) await provider.loadInitialData() let allSports = provider.allSports // Enum sports come first let enumSportIds = Sport.supported.map { $0.sportId } let firstSportIds = allSports.prefix(enumSportIds.count).map { $0.sportId } #expect(firstSportIds == enumSportIds) // Dynamic sports come after let dynamicSportIds = allSports.dropFirst(enumSportIds.count).map { $0.sportId } #expect(dynamicSportIds.contains("xfl")) } @Test("sport(for:) returns enum sport for known IDs") @MainActor func sportForReturnsEnumSport() async throws { let config = ModelConfiguration(isStoredInMemoryOnly: true) let container = try ModelContainer( for: CanonicalStadium.self, CanonicalTeam.self, CanonicalGame.self, CanonicalSport.self, SyncState.self, configurations: config ) let context = container.mainContext let provider = AppDataProvider.shared provider.configure(with: context) await provider.loadInitialData() let mlb = provider.sport(for: "MLB") #expect(mlb != nil) #expect(mlb?.sportId == "MLB") #expect(mlb?.displayName == "Major League Baseball") } @Test("sport(for:) returns dynamic sport for unknown enum IDs") @MainActor func sportForReturnsDynamicSport() async throws { let config = ModelConfiguration(isStoredInMemoryOnly: true) let container = try ModelContainer( for: CanonicalStadium.self, CanonicalTeam.self, CanonicalGame.self, CanonicalSport.self, SyncState.self, configurations: config ) let context = container.mainContext // Add a dynamic sport let xfl = CanonicalSport( id: "xfl", abbreviation: "XFL", displayName: "XFL Football", iconName: "football.fill", colorHex: "#E31837", seasonStartMonth: 2, seasonEndMonth: 5, isActive: true ) context.insert(xfl) try context.save() let provider = AppDataProvider.shared provider.configure(with: context) await provider.loadInitialData() let sport = provider.sport(for: "xfl") #expect(sport != nil) #expect(sport?.sportId == "xfl") #expect(sport?.displayName == "XFL Football") } @Test("sport(for:) returns nil for completely unknown IDs") @MainActor func sportForReturnsNilForUnknown() async throws { let config = ModelConfiguration(isStoredInMemoryOnly: true) let container = try ModelContainer( for: CanonicalStadium.self, CanonicalTeam.self, CanonicalGame.self, CanonicalSport.self, SyncState.self, configurations: config ) let context = container.mainContext let provider = AppDataProvider.shared provider.configure(with: context) await provider.loadInitialData() let sport = provider.sport(for: "unknown_sport_xyz") #expect(sport == nil) } } ``` **Step 2: Run test to verify it fails** Run: ```bash xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/DataProviderDynamicSportTests test 2>&1 | tail -30 ``` Expected: FAIL with "value of type 'AppDataProvider' has no member 'allSports'" **Step 3: Add dynamic sports support to AppDataProvider** Modify `SportsTime/Core/Services/DataProvider.swift`: Add new published property after `stadiums` (around line 20): ```swift @Published private(set) var dynamicSports: [DynamicSport] = [] ``` Add lookup dictionary after `stadiumsById` (around line 27): ```swift private var dynamicSportsById: [String: DynamicSport] = [:] ``` Add computed property after the existing data access methods (around line 117): ```swift // MARK: - Sport Access /// All sports: enum sports first, then dynamic sports sorted by displayName var allSports: [any AnySport] { let enumSports: [any AnySport] = Sport.supported let dynamic: [any AnySport] = dynamicSports.sorted { $0.displayName < $1.displayName } return enumSports + dynamic } /// Look up a sport by ID. Checks enum sports first, then dynamic. func sport(for id: String) -> (any AnySport)? { // Try enum first if let enumSport = Sport(rawValue: id) { return enumSport } // Fall back to dynamic return dynamicSportsById[id] } ``` In `loadInitialData()` method, add after loading teams (around line 82): ```swift // Fetch dynamic sports from SwiftData let sportDescriptor = FetchDescriptor( predicate: #Predicate { $0.isActive } ) let canonicalSports = try context.fetch(sportDescriptor) // Convert to domain models var loadedDynamicSports: [DynamicSport] = [] var dynamicSportLookup: [String: DynamicSport] = [:] for canonical in canonicalSports { let sport = canonical.toDomain() loadedDynamicSports.append(sport) dynamicSportLookup[sport.id] = sport } self.dynamicSports = loadedDynamicSports self.dynamicSportsById = dynamicSportLookup ``` **Step 4: Run test to verify it passes** Run: ```bash xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/DataProviderDynamicSportTests test 2>&1 | tail -30 ``` Expected: All tests pass **Step 5: Commit** ```bash git add SportsTime/Core/Services/DataProvider.swift SportsTimeTests/Data/DataProviderDynamicSportTests.swift git commit -m "$(cat <<'EOF' feat(data): add dynamic sports support to AppDataProvider AppDataProvider now loads dynamic sports from SwiftData and provides allSports computed property that merges enum and dynamic sports. sport(for:) method enables lookup by sport ID. Co-Authored-By: Claude Opus 4.5 EOF )" ``` --- ## Task 9: Update MockAppDataProvider for Testing **Files:** - Modify: `SportsTimeTests/Mocks/MockAppDataProvider.swift` **Step 1: Add dynamic sports support to MockAppDataProvider** Add after the games storage (around line 30): ```swift private var dynamicSports: [DynamicSport] = [] private var dynamicSportsById: [String: DynamicSport] = [:] ``` Add setter method after `setGames` (around line 82): ```swift func setDynamicSports(_ newSports: [DynamicSport]) { self.dynamicSports = newSports self.dynamicSportsById = Dictionary(uniqueKeysWithValues: newSports.map { ($0.id, $0) }) } ``` Add computed property and lookup method (around line 157): ```swift // MARK: - Sport Access var allSports: [any AnySport] { let enumSports: [any AnySport] = Sport.supported let dynamic: [any AnySport] = dynamicSports.sorted { $0.displayName < $1.displayName } return enumSports + dynamic } func sport(for id: String) -> (any AnySport)? { if let enumSport = Sport(rawValue: id) { return enumSport } return dynamicSportsById[id] } ``` Update `reset()` method to clear dynamic sports: ```swift func reset() { teams = [] stadiums = [] games = [] dynamicSports = [] // Add this teamsById = [:] stadiumsById = [:] gamesById = [:] dynamicSportsById = [:] // Add this // ... rest of reset } ``` **Step 2: Verify the file compiles** Run: ```bash xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -20 ``` Expected: BUILD SUCCEEDED **Step 3: Commit** ```bash git add SportsTimeTests/Mocks/MockAppDataProvider.swift git commit -m "$(cat <<'EOF' test(mocks): add dynamic sports support to MockAppDataProvider MockAppDataProvider now supports dynamic sports for testing UI and planning components that use the unified sport types. Co-Authored-By: Claude Opus 4.5 EOF )" ``` --- ## Task 10: Run Full Test Suite **Files:** None (verification only) **Step 1: Run all existing tests to ensure no regressions** Run: ```bash xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' test 2>&1 | tail -50 ``` Expected: All tests pass (no regressions) **Step 2: If any tests fail, investigate and fix** Common issues to check: - ModelContainer schema may need to include `CanonicalSport.self` - Any code assuming `Sport` is the only sport type **Step 3: Commit any fixes if needed** ```bash git add -A git commit -m "$(cat <<'EOF' fix: resolve test regressions from dynamic sports integration Co-Authored-By: Claude Opus 4.5 EOF )" ``` --- ## Summary This implementation plan adds dynamic sports support with these key components: 1. **AnySport Protocol** - Unifies `Sport` enum and `DynamicSport` struct 2. **DynamicSport** - Domain model for CloudKit-defined sports 3. **CanonicalSport** - SwiftData model for local persistence 4. **CKSport** - CloudKit record wrapper 5. **CloudKitService.fetchSportsForSync** - Fetch sports from CloudKit 6. **CanonicalSyncService.syncSports** - Sync sports to local storage 7. **AppDataProvider.allSports/sport(for:)** - Unified access to all sports **Not implemented (YAGNI per design doc):** - Admin UI for managing sports - User-submitted sports - Sport enable/disable toggles per user - Localized sport names - Custom logo URLs **Next steps after this plan:** - Update `SportSelectorGrid` to use `allSports` instead of `Sport.supported` - Update trip creation views to work with `any AnySport` - Update planning engine to accept dynamic sports