feat: Trip Detail as chronological timeline (matches web dashboard)
All checks were successful
Security Checks / dependency-audit (push) Successful in 34s
Security Checks / secret-scanning (push) Successful in 3s
Security Checks / dockerfile-lint (push) Successful in 4s

Replaced type-grouped sections with a unified timeline:
- All items (flights, lodging, places) sorted by date + time
- Grouped by day with "Tuesday, Jan 20" headers
- Vertical timeline line connecting items within each day
- Color-coded icons: blue (flights), warm (lodging), red (places)
- Time shown in 12h format (8:50 AM)
- Category badges for places
- Subtitle with route/location details

Same layout order as the web dashboard — not grouped by type.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yusuf Suleman
2026-04-04 20:31:07 -05:00
parent a739e0de80
commit fa3932c597
2 changed files with 232 additions and 201 deletions

View File

@@ -120,6 +120,7 @@ struct TripLocation: Codable, Identifiable {
let address: String? let address: String?
let latitude: Double? let latitude: Double?
let longitude: Double? let longitude: Double?
let startTime: String?
} }
struct TripNote: Codable, Identifiable { struct TripNote: Codable, Identifiable {

View File

@@ -1,12 +1,7 @@
import SwiftUI import SwiftUI
/// Displays detailed information about a trip. /// Displays detailed information about a trip as a chronological timeline.
/// Adapted from Apple's Wishlist TripDetailView. /// All items (flights, lodging, places) sorted by date/time same as the web dashboard.
///
/// Layout:
/// 1. Tall hero image with trip stats overlay (glass material)
/// 2. Title in largeTitle toolbar (expanded font)
/// 3. Content sections: lodging, flights, places
struct TripDetailView: View { struct TripDetailView: View {
let trip: Trip let trip: Trip
@State private var detail: TripDetail? @State private var detail: TripDetail?
@@ -14,8 +9,8 @@ struct TripDetailView: View {
var body: some View { var body: some View {
ScrollView(.vertical) { ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 20) { VStack(alignment: .leading, spacing: 0) {
// Hero image 510pt, same as Wishlist // Hero image 510pt, Wishlist pattern
TripImageView(url: trip.imageURL, fallbackName: trip.name) TripImageView(url: trip.imageURL, fallbackName: trip.name)
.scaledToFill() .scaledToFill()
.containerRelativeFrame(.horizontal) .containerRelativeFrame(.horizontal)
@@ -25,64 +20,17 @@ struct TripDetailView: View {
tripStatsOverlay tripStatsOverlay
} }
// Content sections // Timeline content
if isLoading { if isLoading {
ProgressView() ProgressView()
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.vertical, 40) .padding(.vertical, 60)
} else if let detail { } else if let detail {
// Lodging let timeline = buildTimeline(detail)
if !detail.lodging.isEmpty { if timeline.isEmpty {
TripSection(title: "Lodging", icon: "bed.double.fill") { emptyState
ForEach(detail.lodging) { item in } else {
LodgingCard(item: item) timelineView(timeline)
}
}
}
// Transportation
if !detail.transportations.isEmpty {
TripSection(title: "Flights & Transport", icon: "airplane") {
ForEach(detail.transportations) { item in
TransportCard(item: item)
}
}
}
// Locations
if !detail.locations.isEmpty {
TripSection(title: "Places", icon: "mappin.and.ellipse") {
ForEach(detail.locations) { item in
LocationCard(item: item)
}
}
}
// AI Suggestions
if let ai = detail.aiSuggestions, !ai.isEmpty {
TripSection(title: "AI Recommendations", icon: "sparkles") {
Text(ai.prefix(500) + (ai.count > 500 ? "..." : ""))
.font(.subheadline)
.foregroundStyle(Color.textSecondary)
.lineSpacing(4)
}
}
// Empty state
if detail.lodging.isEmpty && detail.transportations.isEmpty && detail.locations.isEmpty {
VStack(spacing: 16) {
Image(systemName: "map.fill")
.font(.system(size: 40))
.foregroundStyle(Color.accentWarm.opacity(0.3))
Text("No details yet")
.font(.headline)
.foregroundStyle(Color.textSecondary)
Text("Add lodging, flights, and places on the web")
.font(.subheadline)
.foregroundStyle(Color.textTertiary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 40)
} }
} }
} }
@@ -111,7 +59,178 @@ struct TripDetailView: View {
} }
} }
// MARK: - Trip Stats Overlay // MARK: - Timeline Builder
private func buildTimeline(_ detail: TripDetail) -> [(String, [TimelineItem])] {
var items: [TimelineItem] = []
for t in detail.transportations {
let date = t.date ?? ""
let time = t.startTime ?? ""
let subtitle: String = {
var parts: [String] = []
if let from = t.fromLocation, !from.isEmpty,
let to = t.toLocation, !to.isEmpty {
parts.append("\(from)\(to)")
}
if let flight = t.flightNumber, !flight.isEmpty {
parts.append(flight)
}
return parts.joined(separator: " · ")
}()
items.append(TimelineItem(
date: date, time: time,
icon: t.type == "plane" ? "airplane" : (t.type == "train" ? "tram.fill" : "car.fill"),
iconColor: .blue,
title: t.name, subtitle: subtitle, category: t.type
))
}
for l in detail.lodging {
let date = (l.checkIn ?? "").prefix(10)
let time = l.checkIn?.contains("T") == true ? String(l.checkIn!.suffix(from: l.checkIn!.index(l.checkIn!.startIndex, offsetBy: 11))) : ""
let checkout = l.checkOut != nil ? Trip.formatDisplay(String(l.checkOut!.prefix(10))) : ""
let subtitle = [l.location ?? "", checkout.isEmpty ? "" : "Check-out: \(checkout)"]
.filter { !$0.isEmpty }.joined(separator: " · ")
items.append(TimelineItem(
date: String(date), time: time,
icon: "bed.double.fill",
iconColor: Color.accentWarm,
title: l.name, subtitle: subtitle, category: nil
))
}
for loc in detail.locations {
let date = loc.visitDate ?? ""
let rawDate = date.prefix(10)
let time: String = {
if date.contains("T") {
return String(date.suffix(from: date.index(date.startIndex, offsetBy: 11)))
}
return loc.startTime ?? ""
}()
items.append(TimelineItem(
date: String(rawDate), time: time,
icon: "mappin.and.ellipse",
iconColor: .red,
title: loc.name, subtitle: loc.address ?? "", category: loc.category
))
}
// Sort by date then time
items.sort { a, b in
if a.date == b.date { return a.time < b.time }
return a.date < b.date
}
// Group by date
var grouped: [(String, [TimelineItem])] = []
var currentDate = ""
var currentItems: [TimelineItem] = []
for item in items {
let d = item.date.isEmpty ? "No date" : item.date
if d != currentDate {
if !currentItems.isEmpty {
grouped.append((currentDate, currentItems))
}
currentDate = d
currentItems = [item]
} else {
currentItems.append(item)
}
}
if !currentItems.isEmpty {
grouped.append((currentDate, currentItems))
}
return grouped
}
// MARK: - Timeline View
private func timelineView(_ days: [(String, [TimelineItem])]) -> some View {
VStack(alignment: .leading, spacing: 0) {
ForEach(Array(days.enumerated()), id: \.0) { _, day in
let (date, items) = day
// Day header
HStack(spacing: 8) {
Text(formatDayHeader(date))
.font(.subheadline.weight(.bold))
.fontWidth(.expanded)
.foregroundStyle(Color.accentWarm)
Rectangle()
.fill(Color.accentWarm.opacity(0.2))
.frame(height: 1)
}
.padding(.horizontal, 16)
.padding(.top, 24)
.padding(.bottom, 8)
// Items for this day
ForEach(Array(items.enumerated()), id: \.0) { idx, item in
HStack(alignment: .top, spacing: 12) {
// Timeline line + icon
VStack(spacing: 0) {
Circle()
.fill(item.iconColor)
.frame(width: 32, height: 32)
.overlay {
Image(systemName: item.icon)
.font(.system(size: 13))
.foregroundStyle(.white)
}
if idx < items.count - 1 {
Rectangle()
.fill(Color.textTertiary.opacity(0.3))
.frame(width: 2)
.frame(maxHeight: .infinity)
}
}
.frame(width: 32)
// Content card
VStack(alignment: .leading, spacing: 4) {
if !item.time.isEmpty {
Text(formatTime(item.time))
.font(.caption)
.foregroundStyle(.secondary)
}
Text(item.title)
.font(.body.weight(.medium))
if let cat = item.category, !cat.isEmpty {
Text(cat.uppercased())
.font(.caption2)
.fontWidth(.condensed)
.foregroundStyle(Color.accentWarm)
}
if !item.subtitle.isEmpty {
Text(item.subtitle)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.surfaceCard)
.clipShape(.rect(cornerRadius: 12))
}
.padding(.horizontal, 16)
.padding(.bottom, 4)
}
}
}
.padding(.top, 8)
}
// MARK: - Stats Overlay
private var tripStatsOverlay: some View { private var tripStatsOverlay: some View {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
@@ -128,17 +247,17 @@ struct TripDetailView: View {
} }
if let d = detail { if let d = detail {
if !d.lodging.isEmpty { if !d.lodging.isEmpty {
Label("^[\(d.lodging.count) STAYS](inflect: true)", systemImage: "bed.double") Label("\(d.lodging.count) stays", systemImage: "bed.double")
.font(.footnote) .font(.footnote)
.fontWidth(.condensed) .fontWidth(.condensed)
} }
if !d.transportations.isEmpty { if !d.transportations.isEmpty {
Label("^[\(d.transportations.count) FLIGHTS](inflect: true)", systemImage: "airplane") Label("\(d.transportations.count) flights", systemImage: "airplane")
.font(.footnote) .font(.footnote)
.fontWidth(.condensed) .fontWidth(.condensed)
} }
if !d.locations.isEmpty { if !d.locations.isEmpty {
Label("^[\(d.locations.count) PLACES](inflect: true)", systemImage: "mappin") Label("\(d.locations.count) places", systemImage: "mappin")
.font(.footnote) .font(.footnote)
.fontWidth(.condensed) .fontWidth(.condensed)
} }
@@ -152,7 +271,25 @@ struct TripDetailView: View {
} }
} }
// MARK: - Load Detail // MARK: - Empty State
private var emptyState: some View {
VStack(spacing: 16) {
Image(systemName: "map.fill")
.font(.system(size: 40))
.foregroundStyle(Color.accentWarm.opacity(0.3))
Text("No details yet")
.font(.headline)
.foregroundStyle(Color.textSecondary)
Text("Add lodging, flights, and places on the web")
.font(.subheadline)
.foregroundStyle(Color.textTertiary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 40)
}
// MARK: - Helpers
private func loadDetail() async { private func loadDetail() async {
do { do {
@@ -160,143 +297,36 @@ struct TripDetailView: View {
} catch {} } catch {}
isLoading = false isLoading = false
} }
private func formatDayHeader(_ date: String) -> String {
guard let d = Trip.parseDate(date) else { return date }
let f = DateFormatter()
f.dateFormat = "EEEE, MMM d"
return f.string(from: d)
}
private func formatTime(_ time: String) -> String {
// Handle "HH:mm" or "HH:mm:ss" format
let clean = time.prefix(5)
guard clean.count == 5, let hour = Int(clean.prefix(2)), let min = Int(clean.suffix(2)) else {
return String(time.prefix(5))
}
let ampm = hour >= 12 ? "PM" : "AM"
let h = hour == 0 ? 12 : (hour > 12 ? hour - 12 : hour)
return String(format: "%d:%02d %@", h, min, ampm)
}
} }
// MARK: - Section Container // MARK: - Timeline Item
struct TripSection<Content: View>: View { private struct TimelineItem {
let title: String let date: String
let time: String
let icon: String let icon: String
@ViewBuilder let content: () -> Content let iconColor: Color
let title: String
var body: some View { let subtitle: String
Section { let category: String?
content()
} header: {
HStack(spacing: 6) {
Image(systemName: icon)
.foregroundStyle(Color.accentWarm)
Text(title)
.font(.title3)
.fontWidth(.expanded)
}
}
.padding(.horizontal, 16)
}
}
// MARK: - Lodging Card
struct LodgingCard: View {
let item: TripLodging
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(item.name)
.font(.body.weight(.medium))
if let location = item.location, !location.isEmpty {
Label(location, systemImage: "location")
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
}
if let checkIn = item.checkIn {
HStack {
Text(Trip.formatDisplay(String(checkIn.prefix(10))))
.font(.caption)
.foregroundStyle(.secondary)
if let checkOut = item.checkOut {
Text("")
.font(.caption)
.foregroundStyle(.tertiary)
Text(Trip.formatDisplay(String(checkOut.prefix(10))))
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.surfaceCard)
.clipShape(.rect(cornerRadius: 12))
}
}
// MARK: - Transport Card
struct TransportCard: View {
let item: TripTransportation
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(item.name)
.font(.body.weight(.medium))
Spacer()
if let type = item.type {
Image(systemName: type == "plane" ? "airplane" : "car.fill")
.foregroundStyle(Color.accentWarm)
}
}
if let from = item.fromLocation, let to = item.toLocation {
HStack {
Text(from)
.font(.subheadline)
.foregroundStyle(.secondary)
Image(systemName: "arrow.right")
.font(.caption)
.foregroundStyle(.tertiary)
Text(to)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
if let flight = item.flightNumber, !flight.isEmpty {
Text(flight)
.font(.caption)
.foregroundStyle(.tertiary)
}
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.surfaceCard)
.clipShape(.rect(cornerRadius: 12))
}
}
// MARK: - Location Card
struct LocationCard: View {
let item: TripLocation
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(item.name)
.font(.body.weight(.medium))
if let category = item.category, !category.isEmpty {
Text(category.uppercased())
.font(.caption2)
.fontWidth(.condensed)
.foregroundStyle(Color.accentWarm)
}
if let date = item.visitDate, !date.isEmpty {
Text(Trip.formatDisplay(date))
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.surfaceCard)
.clipShape(.rect(cornerRadius: 12))
}
} }
// MARK: - Gradient Overlay (from Wishlist GradientView) // MARK: - Gradient Overlay (from Wishlist GradientView)