Files
Sportstime/docs/plans/2026-01-12-dynamic-sports-cloudkit-design.md
Trey t 4999c90595 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>
2026-01-12 19:32:42 -06:00

6.5 KiB

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

@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

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

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

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