docs: add canonical ID refactor design

Design to eliminate UUID layer and use canonical IDs as the single
stadium/team/game identifier throughout the client. Key changes:
- Domain models use String IDs (canonical IDs)
- Remove UUID mapping dictionaries from DataProvider
- Simplify AchievementEngine (delete resolution helper)
- StadiumVisit uses single stadiumId field

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-11 22:56:37 -06:00
parent 5c13650742
commit 4b2cacaeba

View File

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