# Dynamic Sports via CloudKit Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Add new sports leagues without shipping app updates by syncing sport definitions from CloudKit.

**Architecture:** Hybrid model - keep `Sport` enum for existing 7 sports (compile-time safety), add `DynamicSport` struct for CloudKit-defined sports, unify via `AnySport` protocol so both work interchangeably in UI and planning engine.

**Tech Stack:** Swift 6, SwiftUI, SwiftData, CloudKit (public database), @Observable pattern

---

## Task 1: Create AnySport Protocol

**Files:**
- Create: `SportsTime/Core/Models/Domain/AnySport.swift`

**Step 1: Create the protocol file**

Create `SportsTime/Core/Models/Domain/AnySport.swift`:

```swift
//
//  AnySport.swift
//  SportsTime
//
//  Protocol unifying Sport enum and DynamicSport for interchangeable use.
//

import SwiftUI

/// Protocol that unifies Sport enum and DynamicSport
/// Allows both to be used interchangeably in UI and planning engine.
protocol AnySport: Identifiable, Hashable, Sendable {
    /// Unique identifier string (e.g., "MLB", "xfl")
    var sportId: String { get }

    /// Display name for UI (e.g., "Major League Baseball", "XFL Football")
    var displayName: String { get }

    /// SF Symbol name for sport icon (e.g., "baseball.fill")
    var iconName: String { get }

    /// Theme color for this sport
    var color: Color { get }

    /// Season months (start and end, 1-12). End may be less than start for wrap-around seasons.
    var seasonMonths: (start: Int, end: Int) { get }
}

extension AnySport {
    /// Check if sport is in season for a given date
    func isInSeason(for date: Date) -> Bool {
        let calendar = Calendar.current
        let month = calendar.component(.month, from: date)

        let (start, end) = seasonMonths
        if start <= end {
            // Normal range (e.g., March to October)
            return month >= start && month <= end
        } else {
            // Season wraps around year boundary (e.g., October to June)
            return month >= start || month <= end
        }
    }
}
```

**Step 2: Verify the file compiles**

Run:
```bash
xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -20
```
Expected: BUILD SUCCEEDED

**Step 3: Commit**

```bash
git add SportsTime/Core/Models/Domain/AnySport.swift
git commit -m "$(cat <<'EOF'
feat(domain): add AnySport protocol for unified sport handling

Defines protocol that both Sport enum and DynamicSport will conform to,
enabling interchangeable use in UI and planning engine.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"
```

---

## Task 2: Add AnySport Conformance to Sport Enum

**Files:**
- Modify: `SportsTime/Core/Models/Domain/Sport.swift`
- Test: `SportsTimeTests/Domain/SportTests.swift` (create)

**Step 1: Write the failing test**

Create `SportsTimeTests/Domain/SportTests.swift`:

```swift
//
//  SportTests.swift
//  SportsTimeTests
//

import Testing
@testable import SportsTime

@Suite("Sport AnySport Conformance")
struct SportAnySportTests {

    @Test("Sport conforms to AnySport protocol")
    func sportConformsToAnySport() {
        let sport: any AnySport = Sport.mlb
        #expect(sport.sportId == "MLB")
        #expect(sport.displayName == "Major League Baseball")
        #expect(sport.iconName == "baseball.fill")
    }

    @Test("Sport.id equals Sport.sportId")
    func sportIdEqualsSportId() {
        for sport in Sport.allCases {
            #expect(sport.id == sport.sportId)
        }
    }

    @Test("Sport isInSeason works correctly")
    func sportIsInSeason() {
        let mlb = Sport.mlb

        // April is in MLB season (March-October)
        let april = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 15))!
        #expect(mlb.isInSeason(for: april))

        // January is not in MLB season
        let january = Calendar.current.date(from: DateComponents(year: 2026, month: 1, day: 15))!
        #expect(!mlb.isInSeason(for: january))
    }

    @Test("Sport with wrap-around season works correctly")
    func sportWrapAroundSeason() {
        let nba = Sport.nba

        // December is in NBA season (October-June wraps)
        let december = Calendar.current.date(from: DateComponents(year: 2026, month: 12, day: 15))!
        #expect(nba.isInSeason(for: december))

        // July is not in NBA season
        let july = Calendar.current.date(from: DateComponents(year: 2026, month: 7, day: 15))!
        #expect(!nba.isInSeason(for: july))
    }
}
```

**Step 2: Run test to verify it fails**

