# Dynamic Sports via CloudKit Design **Date:** 2026-01-12 **Status:** Draft **Scope:** High-level overview for scoping/prioritization ## Goal Add new sports leagues without shipping app updates. Teams, stadiums, and games for new sports sync automatically via existing CloudKit infrastructure. ## Current State - `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 - **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 ### Data Flow ``` 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` ```swift @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 } ``` ### Domain: `DynamicSport` ```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 } ``` ### Protocol: `AnySport` ```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 } } extension Sport: AnySport { var sportId: String { rawValue } // Other properties already exist on the enum } extension DynamicSport: AnySport { var sportId: String { id } var color: Color { Color(hex: colorHex) } var seasonMonths: (start: Int, end: Int) { (seasonStartMonth, seasonEndMonth) } } ``` ## Integration ### AppDataProvider Changes ```swift // Existing var stadiums: [Stadium] var teams: [Team] // New var dynamicSports: [DynamicSport] = [] var allSports: [any AnySport] { let enumSports: [any AnySport] = Sport.supported let dynamic: [any AnySport] = dynamicSports return enumSports + 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 } } ``` ### Sync Flow 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 ### UI Migration | 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 ``` ### Modified Files ``` Core/Models/ ├── Domain/ │ └── Sport.swift # Add AnySport conformance ├── CloudKit/ │ └── CKModels.swift # Add CKSport wrapper ├── Local/ │ └── CanonicalModels.swift # Add CanonicalSport model Core/Services/ ├── DataProvider.swift # Add dynamicSports, allSports ├── CanonicalSyncService.swift # Add syncSports() └── CloudKitService.swift # Add fetchSports() Core/Theme/ └── SportSelectorGrid.swift # Use allSports instead of Sport.supported ``` ## 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 | ## Not Included (YAGNI) - Admin UI for managing sports (use CloudKit Dashboard directly) - User-submitted sports (admin-only via CloudKit) - Sport enable/disable toggles per user - Sport categories or groupings - Localized sport names (English only for now) - Custom logo URLs (use SF Symbols only) ## 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