Files
Sportstime/SportsTime/Features/Trip/Views/TimelineItemView.swift
Trey t 7efcea7bd4 Add canonical ID pipeline and fix UUID consistency for CloudKit sync
- 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>
2026-01-09 10:30:09 -06:00

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()
// }
//}