Run:
```bash
xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/SportAnySportTests test 2>&1 | tail -30
```
Expected: FAIL with "cannot find type 'AnySport'" or similar

**Step 3: Add AnySport conformance to Sport enum**

Modify `SportsTime/Core/Models/Domain/Sport.swift`. Add at end of file before closing brace:

```swift
// MARK: - AnySport Conformance

extension Sport: AnySport {
    var sportId: String { rawValue }

    // Note: displayName, iconName, seasonMonths already exist on Sport
    // color is computed from themeColor in ViewModifiers.swift
}
```

**Step 4: Run test to verify it passes**

Run:
```bash
xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/SportAnySportTests test 2>&1 | tail -30
```
Expected: Test passed

**Step 5: Commit**

```bash
git add SportsTime/Core/Models/Domain/Sport.swift SportsTimeTests/Domain/SportTests.swift
git commit -m "$(cat <<'EOF'
feat(domain): add AnySport conformance to Sport enum

Existing Sport enum now conforms to AnySport protocol, enabling
unified handling with future DynamicSport types.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"
```

---

## Task 3: Create DynamicSport Domain Model

**Files:**
- Create: `SportsTime/Core/Models/Domain/DynamicSport.swift`
- Test: `SportsTimeTests/Domain/DynamicSportTests.swift`

**Step 1: Write the failing test**

Create `SportsTimeTests/Domain/DynamicSportTests.swift`:

```swift
//
//  DynamicSportTests.swift
//  SportsTimeTests
//

import Testing
import SwiftUI
@testable import SportsTime

@Suite("DynamicSport")
struct DynamicSportTests {

    @Test("DynamicSport conforms to AnySport protocol")
    func dynamicSportConformsToAnySport() {
        let xfl = DynamicSport(
            id: "xfl",
            abbreviation: "XFL",
            displayName: "XFL Football",
            iconName: "football.fill",
            colorHex: "#E31837",
            seasonStartMonth: 2,
            seasonEndMonth: 5
        )

        let sport: any AnySport = xfl
        #expect(sport.sportId == "xfl")
        #expect(sport.displayName == "XFL Football")
        #expect(sport.iconName == "football.fill")
    }

    @Test("DynamicSport color parses from hex")
    func dynamicSportColorParsesFromHex() {
        let sport = DynamicSport(
            id: "test",
            abbreviation: "TST",
            displayName: "Test Sport",
            iconName: "star.fill",
            colorHex: "#FF0000",
            seasonStartMonth: 1,
            seasonEndMonth: 12
        )

        // Color should be red
        #expect(sport.color != Color.clear)
    }

    @Test("DynamicSport isInSeason works correctly")
    func dynamicSportIsInSeason() {
        let xfl = DynamicSport(
            id: "xfl",
            abbreviation: "XFL",
            displayName: "XFL Football",
            iconName: "football.fill",
            colorHex: "#E31837",
            seasonStartMonth: 2,
            seasonEndMonth: 5
        )

        // March is in XFL season (Feb-May)
        let march = Calendar.current.date(from: DateComponents(year: 2026, month: 3, day: 15))!
        #expect(xfl.isInSeason(for: march))

        // September is not in XFL season
        let september = Calendar.current.date(from: DateComponents(year: 2026, month: 9, day: 15))!
        #expect(!xfl.isInSeason(for: september))
    }

    @Test("DynamicSport is Hashable")
    func dynamicSportIsHashable() {
        let sport1 = DynamicSport(
            id: "xfl",
            abbreviation: "XFL",
            displayName: "XFL Football",
            iconName: "football.fill",
            colorHex: "#E31837",
            seasonStartMonth: 2,
            seasonEndMonth: 5
        )

        let sport2 = DynamicSport(
            id: "xfl",
            abbreviation: "XFL",
            displayName: "XFL Football",
            iconName: "football.fill",
            colorHex: "#E31837",
            seasonStartMonth: 2,
            seasonEndMonth: 5
        )

        let set: Set<DynamicSport> = [sport1, sport2]
        #expect(set.count == 1)
    }

    @Test("DynamicSport is Codable")
    func dynamicSportIsCodable() throws {
        let original = DynamicSport(
            id: "xfl",
            abbreviation: "XFL",
            displayName: "XFL Football",
            iconName: "football.fill",
            colorHex: "#E31837",
            seasonStartMonth: 2,
            seasonEndMonth: 5
        )

        let encoded = try JSONEncoder().encode(original)
        let decoded = try JSONDecoder().decode(DynamicSport.self, from: encoded)

        #expect(decoded.id == original.id)
        #expect(decoded.abbreviation == original.abbreviation)
        #expect(decoded.displayName == original.displayName)
    }
}
```

