fix(pdf): include all trip days in PDF export

Fixed PDF export missing the last day when games occur on departure date.

Root cause: Trip.itineraryDays() calculated lastActivityDate as departure - 1,
assuming departure is always after the last activity. When games happen ON the
departure date, that day was skipped.

Fix: Check if the last stop has games. If so, include the departure date in
the itinerary loop.

Also includes:
- buildCompleteItineraryItems() to merge games, travel, and custom items
- Magazine-style PDF layout with sport-specific accent colors
- Proper iteration over all trip days in PDF generator

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-19 11:47:25 -06:00
parent 239d22a872
commit 0e7fcb65fc
3 changed files with 540 additions and 822 deletions

View File

@@ -6,7 +6,7 @@
//
// - Expected Behavior:
// - itineraryDays() returns one ItineraryDay per calendar day from first arrival to last activity
// - Last activity day is departure - 1 (departure is when you leave)
// - Last activity day includes departure day if there are games on that day
// - tripDuration is max(1, days between first arrival and last departure + 1)
// - cities returns deduplicated city list preserving visit order
// - displayName uses " " separator between cities
@@ -113,16 +113,19 @@ struct Trip: Identifiable, Codable, Hashable {
var days: [ItineraryDay] = []
let calendar = Calendar.current
guard let firstDate = stops.first?.arrivalDate else { return days }
guard let firstDate = stops.first?.arrivalDate,
let lastStop = stops.last else { return days }
// Find the last day with actual activity (last game date or last arrival)
// Departure date is the day AFTER the last game, so we use day before departure
// Find the last day with actual activity
// If the last stop has games, include the departure day (games can happen on departure day)
// Otherwise, use departure - 1 (departure is a pure travel day)
let lastActivityDate: Date
if let lastDeparture = stops.last?.departureDate {
// Last activity is day before departure (departure is when you leave)
lastActivityDate = calendar.date(byAdding: .day, value: -1, to: lastDeparture) ?? lastDeparture
if !lastStop.games.isEmpty {
// Last stop has games - include departure day since games may occur on it
lastActivityDate = lastStop.departureDate
} else {
lastActivityDate = stops.last?.arrivalDate ?? firstDate
// No games at last stop - departure is just when you leave
lastActivityDate = calendar.date(byAdding: .day, value: -1, to: lastStop.departureDate) ?? lastStop.departureDate
}
var currentDate = calendar.startOfDay(for: firstDate)

File diff suppressed because it is too large Load Diff

View File

@@ -1199,7 +1199,14 @@ struct TripDetailView: View {
exportProgress = nil
do {
let url = try await exportService.exportToPDF(trip: trip, games: games) { progress in
// Build complete itinerary items (games + travel + custom)
let completeItems = buildCompleteItineraryItems()
let url = try await exportService.exportToPDF(
trip: trip,
games: games,
itineraryItems: completeItems
) { progress in
await MainActor.run {
self.exportProgress = progress
}
@@ -1213,6 +1220,57 @@ struct TripDetailView: View {
isExporting = false
}
/// Build complete itinerary items by merging games, travel, and custom items
private func buildCompleteItineraryItems() -> [ItineraryItem] {
var allItems: [ItineraryItem] = []
// Get itinerary days from trip
let tripDays = trip.itineraryDays()
// 1. Add game items using day.gameIds (reliable source from trip stops)
for day in tripDays {
for (gameIndex, gameId) in day.gameIds.enumerated() {
guard let richGame = games[gameId] else { continue }
let gameItem = ItineraryItem(
tripId: trip.id,
day: day.dayNumber,
sortOrder: Double(gameIndex) * 0.01, // Games near the start of the day
kind: .game(gameId: gameId, city: richGame.stadium.city)
)
allItems.append(gameItem)
}
}
// 2. Add travel items (from trip segments + overrides)
for segment in trip.travelSegments {
let travelId = "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())"
// Use override if available, otherwise default to day 1
let override = travelOverrides[travelId]
let day = override?.day ?? 1
let sortOrder = override?.sortOrder ?? 100.0 // After games by default
let travelItem = ItineraryItem(
tripId: trip.id,
day: day,
sortOrder: sortOrder,
kind: .travel(TravelInfo(
fromCity: segment.fromLocation.name,
toCity: segment.toLocation.name,
distanceMeters: segment.distanceMeters,
durationSeconds: segment.durationSeconds
))
)
allItems.append(travelItem)
}
// 3. Add custom items (from CloudKit)
let customItems = itineraryItems.filter { $0.isCustom }
allItems.append(contentsOf: customItems)
return allItems
}
private func toggleSaved() {
if isSaved {
unsaveTrip()