This commit is contained in:
Trey t
2026-01-18 12:32:58 -06:00
parent cd1666e7d1
commit 143b364553
4 changed files with 812 additions and 481 deletions

View File

@@ -2,7 +2,12 @@
"permissions": { "permissions": {
"allow": [ "allow": [
"Skill(superpowers:brainstorming)", "Skill(superpowers:brainstorming)",
"Skill(superpowers:writing-plans)" "Skill(superpowers:writing-plans)",
"Skill(superpowers:using-git-worktrees)",
"Skill(superpowers:subagent-driven-development)",
"Bash(git add:*)",
"Bash(git commit:*)",
"WebSearch"
] ]
} }
} }

View File

@@ -295,7 +295,7 @@ final class ItineraryTableViewController: UITableViewController {
var colorScheme: ColorScheme = .dark var colorScheme: ColorScheme = .dark
// Callbacks // Callbacks
var onTravelMoved: ((String, Int) -> Void)? // travelId, newDay var onTravelMoved: ((String, Int, Double) -> Void)? // travelId, newDay
var onCustomItemMoved: ((UUID, Int, Double) -> Void)? // itemId, newDay, newSortOrder var onCustomItemMoved: ((UUID, Int, Double) -> Void)? // itemId, newDay, newSortOrder
var onCustomItemTapped: ((ItineraryItem) -> Void)? var onCustomItemTapped: ((ItineraryItem) -> Void)?
var onCustomItemDeleted: ((ItineraryItem) -> Void)? var onCustomItemDeleted: ((ItineraryItem) -> Void)?
@@ -338,6 +338,11 @@ final class ItineraryTableViewController: UITableViewController {
/// Using a sorted array enables O(log n) nearest-neighbor lookup /// Using a sorted array enables O(log n) nearest-neighbor lookup
private var validDropRows: [Int] = [] private var validDropRows: [Int] = []
/// Valid destination rows in *proposed* coordinate space (after removing the source row).
/// Precomputed at drag start by simulating the move and validating semantic constraints.
private var validDestinationRowsProposed: [Int] = []
/// IDs of games that act as barriers for the current travel drag (for gold highlighting) /// IDs of games that act as barriers for the current travel drag (for gold highlighting)
private var barrierGameIds: Set<String> = [] private var barrierGameIds: Set<String> = []
@@ -501,19 +506,39 @@ final class ItineraryTableViewController: UITableViewController {
// Add button is embedded in the header to prevent items being dragged between them // Add button is embedded in the header to prevent items being dragged between them
flatItems.append(.dayHeader(dayNumber: day.dayNumber, date: day.date)) flatItems.append(.dayHeader(dayNumber: day.dayNumber, date: day.date))
// 3. Games for this day (bundled as one row, not individually reorderable) // 3. Movable items (travel + custom) split around games boundary.
// Games are determined by the trip planning engine, not user-movable // Convention: sortOrder < 0 renders ABOVE games; sortOrder >= 0 renders BELOW games.
var beforeGames: [ItineraryRowItem] = []
var afterGames: [ItineraryRowItem] = []
for row in day.items {
let so: Double?
switch row {
case .customItem(let item):
so = item.sortOrder
case .travel(let segment, _):
// Travel sortOrder is stored in itineraryItems (kind: .travel)
so = findItineraryItem(for: segment)?.sortOrder
default:
so = nil
}
guard let sortOrder = so else { continue }
if sortOrder < 0 {
beforeGames.append(row)
} else {
afterGames.append(row)
}
}
flatItems.append(contentsOf: beforeGames)
// 4. Games for this day (bundled as one row, not individually reorderable)
if !day.games.isEmpty { if !day.games.isEmpty {
flatItems.append(.games(day.games, dayNumber: day.dayNumber)) flatItems.append(.games(day.games, dayNumber: day.dayNumber))
} }
// 4. Custom items (user-added, already sorted by sortOrder in day.items) flatItems.append(contentsOf: afterGames)
// We filter because day.items may contain other row types from wrapper
for item in day.items {
if case .customItem = item {
flatItems.append(item)
}
}
} }
tableView.reloadData() tableView.reloadData()
@@ -533,13 +558,8 @@ final class ItineraryTableViewController: UITableViewController {
if case .dayHeader(let dayNum, _) = flatItems[i] { if case .dayHeader(let dayNum, _) = flatItems[i] {
return dayNum return dayNum
} }
// Travel stores its destination day, so if we hit travel first,
// we're conceptually still in that travel's destination day
if case .travel(_, let dayNum) = flatItems[i] {
return dayNum
}
} }
return 1 // Fallback to day 1 if no header found (shouldn't happen) return 1
} }
/// Finds the row index of the day header for a specific day number. /// Finds the row index of the day header for a specific day number.
@@ -615,6 +635,7 @@ final class ItineraryTableViewController: UITableViewController {
dragTargetDay = nil dragTargetDay = nil
invalidRowIndices = [] invalidRowIndices = []
validDropRows = [] validDropRows = []
validDestinationRowsProposed = []
barrierGameIds = [] barrierGameIds = []
isInValidZone = true isInValidZone = true
@@ -713,7 +734,7 @@ final class ItineraryTableViewController: UITableViewController {
return allItineraryItems.first { item in return allItineraryItems.first { item in
guard case .travel(let info) = item.kind else { return false } guard case .travel(let info) = item.kind else { return false }
return info.fromCity.lowercased() == segment.fromLocation.name.lowercased() return info.fromCity.lowercased() == segment.fromLocation.name.lowercased()
&& info.toCity.lowercased() == segment.toLocation.name.lowercased() && info.toCity.lowercased() == segment.toLocation.name.lowercased()
} }
} }
@@ -865,10 +886,11 @@ final class ItineraryTableViewController: UITableViewController {
// Notify parent view of the change // Notify parent view of the change
switch item { switch item {
case .travel(let segment, _): case .travel(let segment, _):
// Travel's "day" is the day it arrives on (the next day header after its position) // Travel is positioned within a day using sortOrder (can be before/after games)
let newDay = dayForTravelAt(row: destinationIndexPath.row) let destinationDay = dayNumber(forRow: destinationIndexPath.row)
let sortOrder = calculateSortOrder(at: destinationIndexPath.row)
let travelId = "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())" let travelId = "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())"
onTravelMoved?(travelId, newDay) onTravelMoved?(travelId, destinationDay, sortOrder)
case .customItem(let customItem): case .customItem(let customItem):
// Calculate the new day and sortOrder for the dropped position // Calculate the new day and sortOrder for the dropped position
@@ -943,98 +965,245 @@ final class ItineraryTableViewController: UITableViewController {
/// - sourceIndexPath: Where the item is being dragged FROM /// - sourceIndexPath: Where the item is being dragged FROM
/// - proposedDestinationIndexPath: Where the user is trying to drop /// - proposedDestinationIndexPath: Where the user is trying to drop
/// - Returns: The actual destination (may differ from proposed) /// - Returns: The actual destination (may differ from proposed)
override func tableView(_ tableView: UITableView, targetIndexPathForMoveFromRowAt sourceIndexPath: IndexPath, toProposedIndexPath proposedDestinationIndexPath: IndexPath) -> IndexPath { override func tableView(
let item = flatItems[sourceIndexPath.row] _ tableView: UITableView,
var proposedRow = proposedDestinationIndexPath.row targetIndexPathForMoveFromRowAt sourceIndexPath: IndexPath,
toProposedIndexPath proposedDestinationIndexPath: IndexPath
) -> IndexPath {
// DRAG START DETECTION let sourceRow = sourceIndexPath.row
// The first call to this method indicates drag has started. let item = flatItems[sourceRow]
// Initialize drag state, calculate valid/invalid zones, and trigger pickup haptic.
// Drag start detection
if draggingItem == nil { if draggingItem == nil {
beginDrag(at: sourceIndexPath) beginDrag(at: sourceIndexPath)
validDestinationRowsProposed = computeValidDestinationRowsProposed(sourceRow: sourceRow, dragged: item)
} }
// Global constraint: can't move to position 0 (before all content) var proposedRow = proposedDestinationIndexPath.row
if proposedRow == 0 {
proposedRow = 1
}
// Ensure within bounds // Avoid absolute top (keeps UX sane)
proposedRow = min(proposedRow, flatItems.count - 1) if proposedRow <= 0 { proposedRow = 1 }
// Check for zone transition and trigger haptic feedback proposedRow = min(max(0, proposedRow), max(0, flatItems.count - 1))
// Haptics / visuals
checkZoneTransition(at: proposedRow) checkZoneTransition(at: proposedRow)
switch item { // If already valid, allow it.
case .travel, .customItem: if validDestinationRowsProposed.contains(proposedRow) {
// UNIFIED CONSTRAINT LOGIC using pre-calculated validDropRows return IndexPath(row: proposedRow, section: 0)
// This eliminates bouncing by using a simple lookup instead of recalculating }
return snapToValidRow(proposedRow)
default: // Snap to nearest valid destination (proposed coordinate space)
// Fixed items (shouldn't reach here since canMoveRowAt returns false) guard let snapped = nearestValue(in: validDestinationRowsProposed, to: proposedRow) else {
return sourceIndexPath return sourceIndexPath
} }
return IndexPath(row: snapped, section: 0)
}
// MARK: - Drag Destination Precomputation (semantic day + sortOrder)
/// Nearest value in a sorted Int array to the target (binary search).
private func nearestValue(in sorted: [Int], to target: Int) -> Int? {
guard !sorted.isEmpty else { return nil }
var low = 0
var high = sorted.count
while low < high {
let mid = (low + high) / 2
if sorted[mid] < target { low = mid + 1 } else { high = mid }
}
let after = (low < sorted.count) ? sorted[low] : nil
let before = (low > 0) ? sorted[low - 1] : nil
switch (before, after) {
case let (b?, a?):
return (target - b) <= (a - target) ? b : a
case let (b?, nil):
return b
case let (nil, a?):
return a
default:
return nil
}
} }
/// Snaps a proposed row to the nearest valid drop position. /// Computes all valid destination rows in **proposed** coordinate space (UIKit's coordinate space during drag).
/// /// We simulate the move and validate using semantic constraints: (day, sortOrder).
/// Uses pre-calculated `validDropRows` for O(log n) lookup. private func computeValidDestinationRowsProposed(sourceRow: Int, dragged: ItineraryRowItem) -> [Int] {
/// If the proposed row is already valid, returns it immediately (prevents bouncing). // Proposed rows are in the array AFTER removing the source row.
/// Otherwise, finds the nearest valid row using binary search. let maxProposed = max(0, flatItems.count - 1)
private func snapToValidRow(_ proposedRow: Int) -> IndexPath { guard maxProposed > 0 else { return [] }
// Fast path: if proposed row is valid, return it immediately
// This is the key to preventing bouncing - no recalculation needed switch dragged {
if validDropRows.contains(proposedRow) { case .customItem:
return IndexPath(row: proposedRow, section: 0) // Custom items can go basically anywhere (including before headers = "between days").
// Keep row 0 blocked.
return Array(1...maxProposed)
case .travel(let segment, _):
let travelId = "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())"
let validDayRange = travelValidRanges[travelId]
// Use existing itinerary model if available (for constraints)
let model: ItineraryItem = findItineraryItem(for: segment) ?? ItineraryItem(
tripId: allItineraryItems.first?.tripId ?? UUID(),
day: 1,
sortOrder: 0,
kind: .travel(TravelInfo(fromCity: segment.fromLocation.name, toCity: segment.toLocation.name, distanceMeters: segment.distanceMeters, durationSeconds: segment.durationSeconds))
)
guard let constraints else {
// If no constraint engine, allow all rows (except 0)
return Array(1...maxProposed)
}
var valid: [Int] = []
valid.reserveCapacity(maxProposed)
for proposedRow in 1...maxProposed {
let simulated = simulateMove(original: flatItems, sourceRow: sourceRow, destinationProposedRow: proposedRow)
let destRowInSim = simulated.destinationRowInNewArray
let day = dayNumber(in: simulated.items, forRow: destRowInSim)
if let r = validDayRange, !r.contains(day) {
continue
}
let sortOrder = calculateSortOrder(in: simulated.items, at: destRowInSim)
if constraints.isValidPosition(for: model, day: day, sortOrder: sortOrder) {
valid.append(proposedRow)
}
}
return valid
default:
return []
} }
}
// Proposed row is invalid - find the nearest valid row private struct SimulatedMove {
guard !validDropRows.isEmpty else { let items: [ItineraryRowItem]
return IndexPath(row: proposedRow, section: 0) let destinationRowInNewArray: Int
}
/// Simulate UITableView move semantics: remove at sourceRow from ORIGINAL array, then insert at destinationProposedRow
/// in the NEW array (post-removal coordinate space).
private func simulateMove(original: [ItineraryRowItem], sourceRow: Int, destinationProposedRow: Int) -> SimulatedMove {
var items = original
let moving = items.remove(at: sourceRow)
let clampedDest = min(max(0, destinationProposedRow), items.count)
items.insert(moving, at: clampedDest)
return SimulatedMove(items: items, destinationRowInNewArray: clampedDest)
}
/// Day number lookup within an arbitrary flat array (used during simulation).
private func dayNumber(in items: [ItineraryRowItem], forRow row: Int) -> Int {
guard !items.isEmpty else { return 1 }
let clamped = min(max(0, row), items.count - 1)
for i in stride(from: clamped, through: 0, by: -1) {
if case .dayHeader(let dayNum, _) = items[i] {
return dayNum
}
} }
return 1
}
// Binary search for insertion point /// Calculates sortOrder for insertion at a row within an arbitrary flat array.
var low = 0 /// Uses the same convention as the main function:
var high = validDropRows.count /// - sortOrder < 0 => above games
/// - sortOrder >= 0 => below games
private func calculateSortOrder(in items: [ItineraryRowItem], at row: Int) -> Double {
let day = dayNumber(in: items, forRow: row)
while low < high { // Find games row for this day in the provided items
let mid = (low + high) / 2 var gamesRow: Int? = nil
if validDropRows[mid] < proposedRow { for i in 0..<items.count {
low = mid + 1 if case .games(_, let d) = items[i], d == day {
} else { gamesRow = i
high = mid break
}
if case .dayHeader(let d, _) = items[i], d > day {
break
} }
} }
// low is now the insertion point - check neighbors to find nearest let isBeforeGames = (gamesRow != nil && row <= gamesRow!)
let before = low > 0 ? validDropRows[low - 1] : nil
let after = low < validDropRows.count ? validDropRows[low] : nil
let nearest: Int func movableSortOrder(_ idx: Int) -> Double? {
if let b = before, let a = after { guard idx >= 0 && idx < items.count else { return nil }
// Both neighbors exist - pick the closer one switch items[idx] {
nearest = (proposedRow - b) <= (a - proposedRow) ? b : a case .customItem(let item):
} else if let b = before { return item.sortOrder
nearest = b case .travel(let segment, _):
} else if let a = after { return findItineraryItem(for: segment)?.sortOrder
nearest = a default:
} else { return nil
nearest = proposedRow // Fallback (shouldn't happen) }
} }
return IndexPath(row: nearest, section: 0) func scanBackward(from start: Int) -> Double? {
} var i = start
while i >= 0 {
if case .dayHeader(let d, _) = items[i], d != day { break }
if case .dayHeader = items[i] { break }
if case .games(_, let d) = items[i], d == day { break }
if let v = movableSortOrder(i) {
if isBeforeGames {
if v < 0 { return v }
} else {
if v >= 0 { return v }
}
}
i -= 1
}
return nil
}
/// Calculates which day a travel segment would belong to if dropped at a proposed position. func scanForward(from start: Int) -> Double? {
/// var i = start
/// Similar to `dayForTravelAt`, but used during the drag (before the move completes). while i < items.count {
/// Must exclude the item being dragged from the scan, since it will be removed if case .dayHeader(let d, _) = items[i], d != day { break }
/// from its current position. if case .dayHeader = items[i] { break }
/// if case .games(_, let d) = items[i], d == day { break }
/// - Parameters: if let v = movableSortOrder(i) {
/// - row: The proposed drop position if isBeforeGames {
/// - excluding: The source row to skip (the item being dragged) if v < 0 { return v }
} else {
if v >= 0 { return v }
}
}
i += 1
}
return nil
}
if isBeforeGames {
let prev = scanBackward(from: row - 1)
let next = scanForward(from: row)
let upperBound: Double = 0.0
switch (prev, next) {
case (nil, nil):
return -1.0
case (let p?, nil):
return (p + upperBound) / 2.0
case (nil, let n?):
return n / 2.0
case (let p?, let n?):
return (p + n) / 2.0
}
} else {
let prev = scanBackward(from: row - 1) ?? 0.0
let next = scanForward(from: row)
switch next {
case nil:
return (prev == 0.0) ? 1.0 : (prev + 1.0)
case let n?:
return (prev + n) / 2.0
}
}
}
private func dayForTravelAtProposed(row: Int, excluding: Int) -> Int { private func dayForTravelAtProposed(row: Int, excluding: Int) -> Int {
// Scan forward, skipping the item being moved // Scan forward, skipping the item being moved
for i in row..<flatItems.count { for i in row..<flatItems.count {
@@ -1152,57 +1321,95 @@ final class ItineraryTableViewController: UITableViewController {
/// **Scanning logic:** We scan backwards and forwards from the drop position /// **Scanning logic:** We scan backwards and forwards from the drop position
/// to find adjacent custom items, stopping at day boundaries (headers, travel). /// to find adjacent custom items, stopping at day boundaries (headers, travel).
private func calculateSortOrder(at row: Int) -> Double { private func calculateSortOrder(at row: Int) -> Double {
var prevSortOrder: Double? let day = dayNumber(forRow: row)
var nextSortOrder: Double?
// SCAN BACKWARDS to find previous custom item in this day // Find games row for this day (if any)
for i in stride(from: row - 1, through: 0, by: -1) { var gamesRow: Int? = nil
switch flatItems[i] { for i in 0..<flatItems.count {
case .customItem(let item): if case .games(_, let d) = flatItems[i], d == day {
// Found a custom item - use its sortOrder gamesRow = i
prevSortOrder = item.sortOrder break
case .dayHeader, .travel: }
// Hit a day boundary - no previous custom item in this day if case .dayHeader(let d, _) = flatItems[i], d > day {
break break
case .games:
// Skip non-custom, non-boundary items
continue
} }
// Stop scanning once we found an item or hit a boundary
if prevSortOrder != nil { break }
if case .dayHeader = flatItems[i] { break }
if case .travel = flatItems[i] { break }
} }
// SCAN FORWARDS to find next custom item in this day let isBeforeGames = (gamesRow != nil && row <= gamesRow!)
for i in row..<flatItems.count {
switch flatItems[i] { func movableSortOrder(_ idx: Int) -> Double? {
guard idx >= 0 && idx < flatItems.count else { return nil }
switch flatItems[idx] {
case .customItem(let item): case .customItem(let item):
nextSortOrder = item.sortOrder return item.sortOrder
case .dayHeader, .travel: case .travel(let segment, _):
break return findItineraryItem(for: segment)?.sortOrder
case .games: default:
continue return nil
} }
if nextSortOrder != nil { break }
if case .dayHeader = flatItems[i] { break }
if case .travel = flatItems[i] { break }
} }
// CALCULATE sortOrder based on what we found func scanBackward(from start: Int) -> Double? {
switch (prevSortOrder, nextSortOrder) { var i = start
case (nil, nil): while i >= 0 {
// No adjacent items - first item in this day if case .dayHeader(let d, _) = flatItems[i], d != day { break }
return 1.0 if case .dayHeader = flatItems[i] { break }
case (let prev?, nil): if case .games(_, let d) = flatItems[i], d == day { break }
// After the last item - add 1.0 to create spacing if let v = movableSortOrder(i) {
return prev + 1.0 if isBeforeGames {
case (nil, let next?): if v < 0 { return v }
// Before the first item - halve to stay positive } else {
return next / 2.0 if v >= 0 { return v }
case (let prev?, let next?): }
// Between two items - use exact midpoint }
return (prev + next) / 2.0 i -= 1
}
return nil
}
func scanForward(from start: Int) -> Double? {
var i = start
while i < flatItems.count {
if case .dayHeader(let d, _) = flatItems[i], d != day { break }
if case .dayHeader = flatItems[i] { break }
if case .games(_, let d) = flatItems[i], d == day { break }
if let v = movableSortOrder(i) {
if isBeforeGames {
if v < 0 { return v }
} else {
if v >= 0 { return v }
}
}
i += 1
}
return nil
}
if isBeforeGames {
let prev = scanBackward(from: row - 1)
let next = scanForward(from: row)
let upperBound: Double = 0.0 // games boundary
switch (prev, next) {
case (nil, nil):
return -1.0
case (let p?, nil):
return (p + upperBound) / 2.0
case (nil, let n?):
return n / 2.0
case (let p?, let n?):
return (p + n) / 2.0
}
} else {
let prev = scanBackward(from: row - 1) ?? 0.0
let next = scanForward(from: row)
switch next {
case nil:
return (prev == 0.0) ? 1.0 : (prev + 1.0)
case let n?:
return (prev + n) / 2.0
}
} }
} }

View File

@@ -13,11 +13,11 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresent
let trip: Trip let trip: Trip
let games: [RichGame] let games: [RichGame]
let itineraryItems: [ItineraryItem] let itineraryItems: [ItineraryItem]
let travelDayOverrides: [String: Int] let travelOverrides: [String: TravelOverride]
let headerContent: HeaderContent let headerContent: HeaderContent
// Callbacks // Callbacks
var onTravelMoved: ((String, Int) -> Void)? var onTravelMoved: ((String, Int, Double) -> Void)?
var onCustomItemMoved: ((UUID, Int, Double) -> Void)? // itemId, newDay, newSortOrder var onCustomItemMoved: ((UUID, Int, Double) -> Void)? // itemId, newDay, newSortOrder
var onCustomItemTapped: ((ItineraryItem) -> Void)? var onCustomItemTapped: ((ItineraryItem) -> Void)?
var onCustomItemDeleted: ((ItineraryItem) -> Void)? var onCustomItemDeleted: ((ItineraryItem) -> Void)?
@@ -27,9 +27,9 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresent
trip: Trip, trip: Trip,
games: [RichGame], games: [RichGame],
itineraryItems: [ItineraryItem], itineraryItems: [ItineraryItem],
travelDayOverrides: [String: Int], travelOverrides: [String: TravelOverride],
@ViewBuilder headerContent: () -> HeaderContent, @ViewBuilder headerContent: () -> HeaderContent,
onTravelMoved: ((String, Int) -> Void)? = nil, onTravelMoved: ((String, Int, Double) -> Void)? = nil,
onCustomItemMoved: ((UUID, Int, Double) -> Void)? = nil, onCustomItemMoved: ((UUID, Int, Double) -> Void)? = nil,
onCustomItemTapped: ((ItineraryItem) -> Void)? = nil, onCustomItemTapped: ((ItineraryItem) -> Void)? = nil,
onCustomItemDeleted: ((ItineraryItem) -> Void)? = nil, onCustomItemDeleted: ((ItineraryItem) -> Void)? = nil,
@@ -38,7 +38,7 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresent
self.trip = trip self.trip = trip
self.games = games self.games = games
self.itineraryItems = itineraryItems self.itineraryItems = itineraryItems
self.travelDayOverrides = travelDayOverrides self.travelOverrides = travelOverrides
self.headerContent = headerContent() self.headerContent = headerContent()
self.onTravelMoved = onTravelMoved self.onTravelMoved = onTravelMoved
self.onCustomItemMoved = onCustomItemMoved self.onCustomItemMoved = onCustomItemMoved
@@ -82,8 +82,8 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresent
controller.setTableHeader(hostingController.view) controller.setTableHeader(hostingController.view)
// Load initial data // Load initial data
let (days, validRanges) = buildItineraryData() let (days, validRanges, allItemsForConstraints) = buildItineraryData()
controller.reloadData(days: days, travelValidRanges: validRanges, itineraryItems: itineraryItems) controller.reloadData(days: days, travelValidRanges: validRanges, itineraryItems: allItemsForConstraints)
return controller return controller
} }
@@ -100,73 +100,142 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresent
// This avoids recreating the view hierarchy and prevents infinite loops // This avoids recreating the view hierarchy and prevents infinite loops
context.coordinator.headerHostingController?.rootView = headerContent context.coordinator.headerHostingController?.rootView = headerContent
let (days, validRanges) = buildItineraryData() let (days, validRanges, allItemsForConstraints) = buildItineraryData()
controller.reloadData(days: days, travelValidRanges: validRanges, itineraryItems: itineraryItems) controller.reloadData(days: days, travelValidRanges: validRanges, itineraryItems: allItemsForConstraints)
} }
// MARK: - Build Itinerary Data // MARK: - Build Itinerary Data
private func buildItineraryData() -> ([ItineraryDayData], [String: ClosedRange<Int>]) { private func buildItineraryData() -> ([ItineraryDayData], [String: ClosedRange<Int>], [ItineraryItem]) {
let tripDays = calculateTripDays() let tripDays = calculateTripDays()
var travelValidRanges: [String: ClosedRange<Int>] = [:] var travelValidRanges: [String: ClosedRange<Int>] = [:]
// Pre-calculate travel segment placements // Build travel as semantic items with (day, sortOrder)
var travelByDay: [Int: TravelSegment] = [:] var travelItems: [ItineraryItem] = []
travelItems.reserveCapacity(trip.travelSegments.count)
func cityFromGameId(_ gameId: String) -> String? {
let comps = gameId.components(separatedBy: "-")
guard comps.count >= 2 else { return nil }
return comps[1]
}
func gamesIn(city: String, day: Int) -> [ItineraryItem] {
itineraryItems.filter { item in
guard item.day == day else { return false }
guard case .game(let gid) = item.kind else { return false }
guard let c = cityFromGameId(gid) else { return false }
return cityMatches(c, searchCity: city)
}
}
for segment in trip.travelSegments { for segment in trip.travelSegments {
let travelId = stableTravelAnchorId(segment) let travelId = stableTravelAnchorId(segment)
let fromCity = segment.fromLocation.name let fromCity = segment.fromLocation.name
let toCity = segment.toLocation.name let toCity = segment.toLocation.name
// Calculate valid range // VALID RANGE:
// Travel "on day N" appears BEFORE day N's header // - Earliest: day of last from-city game (travel can happen AFTER that game)
// So minDay must be AFTER the last game day in departure city // - Latest: day of first to-city game (travel can happen BEFORE that game)
let lastGameInFromCity = findLastGameDay(in: fromCity, tripDays: tripDays) let lastFromGameDay = findLastGameDay(in: fromCity, tripDays: tripDays)
let firstGameInToCity = findFirstGameDay(in: toCity, tripDays: tripDays) let firstToGameDay = findFirstGameDay(in: toCity, tripDays: tripDays)
let minDay = max(lastGameInFromCity + 1, 1) // Day AFTER last game in from city
let maxDay = min(firstGameInToCity, tripDays.count) // Can arrive same day as first game
let validRange = minDay <= maxDay ? minDay...maxDay : maxDay...maxDay
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 travelValidRanges[travelId] = validRange
// Calculate default day // Placement (override if valid)
let defaultDay: Int let placement: TravelOverride
if lastGameInFromCity > 0 && lastGameInFromCity + 1 <= tripDays.count { if let override = travelOverrides[travelId], validRange.contains(override.day) {
defaultDay = lastGameInFromCity + 1 placement = override
} else if lastGameInFromCity > 0 {
defaultDay = lastGameInFromCity
} else { } else {
defaultDay = 1 // 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)
} }
// Use override if valid, otherwise use default let travelItem = ItineraryItem(
if let overrideDay = travelDayOverrides[travelId], validRange.contains(overrideDay) { tripId: trip.id,
travelByDay[overrideDay] = segment day: placement.day,
} else { sortOrder: placement.sortOrder,
let clampedDefault = max(validRange.lowerBound, min(defaultDay, validRange.upperBound)) kind: .travel(
travelByDay[clampedDefault] = segment TravelInfo(
} fromCity: fromCity,
toCity: toCity,
distanceMeters: segment.distanceMeters,
durationSeconds: segment.durationSeconds
)
)
)
travelItems.append(travelItem)
} }
// Build day data // Build day data
var days: [ItineraryDayData] = [] var days: [ItineraryDayData] = []
days.reserveCapacity(tripDays.count)
for (index, dayDate) in tripDays.enumerated() { for (index, dayDate) in tripDays.enumerated() {
let dayNum = index + 1 let dayNum = index + 1
let gamesOnDay = gamesOn(date: dayDate) let gamesOnDay = gamesOn(date: dayDate)
var items: [ItineraryRowItem] = []
// Travel before this day (travel is stored on the destination day) var rows: [ItineraryRowItem] = []
let travelBefore: TravelSegment? = travelByDay[dayNum]
// Custom items for this day - filter by day and custom kind, sort by sortOrder // Custom items for this day
// Note: Add button is now embedded in the day header row (not a separate item)
let customItemsForDay = itineraryItems let customItemsForDay = itineraryItems
.filter { $0.day == dayNum && $0.isCustom } .filter { $0.day == dayNum && $0.isCustom }
.sorted { $0.sortOrder < $1.sortOrder } .sorted { $0.sortOrder < $1.sortOrder }
for item in customItemsForDay { for item in customItemsForDay {
items.append(ItineraryRowItem.customItem(item)) 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 {
// Find the segment matching this travel
if let info = travel.travelInfo,
let seg = trip.travelSegments.first(where: {
$0.fromLocation.name.lowercased() == info.fromCity.lowercased()
&& $0.toLocation.name.lowercased() == info.toCity.lowercased()
}) {
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 id = stableTravelAnchorId(seg)
return (travelOverrides[id]?.sortOrder)
?? (travelItems.first(where: { ti in
guard case .travel(let inf) = ti.kind else { return false }
return inf.fromCity.lowercased() == seg.fromLocation.name.lowercased()
&& inf.toCity.lowercased() == seg.toLocation.name.lowercased()
})?.sortOrder ?? 0.0)
default:
return 0.0
}
}
return so(a) < so(b)
} }
let dayData = ItineraryDayData( let dayData = ItineraryDayData(
@@ -174,13 +243,13 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresent
dayNumber: dayNum, dayNumber: dayNum,
date: dayDate, date: dayDate,
games: gamesOnDay, games: gamesOnDay,
items: items, items: rows,
travelBefore: travelBefore travelBefore: nil
) )
days.append(dayData) days.append(dayData)
} }
return (days, travelValidRanges) return (days, travelValidRanges, itineraryItems + travelItems)
} }
// MARK: - Helper Methods // MARK: - Helper Methods