**Step 2: Run test to verify it fails**

Run:
```bash
xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/DynamicSportTests test 2>&1 | tail -30
```
Expected: FAIL with "cannot find type 'DynamicSport'"

**Step 3: Create DynamicSport struct**

Create `SportsTime/Core/Models/Domain/DynamicSport.swift`:

```swift
//
//  DynamicSport.swift
//  SportsTime
//
//  Domain model for CloudKit-defined sports.
//

import SwiftUI

/// A sport defined via CloudKit, as opposed to the hardcoded Sport enum.
/// Conforms to AnySport for interchangeable use with Sport enum.
struct DynamicSport: Identifiable, Hashable, Codable, Sendable {
    let id: String
    let abbreviation: String
    let displayName: String
    let iconName: String
    let colorHex: String
    let seasonStartMonth: Int
    let seasonEndMonth: Int

    init(
        id: String,
        abbreviation: String,
        displayName: String,
        iconName: String,
        colorHex: String,
        seasonStartMonth: Int,
        seasonEndMonth: Int
    ) {
        self.id = id
        self.abbreviation = abbreviation
        self.displayName = displayName
        self.iconName = iconName
        self.colorHex = colorHex
        self.seasonStartMonth = seasonStartMonth
        self.seasonEndMonth = seasonEndMonth
    }
}

// MARK: - AnySport Conformance

extension DynamicSport: AnySport {
    var sportId: String { id }

    var color: Color { Color(hex: colorHex) }

    var seasonMonths: (start: Int, end: Int) {
        (seasonStartMonth, seasonEndMonth)
    }
}
```

**Step 4: Run test to verify it passes**

Run:
```bash
xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/DynamicSportTests test 2>&1 | tail -30
```
Expected: All tests pass

**Step 5: Commit**

```bash
git add SportsTime/Core/Models/Domain/DynamicSport.swift SportsTimeTests/Domain/DynamicSportTests.swift
git commit -m "$(cat <<'EOF'
feat(domain): add DynamicSport model for CloudKit-defined sports

Struct representing sports synced from CloudKit. Conforms to AnySport
protocol for interchangeable use with Sport enum in UI and planning.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"
```

---

## Task 4: Create CanonicalSport SwiftData Model

**Files:**
- Modify: `SportsTime/Core/Models/Local/CanonicalModels.swift`
- Test: `SportsTimeTests/Data/CanonicalSportTests.swift`

**Step 1: Write the failing test**

Create `SportsTimeTests/Data/CanonicalSportTests.swift`:

```swift
//
//  CanonicalSportTests.swift
//  SportsTimeTests
//

import Testing
import SwiftData
@testable import SportsTime

@Suite("CanonicalSport")
struct CanonicalSportTests {

    @Test("CanonicalSport converts to DynamicSport domain model")
    @MainActor
    func canonicalSportToDomain() throws {
        let config = ModelConfiguration(isStoredInMemoryOnly: true)
        let container = try ModelContainer(for: CanonicalSport.self, configurations: config)
        let context = container.mainContext

        let canonical = CanonicalSport(
            id: "xfl",
            abbreviation: "XFL",
            displayName: "XFL Football",
            iconName: "football.fill",
            colorHex: "#E31837",
            seasonStartMonth: 2,
            seasonEndMonth: 5,
            isActive: true
        )
        context.insert(canonical)

        let domain = canonical.toDomain()
        #expect(domain.id == "xfl")
        #expect(domain.abbreviation == "XFL")
        #expect(domain.displayName == "XFL Football")
        #expect(domain.iconName == "football.fill")
        #expect(domain.colorHex == "#E31837")
        #expect(domain.seasonStartMonth == 2)
        #expect(domain.seasonEndMonth == 5)
    }

    @Test("CanonicalSport id is unique")
    @MainActor
    func canonicalSportIdIsUnique() throws {
        let config = ModelConfiguration(isStoredInMemoryOnly: true)
        let container = try ModelContainer(for: CanonicalSport.self, configurations: config)
        let context = container.mainContext

        let sport1 = CanonicalSport(
            id: "xfl",
            abbreviation: "XFL",
            displayName: "XFL Football",
            iconName: "football.fill",
            colorHex: "#E31837",
            seasonStartMonth: 2,
            seasonEndMonth: 5,
            isActive: true
        )
        context.insert(sport1)
        try context.save()

        // Fetching by id should work
        let descriptor = FetchDescriptor<CanonicalSport>(
            predicate: #Predicate { $0.id == "xfl" }
        )
        let fetched = try context.fetch(descriptor)
        #expect(fetched.count == 1)
        #expect(fetched.first?.displayName == "XFL Football")
    }
}
```

