refactor: change domain model IDs from UUID to String canonical IDs

This refactor fixes the achievement system by using stable canonical string
IDs (e.g., "stadium_mlb_fenway_park") instead of random UUIDs. This ensures
stadium mappings for achievements are consistent across app launches and
CloudKit sync operations.

Changes:
- Stadium, Team, Game: id property changed from UUID to String
- Trip, TripStop, TripPreferences: updated to use String IDs for games/stadiums
- CKModels: removed UUID parsing, use canonical IDs directly
- AchievementEngine: now matches against canonical stadium IDs
- All test files updated to use String IDs instead of UUID()

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-12 09:24:33 -06:00
parent 4b2cacaeba
commit 1703ca5b0f
53 changed files with 642 additions and 727 deletions

View File

@@ -80,7 +80,7 @@ enum GameDAGRouter {
/// Composite key for exact deduplication
var uniqueKey: String {
route.map { $0.id.uuidString }.joined(separator: "-")
route.map { $0.id }.joined(separator: "-")
}
}
@@ -106,9 +106,9 @@ enum GameDAGRouter {
///
static func findRoutes(
games: [Game],
stadiums: [UUID: Stadium],
stadiums: [String: Stadium],
constraints: DrivingConstraints,
anchorGameIds: Set<UUID> = [],
anchorGameIds: Set<String> = [],
allowRepeatCities: Bool = true,
beamWidth: Int = defaultBeamWidth
) -> [[Game]] {
@@ -219,10 +219,10 @@ enum GameDAGRouter {
/// Compatibility wrapper that matches GeographicRouteExplorer's interface.
static func findAllSensibleRoutes(
from games: [Game],
stadiums: [UUID: Stadium],
anchorGameIds: Set<UUID> = [],
stadiums: [String: Stadium],
anchorGameIds: Set<String> = [],
allowRepeatCities: Bool = true,
stopBuilder: ([Game], [UUID: Stadium]) -> [ItineraryStop]
stopBuilder: ([Game], [String: Stadium]) -> [ItineraryStop]
) -> [[Game]] {
let constraints = DrivingConstraints.default
return findRoutes(
@@ -244,7 +244,7 @@ enum GameDAGRouter {
/// - Short duration AND long duration
private static func selectDiverseRoutes(
_ routes: [[Game]],
stadiums: [UUID: Stadium],
stadiums: [String: Stadium],
maxCount: Int
) -> [[Game]] {
guard !routes.isEmpty else { return [] }
@@ -362,14 +362,14 @@ enum GameDAGRouter {
/// Keeps routes that span the diversity space rather than just high-scoring ones.
private static func diversityPrune(
_ paths: [[Game]],
stadiums: [UUID: Stadium],
stadiums: [String: Stadium],
targetCount: Int
) -> [[Game]] {
// Remove exact duplicates first
var uniquePaths: [[Game]] = []
var seen = Set<String>()
for path in paths {
let key = path.map { $0.id.uuidString }.joined(separator: "-")
let key = path.map { $0.id }.joined(separator: "-")
if !seen.contains(key) {
seen.insert(key)
uniquePaths.append(path)
@@ -425,7 +425,7 @@ enum GameDAGRouter {
}
/// Builds a profile for a route.
private static func buildProfile(for route: [Game], stadiums: [UUID: Stadium]) -> RouteProfile {
private static func buildProfile(for route: [Game], stadiums: [String: Stadium]) -> RouteProfile {
let gameCount = route.count
let cities = Set(route.compactMap { stadiums[$0.stadiumId]?.city })
let cityCount = cities.count
@@ -488,7 +488,7 @@ enum GameDAGRouter {
private static func canTransition(
from: Game,
to: Game,
stadiums: [UUID: Stadium],
stadiums: [String: Stadium],
constraints: DrivingConstraints
) -> Bool {
// Time must move forward
@@ -562,7 +562,7 @@ enum GameDAGRouter {
private static func estimateDistanceMiles(
from: Game,
to: Game,
stadiums: [UUID: Stadium]
stadiums: [String: Stadium]
) -> Double {
if from.stadiumId == to.stadiumId { return 0 }

View File

@@ -298,7 +298,7 @@ final class ScenarioAPlanner: ScenarioPlanner {
///
private func buildStops(
from games: [Game],
stadiums: [UUID: Stadium]
stadiums: [String: Stadium]
) -> [ItineraryStop] {
guard !games.isEmpty else { return [] }
@@ -308,7 +308,7 @@ final class ScenarioAPlanner: ScenarioPlanner {
// Group consecutive games at the same stadium into stops
// If you visit A, then B, then A again, that's 3 stops (A, B, A)
var stops: [ItineraryStop] = []
var currentStadiumId: UUID? = nil
var currentStadiumId: String? = nil
var currentGames: [Game] = []
for game in sortedGames {
@@ -340,8 +340,8 @@ final class ScenarioAPlanner: ScenarioPlanner {
/// Creates an ItineraryStop from a group of games at the same stadium.
private func createStop(
from games: [Game],
stadiumId: UUID,
stadiums: [UUID: Stadium]
stadiumId: String,
stadiums: [String: Stadium]
) -> ItineraryStop? {
guard !games.isEmpty else { return nil }
@@ -380,7 +380,7 @@ final class ScenarioAPlanner: ScenarioPlanner {
var unique: [[Game]] = []
for route in routes {
let key = route.map { $0.id.uuidString }.sorted().joined(separator: "-")
let key = route.map { $0.id }.sorted().joined(separator: "-")
if !seen.contains(key) {
seen.insert(key)
unique.append(route)
@@ -396,7 +396,7 @@ final class ScenarioAPlanner: ScenarioPlanner {
/// This ensures we get diverse options from East, Central, and West coasts.
private func findRoutesPerRegion(
games: [Game],
stadiums: [UUID: Stadium],
stadiums: [String: Stadium],
allowRepeatCities: Bool
) -> [[Game]] {
// Partition games by region

View File

@@ -316,7 +316,7 @@ final class ScenarioBPlanner: ScenarioPlanner {
/// Creates separate stops when visiting the same city with other cities in between.
private func buildStops(
from games: [Game],
stadiums: [UUID: Stadium]
stadiums: [String: Stadium]
) -> [ItineraryStop] {
guard !games.isEmpty else { return [] }
@@ -325,7 +325,7 @@ final class ScenarioBPlanner: ScenarioPlanner {
// Group consecutive games at the same stadium
var stops: [ItineraryStop] = []
var currentStadiumId: UUID? = nil
var currentStadiumId: String? = nil
var currentGames: [Game] = []
for game in sortedGames {
@@ -357,8 +357,8 @@ final class ScenarioBPlanner: ScenarioPlanner {
/// Creates an ItineraryStop from a group of games at the same stadium.
private func createStop(
from games: [Game],
stadiumId: UUID,
stadiums: [UUID: Stadium]
stadiumId: String,
stadiums: [String: Stadium]
) -> ItineraryStop? {
guard !games.isEmpty else { return nil }
@@ -396,8 +396,8 @@ final class ScenarioBPlanner: ScenarioPlanner {
/// For Scenario B, routes must still contain all anchor games.
private func findRoutesPerRegion(
games: [Game],
stadiums: [UUID: Stadium],
anchorGameIds: Set<UUID>,
stadiums: [String: Stadium],
anchorGameIds: Set<String>,
allowRepeatCities: Bool
) -> [[Game]] {
// First, determine which region(s) the anchor games are in
@@ -459,7 +459,7 @@ final class ScenarioBPlanner: ScenarioPlanner {
var unique: [[Game]] = []
for route in routes {
let key = route.map { $0.id.uuidString }.sorted().joined(separator: "-")
let key = route.map { $0.id }.sorted().joined(separator: "-")
if !seen.contains(key) {
seen.insert(key)
unique.append(route)

View File

@@ -272,7 +272,7 @@ final class ScenarioCPlanner: ScenarioPlanner {
/// Finds all stadiums in a given city (case-insensitive match).
private func findStadiumsInCity(
cityName: String,
stadiums: [UUID: Stadium]
stadiums: [String: Stadium]
) -> [Stadium] {
let normalizedCity = cityName.lowercased().trimmingCharacters(in: .whitespaces)
return stadiums.values.filter { stadium in
@@ -296,14 +296,14 @@ final class ScenarioCPlanner: ScenarioPlanner {
private func findDirectionalStadiums(
from start: CLLocationCoordinate2D,
to end: CLLocationCoordinate2D,
stadiums: [UUID: Stadium]
) -> Set<UUID> {
stadiums: [String: Stadium]
) -> Set<String> {
let directDistance = distanceBetween(start, end)
// Allow detours up to 50% longer than direct distance
let maxDetourDistance = directDistance * 1.5
var directionalIds: Set<UUID> = []
var directionalIds: Set<String> = []
for (id, stadium) in stadiums {
let stadiumCoord = stadium.coordinate
@@ -349,8 +349,8 @@ final class ScenarioCPlanner: ScenarioPlanner {
/// Create a date range from start_game.date to end_game.date
///
private func generateDateRanges(
startStadiumIds: Set<UUID>,
endStadiumIds: Set<UUID>,
startStadiumIds: Set<String>,
endStadiumIds: Set<String>,
allGames: [Game],
request: PlanningRequest
) -> [DateInterval] {
@@ -417,7 +417,7 @@ final class ScenarioCPlanner: ScenarioPlanner {
/// Creates separate stops when visiting the same city with other cities in between.
private func buildStops(
from games: [Game],
stadiums: [UUID: Stadium]
stadiums: [String: Stadium]
) -> [ItineraryStop] {
guard !games.isEmpty else { return [] }
@@ -426,7 +426,7 @@ final class ScenarioCPlanner: ScenarioPlanner {
// Group consecutive games at the same stadium
var stops: [ItineraryStop] = []
var currentStadiumId: UUID? = nil
var currentStadiumId: String? = nil
var currentGames: [Game] = []
for game in sortedGames {
@@ -458,8 +458,8 @@ final class ScenarioCPlanner: ScenarioPlanner {
/// Creates an ItineraryStop from a group of games at the same stadium.
private func createStop(
from games: [Game],
stadiumId: UUID,
stadiums: [UUID: Stadium]
stadiumId: String,
stadiums: [String: Stadium]
) -> ItineraryStop? {
guard !games.isEmpty else { return nil }
@@ -496,7 +496,7 @@ final class ScenarioCPlanner: ScenarioPlanner {
start: LocationInput,
end: LocationInput,
games: [Game],
stadiums: [UUID: Stadium]
stadiums: [String: Stadium]
) -> [ItineraryStop] {
var stops: [ItineraryStop] = []

View File

@@ -275,7 +275,7 @@ final class ScenarioDPlanner: ScenarioPlanner {
// MARK: - Team Filtering
/// Filters games to those involving the followed team (home or away).
private func filterToTeam(_ games: [Game], teamId: UUID) -> [Game] {
private func filterToTeam(_ games: [Game], teamId: String) -> [Game] {
games.filter { game in
game.homeTeamId == teamId || game.awayTeamId == teamId
}
@@ -287,7 +287,7 @@ final class ScenarioDPlanner: ScenarioPlanner {
private func applyRepeatCityFilter(
_ games: [Game],
allowRepeat: Bool,
stadiums: [UUID: Stadium]
stadiums: [String: Stadium]
) -> [Game] {
guard !allowRepeat else {
print("🔍 applyRepeatCityFilter: allowRepeat=true, returning all \(games.count) games")
@@ -317,14 +317,14 @@ final class ScenarioDPlanner: ScenarioPlanner {
/// Same logic as ScenarioAPlanner.
private func buildStops(
from games: [Game],
stadiums: [UUID: Stadium]
stadiums: [String: Stadium]
) -> [ItineraryStop] {
guard !games.isEmpty else { return [] }
let sortedGames = games.sorted { $0.startTime < $1.startTime }
var stops: [ItineraryStop] = []
var currentStadiumId: UUID? = nil
var currentStadiumId: String? = nil
var currentGames: [Game] = []
for game in sortedGames {
@@ -354,8 +354,8 @@ final class ScenarioDPlanner: ScenarioPlanner {
/// Creates an ItineraryStop from a group of games at the same stadium.
private func createStop(
from games: [Game],
stadiumId: UUID,
stadiums: [UUID: Stadium]
stadiumId: String,
stadiums: [String: Stadium]
) -> ItineraryStop? {
guard !games.isEmpty else { return nil }
@@ -393,7 +393,7 @@ final class ScenarioDPlanner: ScenarioPlanner {
var unique: [[Game]] = []
for route in routes {
let key = route.map { $0.id.uuidString }.sorted().joined(separator: "-")
let key = route.map { $0.id }.sorted().joined(separator: "-")
if !seen.contains(key) {
seen.insert(key)
unique.append(route)

View File

@@ -23,7 +23,7 @@ enum ScenarioPlannerFactory {
/// Creates the appropriate planner based on the request inputs
static func planner(for request: PlanningRequest) -> ScenarioPlanner {
print("🔍 ScenarioPlannerFactory: Selecting planner...")
print(" - followTeamId: \(request.preferences.followTeamId?.uuidString ?? "nil")")
print(" - followTeamId: \(request.preferences.followTeamId ?? "nil")")
print(" - selectedGames.count: \(request.selectedGames.count)")
print(" - startLocation: \(request.startLocation?.name ?? "nil")")
print(" - endLocation: \(request.endLocation?.name ?? "nil")")

View File

@@ -251,7 +251,7 @@ struct ItineraryStop: Identifiable, Hashable {
let city: String
let state: String
let coordinate: CLLocationCoordinate2D?
let games: [UUID]
let games: [String] // Canonical game IDs
let arrivalDate: Date
let departureDate: Date
let location: LocationInput
@@ -490,8 +490,8 @@ extension ItineraryOption {
struct PlanningRequest {
let preferences: TripPreferences
let availableGames: [Game]
let teams: [UUID: Team]
let stadiums: [UUID: Stadium]
let teams: [String: Team] // Keyed by canonical ID
let stadiums: [String: Stadium] // Keyed by canonical ID
// MARK: - Computed Properties for Engine