# 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
