- 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>
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
Sportis 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:
- AnySport Protocol - Unifies
Sportenum andDynamicSportstruct - DynamicSport - Domain model for CloudKit-defined sports
- CanonicalSport - SwiftData model for local persistence
- CKSport - CloudKit record wrapper
- CloudKitService.fetchSportsForSync - Fetch sports from CloudKit
- CanonicalSyncService.syncSports - Sync sports to local storage
- 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
SportSelectorGridto useallSportsinstead ofSport.supported - Update trip creation views to work with
any AnySport - Update planning engine to accept dynamic sports