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>
217 lines
6.0 KiB
Markdown
217 lines
6.0 KiB
Markdown
# 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
|