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>
6.0 KiB
6.0 KiB
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:
- Canonical IDs:
"stadium_mlb_fenway_park"(stable identity) - Symbolic IDs:
"stadium_mlb_bos"(team-based, achievements) - UUIDs:
UUIDtype (runtime lookups) - 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
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
@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
@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
// 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:
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
# Delete local SwiftData store
rm -rf ~/Library/Developer/CoreSimulator/.../SportsTime/Library/Application\ Support/default.store
Testing
Compile-time Safety
// Wrong type won't compile
let stadium = stadiums[someUUID] // Error: String key required
Key Test Cases
// 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
- Single source of truth - canonical ID is the only identifier
- Human-readable - see
stadium_mlb_fenway_parkin logs, not UUIDs - No mapping layer - eliminate conversion dictionaries
- Bug category eliminated - ID format mismatches become impossible
- Stable across renames - stadium renames don't break visits
- Compiler catches mistakes - String vs UUID type mismatch won't compile