**Step 2: Run test to verify it fails**

Run:
```bash
xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/CanonicalSportTests test 2>&1 | tail -30
```
Expected: FAIL with "cannot find type 'CanonicalSport'"

**Step 3: Add CanonicalSport to CanonicalModels.swift**

Add to `SportsTime/Core/Models/Local/CanonicalModels.swift` after the SyncState model (around line 86):

```swift
// MARK: - Canonical Sport

@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
    var schemaVersion: Int
    var sourceRaw: String

    init(
        id: String,
        abbreviation: String,
        displayName: String,
        iconName: String,
        colorHex: String,
        seasonStartMonth: Int,
        seasonEndMonth: Int,
        isActive: Bool = true,
        lastModified: Date = Date(),
        schemaVersion: Int = SchemaVersion.current,
        source: DataSource = .cloudKit
    ) {
        self.id = id
        self.abbreviation = abbreviation
        self.displayName = displayName
        self.iconName = iconName
        self.colorHex = colorHex
        self.seasonStartMonth = seasonStartMonth
        self.seasonEndMonth = seasonEndMonth
        self.isActive = isActive
        self.lastModified = lastModified
        self.schemaVersion = schemaVersion
        self.sourceRaw = source.rawValue
    }

    var source: DataSource {
        get { DataSource(rawValue: sourceRaw) ?? .cloudKit }
        set { sourceRaw = newValue.rawValue }
    }

    func toDomain() -> DynamicSport {
        DynamicSport(
            id: id,
            abbreviation: abbreviation,
            displayName: displayName,
            iconName: iconName,
            colorHex: colorHex,
            seasonStartMonth: seasonStartMonth,
            seasonEndMonth: seasonEndMonth
        )
    }
}
```

**Step 4: Run test to verify it passes**

Run:
```bash
xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/CanonicalSportTests test 2>&1 | tail -30
```
Expected: All tests pass

**Step 5: Commit**

```bash
git add SportsTime/Core/Models/Local/CanonicalModels.swift SportsTimeTests/Data/CanonicalSportTests.swift
git commit -m "$(cat <<'EOF'
feat(data): add CanonicalSport SwiftData model

SwiftData model for sports synced from CloudKit. Converts to DynamicSport
domain model via toDomain() method.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"
```

---

## Task 5: Create CKSport CloudKit Wrapper

**Files:**
- Modify: `SportsTime/Core/Models/CloudKit/CKModels.swift`

**Step 1: Add CKSport struct to CKModels.swift**

Add after the `CKRecordType` enum (around line 21):

```swift
// MARK: - CKSport

struct CKSport {
    static let idKey = "sportId"
    static let abbreviationKey = "abbreviation"
    static let displayNameKey = "displayName"
    static let iconNameKey = "iconName"
    static let colorHexKey = "colorHex"
    static let seasonStartMonthKey = "seasonStartMonth"
    static let seasonEndMonthKey = "seasonEndMonth"
    static let isActiveKey = "isActive"
    static let schemaVersionKey = "schemaVersion"
    static let lastModifiedKey = "lastModified"

    let record: CKRecord

    init(record: CKRecord) {
        self.record = record
    }

    /// Convert to CanonicalSport for local storage
    func toCanonical() -> CanonicalSport? {
        guard let id = record[CKSport.idKey] as? String,
              let abbreviation = record[CKSport.abbreviationKey] as? String,
              let displayName = record[CKSport.displayNameKey] as? String,
              let iconName = record[CKSport.iconNameKey] as? String,
              let colorHex = record[CKSport.colorHexKey] as? String,
              let seasonStartMonth = record[CKSport.seasonStartMonthKey] as? Int,
              let seasonEndMonth = record[CKSport.seasonEndMonthKey] as? Int
        else { return nil }

        let isActive = (record[CKSport.isActiveKey] as? Int ?? 1) == 1
        let schemaVersion = record[CKSport.schemaVersionKey] as? Int ?? SchemaVersion.current
        let lastModified = record[CKSport.lastModifiedKey] as? Date ?? record.modificationDate ?? Date()

        return CanonicalSport(
            id: id,
            abbreviation: abbreviation,
            displayName: displayName,
            iconName: iconName,
            colorHex: colorHex,
            seasonStartMonth: seasonStartMonth,
            seasonEndMonth: seasonEndMonth,
            isActive: isActive,
            lastModified: lastModified,
            schemaVersion: schemaVersion,
            source: .cloudKit
        )
    }
}
```

