Refactor trip planning: DAG router + trip options UI + simplified itinerary

- Replace O(2^n) GeographicRouteExplorer with O(n) GameDAGRouter using DAG + beam search
- Add geographic diversity to route selection (returns routes from distinct regions)
- Add trip options selector UI (TripOptionsView, TripOptionCard) to choose between routes
- Simplify itinerary display: separate games and travel segments by date
- Remove complex ItineraryDay bundling, query games/travel directly per day
- Update ScenarioA/B/C planners to use GameDAGRouter
- Add new test suites for planners and travel estimator

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-07 12:26:17 -06:00
parent 405ebe68eb
commit ab89c25f2f
20 changed files with 6372 additions and 1960 deletions

View File

@@ -385,52 +385,7 @@ struct DuplicateGameIdTests {
)
}
@Test("GameCandidate array with duplicate game IDs can build dictionary without crashing")
func candidateMap_HandlesDuplicateGameIds() {
// This test reproduces the bug: Dictionary(uniqueKeysWithValues:) crashes on duplicate keys
// Fix: Use reduce(into:) to handle duplicates gracefully
let stadium = makeStadium()
let homeTeam = makeTeam(stadiumId: stadium.id)
let awayTeam = makeTeam(stadiumId: UUID())
let gameId = UUID() // Same ID for both candidates (simulates duplicate in JSON)
let dateTime = Date()
let game = makeGame(id: gameId, homeTeamId: homeTeam.id, awayTeamId: awayTeam.id, stadiumId: stadium.id, dateTime: dateTime)
// Create two candidates with the same game ID (simulating duplicate JSON data)
let candidate1 = GameCandidate(
id: gameId,
game: game,
stadium: stadium,
homeTeam: homeTeam,
awayTeam: awayTeam,
detourDistance: 0,
score: 1.0
)
let candidate2 = GameCandidate(
id: gameId,
game: game,
stadium: stadium,
homeTeam: homeTeam,
awayTeam: awayTeam,
detourDistance: 0,
score: 2.0
)
let candidates = [candidate1, candidate2]
// This is the fix pattern - should not crash
let candidateMap = candidates.reduce(into: [UUID: GameCandidate]()) { dict, candidate in
if dict[candidate.game.id] == nil {
dict[candidate.game.id] = candidate
}
}
// Should only have one entry (first one wins)
#expect(candidateMap.count == 1)
#expect(candidateMap[gameId]?.score == 1.0, "First candidate should be kept")
}
// Note: GameCandidate test removed - type no longer exists after planning engine refactor
@Test("Duplicate games are deduplicated at load time")
func gamesArray_DeduplicatesById() {