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:
227
docs/plans/2026-01-12-dynamic-sports-cloudkit-design.md
Normal file
227
docs/plans/2026-01-12-dynamic-sports-cloudkit-design.md
Normal 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
|
||||
Reference in New Issue
Block a user