View File

@@ -42,7 +42,7 @@ struct TripDetailView: View {
@State private var draggedItem: ItineraryItem? @State private var draggedItem: ItineraryItem?
@State private var draggedTravelId: String? // Track which travel segment is being dragged @State private var draggedTravelId: String? // Track which travel segment is being dragged
@State private var dropTargetId: String? // Track which drop zone is being hovered @State private var dropTargetId: String? // Track which drop zone is being hovered
@State private var travelDayOverrides: [String: Int] = [:] // Key: travel ID, Value: day number @State private var travelOverrides: [String: TravelOverride] = [:] // Key: travel ID, Value: day + sortOrder
private let exportService = ExportService() private let exportService = ExportService()
private let dataProvider = AppDataProvider.shared private let dataProvider = AppDataProvider.shared
@@ -81,98 +81,82 @@ struct TripDetailView: View {
} }
var body: some View { var body: some View {
mainContent bodyContent
.background(Theme.backgroundGradient(colorScheme)) }
.toolbarBackground(Theme.cardBackground(colorScheme), for: .navigationBar)
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
ShareButton(trip: trip, style: .icon)
.foregroundStyle(Theme.warmOrange)
Button { @ViewBuilder
if StoreManager.shared.isPro { private var bodyContent: some View {
Task { mainContent
await exportPDF() .background(Theme.backgroundGradient(colorScheme))
} .toolbarBackground(Theme.cardBackground(colorScheme), for: .navigationBar)
} else { .toolbar { toolbarContent }
showProPaywall = true .modifier(SheetModifiers(
} showExportSheet: $showExportSheet,
} label: { exportURL: exportURL,
HStack(spacing: 2) { showProPaywall: $showProPaywall,
Image(systemName: "doc.fill") addItemAnchor: $addItemAnchor,
if !StoreManager.shared.isPro { editingItem: $editingItem,
ProBadge() tripId: trip.id,
} saveItineraryItem: saveItineraryItem
} ))
.foregroundStyle(Theme.warmOrange) .onAppear { checkIfSaved() }
.task {
await loadGamesIfNeeded()
if allowCustomItems {
await loadItineraryItems()
await setupSubscription()
} }
} }
} .onDisappear { subscriptionCancellable?.cancel() }
.sheet(isPresented: $showExportSheet) { .onChange(of: itineraryItems) { _, newItems in
if let url = exportURL { handleItineraryItemsChange(newItems)
ShareSheet(items: [url])
} }
} .onChange(of: travelOverrides.count) { _, _ in
.sheet(isPresented: $showProPaywall) { draggedTravelId = nil
PaywallView() dropTargetId = nil
}
.sheet(item: $addItemAnchor) { anchor in
AddItemSheet(
tripId: trip.id,
day: anchor.day,
existingItem: nil
) { item in
Task { await saveItineraryItem(item) }
} }
} .overlay {
.sheet(item: $editingItem) { item in if isExporting { exportProgressOverlay }
AddItemSheet(
tripId: trip.id,
day: item.day,
existingItem: item
) { updatedItem in
Task { await saveItineraryItem(updatedItem) }
} }
} }
.onAppear {
checkIfSaved() @ToolbarContentBuilder
} private var toolbarContent: some ToolbarContent {
.task { ToolbarItemGroup(placement: .primaryAction) {
await loadGamesIfNeeded() ShareButton(trip: trip, style: .icon)
if allowCustomItems { .foregroundStyle(Theme.warmOrange)
await loadItineraryItems()
await setupSubscription() Button {
} if StoreManager.shared.isPro {
} Task { await exportPDF() }
.onDisappear { } else {
subscriptionCancellable?.cancel() showProPaywall = true
}
.onChange(of: itineraryItems) { _, newItems in
// Clear drag state after items update (move completed)
draggedItem = nil
dropTargetId = nil
// Recalculate routes when custom items change (mappable items affect route)
print("🗺️ [MapUpdate] itineraryItems changed, count: \(newItems.count)")
for item in newItems {
if item.isCustom, let info = item.customInfo, info.isMappable {
print("🗺️ [MapUpdate] Mappable: \(info.title) on day \(item.day), sortOrder: \(item.sortOrder)")
} }
} } label: {
Task { HStack(spacing: 2) {
updateMapRegion() Image(systemName: "doc.fill")
await fetchDrivingRoutes() if !StoreManager.shared.isPro {
ProBadge()
}
}
.foregroundStyle(Theme.warmOrange)
} }
} }
.onChange(of: travelDayOverrides) { _, _ in }
// Clear drag state after travel move completed
draggedTravelId = nil private func handleItineraryItemsChange(_ newItems: [ItineraryItem]) {
dropTargetId = nil draggedItem = nil
} dropTargetId = nil
.overlay { print("🗺️ [MapUpdate] itineraryItems changed, count: \(newItems.count)")
if isExporting { for item in newItems {
exportProgressOverlay if item.isCustom, let info = item.customInfo, info.isMappable {
print("🗺️ [MapUpdate] Mappable: \(info.title) on day \(item.day), sortOrder: \(item.sortOrder)")
} }
} }
Task {
updateMapRegion()
await fetchDrivingRoutes()
}
} }
// MARK: - Main Content // MARK: - Main Content
@@ -185,7 +169,7 @@ struct TripDetailView: View {
trip: trip, trip: trip,
games: Array(games.values), games: Array(games.values),
itineraryItems: itineraryItems, itineraryItems: itineraryItems,
travelDayOverrides: travelDayOverrides, travelOverrides: travelOverrides,
headerContent: { headerContent: {
VStack(spacing: 0) { VStack(spacing: 0) {
// Hero Map // Hero Map
@@ -214,10 +198,10 @@ struct TripDetailView: View {
.padding(.bottom, Theme.Spacing.md) .padding(.bottom, Theme.Spacing.md)
} }
}, },
onTravelMoved: { travelId, newDay in onTravelMoved: { travelId, newDay, newSortOrder in
Task { @MainActor in Task { @MainActor in
withAnimation { withAnimation {
travelDayOverrides[travelId] = newDay travelOverrides[travelId] = TravelOverride(day: newDay, sortOrder: newSortOrder)
} }
await saveTravelDayOverride(travelAnchorId: travelId, displayDay: newDay) await saveTravelDayOverride(travelAnchorId: travelId, displayDay: newDay)
} }
@@ -818,8 +802,9 @@ struct TripDetailView: View {
} }
// Check for user override - only use if within valid range // Check for user override - only use if within valid range
if let overrideDay = travelDayOverrides[travelId], validRange.contains(overrideDay) { if let override = travelOverrides[travelId],
travelByDay[overrideDay] = segment validRange.contains(override.day) {
travelByDay[override.day] = segment
} else { } else {
// Use default (clamped to valid range) // Use default (clamped to valid range)
let clampedDefault = max(validRange.lowerBound, min(defaultDay, validRange.upperBound)) let clampedDefault = max(validRange.lowerBound, min(defaultDay, validRange.upperBound))
@@ -1276,16 +1261,18 @@ struct TripDetailView: View {
print("✅ [ItineraryItems] Loaded \(items.count) items from CloudKit") print("✅ [ItineraryItems] Loaded \(items.count) items from CloudKit")
itineraryItems = items itineraryItems = items
// Extract travel day overrides from travel-type items // Extract travel overrides (day + sortOrder) from travel-type items
var overrides: [String: Int] = [:] var overrides: [String: TravelOverride] = [:]
for item in items where item.isTravel { for item in items where item.isTravel {
if let travelInfo = item.travelInfo { guard let travelInfo = item.travelInfo else { continue }
let travelId = "travel:\(travelInfo.fromCity.lowercased())->\(travelInfo.toCity.lowercased())" let travelId = "travel:\(travelInfo.fromCity.lowercased())->\(travelInfo.toCity.lowercased())"
overrides[travelId] = item.day
} overrides[travelId] = TravelOverride(day: item.day, sortOrder: item.sortOrder)
} }
travelDayOverrides = overrides
print("✅ [TravelOverrides] Extracted \(overrides.count) travel day overrides") travelOverrides = overrides
print("✅ [TravelOverrides] Extracted \(overrides.count) travel overrides (day + sortOrder)")
} catch { } catch {
print("❌ [ItineraryItems] Failed to load: \(error)") print("❌ [ItineraryItems] Failed to load: \(error)")
} }
@@ -1357,21 +1344,35 @@ struct TripDetailView: View {
Task { @MainActor in Task { @MainActor in
// Check if this is a travel segment being dropped // Check if this is a travel segment being dropped
if droppedId.hasPrefix("travel:") { if droppedId.hasPrefix("travel:") {
// Validate travel is within valid bounds // Validate travel is within valid bounds (day-level)
if let validRange = self.validDayRange(for: droppedId) { if let validRange = self.validDayRange(for: droppedId) {
guard validRange.contains(dayNumber) else { guard validRange.contains(dayNumber) else {
// Day is outside valid range - reject drop (state already cleared)
return return
} }
} }
// Move travel to this day // Choose a semantic sortOrder for dropping onto a day:
// - If this day has games, default to AFTER games (positive)
// - If no games, default to 1.0
//
// You can later support "before games" drops by using a negative sortOrder
// when the user drops above the games row.
let maxSortOrderOnDay = self.itineraryItems
.filter { $0.day == dayNumber }
.map { $0.sortOrder }
.max() ?? 0.0
let newSortOrder = max(maxSortOrderOnDay + 1.0, 1.0)
withAnimation { withAnimation {
self.travelDayOverrides[droppedId] = dayNumber self.travelOverrides[droppedId] = TravelOverride(day: dayNumber, sortOrder: newSortOrder)
} }
// Persist the override to CloudKit // Persist to CloudKit as a travel ItineraryItem
await self.saveTravelDayOverride(travelAnchorId: droppedId, displayDay: dayNumber) await self.saveTravelDayOverride(
travelAnchorId: droppedId,
displayDay: dayNumber
)
return return
} }
@@ -1901,3 +1902,52 @@ struct TripMapView: View {
) )
} }
} }
// MARK: - Travel Override
struct TravelOverride: Equatable {
let day: Int
let sortOrder: Double
}
// MARK: - Sheet Modifiers
private struct SheetModifiers: ViewModifier {
@Binding var showExportSheet: Bool
let exportURL: URL?
@Binding var showProPaywall: Bool
@Binding var addItemAnchor: AddItemAnchor?
@Binding var editingItem: ItineraryItem?
let tripId: UUID
let saveItineraryItem: (ItineraryItem) async -> Void
func body(content: Content) -> some View {
content
.sheet(isPresented: $showExportSheet) {
if let url = exportURL {
ShareSheet(items: [url])
}
}
.sheet(isPresented: $showProPaywall) {
PaywallView()
}
.sheet(item: $addItemAnchor) { anchor in
AddItemSheet(
tripId: tripId,
day: anchor.day,
existingItem: nil
) { item in
Task { await saveItineraryItem(item) }
}
}
.sheet(item: $editingItem) { item in
AddItemSheet(
tripId: tripId,
day: item.day,
existingItem: item
) { updatedItem in
Task { await saveItineraryItem(updatedItem) }
}
}
}
}