Files
Sportstime/docs/plans/2026-01-12-dynamic-sports-cloudkit-design.md
Trey t f180e5bfed feat(sync): add CloudKit sync for dynamic sports
- Add CKSport model to parse CloudKit Sport records
- Add fetchSportsForSync() to CloudKitService for delta fetching
- Add syncSports() and mergeSport() to CanonicalSyncService
- Update DataProvider with dynamicSports support and allSports computed property
- Update MockAppDataProvider with matching dynamic sports support
- Add comprehensive documentation for adding new sports

The app can now sync sport definitions from CloudKit, enabling new sports
to be added without app updates. Sports are fetched, merged into SwiftData,
and exposed via AppDataProvider.allSports alongside built-in Sport enum cases.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 18:27:56 -06:00

37 KiB

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:

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

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

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:

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

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:

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

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

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:

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

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:

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

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

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:

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

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):

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

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

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):

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

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

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):

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

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

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):

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

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):

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

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):

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:

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

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:

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

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):

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

Add lookup dictionary after stadiumsById (around line 27):

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

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

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

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

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

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):

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

Add setter method after setGames (around line 82):

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):

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

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

Step 2: Verify the file compiles

Run:

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

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:

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

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