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:
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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] ?? []
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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] {
|
||||
|
||||
Reference in New Issue
Block a user