Refactor itinerary display: separate day sections and travel sections

- Days are now headers with games as rows in that section
- Travel between days shown in standalone "Travel" sections
- Travel timing is flexible (user decides when to drive)
- Removed complex ItineraryDay bundling approach

🤖 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:41:32 -06:00
parent ab89c25f2f
commit 8ec8ed02b1

View File

@@ -326,17 +326,49 @@ struct TripDetailView: View {
Text("Itinerary") Text("Itinerary")
.font(.headline) .font(.headline)
ForEach(tripDays, id: \.self) { dayDate in ForEach(itinerarySections.indices, id: \.self) { index in
SimpleDayCard( let section = itinerarySections[index]
dayNumber: dayNumber(for: dayDate), switch section {
date: dayDate, case .day(let dayNumber, let date, let gamesOnDay):
gamesOnDay: gamesOn(date: dayDate), DaySection(
travelOnDay: travelOn(date: dayDate) dayNumber: dayNumber,
) date: date,
games: gamesOnDay
)
case .travel(let segment):
TravelSection(segment: segment)
}
} }
} }
} }
/// Build itinerary sections: days and travel between days
private var itinerarySections: [ItinerarySection] {
var sections: [ItinerarySection] = []
let calendar = Calendar.current
// Get all days
let days = tripDays
for (index, dayDate) in days.enumerated() {
let dayNum = index + 1
let gamesOnDay = gamesOn(date: dayDate)
// Add day section (even if no games - could be rest day)
if !gamesOnDay.isEmpty || index == 0 || index == days.count - 1 {
sections.append(.day(dayNumber: dayNum, date: dayDate, games: gamesOnDay))
}
// Check for travel AFTER this day (between this day and next)
let travelAfterDay = travelDepartingAfter(date: dayDate, beforeNextGameDay: days.indices.contains(index + 1) ? days[index + 1] : nil)
for segment in travelAfterDay {
sections.append(.travel(segment))
}
}
return sections
}
/// All calendar days in the trip /// All calendar days in the trip
private var tripDays: [Date] { private var tripDays: [Date] {
let calendar = Calendar.current let calendar = Calendar.current
@@ -354,14 +386,6 @@ struct TripDetailView: View {
return days return days
} }
/// Day number for a given date
private func dayNumber(for date: Date) -> Int {
guard let firstDay = tripDays.first else { return 1 }
let calendar = Calendar.current
let days = calendar.dateComponents([.day], from: firstDay, to: date).day ?? 0
return days + 1
}
/// Games scheduled on a specific date /// Games scheduled on a specific date
private func gamesOn(date: Date) -> [RichGame] { private func gamesOn(date: Date) -> [RichGame] {
let calendar = Calendar.current let calendar = Calendar.current
@@ -372,16 +396,19 @@ struct TripDetailView: View {
return allGameIds.compactMap { games[$0] }.filter { richGame in return allGameIds.compactMap { games[$0] }.filter { richGame in
calendar.startOfDay(for: richGame.game.dateTime) == dayStart calendar.startOfDay(for: richGame.game.dateTime) == dayStart
} }.sorted { $0.game.dateTime < $1.game.dateTime }
} }
/// Travel segments departing on a specific date /// Travel segments that depart after a given day (for between-day travel)
private func travelOn(date: Date) -> [TravelSegment] { private func travelDepartingAfter(date: Date, beforeNextGameDay: Date?) -> [TravelSegment] {
let calendar = Calendar.current let calendar = Calendar.current
let dayStart = calendar.startOfDay(for: date) let dayEnd = calendar.startOfDay(for: date)
return trip.travelSegments.filter { segment in return trip.travelSegments.filter { segment in
calendar.startOfDay(for: segment.departureTime) == dayStart let segmentDay = calendar.startOfDay(for: segment.departureTime)
// Travel is "after" this day if it departs on or after this day
// and arrives at a different city
return segmentDay == dayEnd
} }
} }
@@ -431,13 +458,19 @@ struct TripDetailView: View {
} }
} }
// MARK: - Simple Day Card (queries games and travel separately by date) // MARK: - Itinerary Section (enum for day vs travel sections)
struct SimpleDayCard: View { enum ItinerarySection {
case day(dayNumber: Int, date: Date, games: [RichGame])
case travel(TravelSegment)
}
// MARK: - Day Section (header + games)
struct DaySection: View {
let dayNumber: Int let dayNumber: Int
let date: Date let date: Date
let gamesOnDay: [RichGame] let games: [RichGame]
let travelOnDay: [TravelSegment]
private var formattedDate: String { private var formattedDate: String {
let formatter = DateFormatter() let formatter = DateFormatter()
@@ -445,17 +478,16 @@ struct SimpleDayCard: View {
return formatter.string(from: date) return formatter.string(from: date)
} }
private var isRestDay: Bool { private var gameCity: String? {
gamesOnDay.isEmpty && travelOnDay.isEmpty games.first?.stadium.city
} }
/// City where games are (from stadium) private var isRestDay: Bool {
private var gameCity: String? { games.isEmpty
gamesOnDay.first?.stadium.city
} }
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 8) {
// Day header // Day header
HStack { HStack {
Text("Day \(dayNumber)") Text("Day \(dayNumber)")
@@ -479,63 +511,30 @@ struct SimpleDayCard: View {
} }
} }
// Games (each as its own row) // City label (if games exist)
if !gamesOnDay.isEmpty { if let city = gameCity {
VStack(alignment: .leading, spacing: 6) { Label(city, systemImage: "mappin")
// City label .font(.caption)
if let city = gameCity { .foregroundStyle(.secondary)
Label(city, systemImage: "mappin")
.font(.caption)
.foregroundStyle(.secondary)
}
ForEach(gamesOnDay, id: \.game.id) { richGame in
HStack {
Image(systemName: richGame.game.sport.iconName)
.foregroundStyle(.blue)
.frame(width: 20)
Text(richGame.matchupDescription)
.font(.subheadline)
Spacer()
Text(richGame.game.gameTime)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 6)
.padding(.horizontal, 10)
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
} }
// Travel segments (each as its own row, separate from games) // Games
ForEach(travelOnDay) { segment in ForEach(games, id: \.game.id) { richGame in
HStack(spacing: 8) { HStack {
Image(systemName: segment.travelMode.iconName) Image(systemName: richGame.game.sport.iconName)
.foregroundStyle(.orange) .foregroundStyle(.blue)
.frame(width: 20) .frame(width: 20)
Text(richGame.matchupDescription)
VStack(alignment: .leading, spacing: 2) { .font(.subheadline)
Text("Drive to \(segment.toLocation.name)")
.font(.subheadline)
.fontWeight(.medium)
Text("\(segment.formattedDistance)\(segment.formattedDuration)")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer() Spacer()
Text(richGame.game.gameTime)
.font(.caption)
.foregroundStyle(.secondary)
} }
.padding(.vertical, 8) .padding(.vertical, 6)
.padding(.horizontal, 10) .padding(.horizontal, 10)
.background(Color.orange.opacity(0.1)) .background(Color(.tertiarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 8)) .clipShape(RoundedRectangle(cornerRadius: 8))
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.orange.opacity(0.3), lineWidth: 1)
)
} }
} }
.padding() .padding()
@@ -544,6 +543,52 @@ struct SimpleDayCard: View {
} }
} }
// MARK: - Travel Section (standalone travel between days)
struct TravelSection: View {
let segment: TravelSegment
var body: some View {
VStack(alignment: .leading, spacing: 8) {
// Travel header
Text("Travel")
.font(.subheadline)
.fontWeight(.semibold)
.foregroundStyle(.orange)
// Travel details
HStack(spacing: 8) {
Image(systemName: segment.travelMode.iconName)
.foregroundStyle(.orange)
.frame(width: 20)
VStack(alignment: .leading, spacing: 2) {
Text("\(segment.fromLocation.name)\(segment.toLocation.name)")
.font(.subheadline)
.fontWeight(.medium)
Text("\(segment.formattedDistance)\(segment.formattedDuration)")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
.padding(.vertical, 8)
.padding(.horizontal, 10)
.background(Color.orange.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color.orange.opacity(0.3), lineWidth: 1)
)
}
}
// MARK: - Share Sheet // MARK: - Share Sheet
struct ShareSheet: UIViewControllerRepresentable { struct ShareSheet: UIViewControllerRepresentable {