- Add local canonicalization pipeline (stadiums, teams, games) that generates deterministic canonical IDs before CloudKit upload - Fix CanonicalSyncService to use deterministic UUIDs from canonical IDs instead of random UUIDs from CloudKit records - Add SyncStadium/SyncTeam/SyncGame types to CloudKitService that preserve canonical ID relationships during sync - Add canonical ID field keys to CKModels for reading from CloudKit records - Bundle canonical JSON files (stadiums_canonical, teams_canonical, games_canonical, stadium_aliases) for consistent bootstrap data - Update BootstrapService to prefer canonical format files over legacy format This ensures all entities use consistent deterministic UUIDs derived from their canonical IDs, preventing duplicate records when syncing CloudKit data with bootstrapped local data. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
471 lines
13 KiB
Swift
471 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
|
|
// )
|
|
//
|
|
// let option = ItineraryOption(
|
|
// rank: 1,
|
|
// stops: [stop1, stop2],
|
|
// travelSegments: [segment],
|
|
// totalDrivingHours: 6,
|
|
// totalDistanceMiles: 380,
|
|
// geographicRationale: "LA → SF"
|
|
// )
|
|
//
|
|
// return ScrollView {
|
|
// TimelineView(option: option, games: [:])
|
|
// .padding()
|
|
// }
|
|
//}
|