- 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>
1294 lines
37 KiB
Markdown
1294 lines
37 KiB
Markdown
# 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
|