diff --git a/docs/plans/2026-01-11-canonical-id-refactor-design.md b/docs/plans/2026-01-11-canonical-id-refactor-design.md new file mode 100644 index 0000000..88d914d --- /dev/null +++ b/docs/plans/2026-01-11-canonical-id-refactor-design.md @@ -0,0 +1,216 @@ +# Canonical ID Refactor Design + +**Date:** 2026-01-11 +**Status:** Approved +**Goal:** Eliminate UUID layer, use canonical IDs as the single identifier throughout the client + +## Problem + +The codebase has 4 different stadium ID formats causing bugs: +1. Canonical IDs: `"stadium_mlb_fenway_park"` (stable identity) +2. Symbolic IDs: `"stadium_mlb_bos"` (team-based, achievements) +3. UUIDs: `UUID` type (runtime lookups) +4. UUID strings: `"550e8400-..."` (stored in visits) + +This led to the Green Monster/Ivy League achievement bug where visits stored UUID strings but achievements expected canonical IDs. + +## Solution + +Use canonical IDs (`String`) as the single identifier everywhere on the client. CloudKit keeps reference integrity at the sync layer. + +## Design + +### 1. Domain Models + +```swift +struct Stadium: Identifiable, Codable, Hashable { + let id: String // "stadium_mlb_fenway_park" - THE identity + let name: String + let city: String + let state: String + let latitude: Double + let longitude: Double + let capacity: Int + let sport: Sport + let yearOpened: Int? + let imageURL: URL? +} + +struct Team: Identifiable, Codable, Hashable { + let id: String // "team_mlb_bos" + let name: String + let abbreviation: String + let sport: Sport + let city: String + let stadiumId: String // FK: "stadium_mlb_fenway_park" + // ... +} + +struct Game: Identifiable, Codable, Hashable { + let id: String // "game_mlb_2026_bos_nyy_0401" + let homeTeamId: String // "team_mlb_bos" + let awayTeamId: String // "team_mlb_nyy" + let stadiumId: String // "stadium_mlb_fenway_park" + let dateTime: Date + let sport: Sport + // ... +} +``` + +### 2. Visit Tracking + +```swift +@Model +final class StadiumVisit { + @Attribute(.unique) var id: UUID // Visit's own identity + + var stadiumId: String // "stadium_mlb_fenway_park" + var stadiumNameAtVisit: String // Frozen: "Fenway Park" + + var visitDate: Date + var sport: String + var visitTypeRaw: String + + var gameId: String? + var homeTeamId: String? + var awayTeamId: String? + // ... +} +``` + +### 3. Data Provider + +```swift +@MainActor +final class AppDataProvider: ObservableObject { + @Published private(set) var teams: [Team] = [] + @Published private(set) var stadiums: [Stadium] = [] + + // Lookup dictionaries - keyed by canonical ID (String) + private var teamsById: [String: Team] = [:] + private var stadiumsById: [String: Stadium] = [:] + + // REMOVED: UUID mapping dictionaries +} +``` + +### 4. Achievement Engine + +```swift +// Achievement definitions use canonical IDs directly +AchievementDefinition( + id: "special_fenway", + name: "Green Monster", + requirement: .specificStadium("stadium_mlb_fenway_park") +) + +// Checking is direct comparison +case .specificStadium(let stadiumId): + return visitedStadiumIds.contains(stadiumId) + +// REMOVED: resolveSymbolicStadiumId() helper +``` + +### 5. CloudKit Layer (Unchanged) + +SwiftData models keep their structure. CloudKit references ensure data integrity at sync time. The `toDomain()` conversion simplifies: + +```swift +extension CanonicalStadium { + func toDomain() -> Stadium { + Stadium( + id: canonicalId, // Direct pass-through + name: name, + // ... + ) + } +} + +extension CanonicalTeam { + func toDomain() -> Team { // No more stadiumUUID parameter + Team( + id: canonicalId, + stadiumId: stadiumCanonicalId, + // ... + ) + } +} +``` + +## Migration + +Since we're in development, this is a clean break. + +### Files to Modify (in order) + +| Phase | Files | Changes | +|-------|-------|---------| +| 1. Domain Models | `Stadium.swift`, `Team.swift`, `Game.swift` | `id: UUID` → `id: String` | +| 2. SwiftData Converters | `CanonicalModels.swift` | Simplify `toDomain()` methods | +| 3. Data Provider | `DataProvider.swift` | Remove UUID mapping dicts | +| 4. Visit Model | `StadiumProgress.swift` | Remove `stadiumUUID`, simplify | +| 5. Achievement Engine | `AchievementEngine.swift` | Delete resolution helper | +| 6. Planning Engine | `GameDAGRouter.swift`, planners | Update dictionary key types | +| 7. View Models | Various | Update UUID references | +| 8. Tests | All test files | Update fixtures | + +### Cleanup + +```bash +# Delete local SwiftData store +rm -rf ~/Library/Developer/CoreSimulator/.../SportsTime/Library/Application\ Support/default.store +``` + +## Testing + +### Compile-time Safety + +```swift +// Wrong type won't compile +let stadium = stadiums[someUUID] // Error: String key required +``` + +### Key Test Cases + +```swift +// 1. ID format validation +func test_stadiumId_followsCanonicalFormat() { + for stadium in AppDataProvider.shared.stadiums { + XCTAssertTrue(stadium.id.hasPrefix("stadium_")) + } +} + +// 2. FK integrity +func test_allTeamStadiumIds_existInStadiums() { + let stadiumIds = Set(dataProvider.stadiums.map { $0.id }) + for team in dataProvider.teams { + XCTAssertTrue(stadiumIds.contains(team.stadiumId)) + } +} + +// 3. Achievement matching +func test_specificStadiumAchievement_matchesVisit() { + let visit = StadiumVisit(stadiumId: "stadium_mlb_fenway_park", ...) + let achievement = AchievementRegistry.achievement(byId: "special_fenway")! + + if case .specificStadium(let requiredId) = achievement.requirement { + XCTAssertEqual(visit.stadiumId, requiredId) + } +} + +// 4. Round-trip consistency +func test_canonicalModel_toDomain_preservesId() { + let canonical = CanonicalStadium(canonicalId: "stadium_mlb_fenway_park", ...) + let domain = canonical.toDomain() + XCTAssertEqual(canonical.canonicalId, domain.id) +} +``` + +## Benefits + +1. **Single source of truth** - canonical ID is the only identifier +2. **Human-readable** - see `stadium_mlb_fenway_park` in logs, not UUIDs +3. **No mapping layer** - eliminate conversion dictionaries +4. **Bug category eliminated** - ID format mismatches become impossible +5. **Stable across renames** - stadium renames don't break visits +6. **Compiler catches mistakes** - String vs UUID type mismatch won't compile