fix(itinerary): add city to game items for proper constraint validation

Travel constraint validation was not working because ItineraryConstraints
had no game items to validate against - games came from RichGame objects
but were never converted to ItineraryItem for constraint checking.

Changes:
- Add city parameter to ItemKind.game enum case
- Create game ItineraryItems from RichGame data in buildItineraryData()
- Update isValidTravelPosition to compare against actual game sortOrders
- Fix tests to use appropriate game sortOrder conventions

Now travel is properly constrained to appear before arrival city games
and after departure city games.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-18 22:46:40 -06:00
parent 72447c61fe
commit e72da7c5a7
11 changed files with 247 additions and 254 deletions

View File

@@ -5,13 +5,6 @@ struct ItineraryConstraints {
let tripDayCount: Int
private let items: [ItineraryItem]
/// City extracted from game ID (format: "game-CityName-xxxx")
private func city(forGameId gameId: String) -> String? {
let components = gameId.components(separatedBy: "-")
guard components.count >= 2 else { return nil }
return components[1]
}
init(tripDayCount: Int, items: [ItineraryItem]) {
self.tripDayCount = tripDayCount
self.items = items
@@ -83,8 +76,10 @@ struct ItineraryConstraints {
// MARK: - Private Helpers
private func isValidTravelPosition(fromCity: String, toCity: String, day: Int, sortOrder: Double) -> Bool {
let departureGameDays = gameDays(in: fromCity)
let arrivalGameDays = gameDays(in: toCity)
let departureGames = games(in: fromCity)
let arrivalGames = games(in: toCity)
let departureGameDays = departureGames.map { $0.day }
let arrivalGameDays = arrivalGames.map { $0.day }
let minDay = departureGameDays.max() ?? 1
let maxDay = arrivalGameDays.min() ?? tripDayCount
@@ -92,26 +87,20 @@ struct ItineraryConstraints {
// Check day is in valid range
guard day >= minDay && day <= maxDay else { return false }
// Check sortOrder constraints on edge days
// Check sortOrder constraints on edge days.
// Must be strictly AFTER all departure games on that day
if departureGameDays.contains(day) {
// On a departure game day: must be after ALL games in that city on that day
let maxGameSortOrder = games(in: fromCity)
.filter { $0.day == day }
.map { $0.sortOrder }
.max() ?? 0
let gamesOnDay = departureGames.filter { $0.day == day }
let maxGameSortOrder = gamesOnDay.map { $0.sortOrder }.max() ?? 0
if sortOrder <= maxGameSortOrder {
return false
}
}
// Must be strictly BEFORE all arrival games on that day
if arrivalGameDays.contains(day) {
// On an arrival game day: must be before ALL games in that city on that day
let minGameSortOrder = games(in: toCity)
.filter { $0.day == day }
.map { $0.sortOrder }
.min() ?? Double.greatestFiniteMagnitude
let gamesOnDay = arrivalGames.filter { $0.day == day }
let minGameSortOrder = gamesOnDay.map { $0.sortOrder }.min() ?? 0
if sortOrder >= minGameSortOrder {
return false
}
@@ -126,8 +115,8 @@ struct ItineraryConstraints {
private func games(in city: String) -> [ItineraryItem] {
return items.filter { item in
guard case .game(let gameId) = item.kind else { return false }
return self.city(forGameId: gameId) == city
guard let gameCity = item.gameCity else { return false }
return gameCity.lowercased() == city.lowercased()
}
}
}

View File

@@ -29,7 +29,7 @@ struct ItineraryItem: Identifiable, Codable, Hashable {
/// The type of itinerary item
enum ItemKind: Codable, Hashable {
case game(gameId: String)
case game(gameId: String, city: String)
case travel(TravelInfo)
case custom(CustomInfo)
}
@@ -106,14 +106,19 @@ extension ItineraryItem {
}
var gameId: String? {
if case .game(let id) = kind { return id }
if case .game(let id, _) = kind { return id }
return nil
}
var gameCity: String? {
if case .game(_, let city) = kind { return city }
return nil
}
/// Display title for the item
var displayTitle: String {
switch kind {
case .game(let gameId):
case .game(let gameId, _):
return "Game: \(gameId)"
case .travel(let info):
return "\(info.fromCity)\(info.toCity)"

View File

@@ -145,8 +145,9 @@ extension ItineraryItem {
// Parse kind
switch kindString {
case "game":
guard let gameId = record["gameId"] as? String else { return nil }
self.kind = .game(gameId: gameId)
guard let gameId = record["gameId"] as? String,
let gameCity = record["gameCity"] as? String else { return nil }
self.kind = .game(gameId: gameId, city: gameCity)
case "travel":
guard let fromCity = record["travelFromCity"] as? String,
@@ -188,9 +189,10 @@ extension ItineraryItem {
record["modifiedAt"] = modifiedAt
switch kind {
case .game(let gameId):
case .game(let gameId, let city):
record["kind"] = "game"
record["gameId"] = gameId
record["gameCity"] = city
case .travel(let info):
record["kind"] = "travel"