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

@@ -227,6 +227,220 @@ struct DrivingConstraints {
}
}
// MARK: - Timeline Item
/// Unified timeline item for displaying trip itinerary.
/// Enables: Stop Travel Stop Rest Travel Stop pattern
enum TimelineItem: Identifiable {
case stop(ItineraryStop)
case travel(TravelSegment)
case rest(RestDay)
var id: UUID {
switch self {
case .stop(let stop): return stop.id
case .travel(let segment): return segment.id
case .rest(let rest): return rest.id
}
}
var date: Date {
switch self {
case .stop(let stop): return stop.arrivalDate
case .travel(let segment): return segment.departureTime
case .rest(let rest): return rest.date
}
}
var isStop: Bool {
if case .stop = self { return true }
return false
}
var isTravel: Bool {
if case .travel = self { return true }
return false
}
var isRest: Bool {
if case .rest = self { return true }
return false
}
/// City/location name for display
var locationName: String {
switch self {
case .stop(let stop): return stop.city
case .travel(let segment): return "\(segment.fromLocation.name)\(segment.toLocation.name)"
case .rest(let rest): return rest.location.name
}
}
}
// MARK: - Rest Day
/// A rest day - staying in one place with no travel or games.
struct RestDay: Identifiable, Hashable {
let id: UUID
let date: Date
let location: LocationInput
let notes: String?
init(
id: UUID = UUID(),
date: Date,
location: LocationInput,
notes: String? = nil
) {
self.id = id
self.date = date
self.location = location
self.notes = notes
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
static func == (lhs: RestDay, rhs: RestDay) -> Bool {
lhs.id == rhs.id
}
}
// MARK: - Timeline Generation
extension ItineraryOption {
/// Generates a unified timeline from stops and travel segments.
///
/// The timeline interleaves stops and travel in chronological order:
/// Stop A Travel AB Stop B Rest Day Travel BC Stop C
///
/// Rest days are inserted when:
/// - There's a gap between arrival at a stop and departure for next travel
/// - Multi-day stays at a location without games
///
func generateTimeline() -> [TimelineItem] {
var timeline: [TimelineItem] = []
let calendar = Calendar.current
for (index, stop) in stops.enumerated() {
// Add the stop
timeline.append(.stop(stop))
// Check for rest days at this stop (days between arrival and departure with no games)
let restDays = calculateRestDays(at: stop, calendar: calendar)
for restDay in restDays {
timeline.append(.rest(restDay))
}
// Add travel segment to next stop (if not last stop)
if index < travelSegments.count {
let segment = travelSegments[index]
// Check if travel spans multiple days
let travelDays = calculateTravelDays(for: segment, calendar: calendar)
if travelDays.count > 1 {
// Multi-day travel: could split into daily segments or keep as one
// For now, keep as single segment with multi-day indicator
timeline.append(.travel(segment))
} else {
timeline.append(.travel(segment))
}
}
}
return timeline
}
/// Calculates rest days at a stop (days with no games).
private func calculateRestDays(
at stop: ItineraryStop,
calendar: Calendar
) -> [RestDay] {
// If stop has no games, the entire stay could be considered rest
// But typically we only insert rest days for multi-day stays
guard stop.hasGames else {
// Start/end locations without games - not rest days, just waypoints
return []
}
var restDays: [RestDay] = []
let arrivalDay = calendar.startOfDay(for: stop.arrivalDate)
let departureDay = calendar.startOfDay(for: stop.departureDate)
// If multi-day stay, check each day for games
var currentDay = arrivalDay
while currentDay <= departureDay {
// Skip arrival and departure days (those have the stop itself)
if currentDay != arrivalDay && currentDay != departureDay {
// This is a day in between - could be rest or another game day
// For simplicity, mark in-between days as rest
let restDay = RestDay(
date: currentDay,
location: stop.location,
notes: "Rest day in \(stop.city)"
)
restDays.append(restDay)
}
currentDay = calendar.date(byAdding: .day, value: 1, to: currentDay) ?? currentDay
}
return restDays
}
/// Calculates which calendar days a travel segment spans.
private func calculateTravelDays(
for segment: TravelSegment,
calendar: Calendar
) -> [Date] {
var days: [Date] = []
let startDay = calendar.startOfDay(for: segment.departureTime)
let endDay = calendar.startOfDay(for: segment.arrivalTime)
var currentDay = startDay
while currentDay <= endDay {
days.append(currentDay)
currentDay = calendar.date(byAdding: .day, value: 1, to: currentDay) ?? currentDay
}
return days
}
/// Timeline organized by date for calendar-style display.
func timelineByDate() -> [Date: [TimelineItem]] {
let calendar = Calendar.current
var byDate: [Date: [TimelineItem]] = [:]
for item in generateTimeline() {
let day = calendar.startOfDay(for: item.date)
byDate[day, default: []].append(item)
}
return byDate
}
/// All dates covered by the itinerary.
func allDates() -> [Date] {
let calendar = Calendar.current
guard let firstStop = stops.first,
let lastStop = stops.last else { return [] }
var dates: [Date] = []
var currentDate = calendar.startOfDay(for: firstStop.arrivalDate)
let endDate = calendar.startOfDay(for: lastStop.departureDate)
while currentDate <= endDate {
dates.append(currentDate)
currentDate = calendar.date(byAdding: .day, value: 1, to: currentDate) ?? currentDate
}
return dates
}
}
// MARK: - Planning Request
/// Input to the planning engine.