refactor(itinerary): replace anchor-based positioning with day/sortOrder

Replace complex anchor system (anchorType, anchorId, anchorDay) with
simple (day: Int, sortOrder: Double) positioning for custom items.

Changes:
- CustomItineraryItem: Remove anchor fields, add day and sortOrder
- CKModels: Add migration fallback from old CloudKit fields
- ItineraryTableViewController: Add calculateSortOrder() for midpoint insertion
- TripDetailView: Simplify callbacks, itinerarySections, and routeWaypoints
- AddItemSheet: Take simple day parameter instead of anchor
- SavedTrip: Update LocalCustomItem SwiftData model

Benefits:
- Items freely movable via drag-and-drop
- Route waypoints follow exact visual order
- Simpler mental model: position = (day, sortOrder)
- Midpoint insertion allows unlimited reordering

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-17 09:47:11 -06:00
parent 59ba2c6965
commit 2a8bfeeff8
10 changed files with 238 additions and 385 deletions

View File

@@ -27,7 +27,7 @@ enum ItineraryRowItem: Identifiable, Equatable {
case dayHeader(dayNumber: Int, date: Date, games: [RichGame])
case travel(TravelSegment, dayNumber: Int) // dayNumber = the day this travel is associated with
case customItem(CustomItineraryItem)
case addButton(day: Int, anchorType: CustomItineraryItem.AnchorType, anchorId: String?)
case addButton(day: Int) // Simplified - just needs day
var id: String {
switch self {
@@ -37,8 +37,8 @@ enum ItineraryRowItem: Identifiable, Equatable {
return "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())"
case .customItem(let item):
return "item:\(item.id.uuidString)"
case .addButton(let day, let anchorType, let anchorId):
return "add:\(day)-\(anchorType.rawValue)-\(anchorId ?? "nil")"
case .addButton(let day):
return "add:\(day)"
}
}
@@ -68,10 +68,10 @@ final class ItineraryTableViewController: UITableViewController {
// Callbacks
var onTravelMoved: ((String, Int) -> Void)? // travelId, newDay
var onCustomItemMoved: ((UUID, Int, CustomItineraryItem.AnchorType, String?) -> Void)?
var onCustomItemMoved: ((UUID, Int, Double) -> Void)? // itemId, newDay, newSortOrder
var onCustomItemTapped: ((CustomItineraryItem) -> Void)?
var onCustomItemDeleted: ((CustomItineraryItem) -> Void)?
var onAddButtonTapped: ((Int, CustomItineraryItem.AnchorType, String?) -> Void)?
var onAddButtonTapped: ((Int) -> Void)? // Just day number
// Cell reuse identifiers
private let dayHeaderCellId = "DayHeaderCell"
@@ -234,9 +234,9 @@ final class ItineraryTableViewController: UITableViewController {
configureCustomItemCell(cell, item: customItem)
return cell
case .addButton(let day, let anchorType, let anchorId):
case .addButton(let day):
let cell = tableView.dequeueReusableCell(withIdentifier: addButtonCellId, for: indexPath)
configureAddButtonCell(cell, day: day, anchorType: anchorType, anchorId: anchorId)
configureAddButtonCell(cell, day: day)
return cell
}
}
@@ -266,8 +266,8 @@ final class ItineraryTableViewController: UITableViewController {
case .customItem(let customItem):
let destinationDay = dayNumber(forRow: destinationIndexPath.row)
let (anchorType, anchorId) = determineAnchor(at: destinationIndexPath.row)
onCustomItemMoved?(customItem.id, destinationDay, anchorType, anchorId)
let sortOrder = calculateSortOrder(at: destinationIndexPath.row)
onCustomItemMoved?(customItem.id, destinationDay, sortOrder)
default:
break
@@ -403,8 +403,8 @@ final class ItineraryTableViewController: UITableViewController {
case .customItem(let customItem):
onCustomItemTapped?(customItem)
case .addButton(let day, let anchorType, let anchorId):
onAddButtonTapped?(day, anchorType, anchorId)
case .addButton(let day):
onAddButtonTapped?(day)
default:
break
@@ -435,53 +435,64 @@ final class ItineraryTableViewController: UITableViewController {
// MARK: - Helper Methods
private func determineAnchor(at row: Int) -> (CustomItineraryItem.AnchorType, String?) {
// Scan backwards to find the day's context
// Structure: travel (optional) -> dayHeader -> items
var foundTravel: TravelSegment?
var foundDayGames: [RichGame] = []
/// Calculate the sortOrder for an item dropped at the given row position
/// Uses midpoint insertion: if between sortOrder 1.0 and 2.0, returns 1.5
private func calculateSortOrder(at row: Int) -> Double {
// Find adjacent custom items to calculate midpoint
var prevSortOrder: Double?
var nextSortOrder: Double?
// Scan backwards for previous custom item in same day
for i in stride(from: row - 1, through: 0, by: -1) {
switch flatItems[i] {
case .travel(let segment, _):
// Found travel - if this is the first significant item, use afterTravel
// But only if we haven't passed a day header yet
if foundDayGames.isEmpty {
foundTravel = segment
}
// Travel marks the boundary - stop scanning
let travelId = "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())"
// If the drop is right after travel (no day header between), use afterTravel
if foundDayGames.isEmpty {
return (.afterTravel, travelId)
}
// Otherwise we already passed a day header, use that context
case .customItem(let item):
prevSortOrder = item.sortOrder
break
case .dayHeader(_, _, let games):
// Found the day header for this section
foundDayGames = games
// If day has games, items dropped after should be afterGame
if let lastGame = games.last {
return (.afterGame, lastGame.game.id)
}
// No games - check if there's travel before this day
// Continue scanning to find travel
continue
case .customItem, .addButton:
// Skip these, keep scanning backwards
case .dayHeader, .travel:
// Hit a boundary - no previous item in this section
break
case .addButton:
continue
}
if prevSortOrder != nil { break }
// If we hit dayHeader or travel, stop scanning
if case .dayHeader = flatItems[i] { break }
if case .travel = flatItems[i] { break }
}
// If we found travel but no games, use afterTravel
if let segment = foundTravel {
let travelId = "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())"
return (.afterTravel, travelId)
// Scan forwards for next custom item in same day
for i in row..<flatItems.count {
switch flatItems[i] {
case .customItem(let item):
nextSortOrder = item.sortOrder
break
case .dayHeader, .travel:
// Hit a boundary - no next item in this section
break
case .addButton:
continue
}
if nextSortOrder != nil { break }
// If we hit dayHeader or travel, stop scanning
if case .dayHeader = flatItems[i] { break }
if case .travel = flatItems[i] { break }
}
return (.startOfDay, nil)
// Calculate appropriate sortOrder
switch (prevSortOrder, nextSortOrder) {
case (nil, nil):
// No adjacent items - use 1.0
return 1.0
case (let prev?, nil):
// After last item - use prev + 1.0
return prev + 1.0
case (nil, let next?):
// Before first item - use next / 2.0
return next / 2.0
case (let prev?, let next?):
// Between two items - use midpoint
return (prev + next) / 2.0
}
}
// MARK: - Cell Configuration
@@ -519,7 +530,7 @@ final class ItineraryTableViewController: UITableViewController {
cell.selectionStyle = .default
}
private func configureAddButtonCell(_ cell: UITableViewCell, day: Int, anchorType: CustomItineraryItem.AnchorType, anchorId: String?) {
private func configureAddButtonCell(_ cell: UITableViewCell, day: Int) {
cell.contentConfiguration = UIHostingConfiguration {
AddButtonRowView(colorScheme: colorScheme)
}