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
|
||||
|
||||
Reference in New Issue
Block a user