**Step 2: Verify the file compiles**

Run:
```bash
xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -20
```
Expected: BUILD SUCCEEDED

**Step 3: Commit**

```bash
git add SportsTime/Core/Models/CloudKit/CKModels.swift
git commit -m "$(cat <<'EOF'
feat(cloudkit): add CKSport wrapper for Sport records

CloudKit record wrapper for Sport type. Converts CloudKit records
to CanonicalSport for local SwiftData storage.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"
```

---

## Task 6: Add Sport Fetch Methods to CloudKitService

**Files:**
- Modify: `SportsTime/Core/Services/CloudKitService.swift`

**Step 1: Add fetchSportsForSync method**

Add after the existing `fetchStadiumAliasChanges` method (around line 419):

```swift
// MARK: - Sport Sync

/// Fetch sports for sync operations
/// - Parameter lastSync: If nil, fetches all sports. If provided, fetches only sports modified since that date.
func fetchSportsForSync(since lastSync: Date?) async throws -> [CanonicalSport] {
    let predicate: NSPredicate
    if let lastSync = lastSync {
        predicate = NSPredicate(format: "modificationDate >= %@", lastSync as NSDate)
    } else {
        predicate = NSPredicate(value: true)
    }
    let query = CKQuery(recordType: CKRecordType.sport, predicate: predicate)

    let (results, _) = try await publicDatabase.records(matching: query)

    return results.compactMap { result -> CanonicalSport? in
        guard case .success(let record) = result.1 else { return nil }
        return CKSport(record: record).toCanonical()
    }
}
```

**Step 2: Verify the file compiles**

Run:
```bash
xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -20
```
Expected: BUILD SUCCEEDED

**Step 3: Commit**

```bash
git add SportsTime/Core/Services/CloudKitService.swift
git commit -m "$(cat <<'EOF'
feat(cloudkit): add fetchSportsForSync method

Fetches Sport records from CloudKit for sync operations.
Supports delta sync via lastSync parameter.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"
```

---

## Task 7: Add Sport Sync to CanonicalSyncService

**Files:**
- Modify: `SportsTime/Core/Services/CanonicalSyncService.swift`

**Step 1: Add syncSports method**

Add after the existing `syncStadiumAliases` method (around line 372):

```swift
@MainActor
private func syncSports(
    context: ModelContext,
    since lastSync: Date?
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
    let remoteSports = try await cloudKitService.fetchSportsForSync(since: lastSync)

    var updated = 0
    var skippedIncompatible = 0
    var skippedOlder = 0

    for remoteSport in remoteSports {
        let result = try mergeSport(remoteSport, context: context)

        switch result {
        case .applied: updated += 1
        case .skippedIncompatible: skippedIncompatible += 1
        case .skippedOlder: skippedOlder += 1
        }
    }

    return (updated, skippedIncompatible, skippedOlder)
}

@MainActor
private func mergeSport(
    _ remote: CanonicalSport,
    context: ModelContext
) throws -> MergeResult {
    // Schema version check
    guard remote.schemaVersion <= SchemaVersion.current else {
        return .skippedIncompatible
    }

    let remoteId = remote.id
    let descriptor = FetchDescriptor<CanonicalSport>(
        predicate: #Predicate { $0.id == remoteId }
    )
    let existing = try context.fetch(descriptor).first

    if let existing = existing {
        // lastModified check
        guard remote.lastModified > existing.lastModified else {
            return .skippedOlder
        }

        // Update all fields (no user fields on sports)
        existing.abbreviation = remote.abbreviation
        existing.displayName = remote.displayName
        existing.iconName = remote.iconName
        existing.colorHex = remote.colorHex
        existing.seasonStartMonth = remote.seasonStartMonth
        existing.seasonEndMonth = remote.seasonEndMonth
        existing.isActive = remote.isActive
        existing.schemaVersion = remote.schemaVersion
        existing.lastModified = remote.lastModified
        existing.source = .cloudKit

        return .applied
    } else {
        // Insert new
        context.insert(remote)
        return .applied
    }
}
```

