Files
Sportstime/SportsTime/Features/Trip/Views/TimelineItemView.swift
Trey t ab89c25f2f Refactor trip planning: DAG router + trip options UI + simplified itinerary
- Replace O(2^n) GeographicRouteExplorer with O(n) GameDAGRouter using DAG + beam search
- Add geographic diversity to route selection (returns routes from distinct regions)
- Add trip options selector UI (TripOptionsView, TripOptionCard) to choose between routes
- Simplify itinerary display: separate games and travel segments by date
- Remove complex ItineraryDay bundling, query games/travel directly per day
- Update ScenarioA/B/C planners to use GameDAGRouter
- Add new test suites for planners and travel estimator

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 12:26:17 -06:00

473 lines
13 KiB
Swift

//
// TimelineItemView.swift
// SportsTime
//
// Unified timeline view components for displaying trip itinerary.
// Renders stops, travel segments, and rest days in a consistent format.
//
import SwiftUI
// MARK: - Timeline Item View
/// Renders a single timeline item (stop, travel, or rest).
struct TimelineItemView: View {
let item: TimelineItem
let games: [UUID: RichGame]
let isFirst: Bool
let isLast: Bool
init(
item: TimelineItem,
games: [UUID: RichGame],
isFirst: Bool = false,
isLast: Bool = false
) {
self.item = item
self.games = games
self.isFirst = isFirst
self.isLast = isLast
}
var body: some View {
HStack(alignment: .top, spacing: 12) {
// Timeline connector
timelineConnector
// Content
itemContent
}
}
// MARK: - Timeline Connector
@ViewBuilder
private var timelineConnector: some View {
VStack(spacing: 0) {
// Line from previous
if !isFirst {
Rectangle()
.fill(connectorColor)
.frame(width: 2, height: 16)
} else {
Spacer().frame(height: 16)
}
// Icon
itemIcon
.frame(width: 32, height: 32)
// Line to next
if !isLast {
Rectangle()
.fill(connectorColor)
.frame(width: 2)
.frame(maxHeight: .infinity)
}
}
.frame(width: 32)
}
private var connectorColor: Color {
Color.secondary.opacity(0.3)
}
@ViewBuilder
private var itemIcon: some View {
switch item {
case .stop(let stop):
if stop.hasGames {
Image(systemName: "sportscourt.fill")
.foregroundStyle(.white)
.frame(width: 32, height: 32)
.background(Circle().fill(.blue))
} else {
Image(systemName: "mappin.circle.fill")
.foregroundStyle(.orange)
.font(.title2)
}
case .travel(let segment):
Image(systemName: segment.travelMode == .drive ? "car.fill" : "airplane")
.foregroundStyle(.white)
.frame(width: 28, height: 28)
.background(Circle().fill(.green))
case .rest:
Image(systemName: "bed.double.fill")
.foregroundStyle(.white)
.frame(width: 28, height: 28)
.background(Circle().fill(.purple))
}
}
// MARK: - Item Content
@ViewBuilder
private var itemContent: some View {
switch item {
case .stop(let stop):
StopItemContent(stop: stop, games: games)
case .travel(let segment):
TravelItemContent(segment: segment)
case .rest(let rest):
RestItemContent(rest: rest)
}
}
}
// MARK: - Stop Item Content
struct StopItemContent: View {
let stop: ItineraryStop
let games: [UUID: RichGame]
private var gamesAtStop: [RichGame] {
stop.games.compactMap { games[$0] }
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
// Header
HStack {
Text(stop.city)
.font(.headline)
if !stop.state.isEmpty {
Text(stop.state)
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
Text(stop.arrivalDate.formatted(date: .abbreviated, time: .omitted))
.font(.caption)
.foregroundStyle(.secondary)
}
// Games
if !gamesAtStop.isEmpty {
ForEach(gamesAtStop, id: \.game.id) { richGame in
TimelineGameRow(richGame: richGame)
}
} else {
Text(stop.hasGames ? "Game details loading..." : "Waypoint")
.font(.subheadline)
.foregroundStyle(.secondary)
.italic()
}
}
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
// MARK: - Travel Item Content
struct TravelItemContent: View {
let segment: TravelSegment
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(segment.travelMode == .drive ? "Drive" : "Fly")
.font(.subheadline)
.fontWeight(.medium)
Text("")
.foregroundStyle(.secondary)
Text(segment.formattedDistance)
.font(.subheadline)
.foregroundStyle(.secondary)
Text("")
.foregroundStyle(.secondary)
Text(segment.formattedDuration)
.font(.subheadline)
.foregroundStyle(.secondary)
}
Text("\(segment.fromLocation.name)\(segment.toLocation.name)")
.font(.caption)
.foregroundStyle(.secondary)
// EV Charging stops if applicable
if !segment.evChargingStops.isEmpty {
HStack(spacing: 4) {
Image(systemName: "bolt.fill")
.foregroundStyle(.green)
Text("\(segment.evChargingStops.count) charging stop(s)")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.padding(.vertical, 8)
.padding(.horizontal, 12)
.background(Color(.tertiarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
// MARK: - Rest Item Content
struct RestItemContent: View {
let rest: RestDay
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text("Rest Day")
.font(.subheadline)
.fontWeight(.medium)
Spacer()
Text(rest.date.formatted(date: .abbreviated, time: .omitted))
.font(.caption)
.foregroundStyle(.secondary)
}
Text(rest.location.name)
.font(.caption)
.foregroundStyle(.secondary)
if let notes = rest.notes {
Text(notes)
.font(.caption)
.foregroundStyle(.secondary)
.italic()
}
}
.padding(.vertical, 8)
.padding(.horizontal, 12)
.background(Color.purple.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
// MARK: - Timeline Game Row
struct TimelineGameRow: View {
let richGame: RichGame
var body: some View {
HStack(spacing: 8) {
// Sport icon
Image(systemName: richGame.game.sport.iconName)
.foregroundStyle(richGame.game.sport.color)
.frame(width: 20)
VStack(alignment: .leading, spacing: 2) {
// Matchup
Text(richGame.matchupDescription)
.font(.subheadline)
.fontWeight(.medium)
// Time and venue
HStack(spacing: 4) {
Text(richGame.game.dateTime.formatted(date: .omitted, time: .shortened))
Text("")
Text(richGame.stadium.name)
}
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
.padding(.vertical, 4)
}
}
// MARK: - Timeline View
/// Full timeline view for an itinerary option.
struct TimelineView: View {
let option: ItineraryOption
let games: [UUID: RichGame]
private var timeline: [TimelineItem] {
option.generateTimeline()
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
ForEach(Array(timeline.enumerated()), id: \.element.id) { index, item in
TimelineItemView(
item: item,
games: games,
isFirst: index == 0,
isLast: index == timeline.count - 1
)
}
}
}
}
// MARK: - Horizontal Timeline View
/// Horizontal scrolling timeline for compact display.
struct HorizontalTimelineView: View {
let option: ItineraryOption
let games: [UUID: RichGame]
private var timeline: [TimelineItem] {
option.generateTimeline()
}
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 0) {
ForEach(Array(timeline.enumerated()), id: \.element.id) { index, item in
HStack(spacing: 0) {
HorizontalTimelineItemView(item: item, games: games)
// Connector to next
if index < timeline.count - 1 {
timelineConnector(for: item)
}
}
}
}
.padding(.horizontal)
}
}
@ViewBuilder
private func timelineConnector(for item: TimelineItem) -> some View {
if item.isTravel {
// Travel already shows direction, minimal connector
Rectangle()
.fill(Color.secondary.opacity(0.3))
.frame(width: 20, height: 2)
} else {
// Standard connector with arrow
HStack(spacing: 0) {
Rectangle()
.fill(Color.secondary.opacity(0.3))
.frame(width: 16, height: 2)
Image(systemName: "chevron.right")
.font(.caption2)
.foregroundStyle(.secondary)
Rectangle()
.fill(Color.secondary.opacity(0.3))
.frame(width: 16, height: 2)
}
}
}
}
// MARK: - Horizontal Timeline Item View
struct HorizontalTimelineItemView: View {
let item: TimelineItem
let games: [UUID: RichGame]
var body: some View {
VStack(spacing: 4) {
itemIcon
Text(shortLabel)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
.frame(width: 60)
}
.padding(.vertical, 8)
}
@ViewBuilder
private var itemIcon: some View {
switch item {
case .stop(let stop):
VStack(spacing: 2) {
Image(systemName: stop.hasGames ? "sportscourt.fill" : "mappin")
.foregroundStyle(stop.hasGames ? .blue : .orange)
Text(String(stop.city.prefix(3)).uppercased())
.font(.caption2)
.fontWeight(.bold)
}
.frame(width: 44, height: 44)
.background(Circle().fill(Color(.secondarySystemBackground)))
case .travel(let segment):
Image(systemName: segment.travelMode == .drive ? "car.fill" : "airplane")
.foregroundStyle(.green)
.frame(width: 32, height: 32)
case .rest:
Image(systemName: "bed.double.fill")
.foregroundStyle(.purple)
.frame(width: 32, height: 32)
}
}
private var shortLabel: String {
switch item {
case .stop(let stop):
return stop.city
case .travel(let segment):
return segment.formattedDuration
case .rest(let rest):
return rest.date.formatted(.dateTime.weekday(.abbreviated))
}
}
}
// MARK: - Preview
#Preview {
let stop1 = ItineraryStop(
city: "Los Angeles",
state: "CA",
coordinate: nil,
games: [],
arrivalDate: Date(),
departureDate: Date(),
location: LocationInput(name: "Los Angeles"),
firstGameStart: nil
)
let stop2 = ItineraryStop(
city: "San Francisco",
state: "CA",
coordinate: nil,
games: [],
arrivalDate: Date().addingTimeInterval(86400),
departureDate: Date().addingTimeInterval(86400),
location: LocationInput(name: "San Francisco"),
firstGameStart: nil
)
let segment = TravelSegment(
fromLocation: LocationInput(name: "Los Angeles"),
toLocation: LocationInput(name: "San Francisco"),
travelMode: .drive,
distanceMeters: 600000,
durationSeconds: 21600,
departureTime: Date(),
arrivalTime: Date().addingTimeInterval(21600)
)
let option = ItineraryOption(
rank: 1,
stops: [stop1, stop2],
travelSegments: [segment],
totalDrivingHours: 6,
totalDistanceMiles: 380,
geographicRationale: "LA → SF"
)
return ScrollView {
TimelineView(option: option, games: [:])
.padding()
}
}