fix: 12 planning engine bugs + App Store preview export at 886x1920
Planning engine fixes (from adversarial code review): - Bug #1: sortByLeisure tie-breaking uses totalDrivingHours - Bug #2: allDates/calculateRestDays guard-let-break prevents infinite loop - Bug #3: same-day trip no longer rejected (>= in dateRange guard) - Bug #4: ScenarioD rationale shows game count not stop count - Bug #5: ScenarioD departureDate advanced to next day after last game - Bug #6: ScenarioC date range boundary uses <= instead of < - Bug #7: DrivingConstraints clamps maxHoursPerDriverPerDay via max(1.0,...) - Bug #8: effectiveTripDuration uses inclusive day counting (+1) - Bug #9: TripWizardViewModel validates endDate >= startDate - Bug #10: allDates() uses min/max instead of first/last for robustness - Bug #12: arrivalBeforeGameStart accounts for game end time at departure - Bug #15: ScenarioBPlanner replaces force unwraps with safe unwrapping Tests: 16 regression test suites + updated existing test expectations Marketing: Remotion canvas set to 886x1920 for App Store preview spec Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -376,9 +376,14 @@ enum ItineraryBuilder {
|
||||
return true // No game = no constraint
|
||||
}
|
||||
|
||||
// Check if there's enough time between departure point and game start
|
||||
// Departure assumed after previous day's activities (use departure date as baseline)
|
||||
let earliestDeparture = fromStop.departureDate
|
||||
// Account for game end time at fromStop — can't depart during a game
|
||||
let typicalGameDuration: TimeInterval = 10800 // 3 hours
|
||||
var earliestDeparture = fromStop.departureDate
|
||||
if let fromGameStart = fromStop.firstGameStart {
|
||||
let gameEnd = fromGameStart.addingTimeInterval(typicalGameDuration)
|
||||
earliestDeparture = max(earliestDeparture, gameEnd)
|
||||
}
|
||||
|
||||
let travelDuration = segment.durationSeconds
|
||||
let earliestArrival = earliestDeparture.addingTimeInterval(travelDuration)
|
||||
let deadline = gameStart.addingTimeInterval(-bufferSeconds)
|
||||
|
||||
@@ -316,16 +316,16 @@ final class ScenarioBPlanner: ScenarioPlanner {
|
||||
// First window: last selected game is on last day of window
|
||||
// Window end = lastGameDate + 1 day (to include the game)
|
||||
// Window start = end - duration days
|
||||
let firstWindowEnd = Calendar.current.date(
|
||||
guard let firstWindowEnd = Calendar.current.date(
|
||||
byAdding: .day,
|
||||
value: 1,
|
||||
to: lastGameDate
|
||||
)!
|
||||
let firstWindowStart = Calendar.current.date(
|
||||
) else { return [] }
|
||||
guard let firstWindowStart = Calendar.current.date(
|
||||
byAdding: .day,
|
||||
value: -duration,
|
||||
to: firstWindowEnd
|
||||
)!
|
||||
) else { return [] }
|
||||
|
||||
// Last window: first selected game is on first day of window
|
||||
// Window start = firstGameDate
|
||||
@@ -334,21 +334,22 @@ final class ScenarioBPlanner: ScenarioPlanner {
|
||||
// Slide from first window to last window
|
||||
var currentStart = firstWindowStart
|
||||
while currentStart <= lastWindowStart {
|
||||
let windowEnd = Calendar.current.date(
|
||||
guard let windowEnd = Calendar.current.date(
|
||||
byAdding: .day,
|
||||
value: duration,
|
||||
to: currentStart
|
||||
)!
|
||||
) else { break }
|
||||
|
||||
let window = DateInterval(start: currentStart, end: windowEnd)
|
||||
dateRanges.append(window)
|
||||
|
||||
// Slide forward one day
|
||||
currentStart = Calendar.current.date(
|
||||
guard let nextStart = Calendar.current.date(
|
||||
byAdding: .day,
|
||||
value: 1,
|
||||
to: currentStart
|
||||
)!
|
||||
) else { break }
|
||||
currentStart = nextStart
|
||||
}
|
||||
|
||||
return dateRanges
|
||||
|
||||
@@ -445,7 +445,7 @@ final class ScenarioCPlanner: ScenarioPlanner {
|
||||
let daysBetween = calendar.dateComponents([.day], from: startDate, to: endDate).day ?? 0
|
||||
|
||||
// Must be within day span
|
||||
guard daysBetween < daySpan else { continue }
|
||||
guard daysBetween <= daySpan else { continue }
|
||||
|
||||
// Create date range (end date + 1 day to include the end game)
|
||||
let rangeEnd = calendar.date(byAdding: .day, value: 1, to: endDate) ?? endDate
|
||||
|
||||
@@ -70,7 +70,8 @@ final class ScenarioDPlanner: ScenarioPlanner {
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Step 1: Validate team selection
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
guard let teamId = request.preferences.followTeamId else {
|
||||
guard let teamId = request.preferences.followTeamId
|
||||
?? request.preferences.selectedTeamIds.first else {
|
||||
return .failure(
|
||||
PlanningFailure(
|
||||
reason: .missingTeamSelection,
|
||||
@@ -261,7 +262,7 @@ final class ScenarioDPlanner: ScenarioPlanner {
|
||||
travelSegments: itinerary.travelSegments,
|
||||
totalDrivingHours: itinerary.totalDrivingHours,
|
||||
totalDistanceMiles: itinerary.totalDistanceMiles,
|
||||
geographicRationale: "Follow Team: \(stops.count) games - \(cities)"
|
||||
geographicRationale: "Follow Team: \(itinerary.stops.reduce(0) { $0 + $1.games.count }) games - \(cities)"
|
||||
)
|
||||
itineraryOptions.append(option)
|
||||
}
|
||||
@@ -396,6 +397,7 @@ final class ScenarioDPlanner: ScenarioPlanner {
|
||||
)
|
||||
|
||||
let lastGameDate = sortedGames.last?.gameDate ?? Date()
|
||||
let calendar = Calendar.current
|
||||
|
||||
return ItineraryStop(
|
||||
city: city,
|
||||
@@ -403,7 +405,7 @@ final class ScenarioDPlanner: ScenarioPlanner {
|
||||
coordinate: coordinate,
|
||||
games: sortedGames.map { $0.id },
|
||||
arrivalDate: sortedGames.first?.gameDate ?? Date(),
|
||||
departureDate: lastGameDate,
|
||||
departureDate: calendar.date(byAdding: .day, value: 1, to: lastGameDate) ?? lastGameDate.addingTimeInterval(86400),
|
||||
location: location,
|
||||
firstGameStart: sortedGames.first?.startTime
|
||||
)
|
||||
|
||||
@@ -58,6 +58,14 @@ enum ScenarioPlannerFactory {
|
||||
return ScenarioEPlanner()
|
||||
}
|
||||
|
||||
// Scenario D fallback: Team-First with single team → follow that team
|
||||
if request.preferences.planningMode == .teamFirst &&
|
||||
request.preferences.selectedTeamIds.count == 1 {
|
||||
// Single team in teamFirst mode → treat as follow-team
|
||||
print("🔍 ScenarioPlannerFactory: → ScenarioDPlanner (team-first single team)")
|
||||
return ScenarioDPlanner()
|
||||
}
|
||||
|
||||
// Scenario D: User wants to follow a specific team
|
||||
if request.preferences.followTeamId != nil {
|
||||
print("🔍 ScenarioPlannerFactory: → ScenarioDPlanner (follow team)")
|
||||
@@ -94,6 +102,11 @@ enum ScenarioPlannerFactory {
|
||||
request.preferences.selectedTeamIds.count >= 2 {
|
||||
return .scenarioE
|
||||
}
|
||||
// Scenario D fallback: Team-First with single team → follow that team
|
||||
if request.preferences.planningMode == .teamFirst &&
|
||||
request.preferences.selectedTeamIds.count == 1 {
|
||||
return .scenarioD
|
||||
}
|
||||
if request.preferences.followTeamId != nil {
|
||||
return .scenarioD
|
||||
}
|
||||
|
||||
@@ -317,7 +317,7 @@ struct DrivingConstraints {
|
||||
|
||||
init(from preferences: TripPreferences) {
|
||||
self.numberOfDrivers = max(1, preferences.numberOfDrivers)
|
||||
self.maxHoursPerDriverPerDay = preferences.maxDrivingHoursPerDriver ?? 8.0
|
||||
self.maxHoursPerDriverPerDay = max(1.0, preferences.maxDrivingHoursPerDriver ?? 8.0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -472,7 +472,8 @@ extension ItineraryOption {
|
||||
)
|
||||
restDays.append(restDay)
|
||||
}
|
||||
currentDay = calendar.date(byAdding: .day, value: 1, to: currentDay) ?? currentDay
|
||||
guard let nextDay = calendar.date(byAdding: .day, value: 1, to: currentDay) else { break }
|
||||
currentDay = nextDay
|
||||
}
|
||||
|
||||
return restDays
|
||||
@@ -497,16 +498,17 @@ extension ItineraryOption {
|
||||
/// All dates covered by the itinerary.
|
||||
func allDates() -> [Date] {
|
||||
let calendar = Calendar.current
|
||||
guard let firstStop = stops.first,
|
||||
let lastStop = stops.last else { return [] }
|
||||
guard let earliestArrival = stops.map(\.arrivalDate).min(),
|
||||
let latestDeparture = stops.map(\.departureDate).max() else { return [] }
|
||||
|
||||
var dates: [Date] = []
|
||||
var currentDate = calendar.startOfDay(for: firstStop.arrivalDate)
|
||||
let endDate = calendar.startOfDay(for: lastStop.departureDate)
|
||||
var currentDate = calendar.startOfDay(for: earliestArrival)
|
||||
let endDate = calendar.startOfDay(for: latestDeparture)
|
||||
|
||||
while currentDate <= endDate {
|
||||
dates.append(currentDate)
|
||||
currentDate = calendar.date(byAdding: .day, value: 1, to: currentDate) ?? currentDate
|
||||
guard let nextDate = calendar.date(byAdding: .day, value: 1, to: currentDate) else { break }
|
||||
currentDate = nextDate
|
||||
}
|
||||
|
||||
return dates
|
||||
@@ -548,7 +550,7 @@ struct PlanningRequest {
|
||||
/// Note: End date is extended to end-of-day to include all games on the last day,
|
||||
/// since DateInterval.contains() uses exclusive end boundary.
|
||||
var dateRange: DateInterval? {
|
||||
guard preferences.endDate > preferences.startDate else { return nil }
|
||||
guard preferences.endDate >= preferences.startDate else { return nil }
|
||||
// Extend end date to end of day (23:59:59) to include games on the last day
|
||||
let calendar = Calendar.current
|
||||
let endOfDay = calendar.date(bySettingHour: 23, minute: 59, second: 59, of: preferences.endDate)
|
||||
|
||||
Reference in New Issue
Block a user