**Step 2: Add sportsUpdated to SyncResult**

Modify the `SyncResult` struct (around line 39) to add the new field:

```swift
struct SyncResult {
    let stadiumsUpdated: Int
    let teamsUpdated: Int
    let gamesUpdated: Int
    let leagueStructuresUpdated: Int
    let teamAliasesUpdated: Int
    let stadiumAliasesUpdated: Int
    let sportsUpdated: Int  // Add this line
    let skippedIncompatible: Int
    let skippedOlder: Int
    let duration: TimeInterval

    var totalUpdated: Int {
        stadiumsUpdated + teamsUpdated + gamesUpdated + leagueStructuresUpdated + teamAliasesUpdated + stadiumAliasesUpdated + sportsUpdated  // Update this line
    }

    var isEmpty: Bool { totalUpdated == 0 }
}
```

**Step 3: Call syncSports in syncAll method**

In the `syncAll` method, add after the stadium sync (around line 117):

```swift
// After syncStadiums call, add:
let (sports, skipIncompat0, skipOlder0) = try await syncSports(
    context: context,
    since: syncState.lastSuccessfulSync
)
var totalSports = sports
totalSkippedIncompatible += skipIncompat0
totalSkippedOlder += skipOlder0
```

And update the return statement (around line 183):

```swift
return SyncResult(
    stadiumsUpdated: totalStadiums,
    teamsUpdated: totalTeams,
    gamesUpdated: totalGames,
    leagueStructuresUpdated: totalLeagueStructures,
    teamAliasesUpdated: totalTeamAliases,
    stadiumAliasesUpdated: totalStadiumAliases,
    sportsUpdated: totalSports,  // Add this line
    skippedIncompatible: totalSkippedIncompatible,
    skippedOlder: totalSkippedOlder,
    duration: Date().timeIntervalSince(startTime)
)
```

Also update the early return for disabled sync (around line 84):

```swift
return SyncResult(
    stadiumsUpdated: 0, teamsUpdated: 0, gamesUpdated: 0,
    leagueStructuresUpdated: 0, teamAliasesUpdated: 0, stadiumAliasesUpdated: 0,
    sportsUpdated: 0,  // Add this line
    skippedIncompatible: 0, skippedOlder: 0,
    duration: 0
)
```

**Step 4: Verify the file compiles**

Run:
```bash
xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -20
```
Expected: BUILD SUCCEEDED

**Step 5: Commit**

```bash
git add SportsTime/Core/Services/CanonicalSyncService.swift
git commit -m "$(cat <<'EOF'
feat(sync): add sport sync to CanonicalSyncService

Sports are now synced from CloudKit as part of the syncAll flow.
Syncs first in dependency order (sports define what teams/stadiums belong to).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"
```

---

## Task 8: Add Dynamic Sports to AppDataProvider

**Files:**
- Modify: `SportsTime/Core/Services/DataProvider.swift`
- Test: `SportsTimeTests/Data/DataProviderDynamicSportTests.swift`

**Step 1: Write the failing test**

Create `SportsTimeTests/Data/DataProviderDynamicSportTests.swift`:

