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 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-12 19:32:42 -06:00
parent 1ef3b0335e
commit 4999c90595

View File

@@ -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