From 4999c90595cd4798667443945f17a8d033842f80 Mon Sep 17 00:00:00 2001 From: Trey t Date: Mon, 12 Jan 2026 19:32:42 -0600 Subject: [PATCH] docs: add dynamic sports via CloudKit design Hybrid approach: keep Sport enum for existing 7 sports, add DynamicSport struct for CloudKit-defined leagues. Unified via AnySport protocol for seamless UI integration. Co-Authored-By: Claude Opus 4.5 --- ...26-01-12-dynamic-sports-cloudkit-design.md | 227 ++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 docs/plans/2026-01-12-dynamic-sports-cloudkit-design.md diff --git a/docs/plans/2026-01-12-dynamic-sports-cloudkit-design.md b/docs/plans/2026-01-12-dynamic-sports-cloudkit-design.md new file mode 100644 index 0000000..2766808 --- /dev/null +++ b/docs/plans/2026-01-12-dynamic-sports-cloudkit-design.md @@ -0,0 +1,227 @@ +# 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