```swift
//
//  DataProviderDynamicSportTests.swift
//  SportsTimeTests
//

import Testing
import SwiftData
@testable import SportsTime

@Suite("AppDataProvider Dynamic Sports")
struct DataProviderDynamicSportTests {

    @Test("allSports returns enum sports first, then dynamic")
    @MainActor
    func allSportsOrdersCorrectly() async throws {
        let config = ModelConfiguration(isStoredInMemoryOnly: true)
        let container = try ModelContainer(
            for: CanonicalStadium.self, CanonicalTeam.self, CanonicalGame.self,
            CanonicalSport.self, SyncState.self,
            configurations: config
        )
        let context = container.mainContext

        // Add a dynamic sport
        let xfl = CanonicalSport(
            id: "xfl",
            abbreviation: "XFL",
            displayName: "XFL Football",
            iconName: "football.fill",
            colorHex: "#E31837",
            seasonStartMonth: 2,
            seasonEndMonth: 5,
            isActive: true
        )
        context.insert(xfl)
        try context.save()

        let provider = AppDataProvider.shared
        provider.configure(with: context)
        await provider.loadInitialData()

        let allSports = provider.allSports

        // Enum sports come first
        let enumSportIds = Sport.supported.map { $0.sportId }
        let firstSportIds = allSports.prefix(enumSportIds.count).map { $0.sportId }
        #expect(firstSportIds == enumSportIds)

        // Dynamic sports come after
        let dynamicSportIds = allSports.dropFirst(enumSportIds.count).map { $0.sportId }
        #expect(dynamicSportIds.contains("xfl"))
    }

    @Test("sport(for:) returns enum sport for known IDs")
    @MainActor
    func sportForReturnsEnumSport() async throws {
        let config = ModelConfiguration(isStoredInMemoryOnly: true)
        let container = try ModelContainer(
            for: CanonicalStadium.self, CanonicalTeam.self, CanonicalGame.self,
            CanonicalSport.self, SyncState.self,
            configurations: config
        )
        let context = container.mainContext

        let provider = AppDataProvider.shared
        provider.configure(with: context)
        await provider.loadInitialData()

        let mlb = provider.sport(for: "MLB")
        #expect(mlb != nil)
        #expect(mlb?.sportId == "MLB")
        #expect(mlb?.displayName == "Major League Baseball")
    }

    @Test("sport(for:) returns dynamic sport for unknown enum IDs")
    @MainActor
    func sportForReturnsDynamicSport() async throws {
        let config = ModelConfiguration(isStoredInMemoryOnly: true)
        let container = try ModelContainer(
            for: CanonicalStadium.self, CanonicalTeam.self, CanonicalGame.self,
            CanonicalSport.self, SyncState.self,
            configurations: config
        )
        let context = container.mainContext

        // Add a dynamic sport
        let xfl = CanonicalSport(
            id: "xfl",
            abbreviation: "XFL",
            displayName: "XFL Football",
            iconName: "football.fill",
            colorHex: "#E31837",
            seasonStartMonth: 2,
            seasonEndMonth: 5,
            isActive: true
        )
        context.insert(xfl)
        try context.save()

        let provider = AppDataProvider.shared
        provider.configure(with: context)
        await provider.loadInitialData()

        let sport = provider.sport(for: "xfl")
        #expect(sport != nil)
        #expect(sport?.sportId == "xfl")
        #expect(sport?.displayName == "XFL Football")
    }

    @Test("sport(for:) returns nil for completely unknown IDs")
    @MainActor
    func sportForReturnsNilForUnknown() async throws {
        let config = ModelConfiguration(isStoredInMemoryOnly: true)
        let container = try ModelContainer(
            for: CanonicalStadium.self, CanonicalTeam.self, CanonicalGame.self,
            CanonicalSport.self, SyncState.self,
            configurations: config
        )
        let context = container.mainContext

        let provider = AppDataProvider.shared
        provider.configure(with: context)
        await provider.loadInitialData()

        let sport = provider.sport(for: "unknown_sport_xyz")
        #expect(sport == nil)
    }
}
```

**Step 2: Run test to verify it fails**

Run:
```bash
xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/DataProviderDynamicSportTests test 2>&1 | tail -30
```
Expected: FAIL with "value of type 'AppDataProvider' has no member 'allSports'"

**Step 3: Add dynamic sports support to AppDataProvider**

Modify `SportsTime/Core/Services/DataProvider.swift`:

Add new published property after `stadiums` (around line 20):

```swift
@Published private(set) var dynamicSports: [DynamicSport] = []
```

Add lookup dictionary after `stadiumsById` (around line 27):

```swift
private var dynamicSportsById: [String: DynamicSport] = [:]
```

Add computed property after the existing data access methods (around line 117):

```swift
// MARK: - Sport Access

/// All sports: enum sports first, then dynamic sports sorted by displayName
var allSports: [any AnySport] {
    let enumSports: [any AnySport] = Sport.supported
    let dynamic: [any AnySport] = dynamicSports.sorted { $0.displayName < $1.displayName }
    return enumSports + dynamic
}

/// Look up a sport by ID. Checks enum sports first, then dynamic.
func sport(for id: String) -> (any AnySport)? {
    // Try enum first
    if let enumSport = Sport(rawValue: id) {
        return enumSport
    }
    // Fall back to dynamic
    return dynamicSportsById[id]
}
```

In `loadInitialData()` method, add after loading teams (around line 82):

