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

@@ -43,7 +43,7 @@ struct CKTeam {
init(team: Team, stadiumRecordID: CKRecord.ID) {
let record = CKRecord(recordType: CKRecordType.team)
record[CKTeam.idKey] = team.id.uuidString
record[CKTeam.idKey] = team.id
record[CKTeam.nameKey] = team.name
record[CKTeam.abbreviationKey] = team.abbreviation
record[CKTeam.sportKey] = team.sport.rawValue
@@ -67,8 +67,8 @@ struct CKTeam {
var team: Team? {
// Use teamId field, or fall back to record name
let idString = (record[CKTeam.idKey] as? String) ?? record.recordID.recordName
guard let id = UUID(uuidString: idString),
let id = (record[CKTeam.idKey] as? String) ?? record.recordID.recordName
guard !id.isEmpty,
let abbreviation = record[CKTeam.abbreviationKey] as? String,
let sportRaw = record[CKTeam.sportKey] as? String,
let sport = Sport(rawValue: sportRaw),
@@ -78,14 +78,13 @@ struct CKTeam {
// Name defaults to abbreviation if not provided
let name = record[CKTeam.nameKey] as? String ?? abbreviation
// Stadium reference is optional - use placeholder UUID if not present
let stadiumId: UUID
if let stadiumRef = record[CKTeam.stadiumRefKey] as? CKRecord.Reference,
let refId = UUID(uuidString: stadiumRef.recordID.recordName) {
stadiumId = refId
// Stadium reference is optional - use placeholder string if not present
let stadiumId: String
if let stadiumRef = record[CKTeam.stadiumRefKey] as? CKRecord.Reference {
stadiumId = stadiumRef.recordID.recordName
} else {
// Generate deterministic placeholder from team ID
stadiumId = UUID()
// Generate placeholder from team ID
stadiumId = "stadium_placeholder_\(id)"
}
let logoURL = (record[CKTeam.logoURLKey] as? String).flatMap { URL(string: $0) }
@@ -126,7 +125,7 @@ struct CKStadium {
init(stadium: Stadium) {
let record = CKRecord(recordType: CKRecordType.stadium)
record[CKStadium.idKey] = stadium.id.uuidString
record[CKStadium.idKey] = stadium.id
record[CKStadium.nameKey] = stadium.name
record[CKStadium.cityKey] = stadium.city
record[CKStadium.stateKey] = stadium.state
@@ -145,8 +144,8 @@ struct CKStadium {
var stadium: Stadium? {
// Use stadiumId field, or fall back to record name
let idString = (record[CKStadium.idKey] as? String) ?? record.recordID.recordName
guard let id = UUID(uuidString: idString),
let id = (record[CKStadium.idKey] as? String) ?? record.recordID.recordName
guard !id.isEmpty,
let name = record[CKStadium.nameKey] as? String,
let city = record[CKStadium.cityKey] as? String
else { return nil }
@@ -199,7 +198,7 @@ struct CKGame {
init(game: Game, homeTeamRecordID: CKRecord.ID, awayTeamRecordID: CKRecord.ID, stadiumRecordID: CKRecord.ID) {
let record = CKRecord(recordType: CKRecordType.game)
record[CKGame.idKey] = game.id.uuidString
record[CKGame.idKey] = game.id
record[CKGame.homeTeamRefKey] = CKRecord.Reference(recordID: homeTeamRecordID, action: .none)
record[CKGame.awayTeamRefKey] = CKRecord.Reference(recordID: awayTeamRecordID, action: .none)
record[CKGame.stadiumRefKey] = CKRecord.Reference(recordID: stadiumRecordID, action: .none)
@@ -231,9 +230,9 @@ struct CKGame {
record[CKGame.stadiumCanonicalIdKey] as? String
}
func game(homeTeamId: UUID, awayTeamId: UUID, stadiumId: UUID) -> Game? {
guard let idString = record[CKGame.idKey] as? String,
let id = UUID(uuidString: idString),
func game(homeTeamId: String, awayTeamId: String, stadiumId: String) -> Game? {
let id = (record[CKGame.idKey] as? String) ?? record.recordID.recordName
guard !id.isEmpty,
let dateTime = record[CKGame.dateTimeKey] as? Date,
let sportRaw = record[CKGame.sportKey] as? String,
let sport = Sport(rawValue: sportRaw),

View File

@@ -591,7 +591,7 @@ enum AchievementRegistry {
sport: .mlb,
iconName: "building.columns.fill",
iconColor: .green,
requirement: .specificStadium("stadium_mlb_bos"),
requirement: .specificStadium("stadium_mlb_fenway_park"),
sortOrder: 600
),
AchievementDefinition(
@@ -602,7 +602,7 @@ enum AchievementRegistry {
sport: .mlb,
iconName: "leaf.fill",
iconColor: .green,
requirement: .specificStadium("stadium_mlb_chc"),
requirement: .specificStadium("stadium_mlb_wrigley_field"),
sortOrder: 601
),
AchievementDefinition(
@@ -613,7 +613,7 @@ enum AchievementRegistry {
sport: .nba,
iconName: "sparkles",
iconColor: .orange,
requirement: .specificStadium("stadium_nba_nyk"),
requirement: .specificStadium("stadium_nba_madison_square_garden"),
sortOrder: 602
)
]

View File

@@ -6,10 +6,10 @@
import Foundation
struct Game: Identifiable, Codable, Hashable {
let id: UUID
let homeTeamId: UUID
let awayTeamId: UUID
let stadiumId: UUID
let id: String // Canonical ID: "game_mlb_2026_bos_nyy_0401"
let homeTeamId: String // FK: "team_mlb_bos"
let awayTeamId: String // FK: "team_mlb_nyy"
let stadiumId: String // FK: "stadium_mlb_fenway_park"
let dateTime: Date
let sport: Sport
let season: String
@@ -17,10 +17,10 @@ struct Game: Identifiable, Codable, Hashable {
let broadcastInfo: String?
init(
id: UUID ,
homeTeamId: UUID,
awayTeamId: UUID,
stadiumId: UUID,
id: String,
homeTeamId: String,
awayTeamId: String,
stadiumId: String,
dateTime: Date,
sport: Sport,
season: String,
@@ -78,7 +78,7 @@ struct RichGame: Identifiable, Hashable, Codable {
let awayTeam: Team
let stadium: Stadium
var id: UUID { game.id }
var id: String { game.id }
var matchupDescription: String {
"\(awayTeam.abbreviation) @ \(homeTeam.abbreviation)"

View File

@@ -7,7 +7,7 @@ import Foundation
import CoreLocation
struct Stadium: Identifiable, Codable, Hashable {
let id: UUID
let id: String // Canonical ID: "stadium_mlb_fenway_park"
let name: String
let city: String
let state: String
@@ -19,7 +19,7 @@ struct Stadium: Identifiable, Codable, Hashable {
let imageURL: URL?
init(
id: UUID,
id: String,
name: String,
city: String,
state: String,

View File

@@ -6,23 +6,23 @@
import Foundation
struct Team: Identifiable, Codable, Hashable {
let id: UUID
let id: String // Canonical ID: "team_mlb_bos"
let name: String
let abbreviation: String
let sport: Sport
let city: String
let stadiumId: UUID
let stadiumId: String // FK: "stadium_mlb_fenway_park"
let logoURL: URL?
let primaryColor: String?
let secondaryColor: String?
init(
id: UUID,
id: String,
name: String,
abbreviation: String,
sport: Sport,
city: String,
stadiumId: UUID,
stadiumId: String,
logoURL: URL? = nil,
primaryColor: String? = nil,
secondaryColor: String? = nil

View File

@@ -174,7 +174,7 @@ struct ItineraryDay: Identifiable, Hashable {
var isRestDay: Bool { stops.first?.isRestDay ?? false }
var hasTravelSegment: Bool { !travelSegments.isEmpty }
var gameIds: [UUID] { stops.flatMap { $0.games } }
var gameIds: [String] { stops.flatMap { $0.games } }
var hasGames: Bool { !gameIds.isEmpty }
var primaryCity: String? { stops.first?.city }
var totalDrivingHours: Double { travelSegments.reduce(0) { $0 + $1.durationHours } }

View File

@@ -215,7 +215,7 @@ struct TripPreferences: Codable, Hashable {
var startLocation: LocationInput?
var endLocation: LocationInput?
var sports: Set<Sport>
var mustSeeGameIds: Set<UUID>
var mustSeeGameIds: Set<String>
var travelMode: TravelMode
var startDate: Date
var endDate: Date
@@ -235,7 +235,7 @@ struct TripPreferences: Codable, Hashable {
var selectedRegions: Set<Region>
/// Team to follow (for Follow Team mode)
var followTeamId: UUID?
var followTeamId: String?
/// Whether to start/end from a home location (vs fly-in/fly-out)
var useHomeLocation: Bool
@@ -248,7 +248,7 @@ struct TripPreferences: Codable, Hashable {
startLocation: LocationInput? = nil,
endLocation: LocationInput? = nil,
sports: Set<Sport> = [],
mustSeeGameIds: Set<UUID> = [],
mustSeeGameIds: Set<String> = [],
travelMode: TravelMode = .drive,
startDate: Date = Date(),
endDate: Date = Date().addingTimeInterval(86400 * 7),
@@ -264,7 +264,7 @@ struct TripPreferences: Codable, Hashable {
maxDrivingHoursPerDriver: Double? = nil,
allowRepeatCities: Bool = true,
selectedRegions: Set<Region> = [.east, .central, .west],
followTeamId: UUID? = nil,
followTeamId: String? = nil,
useHomeLocation: Bool = true,
gameFirstTripDuration: Int = 7
) {

View File

@@ -14,8 +14,8 @@ struct TripStop: Identifiable, Codable, Hashable {
let coordinate: CLLocationCoordinate2D?
let arrivalDate: Date
let departureDate: Date
let games: [UUID]
let stadium: UUID?
let games: [String]
let stadium: String?
let lodging: LodgingSuggestion?
let activities: [ActivitySuggestion]
let isRestDay: Bool
@@ -29,8 +29,8 @@ struct TripStop: Identifiable, Codable, Hashable {
coordinate: CLLocationCoordinate2D? = nil,
arrivalDate: Date,
departureDate: Date,
games: [UUID] = [],
stadium: UUID? = nil,
games: [String] = [],
stadium: String? = nil,
lodging: LodgingSuggestion? = nil,
activities: [ActivitySuggestion] = [],
isRestDay: Bool = false,

View File

@@ -163,7 +163,7 @@ final class CanonicalStadium {
func toDomain() -> Stadium {
Stadium(
id: uuid,
id: canonicalId,
name: name,
city: city,
state: state,
@@ -299,14 +299,14 @@ final class CanonicalTeam {
var sportEnum: Sport? { Sport(rawValue: sport) }
func toDomain(stadiumUUID: UUID) -> Team {
func toDomain() -> Team {
Team(
id: uuid,
id: canonicalId,
name: name,
abbreviation: abbreviation,
sport: sportEnum ?? .mlb,
city: city,
stadiumId: stadiumUUID,
stadiumId: stadiumCanonicalId,
logoURL: logoURL.flatMap { URL(string: $0) },
primaryColor: primaryColor,
secondaryColor: secondaryColor
@@ -466,12 +466,12 @@ final class CanonicalGame {
var sportEnum: Sport? { Sport(rawValue: sport) }
func toDomain(homeTeamUUID: UUID, awayTeamUUID: UUID, stadiumUUID: UUID) -> Game {
func toDomain() -> Game {
Game(
id: uuid,
homeTeamId: homeTeamUUID,
awayTeamId: awayTeamUUID,
stadiumId: stadiumUUID,
id: canonicalId,
homeTeamId: homeTeamCanonicalId,
awayTeamId: awayTeamCanonicalId,
stadiumId: stadiumCanonicalId,
dateTime: dateTime,
sport: sportEnum ?? .mlb,
season: season,

View File

@@ -16,7 +16,7 @@ final class SavedTrip {
var updatedAt: Date
var status: String
var tripData: Data // Encoded Trip struct
var gamesData: Data? // Encoded [UUID: RichGame] dictionary
var gamesData: Data? // Encoded [String: RichGame] dictionary
@Relationship(deleteRule: .cascade)
var votes: [TripVote]?
@@ -43,16 +43,16 @@ final class SavedTrip {
try? JSONDecoder().decode(Trip.self, from: tripData)
}
var games: [UUID: RichGame] {
var games: [String: RichGame] {
guard let data = gamesData else { return [:] }
return (try? JSONDecoder().decode([UUID: RichGame].self, from: data)) ?? [:]
return (try? JSONDecoder().decode([String: RichGame].self, from: data)) ?? [:]
}
var tripStatus: TripStatus {
TripStatus(rawValue: status) ?? .draft
}
static func from(_ trip: Trip, games: [UUID: RichGame] = [:], status: TripStatus = .planned) -> SavedTrip? {
static func from(_ trip: Trip, games: [String: RichGame] = [:], status: TripStatus = .planned) -> SavedTrip? {
guard let tripData = try? JSONEncoder().encode(trip) else { return nil }
let gamesData = try? JSONEncoder().encode(games)
return SavedTrip(
@@ -75,7 +75,7 @@ final class TripVote {
var tripId: UUID
var voterId: String
var voterName: String
var gameVotes: Data // [UUID: Bool] encoded
var gameVotes: Data // [String: Bool] encoded (game IDs to vote)
var routeVotes: Data // [String: Int] encoded
var leisurePreference: String
var createdAt: Date

View File

@@ -65,8 +65,7 @@ final class StadiumVisit {
@Attribute(.unique) var id: UUID
// Stadium identity (stable across renames)
var canonicalStadiumId: String // Links to CanonicalStadium.canonicalId
var stadiumUUID: UUID // Runtime UUID for display lookups
var stadiumId: String // Canonical ID: "stadium_mlb_fenway_park"
var stadiumNameAtVisit: String // Frozen at visit time
// Visit details
@@ -75,9 +74,9 @@ final class StadiumVisit {
var visitTypeRaw: String // VisitType.rawValue
// Game info (optional - nil for tours/other visits)
var gameId: UUID?
var homeTeamId: UUID?
var awayTeamId: UUID?
var gameId: String? // Canonical ID: "game_mlb_2026_bos_nyy_0401"
var homeTeamId: String? // Canonical ID: "team_mlb_bos"
var awayTeamId: String? // Canonical ID: "team_mlb_nyy"
var homeTeamName: String? // For display when team lookup fails
var awayTeamName: String?
var finalScore: String? // "5-3" format
@@ -109,15 +108,14 @@ final class StadiumVisit {
init(
id: UUID = UUID(),
canonicalStadiumId: String,
stadiumUUID: UUID,
stadiumId: String,
stadiumNameAtVisit: String,
visitDate: Date,
sport: Sport,
visitType: VisitType = .game,
gameId: UUID? = nil,
homeTeamId: UUID? = nil,
awayTeamId: UUID? = nil,
gameId: String? = nil,
homeTeamId: String? = nil,
awayTeamId: String? = nil,
homeTeamName: String? = nil,
awayTeamName: String? = nil,
finalScore: String? = nil,
@@ -133,8 +131,7 @@ final class StadiumVisit {
source: VisitSource = .manual
) {
self.id = id
self.canonicalStadiumId = canonicalStadiumId
self.stadiumUUID = stadiumUUID
self.stadiumId = stadiumId
self.stadiumNameAtVisit = stadiumNameAtVisit
self.visitDate = visitDate
self.sport = sport.rawValue

View File

@@ -44,7 +44,7 @@ final class AchievementEngine {
func recalculateAllAchievements() async throws -> AchievementDelta {
// Get all visits
let visits = try fetchAllVisits()
let visitedStadiumIds = Set(visits.map { $0.canonicalStadiumId })
let visitedStadiumIds = Set(visits.map { $0.stadiumId })
// Get currently earned achievements
let currentAchievements = try fetchEarnedAchievements()
@@ -112,7 +112,7 @@ final class AchievementEngine {
/// Quick check after new visit (incremental)
func checkAchievementsForNewVisit(_ visit: StadiumVisit) async throws -> [AchievementDefinition] {
let visits = try fetchAllVisits()
let visitedStadiumIds = Set(visits.map { $0.canonicalStadiumId })
let visitedStadiumIds = Set(visits.map { $0.stadiumId })
let currentAchievements = try fetchEarnedAchievements()
let currentAchievementIds = Set(currentAchievements.map { $0.achievementTypeId })
@@ -152,7 +152,7 @@ final class AchievementEngine {
/// Get progress toward all achievements
func getProgress() async throws -> [AchievementProgress] {
let visits = try fetchAllVisits()
let visitedStadiumIds = Set(visits.map { $0.canonicalStadiumId })
let visitedStadiumIds = Set(visits.map { $0.stadiumId })
let earnedAchievements = try fetchEarnedAchievements()
let earnedIds = Set(earnedAchievements.map { $0.achievementTypeId })
@@ -196,7 +196,7 @@ final class AchievementEngine {
case .visitCountForSport(let count, let sport):
let sportVisits = visits.filter { $0.sport == sport.rawValue }
let sportStadiums = Set(sportVisits.map { $0.canonicalStadiumId })
let sportStadiums = Set(sportVisits.map { $0.stadiumId })
return sportStadiums.count >= count
case .completeDivision(let divisionId):
@@ -214,41 +214,12 @@ final class AchievementEngine {
case .multipleLeagues(let leagueCount):
return checkMultipleLeagues(visits: visits, requiredLeagues: leagueCount)
case .specificStadium(let symbolicId):
// Resolve symbolic ID (e.g., "stadium_mlb_bos") to actual UUID string
guard let resolvedId = resolveSymbolicStadiumId(symbolicId) else { return false }
return visitedStadiumIds.contains(resolvedId)
case .specificStadium(let stadiumId):
// Direct comparison - canonical IDs match everywhere
return visitedStadiumIds.contains(stadiumId)
}
}
/// Resolves symbolic stadium IDs (e.g., "stadium_mlb_bos") to actual stadium UUID strings
private func resolveSymbolicStadiumId(_ symbolicId: String) -> String? {
// Parse symbolic ID format: "stadium_{sport}_{teamAbbrev}"
let parts = symbolicId.split(separator: "_")
guard parts.count == 3,
parts[0] == "stadium" else {
return nil
}
// Sport raw values are uppercase (e.g., "MLB"), but symbolic IDs use lowercase
let sportString = String(parts[1]).uppercased()
guard let sport = Sport(rawValue: sportString) else {
return nil
}
let teamAbbrev = String(parts[2]).uppercased()
// Find team by abbreviation and sport
guard let team = dataProvider.teams.first(where: {
$0.abbreviation.uppercased() == teamAbbrev && $0.sport == sport
}) else {
return nil
}
// Return the stadium UUID as string (matches visit's canonicalStadiumId format)
return team.stadiumId.uuidString
}
private func checkDivisionComplete(_ divisionId: String, visitedStadiumIds: Set<String>) -> Bool {
guard let division = LeagueStructure.division(byId: divisionId) else { return false }
@@ -291,7 +262,7 @@ final class AchievementEngine {
if daysDiff < withinDays {
// Check unique stadiums in window
let windowVisits = Array(sortedVisits[i..<(i + requiredVisits)])
let uniqueStadiums = Set(windowVisits.map { $0.canonicalStadiumId })
let uniqueStadiums = Set(windowVisits.map { $0.stadiumId })
if uniqueStadiums.count >= requiredVisits {
return true
}
@@ -322,7 +293,7 @@ final class AchievementEngine {
case .visitCountForSport(let count, let sport):
let sportVisits = visits.filter { $0.sport == sport.rawValue }
let sportStadiums = Set(sportVisits.map { $0.canonicalStadiumId })
let sportStadiums = Set(sportVisits.map { $0.stadiumId })
return (sportStadiums.count, count)
case .completeDivision(let divisionId):
@@ -348,10 +319,9 @@ final class AchievementEngine {
let leagues = Set(visits.compactMap { Sport(rawValue: $0.sport) })
return (leagues.count, leagueCount)
case .specificStadium(let symbolicId):
// Resolve symbolic ID to actual UUID string
guard let resolvedId = resolveSymbolicStadiumId(symbolicId) else { return (0, 1) }
return (visitedStadiumIds.contains(resolvedId) ? 1 : 0, 1)
case .specificStadium(let stadiumId):
// Direct comparison - canonical IDs match everywhere
return (visitedStadiumIds.contains(stadiumId) ? 1 : 0, 1)
}
}
@@ -368,15 +338,15 @@ final class AchievementEngine {
case .completeDivision(let divisionId):
let stadiumIds = Set(getStadiumIdsForDivision(divisionId))
return visits.filter { stadiumIds.contains($0.canonicalStadiumId) }.map { $0.id }
return visits.filter { stadiumIds.contains($0.stadiumId) }.map { $0.id }
case .completeConference(let conferenceId):
let stadiumIds = Set(getStadiumIdsForConference(conferenceId))
return visits.filter { stadiumIds.contains($0.canonicalStadiumId) }.map { $0.id }
return visits.filter { stadiumIds.contains($0.stadiumId) }.map { $0.id }
case .completeLeague(let sport):
let stadiumIds = Set(getStadiumIdsForLeague(sport))
return visits.filter { stadiumIds.contains($0.canonicalStadiumId) }.map { $0.id }
return visits.filter { stadiumIds.contains($0.stadiumId) }.map { $0.id }
case .visitsInDays(let requiredVisits, let days):
// Find the qualifying window of visits
@@ -391,10 +361,9 @@ final class AchievementEngine {
}
return []
case .specificStadium(let symbolicId):
// Resolve symbolic ID to actual UUID string
guard let resolvedId = resolveSymbolicStadiumId(symbolicId) else { return [] }
return visits.filter { $0.canonicalStadiumId == resolvedId }.map { $0.id }
case .specificStadium(let stadiumId):
// Direct comparison - canonical IDs match everywhere
return visits.filter { $0.stadiumId == stadiumId }.map { $0.id }
}
}
@@ -410,17 +379,8 @@ final class AchievementEngine {
return []
}
// Get stadium UUIDs for these teams
// CanonicalTeam has stadiumCanonicalId, we need to find the corresponding Stadium UUID
var stadiumIds: [String] = []
for canonicalTeam in canonicalTeams {
// Find the domain team by matching name/abbreviation to get stadium UUID
if let team = dataProvider.teams.first(where: { $0.abbreviation == canonicalTeam.abbreviation && $0.sport.rawValue == canonicalTeam.sport }) {
stadiumIds.append(team.stadiumId.uuidString)
}
}
return stadiumIds
// Get canonical stadium IDs for these teams
return canonicalTeams.map { $0.stadiumCanonicalId }
}
private func getStadiumIdsForConference(_ conferenceId: String) -> [String] {
@@ -434,7 +394,7 @@ final class AchievementEngine {
}
private func getStadiumIdsForLeague(_ sport: Sport) -> [String] {
// Get all stadiums for this sport - return UUID strings to match visit format
// Get all stadium canonical IDs for this sport
return dataProvider.stadiums
.filter { stadium in
// Check if stadium hosts teams of this sport
@@ -442,7 +402,7 @@ final class AchievementEngine {
team.stadiumId == stadium.id && team.sport == sport
}
}
.map { $0.id.uuidString }
.map { $0.id }
}
// MARK: - Data Fetching

View File

@@ -165,18 +165,18 @@ actor CloudKitService {
let ckGame = CKGame(record: record)
guard let homeRef = record[CKGame.homeTeamRefKey] as? CKRecord.Reference,
let awayRef = record[CKGame.awayTeamRefKey] as? CKRecord.Reference,
let homeId = UUID(uuidString: homeRef.recordID.recordName),
let awayId = UUID(uuidString: awayRef.recordID.recordName)
let awayRef = record[CKGame.awayTeamRefKey] as? CKRecord.Reference
else { return nil }
let homeId = homeRef.recordID.recordName
let awayId = awayRef.recordID.recordName
// Stadium ref is optional - use placeholder if not present
let stadiumId: UUID
if let stadiumRef = record[CKGame.stadiumRefKey] as? CKRecord.Reference,
let refId = UUID(uuidString: stadiumRef.recordID.recordName) {
stadiumId = refId
let stadiumId: String
if let stadiumRef = record[CKGame.stadiumRefKey] as? CKRecord.Reference {
stadiumId = stadiumRef.recordID.recordName
} else {
stadiumId = UUID() // Placeholder - will be resolved via team lookup
stadiumId = "stadium_placeholder_\(UUID().uuidString)" // Placeholder - will be resolved via team lookup
}
return ckGame.game(homeTeamId: homeId, awayTeamId: awayId, stadiumId: stadiumId)
@@ -188,8 +188,8 @@ actor CloudKitService {
return allGames.sorted { $0.dateTime < $1.dateTime }
}
func fetchGame(by id: UUID) async throws -> Game? {
let predicate = NSPredicate(format: "gameId == %@", id.uuidString)
func fetchGame(by id: String) async throws -> Game? {
let predicate = NSPredicate(format: "gameId == %@", id)
let query = CKQuery(recordType: CKRecordType.game, predicate: predicate)
let (results, _) = try await publicDatabase.records(matching: query)
@@ -201,12 +201,13 @@ actor CloudKitService {
guard let homeRef = record[CKGame.homeTeamRefKey] as? CKRecord.Reference,
let awayRef = record[CKGame.awayTeamRefKey] as? CKRecord.Reference,
let stadiumRef = record[CKGame.stadiumRefKey] as? CKRecord.Reference,
let homeId = UUID(uuidString: homeRef.recordID.recordName),
let awayId = UUID(uuidString: awayRef.recordID.recordName),
let stadiumId = UUID(uuidString: stadiumRef.recordID.recordName)
let stadiumRef = record[CKGame.stadiumRefKey] as? CKRecord.Reference
else { return nil }
let homeId = homeRef.recordID.recordName
let awayId = awayRef.recordID.recordName
let stadiumId = stadiumRef.recordID.recordName
return ckGame.game(homeTeamId: homeId, awayTeamId: awayId, stadiumId: stadiumId)
}
@@ -277,13 +278,11 @@ actor CloudKitService {
let stadiumCanonicalId = ckGame.stadiumCanonicalId
else { return nil }
// For the Game domain object, we still need UUIDs - use placeholder
// The sync service will use canonical IDs for relationships
let placeholderUUID = UUID()
// For the Game domain object, use canonical IDs directly
guard let game = ckGame.game(
homeTeamId: placeholderUUID,
awayTeamId: placeholderUUID,
stadiumId: placeholderUUID
homeTeamId: homeTeamCanonicalId,
awayTeamId: awayTeamCanonicalId,
stadiumId: stadiumCanonicalId
) else { return nil }
return SyncGame(

View File

@@ -21,14 +21,9 @@ final class AppDataProvider: ObservableObject {
@Published private(set) var error: Error?
@Published private(set) var errorMessage: String?
private var teamsById: [UUID: Team] = [:]
private var stadiumsById: [UUID: Stadium] = [:]
private var stadiumsByCanonicalId: [String: Stadium] = [:]
private var teamsByCanonicalId: [String: Team] = [:]
// Canonical ID lookups for game conversion
private var canonicalTeamUUIDs: [String: UUID] = [:]
private var canonicalStadiumUUIDs: [String: UUID] = [:]
// Lookup dictionaries - keyed by canonical ID (String)
private var teamsById: [String: Team] = [:]
private var stadiumsById: [String: Stadium] = [:]
private var modelContext: ModelContext?
@@ -63,11 +58,11 @@ final class AppDataProvider: ObservableObject {
// Convert to domain models and build lookups
var loadedStadiums: [Stadium] = []
var stadiumLookup: [String: Stadium] = [:]
for canonical in canonicalStadiums {
let stadium = canonical.toDomain()
loadedStadiums.append(stadium)
stadiumsByCanonicalId[canonical.canonicalId] = stadium
canonicalStadiumUUIDs[canonical.canonicalId] = stadium.id
stadiumLookup[stadium.id] = stadium
}
// Fetch canonical teams from SwiftData
@@ -78,31 +73,17 @@ final class AppDataProvider: ObservableObject {
// Convert to domain models
var loadedTeams: [Team] = []
var teamLookup: [String: Team] = [:]
for canonical in canonicalTeams {
// Get stadium UUID for this team
let stadiumUUID = canonicalStadiumUUIDs[canonical.stadiumCanonicalId] ?? UUID()
let team = canonical.toDomain(stadiumUUID: stadiumUUID)
let team = canonical.toDomain()
loadedTeams.append(team)
teamsByCanonicalId[canonical.canonicalId] = team
canonicalTeamUUIDs[canonical.canonicalId] = team.id
teamLookup[team.id] = team
}
self.teams = loadedTeams
self.stadiums = loadedStadiums
// Build lookup dictionaries (use reduce to handle potential duplicates gracefully)
self.teamsById = loadedTeams.reduce(into: [:]) { dict, team in
if dict[team.id] != nil {
print("⚠️ Duplicate team UUID: \(team.id) - \(team.name)")
}
dict[team.id] = team
}
self.stadiumsById = loadedStadiums.reduce(into: [:]) { dict, stadium in
if dict[stadium.id] != nil {
print("⚠️ Duplicate stadium UUID: \(stadium.id) - \(stadium.name)")
}
dict[stadium.id] = stadium
}
self.teamsById = teamLookup
self.stadiumsById = stadiumLookup
} catch {
self.error = error
@@ -123,11 +104,11 @@ final class AppDataProvider: ObservableObject {
// MARK: - Data Access
func team(for id: UUID) -> Team? {
func team(for id: String) -> Team? {
teamsById[id]
}
func stadium(for id: UUID) -> Stadium? {
func stadium(for id: String) -> Stadium? {
stadiumsById[id]
}
@@ -156,47 +137,27 @@ final class AppDataProvider: ObservableObject {
let canonicalGames = try context.fetch(descriptor)
// Filter by sport and convert to domain models
let result = canonicalGames.compactMap { canonical -> Game? in
return canonicalGames.compactMap { canonical -> Game? in
guard sportStrings.contains(canonical.sport) else { return nil }
let homeTeamUUID = canonicalTeamUUIDs[canonical.homeTeamCanonicalId] ?? UUID()
let awayTeamUUID = canonicalTeamUUIDs[canonical.awayTeamCanonicalId] ?? UUID()
let stadiumUUID = canonicalStadiumUUIDs[canonical.stadiumCanonicalId] ?? UUID()
return canonical.toDomain(
homeTeamUUID: homeTeamUUID,
awayTeamUUID: awayTeamUUID,
stadiumUUID: stadiumUUID
)
return canonical.toDomain()
}
return result
}
/// Fetch a single game by ID
func fetchGame(by id: UUID) async throws -> Game? {
/// Fetch a single game by canonical ID
func fetchGame(by id: String) async throws -> Game? {
guard let context = modelContext else {
throw DataProviderError.contextNotConfigured
}
let idString = id.uuidString
let descriptor = FetchDescriptor<CanonicalGame>(
predicate: #Predicate<CanonicalGame> { $0.canonicalId == idString }
predicate: #Predicate<CanonicalGame> { $0.canonicalId == id }
)
guard let canonical = try context.fetch(descriptor).first else {
return nil
}
let homeTeamUUID = canonicalTeamUUIDs[canonical.homeTeamCanonicalId] ?? UUID()
let awayTeamUUID = canonicalTeamUUIDs[canonical.awayTeamCanonicalId] ?? UUID()
let stadiumUUID = canonicalStadiumUUIDs[canonical.stadiumCanonicalId] ?? UUID()
return canonical.toDomain(
homeTeamUUID: homeTeamUUID,
awayTeamUUID: awayTeamUUID,
stadiumUUID: stadiumUUID
)
return canonical.toDomain()
}
/// Fetch games with full team and stadium data

View File

@@ -43,7 +43,7 @@ enum NoMatchReason: Sendable {
// MARK: - Game Match Result
struct GameMatchCandidate: Identifiable, Sendable {
let id: UUID
let id: String
let game: Game
let stadium: Stadium
let homeTeam: Team
@@ -67,14 +67,20 @@ struct GameMatchCandidate: Identifiable, Sendable {
/// Initialize from a scraped historical game
init(scrapedGame: ScrapedGame, stadium: Stadium) {
self.id = UUID()
let matchId = UUID()
self.id = "scraped_match_\(matchId.uuidString)"
self.stadium = stadium
// Generate synthetic IDs for scraped games
let syntheticHomeTeamId = "scraped_team_\(UUID().uuidString)"
let syntheticAwayTeamId = "scraped_team_\(UUID().uuidString)"
let syntheticGameId = "scraped_game_\(matchId.uuidString)"
// Create synthetic Team objects from scraped names
// Scraped names already include city (e.g., "Chicago Cubs"), so we use empty city
// to avoid duplication in fullName computed property
self.homeTeam = Team(
id: UUID(),
id: syntheticHomeTeamId,
name: scrapedGame.homeTeam,
abbreviation: String(scrapedGame.homeTeam.suffix(3)).uppercased(),
sport: scrapedGame.sport,
@@ -83,7 +89,7 @@ struct GameMatchCandidate: Identifiable, Sendable {
)
self.awayTeam = Team(
id: UUID(),
id: syntheticAwayTeamId,
name: scrapedGame.awayTeam,
abbreviation: String(scrapedGame.awayTeam.suffix(3)).uppercased(),
sport: scrapedGame.sport,
@@ -94,7 +100,7 @@ struct GameMatchCandidate: Identifiable, Sendable {
// Create synthetic Game object
let year = Calendar.current.component(.year, from: scrapedGame.date)
self.game = Game(
id: self.id,
id: syntheticGameId,
homeTeamId: self.homeTeam.id,
awayTeamId: self.awayTeam.id,
stadiumId: stadium.id,

View File

@@ -132,7 +132,7 @@ struct RouteDescriptionInput: Identifiable {
let totalMiles: Double
let totalDrivingHours: Double
init(from option: ItineraryOption, games: [UUID: RichGame]) {
init(from option: ItineraryOption, games: [String: RichGame]) {
self.id = option.id
self.cities = Array(NSOrderedSet(array: option.stops.map { $0.city })) as? [String] ?? []

View File

@@ -60,7 +60,7 @@ extension MatchConfidence: Comparable {
// MARK: - Stadium Match
struct StadiumMatch: Identifiable, Sendable {
let id: UUID
let id: String
let stadium: Stadium
let distance: CLLocationDistance
let confidence: MatchConfidence

View File

@@ -16,7 +16,7 @@ struct SuggestedTrip: Identifiable {
let region: Region
let isSingleSport: Bool
let trip: Trip
let richGames: [UUID: RichGame]
let richGames: [String: RichGame]
let sports: Set<Sport>
var displaySports: [Sport] {
@@ -119,8 +119,8 @@ final class SuggestedTripsGenerator {
}
// Build lookups (use reduce to handle potential duplicate UUIDs gracefully)
let stadiumsById = dataProvider.stadiums.reduce(into: [UUID: Stadium]()) { $0[$1.id] = $1 }
let teamsById = dataProvider.teams.reduce(into: [UUID: Team]()) { $0[$1.id] = $1 }
let stadiumsById = dataProvider.stadiums.reduce(into: [String: Stadium]()) { $0[$1.id] = $1 }
let teamsById = dataProvider.teams.reduce(into: [String: Team]()) { $0[$1.id] = $1 }
var generatedTrips: [SuggestedTrip] = []
@@ -208,8 +208,8 @@ final class SuggestedTripsGenerator {
games: [Game],
region: Region,
singleSport: Bool,
stadiums: [UUID: Stadium],
teams: [UUID: Team],
stadiums: [String: Stadium],
teams: [String: Team],
startDate: Date,
endDate: Date,
excludingSport: Sport? = nil
@@ -292,8 +292,8 @@ final class SuggestedTripsGenerator {
}
}
private func buildRichGames(from games: [Game], teams: [UUID: Team], stadiums: [UUID: Stadium]) -> [UUID: RichGame] {
var result: [UUID: RichGame] = [:]
private func buildRichGames(from games: [Game], teams: [String: Team], stadiums: [String: Stadium]) -> [String: RichGame] {
var result: [String: RichGame] = [:]
for game in games {
guard let homeTeam = teams[game.homeTeamId],
let awayTeam = teams[game.awayTeamId],
@@ -305,8 +305,8 @@ final class SuggestedTripsGenerator {
private func generateCrossCountryTrip(
games: [Game],
stadiums: [UUID: Stadium],
teams: [UUID: Team],
stadiums: [String: Stadium],
teams: [String: Team],
startDate: Date,
endDate: Date,
excludeGames: [Game]
@@ -392,7 +392,7 @@ final class SuggestedTripsGenerator {
// Build stops by grouping consecutive games at the same stadium
var tripStops: [TripStop] = []
var currentStadiumId: UUID? = nil
var currentStadiumId: String? = nil
var currentGames: [Game] = []
for game in selectedGames {
@@ -463,7 +463,7 @@ final class SuggestedTripsGenerator {
}
/// Builds a TripStop from a group of games at the same stadium
private func buildTripStop(from games: [Game], stadiumId: UUID, stadiums: [UUID: Stadium], stopNumber: Int) -> TripStop? {
private func buildTripStop(from games: [Game], stadiumId: String, stadiums: [String: Stadium], stopNumber: Int) -> TripStop? {
guard !games.isEmpty else { return nil }
let sortedGames = games.sorted { $0.dateTime < $1.dateTime }
@@ -488,7 +488,7 @@ final class SuggestedTripsGenerator {
}
/// Builds travel segments between consecutive stops using TravelEstimator
private func buildTravelSegments(from stops: [TripStop], stadiums: [UUID: Stadium]) -> [TravelSegment] {
private func buildTravelSegments(from stops: [TripStop], stadiums: [String: Stadium]) -> [TravelSegment] {
guard stops.count >= 2 else { return [] }
var segments: [TravelSegment] = []
@@ -560,7 +560,7 @@ final class SuggestedTripsGenerator {
/// Builds a trip following a geographic corridor (moving consistently east or west)
private func buildCorridorTrip(
games: [(game: Game, lon: Double)],
stadiums: [UUID: Stadium],
stadiums: [String: Stadium],
direction: Direction,
calendar: Calendar
) -> [Game] {
@@ -631,7 +631,7 @@ final class SuggestedTripsGenerator {
startGames: [Game],
middleGames: [Game],
endGames: [Game],
stadiums: [UUID: Stadium],
stadiums: [String: Stadium],
calendar: Calendar
) -> [Game] {
// Sort all games by date
@@ -668,7 +668,7 @@ final class SuggestedTripsGenerator {
var totalDistance: Double = 0
// Track all days with games and their stadiums for conflict detection
var gamesByDay: [Date: UUID] = [:]
var gamesByDay: [Date: String] = [:]
gamesByDay[calendar.startOfDay(for: startGame.dateTime)] = startGame.stadiumId
// Find middle games that fit between start and end (limit to 2-3 middle stops)
@@ -774,7 +774,7 @@ final class SuggestedTripsGenerator {
/// Validates that no two games in the route are on the same calendar day at different stadiums
private func validateNoSameDayConflicts(_ games: [Game], calendar: Calendar) -> Bool {
var gamesByDay: [Date: UUID] = [:]
var gamesByDay: [Date: String] = [:]
for game in games {
let day = calendar.startOfDay(for: game.dateTime)
if let existingStadiumId = gamesByDay[day] {