Files
Sportstime/docs/plans/2026-01-11-canonical-id-refactor-design.md
Trey t 4b2cacaeba 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>
2026-01-11 22:56:37 -06:00

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:

  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

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: UUIDid: 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

  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