```swift
// Fetch dynamic sports from SwiftData
let sportDescriptor = FetchDescriptor<CanonicalSport>(
    predicate: #Predicate { $0.isActive }
)
let canonicalSports = try context.fetch(sportDescriptor)

// Convert to domain models
var loadedDynamicSports: [DynamicSport] = []
var dynamicSportLookup: [String: DynamicSport] = [:]
for canonical in canonicalSports {
    let sport = canonical.toDomain()
    loadedDynamicSports.append(sport)
    dynamicSportLookup[sport.id] = sport
}

self.dynamicSports = loadedDynamicSports
self.dynamicSportsById = dynamicSportLookup
```

**Step 4: Run test to verify it passes**

Run:
```bash
xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/DataProviderDynamicSportTests test 2>&1 | tail -30
```
Expected: All tests pass

**Step 5: Commit**

```bash
git add SportsTime/Core/Services/DataProvider.swift SportsTimeTests/Data/DataProviderDynamicSportTests.swift
git commit -m "$(cat <<'EOF'
feat(data): add dynamic sports support to AppDataProvider

AppDataProvider now loads dynamic sports from SwiftData and provides
allSports computed property that merges enum and dynamic sports.
sport(for:) method enables lookup by sport ID.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"
```

---

## Task 9: Update MockAppDataProvider for Testing

**Files:**
- Modify: `SportsTimeTests/Mocks/MockAppDataProvider.swift`

**Step 1: Add dynamic sports support to MockAppDataProvider**

Add after the games storage (around line 30):

```swift
private var dynamicSports: [DynamicSport] = []
private var dynamicSportsById: [String: DynamicSport] = [:]
```

Add setter method after `setGames` (around line 82):

```swift
func setDynamicSports(_ newSports: [DynamicSport]) {
    self.dynamicSports = newSports
    self.dynamicSportsById = Dictionary(uniqueKeysWithValues: newSports.map { ($0.id, $0) })
}
```

Add computed property and lookup method (around line 157):

```swift
// MARK: - Sport Access

var allSports: [any AnySport] {
    let enumSports: [any AnySport] = Sport.supported
    let dynamic: [any AnySport] = dynamicSports.sorted { $0.displayName < $1.displayName }
    return enumSports + dynamic
}

func sport(for id: String) -> (any AnySport)? {
    if let enumSport = Sport(rawValue: id) {
        return enumSport
    }
    return dynamicSportsById[id]
}
```

Update `reset()` method to clear dynamic sports:

```swift
func reset() {
    teams = []
    stadiums = []
    games = []
    dynamicSports = []  // Add this
    teamsById = [:]
    stadiumsById = [:]
    gamesById = [:]
    dynamicSportsById = [:]  // Add this
    // ... rest of reset
}
```

**Step 2: Verify the file compiles**

Run:
```bash
xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -20
```
Expected: BUILD SUCCEEDED

**Step 3: Commit**

```bash
git add SportsTimeTests/Mocks/MockAppDataProvider.swift
git commit -m "$(cat <<'EOF'
test(mocks): add dynamic sports support to MockAppDataProvider

MockAppDataProvider now supports dynamic sports for testing UI and
planning components that use the unified sport types.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"
```

---

## Task 10: Run Full Test Suite

**Files:** None (verification only)

**Step 1: Run all existing tests to ensure no regressions**

Run:
```bash
xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' test 2>&1 | tail -50
```
Expected: All tests pass (no regressions)

**Step 2: If any tests fail, investigate and fix**

Common issues to check:
- ModelContainer schema may need to include `CanonicalSport.self`
- Any code assuming `Sport` is the only sport type

**Step 3: Commit any fixes if needed**

```bash
git add -A
git commit -m "$(cat <<'EOF'
fix: resolve test regressions from dynamic sports integration

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"
```

---

## Summary

This implementation plan adds dynamic sports support with these key components:

1. **AnySport Protocol** - Unifies `Sport` enum and `DynamicSport` struct
2. **DynamicSport** - Domain model for CloudKit-defined sports
3. **CanonicalSport** - SwiftData model for local persistence
4. **CKSport** - CloudKit record wrapper
5. **CloudKitService.fetchSportsForSync** - Fetch sports from CloudKit
6. **CanonicalSyncService.syncSports** - Sync sports to local storage
7. **AppDataProvider.allSports/sport(for:)** - Unified access to all sports

**Not implemented (YAGNI per design doc):**
- Admin UI for managing sports
- User-submitted sports
- Sport enable/disable toggles per user
- Localized sport names
- Custom logo URLs

**Next steps after this plan:**
- Update `SportSelectorGrid` to use `allSports` instead of `Sport.supported`
- Update trip creation views to work with `any AnySport`
- Update planning engine to accept dynamic sports
