From f180e5bfed41027b09c89702d27f4804040ebbd6 Mon Sep 17 00:00:00 2001 From: Trey t Date: Tue, 13 Jan 2026 18:27:56 -0600 Subject: [PATCH] feat(sync): add CloudKit sync for dynamic sports - Add CKSport model to parse CloudKit Sport records - Add fetchSportsForSync() to CloudKitService for delta fetching - Add syncSports() and mergeSport() to CanonicalSyncService - Update DataProvider with dynamicSports support and allSports computed property - Update MockAppDataProvider with matching dynamic sports support - Add comprehensive documentation for adding new sports The app can now sync sport definitions from CloudKit, enabling new sports to be added without app updates. Sports are fetched, merged into SwiftData, and exposed via AppDataProvider.allSports alongside built-in Sport enum cases. Co-Authored-By: Claude Opus 4.5 --- .../Core/Models/CloudKit/CKModels.swift | 51 + .../Core/Models/Local/CanonicalModels.swift | 60 + .../Core/Services/CanonicalSyncService.swift | 81 +- .../Core/Services/CloudKitService.swift | 21 + SportsTime/Core/Services/DataProvider.swift | 30 + SportsTime/SportsTimeApp.swift | 1 + .../Mocks/MockAppDataProvider.swift | 29 + TO-DOS.md | 5 +- docs/HOW_TO_ADD_A_DYNAMIC_SPORT.md | 587 +++++++ ...26-01-12-dynamic-sports-cloudkit-design.md | 1376 +++++++++++++++-- 10 files changed, 2080 insertions(+), 161 deletions(-) create mode 100644 docs/HOW_TO_ADD_A_DYNAMIC_SPORT.md diff --git a/SportsTime/Core/Models/CloudKit/CKModels.swift b/SportsTime/Core/Models/CloudKit/CKModels.swift index 2378d36..58e6c4f 100644 --- a/SportsTime/Core/Models/CloudKit/CKModels.swift +++ b/SportsTime/Core/Models/CloudKit/CKModels.swift @@ -315,6 +315,57 @@ struct CKLeagueStructure { } } +// 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 + ) + } +} + // MARK: - CKStadiumAlias struct CKStadiumAlias { diff --git a/SportsTime/Core/Models/Local/CanonicalModels.swift b/SportsTime/Core/Models/Local/CanonicalModels.swift index d6aa529..bde61a5 100644 --- a/SportsTime/Core/Models/Local/CanonicalModels.swift +++ b/SportsTime/Core/Models/Local/CanonicalModels.swift @@ -481,6 +481,66 @@ final class CanonicalGame { } } +// 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 + ) + } +} + // MARK: - Bundled Data Timestamps /// Timestamps for bundled data files. diff --git a/SportsTime/Core/Services/CanonicalSyncService.swift b/SportsTime/Core/Services/CanonicalSyncService.swift index a2c5501..ccd889d 100644 --- a/SportsTime/Core/Services/CanonicalSyncService.swift +++ b/SportsTime/Core/Services/CanonicalSyncService.swift @@ -43,12 +43,13 @@ actor CanonicalSyncService { let leagueStructuresUpdated: Int let teamAliasesUpdated: Int let stadiumAliasesUpdated: Int + let sportsUpdated: Int let skippedIncompatible: Int let skippedOlder: Int let duration: TimeInterval var totalUpdated: Int { - stadiumsUpdated + teamsUpdated + gamesUpdated + leagueStructuresUpdated + teamAliasesUpdated + stadiumAliasesUpdated + stadiumsUpdated + teamsUpdated + gamesUpdated + leagueStructuresUpdated + teamAliasesUpdated + stadiumAliasesUpdated + sportsUpdated } var isEmpty: Bool { totalUpdated == 0 } @@ -83,7 +84,7 @@ actor CanonicalSyncService { return SyncResult( stadiumsUpdated: 0, teamsUpdated: 0, gamesUpdated: 0, leagueStructuresUpdated: 0, teamAliasesUpdated: 0, stadiumAliasesUpdated: 0, - skippedIncompatible: 0, skippedOlder: 0, + sportsUpdated: 0, skippedIncompatible: 0, skippedOlder: 0, duration: 0 ) } @@ -103,6 +104,7 @@ actor CanonicalSyncService { var totalLeagueStructures = 0 var totalTeamAliases = 0 var totalStadiumAliases = 0 + var totalSports = 0 var totalSkippedIncompatible = 0 var totalSkippedOlder = 0 @@ -156,6 +158,14 @@ actor CanonicalSyncService { totalSkippedIncompatible += skipIncompat6 totalSkippedOlder += skipOlder6 + let (sports, skipIncompat7, skipOlder7) = try await syncSports( + context: context, + since: syncState.lastSuccessfulSync + ) + totalSports = sports + totalSkippedIncompatible += skipIncompat7 + totalSkippedOlder += skipOlder7 + // Mark sync successful syncState.syncInProgress = false syncState.lastSuccessfulSync = Date() @@ -187,6 +197,7 @@ actor CanonicalSyncService { leagueStructuresUpdated: totalLeagueStructures, teamAliasesUpdated: totalTeamAliases, stadiumAliasesUpdated: totalStadiumAliases, + sportsUpdated: totalSports, skippedIncompatible: totalSkippedIncompatible, skippedOlder: totalSkippedOlder, duration: Date().timeIntervalSince(startTime) @@ -371,6 +382,30 @@ actor CanonicalSyncService { return (updated, skippedIncompatible, skippedOlder) } + @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) + } + // MARK: - Merge Logic private enum MergeResult { @@ -672,4 +707,46 @@ actor CanonicalSyncService { return .applied } } + + @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 CanonicalSport) + 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.sourceRaw = DataSource.cloudKit.rawValue + + return .applied + } else { + // Insert new + context.insert(remote) + return .applied + } + } } diff --git a/SportsTime/Core/Services/CloudKitService.swift b/SportsTime/Core/Services/CloudKitService.swift index 844ba75..2e86e0b 100644 --- a/SportsTime/Core/Services/CloudKitService.swift +++ b/SportsTime/Core/Services/CloudKitService.swift @@ -418,6 +418,27 @@ actor CloudKitService { } } + // 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() + } + } + // MARK: - Sync Status func checkAccountStatus() async -> CKAccountStatus { diff --git a/SportsTime/Core/Services/DataProvider.swift b/SportsTime/Core/Services/DataProvider.swift index e308879..6b5dcec 100644 --- a/SportsTime/Core/Services/DataProvider.swift +++ b/SportsTime/Core/Services/DataProvider.swift @@ -17,6 +17,7 @@ final class AppDataProvider: ObservableObject { @Published private(set) var teams: [Team] = [] @Published private(set) var stadiums: [Stadium] = [] + @Published private(set) var dynamicSports: [DynamicSport] = [] @Published private(set) var isLoading = false @Published private(set) var error: Error? @Published private(set) var errorMessage: String? @@ -24,6 +25,7 @@ final class AppDataProvider: ObservableObject { // Lookup dictionaries - keyed by canonical ID (String) private var teamsById: [String: Team] = [:] private var stadiumsById: [String: Stadium] = [:] + private var dynamicSportsById: [String: DynamicSport] = [:] private var modelContext: ModelContext? @@ -80,10 +82,27 @@ final class AppDataProvider: ObservableObject { teamLookup[team.id] = team } + // Fetch canonical sports from SwiftData + let sportDescriptor = FetchDescriptor( + predicate: #Predicate { $0.isActive == true } + ) + let canonicalSports = try context.fetch(sportDescriptor) + + // Convert to domain models + var loadedSports: [DynamicSport] = [] + var sportLookup: [String: DynamicSport] = [:] + for canonical in canonicalSports { + let sport = canonical.toDomain() + loadedSports.append(sport) + sportLookup[sport.id] = sport + } + self.teams = loadedTeams self.stadiums = loadedStadiums + self.dynamicSports = loadedSports self.teamsById = teamLookup self.stadiumsById = stadiumLookup + self.dynamicSportsById = sportLookup } catch { self.error = error @@ -116,6 +135,17 @@ final class AppDataProvider: ObservableObject { teams.filter { $0.sport == sport } } + func dynamicSport(for id: String) -> DynamicSport? { + dynamicSportsById[id] + } + + /// All sports: built-in Sport enum cases + CloudKit-defined DynamicSports + var allSports: [any AnySport] { + let builtIn: [any AnySport] = Sport.allCases + let dynamic: [any AnySport] = dynamicSports + return builtIn + dynamic + } + // MARK: - Game Fetching /// Filter games from SwiftData within date range diff --git a/SportsTime/SportsTimeApp.swift b/SportsTime/SportsTimeApp.swift index 7a8c0c6..11507bf 100644 --- a/SportsTime/SportsTimeApp.swift +++ b/SportsTime/SportsTimeApp.swift @@ -38,6 +38,7 @@ struct SportsTimeApp: App { TeamAlias.self, LeagueStructureModel.self, CanonicalGame.self, + CanonicalSport.self, ]) let modelConfiguration = ModelConfiguration( schema: schema, diff --git a/SportsTimeTests/Mocks/MockAppDataProvider.swift b/SportsTimeTests/Mocks/MockAppDataProvider.swift index f2087d7..f737bb1 100644 --- a/SportsTimeTests/Mocks/MockAppDataProvider.swift +++ b/SportsTimeTests/Mocks/MockAppDataProvider.swift @@ -18,6 +18,7 @@ final class MockAppDataProvider: ObservableObject { @Published private(set) var teams: [Team] = [] @Published private(set) var stadiums: [Stadium] = [] + @Published private(set) var dynamicSports: [DynamicSport] = [] @Published private(set) var isLoading = false @Published private(set) var error: Error? @Published private(set) var errorMessage: String? @@ -26,6 +27,7 @@ final class MockAppDataProvider: ObservableObject { private var teamsById: [String: Team] = [:] private var stadiumsById: [String: Stadium] = [:] + private var dynamicSportsById: [String: DynamicSport] = [:] private var games: [Game] = [] private var gamesById: [String: Game] = [:] @@ -80,12 +82,19 @@ final class MockAppDataProvider: ObservableObject { self.gamesById = Dictionary(uniqueKeysWithValues: newGames.map { ($0.id, $0) }) } + func setDynamicSports(_ newSports: [DynamicSport]) { + self.dynamicSports = newSports + self.dynamicSportsById = Dictionary(uniqueKeysWithValues: newSports.map { ($0.id, $0) }) + } + func reset() { teams = [] stadiums = [] + dynamicSports = [] games = [] teamsById = [:] stadiumsById = [:] + dynamicSportsById = [:] gamesById = [:] isLoading = false error = nil @@ -156,6 +165,17 @@ final class MockAppDataProvider: ObservableObject { teams.filter { $0.sport == sport } } + func dynamicSport(for id: String) -> DynamicSport? { + dynamicSportsById[id] + } + + /// All sports: built-in Sport enum cases + CloudKit-defined DynamicSports + var allSports: [any AnySport] { + let builtIn: [any AnySport] = Sport.allCases + let dynamic: [any AnySport] = dynamicSports + return builtIn + dynamic + } + // MARK: - Game Filtering (Local Queries) func filterGames(sports: Set, startDate: Date, endDate: Date) async throws -> [Game] { @@ -287,4 +307,13 @@ extension MockAppDataProvider { /// Get stadiums count var stadiumsCount: Int { stadiums.count } + + /// Add a single dynamic sport + func addDynamicSport(_ sport: DynamicSport) { + dynamicSports.append(sport) + dynamicSportsById[sport.id] = sport + } + + /// Get dynamic sports count + var dynamicSportsCount: Int { dynamicSports.count } } diff --git a/TO-DOS.md b/TO-DOS.md index 3412645..5447f87 100644 --- a/TO-DOS.md +++ b/TO-DOS.md @@ -24,12 +24,9 @@ read docs/TEST_PLAN.md in full question: do we need sync schedules anymore in settings // things that are new -need to plan: build ios in app purchase for storekit -need to plan: build complete receipt checking system to check if the user has purchased any subscriptions setting a value that can easily be checked anywhere in the app -need to plan: use frontend-design skill to redesign the app + // new ish to existing features -feature: add the ability to add sports from cloudKit. name, icon, stadiums, etc // bugs Issue: sharing looks really dumb. need to be able to share achievements, league progress, and a trip diff --git a/docs/HOW_TO_ADD_A_DYNAMIC_SPORT.md b/docs/HOW_TO_ADD_A_DYNAMIC_SPORT.md new file mode 100644 index 0000000..99b316a --- /dev/null +++ b/docs/HOW_TO_ADD_A_DYNAMIC_SPORT.md @@ -0,0 +1,587 @@ +# How to Add a New Sport to SportsTime + +> A guide so simple, even a 5-year-old can follow along! + +--- + +## The Big Picture (What's Happening?) + +Imagine you have a **magic toy box** (that's CloudKit - Apple's cloud storage). + +When you put a new toy in the box, all your friends' toy boxes get the same toy automatically! + +That's how SportsTime works: +1. You put a new sport in the **magic cloud box** +2. Everyone's app gets the new sport - without downloading a new app! + +--- + +## Step 1: Open the Magic Box (CloudKit Dashboard) + +1. Go to [iCloud Dashboard](https://icloud.developer.apple.com/) +2. Sign in with the developer Apple ID +3. Click on **CloudKit Database** +4. Select **SportsTime** container: `iCloud.com.sportstime.app` +5. Click **Public Database** (this is the shared toy box everyone can see) + +``` +Think of it like this: + + [Your Computer] + | + v + [CloudKit Dashboard] <-- This is where you add new sports! + | + v + [Magic Cloud Box] + | + _____|_____ + | | | + v v v + [App] [App] [App] <-- Everyone's app gets the update! +``` + +--- + +## Step 2: Create the New Sport Record + +Click **Records** in the sidebar, then click the **+** button to create a new record. + +### Choose the Record Type +Select: `Sport` + +### Fill in These Fields + +| Field Name | What It Means | Example | +|------------|---------------|---------| +| `sportId` | A short code name (no spaces!) | `mls` | +| `abbreviation` | How we shorten it | `MLS` | +| `displayName` | The pretty name people see | `Major League Soccer` | +| `iconName` | SF Symbol name for the icon | `soccerball` | +| `colorHex` | The sport's color (like a crayon!) | `#00A859` | +| `seasonStartMonth` | When games start (1-12) | `3` (March) | +| `seasonEndMonth` | When games end (1-12) | `11` (November) | +| `isActive` | Is it turned on? (1=yes, 0=no) | `1` | +| `schemaVersion` | Always use this number | `1` | +| `lastModified` | Today's date | (auto-filled) | + +### Example: Adding Soccer (MLS) + +``` +sportId: mls +abbreviation: MLS +displayName: Major League Soccer +iconName: soccerball +colorHex: #00A859 +seasonStartMonth: 3 +seasonEndMonth: 11 +isActive: 1 +schemaVersion: 1 +``` + +Click **Save Record**! + +--- + +## Step 3: What Happens Next? (The Magic!) + +Now let's follow the journey of your new sport, step by step: + +``` + YOU ADD SPORT HERE + | + v + +------------------------------------------+ + | CloudKit (Magic Cloud Box) | + | | + | +----------------------------------+ | + | | Sport Record: "MLS" | | + | | - displayName: Major League... | | + | | - iconName: soccerball | | + | +----------------------------------+ | + +------------------------------------------+ + | + | (App asks: "Any new sports?") + v + +------------------------------------------+ + | User's iPhone | + | | + | 1. CloudKitService.fetchSportsForSync() | + | "Hey cloud, what's new?" | + | | | + | v | + | 2. CKSport.toCanonical() | + | "Let me understand this..." | + | | | + | v | + | 3. CanonicalSyncService.syncSports() | + | "Save it to my phone!" | + | | | + | v | + | 4. SwiftData (Phone's Memory) | + | [MLS is now saved locally!] | + | | | + | v | + | 5. AppDataProvider.loadInitialData() | + | "Load it so the app can use it!" | + | | | + | v | + | 6. App Shows MLS! | + | "Pick your sports: MLB, NBA, MLS"| + +------------------------------------------+ +``` + +--- + +## The Journey in Kid Terms + +### 1. You Put a Toy in the Cloud Box +``` +You: "Here's a new soccer ball toy!" + *puts MLS in CloudKit* +``` + +### 2. The App Wakes Up and Checks +``` +App: "Good morning! Let me check if there are new toys..." + *calls CloudKitService.fetchSportsForSync()* +``` + +### 3. The App Finds Your New Toy +``` +Cloud: "Yes! Someone added a soccer ball!" +App: "Ooh! Let me grab it!" + *CKSport wraps the cloud data* +``` + +### 4. The App Puts It in Its Pocket +``` +App: "I'll save this soccer ball in my pocket so I don't forget it!" + *CanonicalSyncService saves to SwiftData* +``` + +### 5. The App Shows It to the User +``` +App: "Hey user! Look what I found! You can now pick Soccer too!" + *AppDataProvider.allSports includes MLS* +``` + +--- + +## The Code Path (For Grown-Ups) + +Here's exactly which files do what: + +``` +CloudKit Dashboard + | + | (1) You create Sport record + v +CloudKitService.swift + | + | (2) fetchSportsForSync() downloads new sports + v +CKModels.swift (CKSport) + | + | (3) toCanonical() converts cloud data to app data + v +CanonicalSyncService.swift + | + | (4) syncSports() + mergeSport() saves to phone + v +CanonicalModels.swift (CanonicalSport) + | + | (5) Stored in SwiftData (phone's database) + v +DataProvider.swift + | + | (6) loadInitialData() loads into memory + | (7) allSports returns Sport enum + DynamicSports + v +App UI Shows New Sport! +``` + +--- + +## When Does the App Check for New Sports? + +The app checks for new toys (sports) at these times: + +| When | What Happens | +|------|--------------| +| App opens | "Let me see if anything is new!" | +| Pull to refresh | "User wants me to check again!" | +| Background refresh | "Checking while you're not looking..." | + +--- + +## Troubleshooting: My Sport Isn't Showing Up! + +### Checklist (Like checking your backpack!) + +1. **Is `isActive` set to `1`?** + - If it's `0`, the app ignores it (like a toy that's turned off) + +2. **Is the `sportId` unique?** + - Can't have two toys with the same name! + +3. **Did you save the record?** + - Click that Save button! + +4. **Is the app connected to internet?** + - No wifi = no checking the cloud box + +5. **Try force-refreshing:** + - Pull down on the screen to refresh + +--- + +## Quick Reference Card + +``` ++--------------------------------------------------+ +| ADDING A NEW SPORT CHECKLIST | ++--------------------------------------------------+ +| | +| [ ] 1. Open CloudKit Dashboard | +| [ ] 2. Go to Public Database | +| [ ] 3. Create new "Sport" record | +| [ ] 4. Fill in ALL required fields: | +| - sportId (unique!) | +| - abbreviation | +| - displayName | +| - iconName (SF Symbol) | +| - colorHex | +| - seasonStartMonth | +| - seasonEndMonth | +| - isActive = 1 | +| - schemaVersion = 1 | +| [ ] 5. Click Save | +| [ ] 6. Open app and pull to refresh | +| [ ] 7. See your new sport! | +| | ++--------------------------------------------------+ +``` + +--- + +## Summary: The Whole Story + +1. **You** add a sport to CloudKit (the magic cloud box) +2. **CloudKitService** fetches it when the app checks +3. **CKSport** translates cloud-speak to app-speak +4. **CanonicalSyncService** saves it to the phone +5. **AppDataProvider** serves it up to the app +6. **User** sees the new sport and smiles! + +**The End!** + +--- + +## Part 2: Getting Game Data (The Data Pipeline) + +Now that you've added the sport definition to CloudKit, you need game data! This is where the **data scrapers** come in - they're like robots that go around collecting game schedules from websites and putting them in the cloud. + +--- + +## Step 4: Tell the Scraper About Your New Sport + +### 4a. Add to the Sports List + +Open `Scripts/sportstime_parser/config.py` and add your sport to the `SUPPORTED_SPORTS` list: + +```python +# Supported sports +SUPPORTED_SPORTS: list[str] = [ + "nba", + "mlb", + "nfl", + "nhl", + "mls", + "wnba", + "nwsl", + "your_new_sport", # <-- Add your sport here! +] +``` + +Also add an expected game count (this helps validate that scraping worked): + +```python +EXPECTED_GAME_COUNTS: dict[str, int] = { + "nba": 1230, + "mlb": 2430, + # ... other sports ... + "your_new_sport": 500, # <-- Approximate games per season +} +``` + +### 4b. Create a Scraper Class + +Create a new file: `Scripts/sportstime_parser/scrapers/your_sport.py` + +Here's the template (like a recipe card for your robot): + +```python +"""Your Sport scraper implementation.""" + +from datetime import datetime, date +from typing import Optional + +from .base import BaseScraper, RawGameData, ScrapeResult +from ..models.game import Game +from ..models.team import Team +from ..models.stadium import Stadium + + +class YourSportScraper(BaseScraper): + """Your Sport schedule scraper. + + Sources (in priority order): + 1. ESPN API - Primary source + 2. Official League Website - Backup + """ + + def __init__(self, season: int, **kwargs): + super().__init__("your_sport", season, **kwargs) + + def _get_sources(self) -> list[str]: + """Return source list in priority order.""" + return ["espn", "official_site"] + + def _get_source_url(self, source: str, **kwargs) -> str: + """Build URL for a source.""" + if source == "espn": + # ESPN API URL for your sport + return f"https://site.api.espn.com/apis/site/v2/sports/..." + raise ValueError(f"Unknown source: {source}") + + def _get_season_months(self) -> list[tuple[int, int]]: + """What months does the season run?""" + # Return list of (year, month) tuples + return [(self.season, month) for month in range(3, 12)] + + def _scrape_games_from_source(self, source: str) -> list[RawGameData]: + """Scrape games from a specific source.""" + if source == "espn": + return self._scrape_espn() + raise ValueError(f"Unknown source: {source}") + + def _scrape_espn(self) -> list[RawGameData]: + """Scrape games from ESPN API.""" + # Your scraping logic here + pass + + def _normalize_games( + self, + raw_games: list[RawGameData], + ) -> tuple[list[Game], list]: + """Convert raw data to Game objects.""" + # Your normalization logic here + pass + + def scrape_teams(self) -> list[Team]: + """Get all teams for your sport.""" + pass + + def scrape_stadiums(self) -> list[Stadium]: + """Get all stadiums for your sport.""" + pass + + +def create_your_sport_scraper(season: int) -> YourSportScraper: + """Factory function to create the scraper.""" + return YourSportScraper(season=season) +``` + +### 4c. Register the Scraper + +Open `Scripts/sportstime_parser/cli.py` and add your scraper to the `get_scraper()` function: + +```python +def get_scraper(sport: str, season: int): + if sport == "nba": + from .scrapers.nba import create_nba_scraper + return create_nba_scraper(season) + # ... other sports ... + elif sport == "your_sport": # <-- Add this! + from .scrapers.your_sport import create_your_sport_scraper + return create_your_sport_scraper(season) + else: + raise NotImplementedError(f"Scraper for {sport} not yet implemented") +``` + +--- + +## Step 5: Run the Scraper Robot! + +Now let's send your robot out to collect games: + +```bash +# Go to the Scripts folder +cd Scripts + +# Install dependencies (only needed once) +pip install -r requirements.txt + +# Scrape your sport for the 2026 season +python -m sportstime_parser scrape your_sport --season 2026 +``` + +``` +What the robot does: + + [Robot wakes up] + | + v + "I need to find your_sport games!" + | + v + [Goes to ESPN website] + | + v + "Found 500 games! Let me organize them..." + | + v + [Saves to Scripts/output/games_your_sport_2026.json] + | + v + "All done! Here's what I found!" +``` + +--- + +## Step 6: Upload to CloudKit + +Now let's put those games in the magic cloud box: + +```bash +# Upload your sport's data to CloudKit +python -m sportstime_parser upload your_sport --season 2026 +``` + +``` +What happens: + + [Your Computer] + | + | "Here are 500 games!" + v + [CloudKit (Magic Cloud Box)] + | + | "Got them! I'll tell all the apps!" + v + [Everyone's App Gets the Games!] +``` + +--- + +## The Complete Data Journey + +``` + YOU (The Data Collector) + | + | Step 4: Create scraper + v + +------------------------------------------+ + | Python Scraper Robot | + | | + | 1. Goes to ESPN/league websites | + | 2. Collects all game schedules | + | 3. Organizes them nicely | + | 4. Saves to JSON files | + +------------------------------------------+ + | + | Step 6: Upload + v + +------------------------------------------+ + | CloudKit (Magic Cloud Box) | + | | + | Stores: | + | - Sport definition (Step 2) | + | - Teams | + | - Stadiums | + | - Game schedules | + +------------------------------------------+ + | + | App syncs automatically + v + +------------------------------------------+ + | User's iPhone | + | | + | "Look! There's a new sport with | + | 500 games to explore!" | + +------------------------------------------+ +``` + +--- + +## Troubleshooting: My Games Aren't Showing Up! + +### Scraper Problems + +1. **"No games found"** + - Check if the source website changed its format + - Try a different source (ESPN, official site, etc.) + - Look at the validation report in `Scripts/output/` + +2. **"Rate limited"** + - The website said "slow down!" - wait a bit and try again + - The scraper has built-in delays, but sometimes sites are extra strict + +3. **"Authentication error"** + - For CloudKit upload, make sure you have `CLOUDKIT_KEY_ID` and `CLOUDKIT_PRIVATE_KEY_PATH` set + - Get credentials from Apple Developer Portal + +### Useful Commands + +```bash +# Check what data you have +python -m sportstime_parser status + +# Validate your scraped data +python -m sportstime_parser validate your_sport --season 2026 + +# Retry failed uploads +python -m sportstime_parser retry your_sport --season 2026 + +# Clear upload state and start fresh +python -m sportstime_parser clear your_sport --season 2026 +``` + +--- + +## Quick Reference: Adding a Sport End-to-End + +``` ++--------------------------------------------------+ +| COMPLETE SPORT ADDITION CHECKLIST | ++--------------------------------------------------+ +| | +| PART 1: CloudKit (Sport Definition) | +| [ ] 1. Open CloudKit Dashboard | +| [ ] 2. Create "Sport" record with all fields | +| [ ] 3. Save record | +| | +| PART 2: Python Scraper (Game Data) | +| [ ] 4a. Add to SUPPORTED_SPORTS in config.py | +| [ ] 4b. Add EXPECTED_GAME_COUNTS in config.py | +| [ ] 4c. Create scraper class in scrapers/ | +| [ ] 4d. Register in cli.py get_scraper() | +| [ ] 5. Run: scrape your_sport --season 2026 | +| [ ] 6. Run: upload your_sport --season 2026 | +| | +| VERIFY: | +| [ ] 7. Open app and pull to refresh | +| [ ] 8. See your new sport with games! | +| | ++--------------------------------------------------+ +``` + +--- + +**Now you're a pro! The End (for real this time)!** + +--- + +*Document created: January 2026* +*For the SportsTime app dynamic sports feature* diff --git a/docs/plans/2026-01-12-dynamic-sports-cloudkit-design.md b/docs/plans/2026-01-12-dynamic-sports-cloudkit-design.md index 2766808..bbb4eb3 100644 --- a/docs/plans/2026-01-12-dynamic-sports-cloudkit-design.md +++ b/docs/plans/2026-01-12-dynamic-sports-cloudkit-design.md @@ -1,58 +1,523 @@ -# Dynamic Sports via CloudKit Design +# Dynamic Sports via CloudKit Implementation Plan -**Date:** 2026-01-12 -**Status:** Draft -**Scope:** High-level overview for scoping/prioritization +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. -## Goal +**Goal:** Add new sports leagues without shipping app updates by syncing sport definitions from CloudKit. -Add new sports leagues without shipping app updates. Teams, stadiums, and games for new sports sync automatically via existing CloudKit infrastructure. +**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. -## Current State +**Tech Stack:** Swift 6, SwiftUI, SwiftData, CloudKit (public database), @Observable pattern -- `Sport` is a hardcoded enum with 7 sports (MLB, NBA, NHL, NFL, MLS, WNBA, NWSL) -- Each sport has: rawValue, displayName, iconName, color, seasonMonths -- Teams, stadiums, and games are already CloudKit-synced -- Teams/stadiums/games store sport as a string key (e.g., `"MLB"`) +--- -## Approach: Hybrid Model +## Task 1: Create AnySport Protocol -- **Keep** `Sport` enum for the 7 existing sports (compile-time safety, existing code unchanged) -- **Add** `DynamicSport` struct for CloudKit-defined sports -- **Unify** via `AnySport` protocol so both work interchangeably in UI +**Files:** +- Create: `SportsTime/Core/Models/Domain/AnySport.swift` -### Data Flow +**Step 1: Create the protocol file** -``` -CloudKit "Sport" records - ↓ -CanonicalSyncService.syncSports() - ↓ -SwiftData: CanonicalSport model - ↓ -AppDataProvider.shared.allSports → [any AnySport] - ↓ -UI: SportSelectorGrid shows both enum + dynamic sports -``` - -## Data Model - -### CloudKit Record: `Sport` - -| Field | Type | Example | -|-------|------|---------| -| `sportId` | String | `"xfl"` | -| `abbreviation` | String | `"XFL"` | -| `displayName` | String | `"XFL Football"` | -| `iconName` | String | `"football.fill"` | -| `colorHex` | String | `"#E31837"` | -| `seasonStartMonth` | Int | `2` | -| `seasonEndMonth` | Int | `5` | -| `isActive` | Int | `1` | - -### SwiftData: `CanonicalSport` +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 @@ -64,164 +529,765 @@ final class CanonicalSport { 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 + ) + } } ``` -### Domain: `DynamicSport` +**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 -struct DynamicSport: Identifiable, Hashable { - let id: String - let abbreviation: String - let displayName: String - let iconName: String - let colorHex: String - let seasonStartMonth: Int - let seasonEndMonth: Int +// 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 + ) + } } ``` -### Protocol: `AnySport` +**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 -protocol AnySport: Identifiable, Hashable { - var sportId: String { get } - var displayName: String { get } - var iconName: String { get } - var color: Color { get } - var seasonMonths: (start: Int, end: Int) { get } -} +// MARK: - Sport Sync -extension Sport: AnySport { - var sportId: String { rawValue } - // Other properties already exist on the enum -} +/// 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) -extension DynamicSport: AnySport { - var sportId: String { id } - var color: Color { Color(hex: colorHex) } - var seasonMonths: (start: Int, end: Int) { (seasonStartMonth, seasonEndMonth) } + 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() + } } ``` -## Integration +**Step 2: Verify the file compiles** -### AppDataProvider Changes +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 -// Existing -var stadiums: [Stadium] -var teams: [Team] +@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) -// New -var dynamicSports: [DynamicSport] = [] + 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 + 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 dynamicSports.first { $0.id == id } + return dynamicSportsById[id] } ``` -### Sync Flow +In `loadInitialData()` method, add after loading teams (around line 82): -1. `CanonicalSyncService.syncSports()` fetches CloudKit `Sport` records -2. Converts to `CanonicalSport` SwiftData models -3. On `AppDataProvider.loadInitialData()`, loads dynamic sports into memory -4. UI queries `allSports` which merges enum + dynamic +```swift +// Fetch dynamic sports from SwiftData +let sportDescriptor = FetchDescriptor( + predicate: #Predicate { $0.isActive } +) +let canonicalSports = try context.fetch(sportDescriptor) -### UI Migration +// 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 +} -| Component | Current | Updated | -|-----------|---------|---------| -| `SportSelectorGrid` | `ForEach(Sport.supported)` | `ForEach(dataProvider.allSports)` | -| `sportsSection` in TripCreation | Uses `Sport` enum directly | Uses `any AnySport` | -| Filters/pickers | Hardcoded `Sport.allCases` | Uses `dataProvider.allSports` | - -### Backward Compatibility - -- Existing `Sport` enum untouched—no migration needed for current data -- Teams/stadiums/games already store sport as `String` (e.g., `"MLB"`) -- New dynamic sports use same pattern (e.g., `"XFL"`) -- `Sport(rawValue:)` returns `nil` for unknown sports → falls back to dynamic lookup - -### Bootstrap & Offline - -- Bundled JSON includes only the 7 enum sports (no dynamic sports at first launch) -- Dynamic sports require CloudKit sync to appear -- Graceful degradation: offline users see only enum sports - -## File Structure - -### New Files - -``` -Core/Models/ -├── Domain/ -│ ├── DynamicSport.swift # NEW - struct for CloudKit sports -│ └── AnySport.swift # NEW - protocol unifying both +self.dynamicSports = loadedDynamicSports +self.dynamicSportsById = dynamicSportLookup ``` -### Modified Files +**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 ``` -Core/Models/ -├── Domain/ -│ └── Sport.swift # Add AnySport conformance -├── CloudKit/ -│ └── CKModels.swift # Add CKSport wrapper -├── Local/ -│ └── CanonicalModels.swift # Add CanonicalSport model +Expected: All tests pass -Core/Services/ -├── DataProvider.swift # Add dynamicSports, allSports -├── CanonicalSyncService.swift # Add syncSports() -└── CloudKitService.swift # Add fetchSports() +**Step 5: Commit** -Core/Theme/ -└── SportSelectorGrid.swift # Use allSports instead of Sport.supported +```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 +)" ``` -## Key Decisions +--- -| Decision | Choice | Rationale | -|----------|--------|-----------| -| Protocol name | `AnySport` | Clear, follows Swift naming conventions | -| Enum sports in CloudKit? | No | Enum sports are source of truth, avoid sync conflicts | -| Icon validation | Accept any SF Symbol string | Trust CloudKit data, invalid icons show placeholder | -| Color format | Hex string (e.g., `#E31837`) | Standard, easy to manage in CloudKit dashboard | -| Sort order | Enum sports first, then dynamic by displayName | Keeps familiar sports at top | -| Offline behavior | Show only enum sports | Dynamic sports require at least one successful sync | -| Discovery | Auto-show all | Any sport in CloudKit automatically appears in the app | +## Task 9: Update MockAppDataProvider for Testing -## Not Included (YAGNI) +**Files:** +- Modify: `SportsTimeTests/Mocks/MockAppDataProvider.swift` -- Admin UI for managing sports (use CloudKit Dashboard directly) -- User-submitted sports (admin-only via CloudKit) +**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 -- Sport categories or groupings -- Localized sport names (English only for now) -- Custom logo URLs (use SF Symbols only) +- Localized sport names +- Custom logo URLs -## Dependencies - -- Existing CloudKit infrastructure (already in place) -- Existing CanonicalSyncService patterns - -## Testing Considerations - -- Test with 0 dynamic sports (offline/first launch) -- Test UI with 10+ sports (scrolling, layout) -- Test sport lookup when team references unknown sport -- Test sync adds new sport mid-session -- Test invalid SF Symbol name (should show placeholder) -- Test color parsing with various hex formats +**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