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