Files
Sportstime/SportsTime/Features/Trip/Views/ItineraryTableViewWrapper.swift
Trey t 9736773475 feat: improve planning engine travel handling, itinerary reordering, and scenario planners
Add TravelInfo initializers and city normalization helpers to fix repeat
city-pair disambiguation. Improve drag-and-drop reordering with segment
index tracking and source-row-aware zone calculation. Enhance all five
scenario planners with better next-day departure handling and travel
segment placement. Add comprehensive tests across all planners.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 08:55:23 -06:00

353 lines
14 KiB
Swift

//
// ItineraryTableViewWrapper.swift
// SportsTime
//
// UIViewControllerRepresentable wrapper for ItineraryTableViewController
//
import SwiftUI
struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresentable {
@Environment(\.colorScheme) private var colorScheme
let trip: Trip
let games: [RichGame]
let itineraryItems: [ItineraryItem]
let travelOverrides: [String: TravelOverride]
let headerContent: HeaderContent
// Callbacks
var onTravelMoved: ((String, Int, Double) -> Void)?
var onCustomItemMoved: ((UUID, Int, Double) -> Void)? // itemId, newDay, newSortOrder
var onCustomItemTapped: ((ItineraryItem) -> Void)?
var onCustomItemDeleted: ((ItineraryItem) -> Void)?
var onAddButtonTapped: ((Int) -> Void)? // Just day number
init(
trip: Trip,
games: [RichGame],
itineraryItems: [ItineraryItem],
travelOverrides: [String: TravelOverride],
@ViewBuilder headerContent: () -> HeaderContent,
onTravelMoved: ((String, Int, Double) -> Void)? = nil,
onCustomItemMoved: ((UUID, Int, Double) -> Void)? = nil,
onCustomItemTapped: ((ItineraryItem) -> Void)? = nil,
onCustomItemDeleted: ((ItineraryItem) -> Void)? = nil,
onAddButtonTapped: ((Int) -> Void)? = nil
) {
self.trip = trip
self.games = games
self.itineraryItems = itineraryItems
self.travelOverrides = travelOverrides
self.headerContent = headerContent()
self.onTravelMoved = onTravelMoved
self.onCustomItemMoved = onCustomItemMoved
self.onCustomItemTapped = onCustomItemTapped
self.onCustomItemDeleted = onCustomItemDeleted
self.onAddButtonTapped = onAddButtonTapped
}
func makeCoordinator() -> Coordinator {
Coordinator()
}
class Coordinator {
var headerHostingController: UIHostingController<HeaderContent>?
}
func makeUIViewController(context: Context) -> ItineraryTableViewController {
let controller = ItineraryTableViewController(style: .plain)
controller.colorScheme = colorScheme
controller.onTravelMoved = onTravelMoved
controller.onCustomItemMoved = onCustomItemMoved
controller.onCustomItemTapped = onCustomItemTapped
controller.onCustomItemDeleted = onCustomItemDeleted
controller.onAddButtonTapped = onAddButtonTapped
// Set header with proper sizing
let hostingController = UIHostingController(rootView: headerContent)
hostingController.view.backgroundColor = .clear
// Store in coordinator for later updates
context.coordinator.headerHostingController = hostingController
// Pre-size the header view
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
let targetWidth = UIScreen.main.bounds.width
let targetSize = CGSize(width: targetWidth, height: UIView.layoutFittingCompressedSize.height)
let size = hostingController.view.systemLayoutSizeFitting(targetSize, withHorizontalFittingPriority: .required, verticalFittingPriority: .fittingSizeLevel)
hostingController.view.frame = CGRect(origin: .zero, size: CGSize(width: targetWidth, height: max(size.height, 450)))
hostingController.view.translatesAutoresizingMaskIntoConstraints = true
controller.setTableHeader(hostingController.view)
// Load initial data
let (days, validRanges, allItemsForConstraints, travelSegmentIndices) = buildItineraryData()
controller.reloadData(
days: days,
travelValidRanges: validRanges,
itineraryItems: allItemsForConstraints,
travelSegmentIndices: travelSegmentIndices
)
return controller
}
func updateUIViewController(_ controller: ItineraryTableViewController, context: Context) {
controller.colorScheme = colorScheme
controller.onTravelMoved = onTravelMoved
controller.onCustomItemMoved = onCustomItemMoved
controller.onCustomItemTapped = onCustomItemTapped
controller.onCustomItemDeleted = onCustomItemDeleted
controller.onAddButtonTapped = onAddButtonTapped
// Update header content by updating the hosting controller's rootView
// This avoids recreating the view hierarchy and prevents infinite loops
context.coordinator.headerHostingController?.rootView = headerContent
let (days, validRanges, allItemsForConstraints, travelSegmentIndices) = buildItineraryData()
controller.reloadData(
days: days,
travelValidRanges: validRanges,
itineraryItems: allItemsForConstraints,
travelSegmentIndices: travelSegmentIndices
)
}
// MARK: - Build Itinerary Data
private func buildItineraryData() -> ([ItineraryDayData], [String: ClosedRange<Int>], [ItineraryItem], [UUID: Int]) {
let tripDays = calculateTripDays()
var travelValidRanges: [String: ClosedRange<Int>] = [:]
let travelSegmentIndices = Dictionary(uniqueKeysWithValues: trip.travelSegments.enumerated().map { ($1.id, $0) })
// Build game items from RichGame data for constraint validation
var gameItems: [ItineraryItem] = []
for (index, dayDate) in tripDays.enumerated() {
let dayNum = index + 1
let gamesOnDay = gamesOn(date: dayDate)
for (gameIndex, richGame) in gamesOnDay.enumerated() {
let gameItem = ItineraryItem(
tripId: trip.id,
day: dayNum,
sortOrder: Double(gameIndex) * 0.01, // Games have sortOrder ~0 (at the visual boundary)
kind: .game(gameId: richGame.game.id, city: richGame.stadium.city)
)
gameItems.append(gameItem)
}
}
// Build travel as semantic items with (day, sortOrder)
var travelItems: [ItineraryItem] = []
travelItems.reserveCapacity(trip.travelSegments.count)
func gamesIn(city: String, day: Int) -> [ItineraryItem] {
gameItems.filter { item in
guard item.day == day else { return false }
guard let gameCity = item.gameCity else { return false }
return cityMatches(gameCity, searchCity: city)
}
}
for (segmentIndex, segment) in trip.travelSegments.enumerated() {
let travelId = stableTravelAnchorId(segment, at: segmentIndex)
let fromCity = segment.fromLocation.name
let toCity = segment.toLocation.name
// VALID RANGE:
// - Earliest: day of last from-city game (travel can happen AFTER that game)
// - Latest: day of first to-city game (travel can happen BEFORE that game)
let lastFromGameDay = findLastGameDay(in: fromCity, tripDays: tripDays)
let firstToGameDay = findFirstGameDay(in: toCity, tripDays: tripDays)
let minDay = max(lastFromGameDay == 0 ? 1 : lastFromGameDay, 1)
let maxDay = min(firstToGameDay == 0 ? tripDays.count : firstToGameDay, tripDays.count)
let validRange = (minDay <= maxDay) ? (minDay...maxDay) : (maxDay...maxDay)
travelValidRanges[travelId] = validRange
// Placement (override if valid)
let placement: TravelOverride
if let override = travelOverrides[travelId], validRange.contains(override.day) {
placement = override
} else {
// Default day: minDay. Default sortOrder depends on whether it's an edge game day.
let day = minDay
// If we're on the last-from-game day, default to AFTER those games.
let fromGames = gamesIn(city: fromCity, day: day)
let maxFrom = fromGames.map { $0.sortOrder }.max() ?? 0.0
var sortOrder = maxFrom + 1.0
// If we're on the first-to-game day (and it's the same chosen day), default to BEFORE those games.
let toGames = gamesIn(city: toCity, day: day)
if !toGames.isEmpty {
let minTo = toGames.map { $0.sortOrder }.min() ?? 0.0
sortOrder = minTo - 1.0
}
placement = TravelOverride(day: day, sortOrder: sortOrder)
}
let travelItem = ItineraryItem(
tripId: trip.id,
day: placement.day,
sortOrder: placement.sortOrder,
kind: .travel(
TravelInfo(segment: segment, segmentIndex: segmentIndex)
)
)
travelItems.append(travelItem)
}
// Build day data
var days: [ItineraryDayData] = []
days.reserveCapacity(tripDays.count)
for (index, dayDate) in tripDays.enumerated() {
let dayNum = index + 1
let gamesOnDay = gamesOn(date: dayDate)
var rows: [ItineraryRowItem] = []
// Custom items for this day
let customItemsForDay = itineraryItems
.filter { $0.day == dayNum && $0.isCustom }
.sorted { $0.sortOrder < $1.sortOrder }
for item in customItemsForDay {
rows.append(.customItem(item))
}
// Travel items for this day (as rows). Ordering comes from sortOrder via controller lookup.
let travelsForDay = travelItems
.filter { $0.day == dayNum }
.sorted { $0.sortOrder < $1.sortOrder }
for travel in travelsForDay {
if let info = travel.travelInfo {
guard let idx = info.segmentIndex,
idx >= 0,
idx < trip.travelSegments.count else { continue }
let seg = trip.travelSegments[idx]
guard info.matches(segment: seg) else { continue }
rows.append(.travel(seg, dayNumber: dayNum))
}
}
// Sort rows by semantic sortOrder (custom uses its own; travel via travelItems)
rows.sort { a, b in
func so(_ r: ItineraryRowItem) -> Double {
switch r {
case .customItem(let it): return it.sortOrder
case .travel(let seg, _):
let segIdx = travelSegmentIndices[seg.id] ?? 0
let id = stableTravelAnchorId(seg, at: segIdx)
return (travelOverrides[id]?.sortOrder)
?? (travelItems.first(where: { ti in
guard case .travel(let inf) = ti.kind else { return false }
return inf.segmentIndex == segIdx
})?.sortOrder ?? 0.0)
default:
return 0.0
}
}
return so(a) < so(b)
}
let dayData = ItineraryDayData(
id: dayNum,
dayNumber: dayNum,
date: dayDate,
games: gamesOnDay,
items: rows,
travelBefore: nil
)
days.append(dayData)
}
return (days, travelValidRanges, gameItems + itineraryItems + travelItems, travelSegmentIndices)
}
// MARK: - Helper Methods
private func calculateTripDays() -> [Date] {
let start = trip.startDate
let end = trip.endDate
var days: [Date] = []
var current = Calendar.current.startOfDay(for: start)
let endDay = Calendar.current.startOfDay(for: end)
while current <= endDay {
days.append(current)
current = Calendar.current.date(byAdding: .day, value: 1, to: current) ?? current
}
return days
}
private func gamesOn(date: Date) -> [RichGame] {
let calendar = Calendar.current
return games.filter { calendar.isDate($0.game.dateTime, inSameDayAs: date) }
.sorted { $0.game.dateTime < $1.game.dateTime }
}
private func stableTravelAnchorId(_ segment: TravelSegment, at index: Int) -> String {
let from = TravelInfo.normalizeCityName(segment.fromLocation.name)
let to = TravelInfo.normalizeCityName(segment.toLocation.name)
return "travel:\(index):\(from)->\(to)"
}
private func findLastGameDay(in city: String, tripDays: [Date]) -> Int {
var lastDay = 0
for (index, dayDate) in tripDays.enumerated() {
let dayNum = index + 1
let gamesOnDay = gamesOn(date: dayDate)
if gamesOnDay.contains(where: { cityMatches($0.stadium.city, searchCity: city) }) {
lastDay = dayNum
}
}
return lastDay
}
private func findFirstGameDay(in city: String, tripDays: [Date]) -> Int {
for (index, dayDate) in tripDays.enumerated() {
let dayNum = index + 1
let gamesOnDay = gamesOn(date: dayDate)
if gamesOnDay.contains(where: { cityMatches($0.stadium.city, searchCity: city) }) {
return dayNum
}
}
return tripDays.count
}
/// Fuzzy city matching - handles "Salt Lake City" vs "Salt Lake" etc.
private func cityMatches(_ stadiumCity: String, searchCity: String) -> Bool {
let stadiumLower = stadiumCity.lowercased().trimmingCharacters(in: .whitespacesAndNewlines)
let searchLower = searchCity.lowercased().trimmingCharacters(in: .whitespacesAndNewlines)
// Exact match
if stadiumLower == searchLower {
return true
}
// One contains the other
if stadiumLower.contains(searchLower) || searchLower.contains(stadiumLower) {
return true
}
// Word-based matching (handles "Salt Lake" matching "Salt Lake City")
let stadiumWords = Set(stadiumLower.components(separatedBy: .whitespaces).filter { !$0.isEmpty })
let searchWords = Set(searchLower.components(separatedBy: .whitespaces).filter { !$0.isEmpty })
if !searchWords.isEmpty && searchWords.isSubset(of: stadiumWords) {
return true
}
if !stadiumWords.isEmpty && stadiumWords.isSubset(of: searchWords) {
return true
}
return false
}
}