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:
@@ -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 A→B → Stop B → Rest Day → Travel B→C → 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.
|
||||
|
||||
Reference in New Issue
Block a user