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:
Trey t
2026-02-15 17:08:50 -06:00
parent b320a773aa
commit 787a0f795e
14 changed files with 820 additions and 27 deletions

View File

@@ -325,7 +325,7 @@ struct TripPreferences: Codable, Hashable {
var effectiveTripDuration: Int {
if let duration = tripDuration { return duration }
let days = Calendar.current.dateComponents([.day], from: startDate, to: endDate).day ?? 7
return max(1, days)
return max(1, days + 1)
}
/// Maximum trip duration for Team-First mode (2 days per selected team)

View File

@@ -124,6 +124,9 @@ final class TripWizardViewModel {
// Common requirements for all modes
guard hasSetRoutePreference && hasSetRepeatCities else { return false }
// Date validation: endDate must not be before startDate for modes that use dates
if hasSetDates && endDate < startDate { return false }
switch mode {
case .dateRange:
return hasSetDates && !selectedSports.isEmpty && !selectedRegions.isEmpty

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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
)

View File

@@ -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
}

View File

@@ -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)