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:
@@ -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),
|
||||
|
||||
@@ -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
|
||||
)
|
||||
]
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 } }
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -28,7 +28,7 @@ final class PDFGenerator {
|
||||
|
||||
func generatePDF(
|
||||
for trip: Trip,
|
||||
games: [UUID: RichGame],
|
||||
games: [String: RichGame],
|
||||
assets: PDFAssetPrefetcher.PrefetchedAssets? = nil
|
||||
) async throws -> Data {
|
||||
let pdfRenderer = UIGraphicsPDFRenderer(
|
||||
@@ -244,7 +244,7 @@ final class PDFGenerator {
|
||||
private func drawItineraryPages(
|
||||
context: UIGraphicsPDFRendererContext,
|
||||
trip: Trip,
|
||||
games: [UUID: RichGame],
|
||||
games: [String: RichGame],
|
||||
assets: PDFAssetPrefetcher.PrefetchedAssets?
|
||||
) {
|
||||
var pageNumber = 3
|
||||
@@ -283,7 +283,7 @@ final class PDFGenerator {
|
||||
drawFooter(context: context, pageNumber: pageNumber)
|
||||
}
|
||||
|
||||
private func estimateDayHeight(day: ItineraryDay, games: [UUID: RichGame]) -> CGFloat {
|
||||
private func estimateDayHeight(day: ItineraryDay, games: [String: RichGame]) -> CGFloat {
|
||||
var height: CGFloat = 60 // Day header + city
|
||||
|
||||
// Games
|
||||
@@ -305,7 +305,7 @@ final class PDFGenerator {
|
||||
private func drawDay(
|
||||
context: UIGraphicsPDFRendererContext,
|
||||
day: ItineraryDay,
|
||||
games: [UUID: RichGame],
|
||||
games: [String: RichGame],
|
||||
assets: PDFAssetPrefetcher.PrefetchedAssets?,
|
||||
y: CGFloat
|
||||
) -> CGFloat {
|
||||
@@ -520,7 +520,7 @@ final class PDFGenerator {
|
||||
private func drawCitySpotlightPages(
|
||||
context: UIGraphicsPDFRendererContext,
|
||||
trip: Trip,
|
||||
games: [UUID: RichGame],
|
||||
games: [String: RichGame],
|
||||
assets: PDFAssetPrefetcher.PrefetchedAssets?
|
||||
) {
|
||||
guard let cityPOIs = assets?.cityPOIs, !cityPOIs.isEmpty else { return }
|
||||
@@ -661,7 +661,7 @@ final class PDFGenerator {
|
||||
private func drawSummaryPage(
|
||||
context: UIGraphicsPDFRendererContext,
|
||||
trip: Trip,
|
||||
games: [UUID: RichGame]
|
||||
games: [String: RichGame]
|
||||
) {
|
||||
var y: CGFloat = margin
|
||||
|
||||
@@ -896,7 +896,7 @@ final class ExportService {
|
||||
/// Export trip to PDF with full prefetched assets
|
||||
func exportToPDF(
|
||||
trip: Trip,
|
||||
games: [UUID: RichGame],
|
||||
games: [String: RichGame],
|
||||
progressCallback: ((PDFAssetPrefetcher.PrefetchProgress) async -> Void)? = nil
|
||||
) async throws -> URL {
|
||||
// Prefetch all assets
|
||||
@@ -918,7 +918,7 @@ final class ExportService {
|
||||
}
|
||||
|
||||
/// Quick export without prefetching (basic PDF)
|
||||
func exportToPDFBasic(trip: Trip, games: [UUID: RichGame]) async throws -> URL {
|
||||
func exportToPDFBasic(trip: Trip, games: [String: RichGame]) async throws -> URL {
|
||||
let data = try await pdfGenerator.generatePDF(for: trip, games: games, assets: nil)
|
||||
|
||||
let fileName = "\(trip.name.replacingOccurrences(of: " ", with: "_"))_\(Date().timeIntervalSince1970).pdf"
|
||||
|
||||
@@ -15,8 +15,8 @@ actor PDFAssetPrefetcher {
|
||||
struct PrefetchedAssets {
|
||||
let routeMap: UIImage?
|
||||
let cityMaps: [String: UIImage]
|
||||
let teamLogos: [UUID: UIImage]
|
||||
let stadiumPhotos: [UUID: UIImage]
|
||||
let teamLogos: [String: UIImage]
|
||||
let stadiumPhotos: [String: UIImage]
|
||||
let cityPOIs: [String: [POISearchService.POI]]
|
||||
|
||||
var isEmpty: Bool {
|
||||
@@ -63,7 +63,7 @@ actor PDFAssetPrefetcher {
|
||||
/// - Returns: All prefetched assets
|
||||
func prefetchAssets(
|
||||
for trip: Trip,
|
||||
games: [UUID: RichGame],
|
||||
games: [String: RichGame],
|
||||
progressCallback: ((PrefetchProgress) async -> Void)? = nil
|
||||
) async -> PrefetchedAssets {
|
||||
var progress = PrefetchProgress()
|
||||
@@ -71,8 +71,8 @@ actor PDFAssetPrefetcher {
|
||||
// Collect unique teams and stadiums from games
|
||||
var teams: [Team] = []
|
||||
var stadiums: [Stadium] = []
|
||||
var seenTeamIds: Set<UUID> = []
|
||||
var seenStadiumIds: Set<UUID> = []
|
||||
var seenTeamIds: Set<String> = []
|
||||
var seenStadiumIds: Set<String> = []
|
||||
|
||||
for (_, richGame) in games {
|
||||
if !seenTeamIds.contains(richGame.homeTeam.id) {
|
||||
|
||||
@@ -111,8 +111,8 @@ actor RemoteImageService {
|
||||
}
|
||||
|
||||
/// Fetch team logos by team ID
|
||||
func fetchTeamLogos(teams: [Team]) async -> [UUID: UIImage] {
|
||||
let urlToTeam: [URL: UUID] = Dictionary(
|
||||
func fetchTeamLogos(teams: [Team]) async -> [String: UIImage] {
|
||||
let urlToTeam: [URL: String] = Dictionary(
|
||||
uniqueKeysWithValues: teams.compactMap { team in
|
||||
guard let logoURL = team.logoURL else { return nil }
|
||||
return (logoURL, team.id)
|
||||
@@ -121,7 +121,7 @@ actor RemoteImageService {
|
||||
|
||||
let images = await fetchImages(from: Array(urlToTeam.keys))
|
||||
|
||||
var result: [UUID: UIImage] = [:]
|
||||
var result: [String: UIImage] = [:]
|
||||
for (url, image) in images {
|
||||
if let teamId = urlToTeam[url] {
|
||||
result[teamId] = image
|
||||
@@ -132,8 +132,8 @@ actor RemoteImageService {
|
||||
}
|
||||
|
||||
/// Fetch stadium photos by stadium ID
|
||||
func fetchStadiumPhotos(stadiums: [Stadium]) async -> [UUID: UIImage] {
|
||||
let urlToStadium: [URL: UUID] = Dictionary(
|
||||
func fetchStadiumPhotos(stadiums: [Stadium]) async -> [String: UIImage] {
|
||||
let urlToStadium: [URL: String] = Dictionary(
|
||||
uniqueKeysWithValues: stadiums.compactMap { stadium in
|
||||
guard let imageURL = stadium.imageURL else { return nil }
|
||||
return (imageURL, stadium.id)
|
||||
@@ -142,7 +142,7 @@ actor RemoteImageService {
|
||||
|
||||
let images = await fetchImages(from: Array(urlToStadium.keys))
|
||||
|
||||
var result: [UUID: UIImage] = [:]
|
||||
var result: [String: UIImage] = [:]
|
||||
for (url, image) in images {
|
||||
if let stadiumId = urlToStadium[url] {
|
||||
result[stadiumId] = image
|
||||
|
||||
@@ -174,12 +174,14 @@ final class PhotoImportViewModel {
|
||||
|
||||
// Create the visit
|
||||
let visit = StadiumVisit(
|
||||
canonicalStadiumId: match.stadium.id.uuidString,
|
||||
stadiumUUID: match.stadium.id,
|
||||
stadiumId: match.stadium.id,
|
||||
stadiumNameAtVisit: match.stadium.name,
|
||||
visitDate: match.game.dateTime,
|
||||
sport: match.game.sport,
|
||||
visitType: .game,
|
||||
gameId: match.game.id,
|
||||
homeTeamId: match.homeTeam.id,
|
||||
awayTeamId: match.awayTeam.id,
|
||||
homeTeamName: match.homeTeam.fullName,
|
||||
awayTeamName: match.awayTeam.fullName,
|
||||
finalScore: match.formattedFinalScore,
|
||||
|
||||
@@ -41,10 +41,10 @@ final class ProgressViewModel {
|
||||
let visitedStadiumIds = Set(
|
||||
visits
|
||||
.filter { $0.sportEnum == selectedSport }
|
||||
.compactMap { visit -> UUID? in
|
||||
.compactMap { visit -> String? in
|
||||
// Match visit's canonical stadium ID to a stadium
|
||||
stadiums.first { stadium in
|
||||
stadium.id == visit.stadiumUUID
|
||||
stadium.id == visit.stadiumId
|
||||
}?.id
|
||||
}
|
||||
)
|
||||
@@ -62,11 +62,11 @@ final class ProgressViewModel {
|
||||
}
|
||||
|
||||
/// Stadium visit status indexed by stadium ID
|
||||
var stadiumVisitStatus: [UUID: StadiumVisitStatus] {
|
||||
var statusMap: [UUID: StadiumVisitStatus] = [:]
|
||||
var stadiumVisitStatus: [String: StadiumVisitStatus] {
|
||||
var statusMap: [String: StadiumVisitStatus] = [:]
|
||||
|
||||
// Group visits by stadium
|
||||
let visitsByStadium = Dictionary(grouping: visits.filter { $0.sportEnum == selectedSport }) { $0.stadiumUUID }
|
||||
let visitsByStadium = Dictionary(grouping: visits.filter { $0.sportEnum == selectedSport }) { $0.stadiumId }
|
||||
|
||||
for stadium in stadiums {
|
||||
if let stadiumVisits = visitsByStadium[stadium.id], !stadiumVisits.isEmpty {
|
||||
@@ -114,7 +114,7 @@ final class ProgressViewModel {
|
||||
.sorted { $0.visitDate > $1.visitDate }
|
||||
.prefix(10)
|
||||
.compactMap { visit -> VisitSummary? in
|
||||
guard let stadium = stadiums.first(where: { $0.id == visit.stadiumUUID }),
|
||||
guard let stadium = stadiums.first(where: { $0.id == visit.stadiumId }),
|
||||
let sport = visit.sportEnum else {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import MapKit
|
||||
|
||||
struct ProgressMapView: View {
|
||||
let stadiums: [Stadium]
|
||||
let visitStatus: [UUID: StadiumVisitStatus]
|
||||
let visitStatus: [String: StadiumVisitStatus]
|
||||
@Binding var selectedStadium: Stadium?
|
||||
|
||||
// Fixed region for continental US - map is locked to this view
|
||||
|
||||
@@ -389,8 +389,7 @@ struct StadiumVisitSheet: View {
|
||||
|
||||
// Create the visit
|
||||
let visit = StadiumVisit(
|
||||
canonicalStadiumId: stadium.id.uuidString, // Simplified - in production use StadiumIdentityService
|
||||
stadiumUUID: stadium.id,
|
||||
stadiumId: stadium.id,
|
||||
stadiumNameAtVisit: stadium.name,
|
||||
visitDate: visitDate,
|
||||
sport: selectedSport,
|
||||
|
||||
@@ -523,7 +523,7 @@ extension VisitSource {
|
||||
|
||||
#Preview {
|
||||
let stadium = Stadium(
|
||||
id: UUID(),
|
||||
id: "stadium_preview_oracle_park",
|
||||
name: "Oracle Park",
|
||||
city: "San Francisco",
|
||||
state: "CA",
|
||||
|
||||
@@ -108,7 +108,7 @@ final class TripCreationViewModel {
|
||||
}
|
||||
|
||||
// Games
|
||||
var mustSeeGameIds: Set<UUID> = []
|
||||
var mustSeeGameIds: Set<String> = []
|
||||
var availableGames: [RichGame] = []
|
||||
var isLoadingGames: Bool = false
|
||||
|
||||
@@ -134,7 +134,7 @@ final class TripCreationViewModel {
|
||||
var selectedRegions: Set<Region> = [.east, .central, .west]
|
||||
|
||||
// Follow Team Mode
|
||||
var followTeamId: UUID?
|
||||
var followTeamId: String?
|
||||
var useHomeLocation: Bool = true
|
||||
|
||||
// Game First Mode - Trip duration for sliding windows
|
||||
@@ -148,8 +148,8 @@ final class TripCreationViewModel {
|
||||
|
||||
// MARK: - Cached Data
|
||||
|
||||
private var teams: [UUID: Team] = [:]
|
||||
private var stadiums: [UUID: Stadium] = [:]
|
||||
private var teams: [String: Team] = [:]
|
||||
private var stadiums: [String: Stadium] = [:]
|
||||
private var games: [Game] = []
|
||||
private(set) var currentPreferences: TripPreferences?
|
||||
|
||||
@@ -454,7 +454,7 @@ final class TripCreationViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
func toggleMustSeeGame(_ gameId: UUID) {
|
||||
func toggleMustSeeGame(_ gameId: String) {
|
||||
if mustSeeGameIds.contains(gameId) {
|
||||
mustSeeGameIds.remove(gameId)
|
||||
} else {
|
||||
|
||||
@@ -13,13 +13,13 @@ import SwiftUI
|
||||
/// Renders a single timeline item (stop, travel, or rest).
|
||||
struct TimelineItemView: View {
|
||||
let item: TimelineItem
|
||||
let games: [UUID: RichGame]
|
||||
let games: [String: RichGame]
|
||||
let isFirst: Bool
|
||||
let isLast: Bool
|
||||
|
||||
init(
|
||||
item: TimelineItem,
|
||||
games: [UUID: RichGame],
|
||||
games: [String: RichGame],
|
||||
isFirst: Bool = false,
|
||||
isLast: Bool = false
|
||||
) {
|
||||
@@ -122,7 +122,7 @@ struct TimelineItemView: View {
|
||||
|
||||
struct StopItemContent: View {
|
||||
let stop: ItineraryStop
|
||||
let games: [UUID: RichGame]
|
||||
let games: [String: RichGame]
|
||||
|
||||
private var gamesAtStop: [RichGame] {
|
||||
stop.games.compactMap { games[$0] }
|
||||
@@ -291,7 +291,7 @@ struct TimelineGameRow: View {
|
||||
/// Full timeline view for an itinerary option.
|
||||
struct TimelineView: View {
|
||||
let option: ItineraryOption
|
||||
let games: [UUID: RichGame]
|
||||
let games: [String: RichGame]
|
||||
|
||||
private var timeline: [TimelineItem] {
|
||||
option.generateTimeline()
|
||||
@@ -316,7 +316,7 @@ struct TimelineView: View {
|
||||
/// Horizontal scrolling timeline for compact display.
|
||||
struct HorizontalTimelineView: View {
|
||||
let option: ItineraryOption
|
||||
let games: [UUID: RichGame]
|
||||
let games: [String: RichGame]
|
||||
|
||||
private var timeline: [TimelineItem] {
|
||||
option.generateTimeline()
|
||||
@@ -368,7 +368,7 @@ struct HorizontalTimelineView: View {
|
||||
|
||||
struct HorizontalTimelineItemView: View {
|
||||
let item: TimelineItem
|
||||
let games: [UUID: RichGame]
|
||||
let games: [String: RichGame]
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 4) {
|
||||
|
||||
@@ -926,7 +926,7 @@ struct TripCreationView: View {
|
||||
)
|
||||
}
|
||||
|
||||
private func buildGamesDictionary() -> [UUID: RichGame] {
|
||||
private func buildGamesDictionary() -> [String: RichGame] {
|
||||
viewModel.availableGames.reduce(into: [:]) { $0[$1.id] = $1 }
|
||||
}
|
||||
}
|
||||
@@ -949,16 +949,16 @@ extension TripCreationViewModel.ViewState {
|
||||
|
||||
struct GamePickerSheet: View {
|
||||
let games: [RichGame]
|
||||
@Binding var selectedIds: Set<UUID>
|
||||
@Binding var selectedIds: Set<String>
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
@State private var expandedSports: Set<Sport> = []
|
||||
@State private var expandedTeams: Set<UUID> = []
|
||||
@State private var expandedTeams: Set<String> = []
|
||||
|
||||
// Group games by Sport → Team (home team only to avoid duplicates)
|
||||
private var gamesBySport: [Sport: [TeamWithGames]] {
|
||||
var result: [Sport: [UUID: TeamWithGames]] = [:]
|
||||
var result: [Sport: [String: TeamWithGames]] = [:]
|
||||
|
||||
for game in games {
|
||||
let sport = game.game.sport
|
||||
@@ -1063,9 +1063,9 @@ struct GamePickerSheet: View {
|
||||
struct SportSection: View {
|
||||
let sport: Sport
|
||||
let teams: [TeamWithGames]
|
||||
@Binding var selectedIds: Set<UUID>
|
||||
@Binding var selectedIds: Set<String>
|
||||
@Binding var expandedSports: Set<Sport>
|
||||
@Binding var expandedTeams: Set<UUID>
|
||||
@Binding var expandedTeams: Set<String>
|
||||
let selectedCount: Int
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@@ -1146,8 +1146,8 @@ struct SportSection: View {
|
||||
|
||||
struct TeamSection: View {
|
||||
let teamData: TeamWithGames
|
||||
@Binding var selectedIds: Set<UUID>
|
||||
@Binding var expandedTeams: Set<UUID>
|
||||
@Binding var selectedIds: Set<String>
|
||||
@Binding var expandedTeams: Set<String>
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
@@ -1310,7 +1310,7 @@ struct TeamWithGames: Identifiable {
|
||||
let sport: Sport
|
||||
var games: [RichGame]
|
||||
|
||||
var id: UUID { team.id }
|
||||
var id: String { team.id }
|
||||
|
||||
var sortedGames: [RichGame] {
|
||||
games.sorted { $0.game.dateTime < $1.game.dateTime }
|
||||
@@ -1504,7 +1504,7 @@ enum CitiesFilter: Int, CaseIterable, Identifiable {
|
||||
|
||||
struct TripOptionsView: View {
|
||||
let options: [ItineraryOption]
|
||||
let games: [UUID: RichGame]
|
||||
let games: [String: RichGame]
|
||||
let preferences: TripPreferences?
|
||||
let convertToTrip: (ItineraryOption) -> Trip
|
||||
|
||||
@@ -1774,7 +1774,7 @@ struct TripOptionsView: View {
|
||||
|
||||
struct TripOptionCard: View {
|
||||
let option: ItineraryOption
|
||||
let games: [UUID: RichGame]
|
||||
let games: [String: RichGame]
|
||||
let onSelect: () -> Void
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
@@ -2393,7 +2393,7 @@ struct DayCell: View {
|
||||
// MARK: - Team Picker Sheet
|
||||
|
||||
struct TeamPickerSheet: View {
|
||||
@Binding var selectedTeamId: UUID?
|
||||
@Binding var selectedTeamId: String?
|
||||
let teamsBySport: [Sport: [Team]]
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@@ -12,7 +12,7 @@ struct TripDetailView: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
let trip: Trip
|
||||
let games: [UUID: RichGame]
|
||||
let games: [String: RichGame]
|
||||
|
||||
@State private var selectedDay: ItineraryDay?
|
||||
@State private var showExportSheet = false
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user