From 8ec8ed02b185aaba0cfb25d1054c21137cfd6409 Mon Sep 17 00:00:00 2001 From: Trey t Date: Wed, 7 Jan 2026 12:41:32 -0600 Subject: [PATCH] Refactor itinerary display: separate day sections and travel sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../Features/Trip/Views/TripDetailView.swift | 205 +++++++++++------- 1 file changed, 125 insertions(+), 80 deletions(-) diff --git a/SportsTime/Features/Trip/Views/TripDetailView.swift b/SportsTime/Features/Trip/Views/TripDetailView.swift index e826fd6..14c2ad9 100644 --- a/SportsTime/Features/Trip/Views/TripDetailView.swift +++ b/SportsTime/Features/Trip/Views/TripDetailView.swift @@ -326,17 +326,49 @@ struct TripDetailView: View { Text("Itinerary") .font(.headline) - ForEach(tripDays, id: \.self) { dayDate in - SimpleDayCard( - dayNumber: dayNumber(for: dayDate), - date: dayDate, - gamesOnDay: gamesOn(date: dayDate), - travelOnDay: travelOn(date: dayDate) - ) + ForEach(itinerarySections.indices, id: \.self) { index in + let section = itinerarySections[index] + switch section { + case .day(let dayNumber, let date, let gamesOnDay): + DaySection( + 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 private var tripDays: [Date] { let calendar = Calendar.current @@ -354,14 +386,6 @@ struct TripDetailView: View { 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 private func gamesOn(date: Date) -> [RichGame] { let calendar = Calendar.current @@ -372,16 +396,19 @@ struct TripDetailView: View { return allGameIds.compactMap { games[$0] }.filter { richGame in calendar.startOfDay(for: richGame.game.dateTime) == dayStart - } + }.sorted { $0.game.dateTime < $1.game.dateTime } } - /// Travel segments departing on a specific date - private func travelOn(date: Date) -> [TravelSegment] { + /// Travel segments that depart after a given day (for between-day travel) + private func travelDepartingAfter(date: Date, beforeNextGameDay: Date?) -> [TravelSegment] { let calendar = Calendar.current - let dayStart = calendar.startOfDay(for: date) + let dayEnd = calendar.startOfDay(for: date) 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 date: Date - let gamesOnDay: [RichGame] - let travelOnDay: [TravelSegment] + let games: [RichGame] private var formattedDate: String { let formatter = DateFormatter() @@ -445,17 +478,16 @@ struct SimpleDayCard: View { return formatter.string(from: date) } - private var isRestDay: Bool { - gamesOnDay.isEmpty && travelOnDay.isEmpty + private var gameCity: String? { + games.first?.stadium.city } - /// City where games are (from stadium) - private var gameCity: String? { - gamesOnDay.first?.stadium.city + private var isRestDay: Bool { + games.isEmpty } var body: some View { - VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 8) { // Day header HStack { Text("Day \(dayNumber)") @@ -479,63 +511,30 @@ struct SimpleDayCard: View { } } - // Games (each as its own row) - if !gamesOnDay.isEmpty { - VStack(alignment: .leading, spacing: 6) { - // City label - if let city = gameCity { - 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)) - } - } + // City label (if games exist) + if let city = gameCity { + Label(city, systemImage: "mappin") + .font(.caption) + .foregroundStyle(.secondary) } - // Travel segments (each as its own row, separate from games) - ForEach(travelOnDay) { segment in - HStack(spacing: 8) { - Image(systemName: segment.travelMode.iconName) - .foregroundStyle(.orange) + // Games + ForEach(games, id: \.game.id) { richGame in + HStack { + Image(systemName: richGame.game.sport.iconName) + .foregroundStyle(.blue) .frame(width: 20) - - VStack(alignment: .leading, spacing: 2) { - Text("Drive to \(segment.toLocation.name)") - .font(.subheadline) - .fontWeight(.medium) - - Text("\(segment.formattedDistance) • \(segment.formattedDuration)") - .font(.caption) - .foregroundStyle(.secondary) - } - + Text(richGame.matchupDescription) + .font(.subheadline) Spacer() + Text(richGame.game.gameTime) + .font(.caption) + .foregroundStyle(.secondary) } - .padding(.vertical, 8) + .padding(.vertical, 6) .padding(.horizontal, 10) - .background(Color.orange.opacity(0.1)) + .background(Color(.tertiarySystemBackground)) .clipShape(RoundedRectangle(cornerRadius: 8)) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(Color.orange.opacity(0.3), lineWidth: 1) - ) } } .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 struct ShareSheet: UIViewControllerRepresentable {