fix(itinerary): add city to game items for proper constraint validation

Travel constraint validation was not working because ItineraryConstraints
had no game items to validate against - games came from RichGame objects
but were never converted to ItineraryItem for constraint checking.

Changes:
- Add city parameter to ItemKind.game enum case
- Create game ItineraryItems from RichGame data in buildItineraryData()
- Update isValidTravelPosition to compare against actual game sortOrders
- Fix tests to use appropriate game sortOrder conventions

Now travel is properly constrained to appear before arrival city games
and after departure city games.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-18 22:46:40 -06:00
parent 72447c61fe
commit e72da7c5a7
11 changed files with 247 additions and 254 deletions

View File

@@ -5,13 +5,6 @@ struct ItineraryConstraints {
let tripDayCount: Int let tripDayCount: Int
private let items: [ItineraryItem] private let items: [ItineraryItem]
/// City extracted from game ID (format: "game-CityName-xxxx")
private func city(forGameId gameId: String) -> String? {
let components = gameId.components(separatedBy: "-")
guard components.count >= 2 else { return nil }
return components[1]
}
init(tripDayCount: Int, items: [ItineraryItem]) { init(tripDayCount: Int, items: [ItineraryItem]) {
self.tripDayCount = tripDayCount self.tripDayCount = tripDayCount
self.items = items self.items = items
@@ -83,8 +76,10 @@ struct ItineraryConstraints {
// MARK: - Private Helpers // MARK: - Private Helpers
private func isValidTravelPosition(fromCity: String, toCity: String, day: Int, sortOrder: Double) -> Bool { private func isValidTravelPosition(fromCity: String, toCity: String, day: Int, sortOrder: Double) -> Bool {
let departureGameDays = gameDays(in: fromCity) let departureGames = games(in: fromCity)
let arrivalGameDays = gameDays(in: toCity) let arrivalGames = games(in: toCity)
let departureGameDays = departureGames.map { $0.day }
let arrivalGameDays = arrivalGames.map { $0.day }
let minDay = departureGameDays.max() ?? 1 let minDay = departureGameDays.max() ?? 1
let maxDay = arrivalGameDays.min() ?? tripDayCount let maxDay = arrivalGameDays.min() ?? tripDayCount
@@ -92,26 +87,20 @@ struct ItineraryConstraints {
// Check day is in valid range // Check day is in valid range
guard day >= minDay && day <= maxDay else { return false } guard day >= minDay && day <= maxDay else { return false }
// Check sortOrder constraints on edge days // Check sortOrder constraints on edge days.
// Must be strictly AFTER all departure games on that day
if departureGameDays.contains(day) { if departureGameDays.contains(day) {
// On a departure game day: must be after ALL games in that city on that day let gamesOnDay = departureGames.filter { $0.day == day }
let maxGameSortOrder = games(in: fromCity) let maxGameSortOrder = gamesOnDay.map { $0.sortOrder }.max() ?? 0
.filter { $0.day == day }
.map { $0.sortOrder }
.max() ?? 0
if sortOrder <= maxGameSortOrder { if sortOrder <= maxGameSortOrder {
return false return false
} }
} }
// Must be strictly BEFORE all arrival games on that day
if arrivalGameDays.contains(day) { if arrivalGameDays.contains(day) {
// On an arrival game day: must be before ALL games in that city on that day let gamesOnDay = arrivalGames.filter { $0.day == day }
let minGameSortOrder = games(in: toCity) let minGameSortOrder = gamesOnDay.map { $0.sortOrder }.min() ?? 0
.filter { $0.day == day }
.map { $0.sortOrder }
.min() ?? Double.greatestFiniteMagnitude
if sortOrder >= minGameSortOrder { if sortOrder >= minGameSortOrder {
return false return false
} }
@@ -126,8 +115,8 @@ struct ItineraryConstraints {
private func games(in city: String) -> [ItineraryItem] { private func games(in city: String) -> [ItineraryItem] {
return items.filter { item in return items.filter { item in
guard case .game(let gameId) = item.kind else { return false } guard let gameCity = item.gameCity else { return false }
return self.city(forGameId: gameId) == city return gameCity.lowercased() == city.lowercased()
} }
} }
} }

View File

@@ -29,7 +29,7 @@ struct ItineraryItem: Identifiable, Codable, Hashable {
/// The type of itinerary item /// The type of itinerary item
enum ItemKind: Codable, Hashable { enum ItemKind: Codable, Hashable {
case game(gameId: String) case game(gameId: String, city: String)
case travel(TravelInfo) case travel(TravelInfo)
case custom(CustomInfo) case custom(CustomInfo)
} }
@@ -106,14 +106,19 @@ extension ItineraryItem {
} }
var gameId: String? { var gameId: String? {
if case .game(let id) = kind { return id } if case .game(let id, _) = kind { return id }
return nil
}
var gameCity: String? {
if case .game(_, let city) = kind { return city }
return nil return nil
} }
/// Display title for the item /// Display title for the item
var displayTitle: String { var displayTitle: String {
switch kind { switch kind {
case .game(let gameId): case .game(let gameId, _):
return "Game: \(gameId)" return "Game: \(gameId)"
case .travel(let info): case .travel(let info):
return "\(info.fromCity)\(info.toCity)" return "\(info.fromCity)\(info.toCity)"

View File

@@ -145,8 +145,9 @@ extension ItineraryItem {
// Parse kind // Parse kind
switch kindString { switch kindString {
case "game": case "game":
guard let gameId = record["gameId"] as? String else { return nil } guard let gameId = record["gameId"] as? String,
self.kind = .game(gameId: gameId) let gameCity = record["gameCity"] as? String else { return nil }
self.kind = .game(gameId: gameId, city: gameCity)
case "travel": case "travel":
guard let fromCity = record["travelFromCity"] as? String, guard let fromCity = record["travelFromCity"] as? String,
@@ -188,9 +189,10 @@ extension ItineraryItem {
record["modifiedAt"] = modifiedAt record["modifiedAt"] = modifiedAt
switch kind { switch kind {
case .game(let gameId): case .game(let gameId, let city):
record["kind"] = "game" record["kind"] = "game"
record["gameId"] = gameId record["gameId"] = gameId
record["gameCity"] = city
case .travel(let info): case .travel(let info):
record["kind"] = "travel" record["kind"] = "travel"

View File

@@ -388,11 +388,9 @@ final class PDFGenerator {
var primaryCity: String? var primaryCity: String?
for item in items { for item in items {
switch item.kind { switch item.kind {
case .game(let gameId): case .game(_, let city):
if let richGame = games[gameId] { primaryCity = city
primaryCity = richGame.stadium.city break
break
}
case .travel(let info): case .travel(let info):
primaryCity = info.toCity primaryCity = info.toCity
break break
@@ -419,7 +417,7 @@ final class PDFGenerator {
var hasContent = false var hasContent = false
for item in items { for item in items {
switch item.kind { switch item.kind {
case .game(let gameId): case .game(let gameId, _):
if let richGame = games[gameId] { if let richGame = games[gameId] {
currentY = drawGameCard( currentY = drawGameCard(
context: context, context: context,

View File

@@ -5,9 +5,6 @@
// Pure functions for itinerary reordering logic. // Pure functions for itinerary reordering logic.
// Extracted from ItineraryTableViewController for testability. // Extracted from ItineraryTableViewController for testability.
// //
// All functions in this enum are pure - they take inputs and return outputs
// with no side effects, making them fully unit-testable without UIKit.
//
// SEMANTIC TRAVEL MODEL: // SEMANTIC TRAVEL MODEL:
// - Travel items are positioned semantically via (day, sortOrder), not structurally. // - Travel items are positioned semantically via (day, sortOrder), not structurally.
// - Travel can appear before games (sortOrder < 0) or after games (sortOrder >= 0). // - Travel can appear before games (sortOrder < 0) or after games (sortOrder >= 0).
@@ -15,40 +12,13 @@
// - All movable items (custom + travel) use the same day computation: backward scan to nearest dayHeader. // - All movable items (custom + travel) use the same day computation: backward scan to nearest dayHeader.
// //
// COORDINATE SPACE CONVENTIONS: // COORDINATE SPACE CONVENTIONS:
// // - "Original indices": Row indices in the current flatItems array (0..<flatItems.count)
// Two coordinate spaces exist during drag-drop operations: // - "Proposed indices": Row indices in post-removal array (UITableView move semantics)
// // After removing sourceRow, the array has count-1 elements. Insert positions are 0...count-1.
// 1. ORIGINAL SPACE (flatItems indices) // - simulateMove: Takes proposed index, returns post-move array + actual destination
// - Row indices in the current flatItems array: 0..<flatItems.count // - computeValidDestinationRowsProposed: Returns PROPOSED indices (for UITableView delegate)
// - Used by: DragZones (invalidRowIndices, validDropRows), UI highlighting // - DragZones: invalidRowIndices and validDropRows are in ORIGINAL space (for UI highlighting)
// - Source row is always specified in original space // - To convert: proposedToOriginal(proposed, sourceRow) and originalToProposed(original, sourceRow)
//
// 2. PROPOSED SPACE (UITableView post-removal)
// - Row indices after sourceRow is removed from the array
// - After removal: array has count-1 elements, valid insert positions are 0...(count-1)
// - Used by: UITableView delegate methods, computeValidDestinationRowsProposed return value
// - Proposed index N means: remove source, insert at position N in the remaining array
//
// FUNCTION REFERENCE:
// - simulateMove: Takes PROPOSED index returns post-move array + actual destination
// - computeValidDestinationRowsProposed: Returns PROPOSED indices (for tableView delegate)
// - calculateSortOrder: Takes row in POST-MOVE array (item already at destination)
// - calculateTravelDragZones/calculateCustomItemDragZones: Return ORIGINAL indices
//
// COORDINATE CONVERSION:
// - proposedToOriginal(proposed, sourceRow): Converts proposed original
// If proposed >= sourceRow: return proposed + 1 (shift up past removed source)
// If proposed < sourceRow: return proposed (unchanged)
// - originalToProposed(original, sourceRow): Converts original proposed
// If original == sourceRow: return nil (source has no proposed equivalent)
// If original > sourceRow: return original - 1 (shift down)
// If original < sourceRow: return original (unchanged)
//
// WHY THIS MATTERS:
// - DragZones are used for UI highlighting (which cells to dim/enable)
// - UI highlighting operates on the visible table, which uses ORIGINAL indices
// - But validation uses simulation, which operates in PROPOSED space
// - Getting this wrong causes visual bugs (wrong rows highlighted) or logic bugs
// //
import Foundation import Foundation
@@ -58,13 +28,13 @@ import Foundation
/// Container for all pure reordering logic. /// Container for all pure reordering logic.
/// Using an enum (no cases) as a namespace for static functions. /// Using an enum (no cases) as a namespace for static functions.
enum ItineraryReorderingLogic { enum ItineraryReorderingLogic {
// MARK: - Row Flattening // MARK: - Row Flattening
/// Default sortOrder for travel when lookup returns nil. /// Default sortOrder for travel when lookup returns nil.
/// Travel defaults to after-games region (positive value). /// Travel defaults to after-games region (positive value).
private static let defaultTravelSortOrder: Double = 1.0 private static let defaultTravelSortOrder: Double = 1.0
/// Flattens hierarchical day data into a single array of row items. /// Flattens hierarchical day data into a single array of row items.
/// ///
/// **SEMANTIC MODEL**: This function ignores `day.travelBefore` entirely. /// **SEMANTIC MODEL**: This function ignores `day.travelBefore` entirely.
@@ -85,74 +55,68 @@ enum ItineraryReorderingLogic {
findTravelSortOrder: (TravelSegment) -> Double? findTravelSortOrder: (TravelSegment) -> Double?
) -> [ItineraryRowItem] { ) -> [ItineraryRowItem] {
var flatItems: [ItineraryRowItem] = [] var flatItems: [ItineraryRowItem] = []
for day in days { for day in days {
// NOTE: day.travelBefore is IGNORED under semantic travel model. // NOTE: day.travelBefore is IGNORED under semantic travel model.
// Travel must be in day.items with a sortOrder to appear. // Travel must be in day.items with a sortOrder to appear.
// 1. Day header (structural anchor) // 1. Day header (structural anchor)
flatItems.append(.dayHeader(dayNumber: day.dayNumber, date: day.date)) flatItems.append(.dayHeader(dayNumber: day.dayNumber, date: day.date))
// 2. Partition movable items around games boundary // 2. Partition movable items around games boundary
// Tuple includes tiebreaker for stable sorting when sortOrders are equal // Tuple includes tiebreaker for stable sorting when sortOrders are equal
var beforeGames: [(sortOrder: Double, tiebreaker: Int, item: ItineraryRowItem)] = [] var beforeGames: [(sortOrder: Double, tiebreaker: Int, item: ItineraryRowItem)] = []
var afterGames: [(sortOrder: Double, tiebreaker: Int, item: ItineraryRowItem)] = [] var afterGames: [(sortOrder: Double, tiebreaker: Int, item: ItineraryRowItem)] = []
var insertionOrder = 0 var insertionOrder = 0
for row in day.items { for row in day.items {
let sortOrder: Double let sortOrder: Double
let tiebreaker = insertionOrder let tiebreaker = insertionOrder
insertionOrder += 1 insertionOrder += 1
switch row { switch row {
case .customItem(let item): case .customItem(let item):
sortOrder = item.sortOrder sortOrder = item.sortOrder
case .travel(let segment, _): case .travel(let segment, _):
if let so = findTravelSortOrder(segment) { // Use provided sortOrder if available, otherwise default to after-games position.
sortOrder = so // nil is valid during initial display before travel is persisted.
} else { let lookedUp = findTravelSortOrder(segment)
// Travel without stored sortOrder gets a safe default. sortOrder = lookedUp ?? defaultTravelSortOrder
// Log a warning in debug builds - this shouldn't happen in production. print("📋 [flattenDays] Travel \(segment.fromLocation.name)->\(segment.toLocation.name) on day \(day.dayNumber): lookedUp=\(String(describing: lookedUp)), using sortOrder=\(sortOrder)")
#if DEBUG
print("⚠️ flattenDays: Travel segment missing sortOrder: \(segment.fromLocation.name)\(segment.toLocation.name). Using default: \(defaultTravelSortOrder)")
#endif
sortOrder = defaultTravelSortOrder
}
case .games, .dayHeader: case .games, .dayHeader:
// These item types are not movable and handled separately. // These item types are not movable and handled separately.
// Skip explicitly - games are added after partitioning.
continue continue
} }
if sortOrder < 0 { if sortOrder < 0 {
beforeGames.append((sortOrder, tiebreaker, row)) beforeGames.append((sortOrder, tiebreaker, row))
} else { } else {
afterGames.append((sortOrder, tiebreaker, row)) afterGames.append((sortOrder, tiebreaker, row))
} }
} }
// Sort by sortOrder within each region, with stable tiebreaker
beforeGames.sort { ($0.sortOrder, $0.tiebreaker) < ($1.sortOrder, $1.tiebreaker) } beforeGames.sort { ($0.sortOrder, $0.tiebreaker) < ($1.sortOrder, $1.tiebreaker) }
afterGames.sort { ($0.sortOrder, $0.tiebreaker) < ($1.sortOrder, $1.tiebreaker) } afterGames.sort { ($0.sortOrder, $0.tiebreaker) < ($1.sortOrder, $1.tiebreaker) }
flatItems.append(contentsOf: beforeGames.map { $0.item }) flatItems.append(contentsOf: beforeGames.map { $0.item })
// 3. Games for this day (bundled as one row) // 3. Games for this day (bundled as one row)
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. Items after games // 4. Items after games
flatItems.append(contentsOf: afterGames.map { $0.item }) flatItems.append(contentsOf: afterGames.map { $0.item })
} }
return flatItems return flatItems
} }
// MARK: - Day Number Lookup // MARK: - Day Number Lookup
/// Finds which day a row at the given index belongs to. /// Finds which day a row at the given index belongs to.
/// ///
/// Scans backwards from the row to find a `.dayHeader`. /// Scans backwards from the row to find a `.dayHeader`.
@@ -172,7 +136,7 @@ enum ItineraryReorderingLogic {
} }
return 1 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.
/// ///
/// - Parameters: /// - Parameters:
@@ -187,7 +151,7 @@ enum ItineraryReorderingLogic {
} }
return nil return nil
} }
/// Finds the row index of the travel segment on a specific day. /// Finds the row index of the travel segment on a specific day.
/// ///
/// **SEMANTIC MODEL**: Does NOT use the embedded dayNumber in .travel(). /// **SEMANTIC MODEL**: Does NOT use the embedded dayNumber in .travel().
@@ -199,16 +163,13 @@ enum ItineraryReorderingLogic {
/// - day: The day number to find /// - day: The day number to find
/// - Returns: The row index, or nil if no travel on that day /// - Returns: The row index, or nil if no travel on that day
static func travelRow(in items: [ItineraryRowItem], forDay day: Int) -> Int? { static func travelRow(in items: [ItineraryRowItem], forDay day: Int) -> Int? {
// Find the day header row
guard let headerRow = dayHeaderRow(in: items, forDay: day) else { guard let headerRow = dayHeaderRow(in: items, forDay: day) else {
return nil return nil
} }
// Scan forward until next day header, looking for travel
for i in (headerRow + 1)..<items.count { for i in (headerRow + 1)..<items.count {
switch items[i] { switch items[i] {
case .dayHeader: case .dayHeader:
// Reached next day, no travel found
return nil return nil
case .travel: case .travel:
return i return i
@@ -218,7 +179,8 @@ enum ItineraryReorderingLogic {
} }
return nil return nil
} }
/// Legacy version that uses embedded dayNumber (unreliable under semantic model). /// Legacy version that uses embedded dayNumber (unreliable under semantic model).
@available(*, deprecated, message: "Use travelRow(in:forDay:) which uses semantic day lookup") @available(*, deprecated, message: "Use travelRow(in:forDay:) which uses semantic day lookup")
static func travelRowByEmbeddedDay(in items: [ItineraryRowItem], forDay day: Int) -> Int? { static func travelRowByEmbeddedDay(in items: [ItineraryRowItem], forDay day: Int) -> Int? {
@@ -229,7 +191,7 @@ enum ItineraryReorderingLogic {
} }
return nil return nil
} }
/// Determines which day a travel segment belongs to at a given row position. /// Determines which day a travel segment belongs to at a given row position.
/// ///
/// **SEMANTIC MODEL**: Uses backward scan to find the nearest preceding dayHeader. /// **SEMANTIC MODEL**: Uses backward scan to find the nearest preceding dayHeader.
@@ -240,20 +202,19 @@ enum ItineraryReorderingLogic {
/// - items: The flat array of row items /// - items: The flat array of row items
/// - Returns: The day number the travel belongs to /// - Returns: The day number the travel belongs to
static func dayForTravelAt(row: Int, in items: [ItineraryRowItem]) -> Int { static func dayForTravelAt(row: Int, in items: [ItineraryRowItem]) -> Int {
// Semantic model: scan backward to find the day this item belongs to
// (same logic as dayNumber)
return dayNumber(in: items, forRow: row) return dayNumber(in: items, forRow: row)
} }
// MARK: - Move Simulation // MARK: - Move Simulation
/// Result of simulating a move operation. /// Result of simulating a move operation.
struct SimulatedMove { struct SimulatedMove {
let items: [ItineraryRowItem] let items: [ItineraryRowItem]
let destinationRowInNewArray: Int let destinationRowInNewArray: Int
let didMove: Bool // false if move was invalid/no-op let didMove: Bool // false if move was invalid/no-op
} }
/// Simulates UITableView move semantics with bounds safety. /// Simulates UITableView move semantics with bounds safety.
/// ///
/// UITableView moves work as: remove at sourceRow from ORIGINAL array, /// UITableView moves work as: remove at sourceRow from ORIGINAL array,
@@ -269,20 +230,20 @@ enum ItineraryReorderingLogic {
sourceRow: Int, sourceRow: Int,
destinationProposedRow: Int destinationProposedRow: Int
) -> SimulatedMove { ) -> SimulatedMove {
// Bounds safety: return original unchanged if sourceRow is invalid
guard sourceRow >= 0 && sourceRow < original.count else { guard sourceRow >= 0 && sourceRow < original.count else {
return SimulatedMove(items: original, destinationRowInNewArray: sourceRow, didMove: false) return SimulatedMove(items: original, destinationRowInNewArray: sourceRow, didMove: false)
} }
var items = original var items = original
let moving = items.remove(at: sourceRow) let moving = items.remove(at: sourceRow)
let clampedDest = min(max(0, destinationProposedRow), items.count) let clampedDest = min(max(0, destinationProposedRow), items.count)
items.insert(moving, at: clampedDest) items.insert(moving, at: clampedDest)
return SimulatedMove(items: items, destinationRowInNewArray: clampedDest, didMove: true) return SimulatedMove(items: items, destinationRowInNewArray: clampedDest, didMove: true)
} }
// MARK: - Coordinate Space Conversion // MARK: - Coordinate Space Conversion
/// Converts a proposed destination index to the equivalent original index. /// Converts a proposed destination index to the equivalent original index.
/// ///
/// UITableView move semantics: remove at sourceRow first, then insert at proposed position. /// UITableView move semantics: remove at sourceRow first, then insert at proposed position.
@@ -299,7 +260,7 @@ enum ItineraryReorderingLogic {
return proposed return proposed
} }
} }
/// Converts an original index to the equivalent proposed destination index. /// Converts an original index to the equivalent proposed destination index.
/// ///
/// - Parameters: /// - Parameters:
@@ -316,9 +277,9 @@ enum ItineraryReorderingLogic {
return original return original
} }
} }
// MARK: - Sort Order Calculation // MARK: - Sort Order Calculation
/// Calculates the sortOrder for an item dropped at the given row position. /// Calculates the sortOrder for an item dropped at the given row position.
/// ///
/// Uses **midpoint insertion** algorithm to avoid renumbering existing items: /// Uses **midpoint insertion** algorithm to avoid renumbering existing items:
@@ -357,16 +318,27 @@ enum ItineraryReorderingLogic {
} }
} }
// DEBUG: Log the row positions
print("🔢 [calculateSortOrder] row=\(row), day=\(day), gamesRow=\(String(describing: gamesRow))")
print("🔢 [calculateSortOrder] items around row:")
for i in max(0, row - 2)...min(items.count - 1, row + 2) {
let marker = i == row ? "" : " "
let gMarker = (gamesRow == i) ? " [GAMES]" : ""
print("🔢 \(marker) [\(i)] \(items[i])\(gMarker)")
}
// Strict region classification: // Strict region classification:
// - row < gamesRow => before-games (negative sortOrder) // - row < gamesRow => before-games (negative sortOrder)
// - row >= gamesRow OR no games => after-games (positive sortOrder) // - row >= gamesRow OR no games => after-games (positive sortOrder)
let isBeforeGames: Bool let isBeforeGames: Bool
if let gr = gamesRow { if let gr = gamesRow {
isBeforeGames = row < gr isBeforeGames = row < gr
print("🔢 [calculateSortOrder] row(\(row)) < gamesRow(\(gr)) = \(isBeforeGames) → isBeforeGames=\(isBeforeGames)")
} else { } else {
isBeforeGames = false // No games means everything is "after games" isBeforeGames = false // No games means everything is "after games"
print("🔢 [calculateSortOrder] No games on day \(day) → isBeforeGames=false")
} }
/// Get sortOrder from a movable item (custom item or travel) /// Get sortOrder from a movable item (custom item or travel)
func movableSortOrder(_ idx: Int) -> Double? { func movableSortOrder(_ idx: Int) -> Double? {
guard idx >= 0 && idx < items.count else { return nil } guard idx >= 0 && idx < items.count else { return nil }
@@ -379,7 +351,7 @@ enum ItineraryReorderingLogic {
return nil return nil
} }
} }
/// Scan backward from start, stopping at boundaries, looking for movable items in the same region /// Scan backward from start, stopping at boundaries, looking for movable items in the same region
func scanBackward(from start: Int) -> Double? { func scanBackward(from start: Int) -> Double? {
var i = start var i = start
@@ -391,7 +363,7 @@ enum ItineraryReorderingLogic {
} }
// Stop at games boundary (don't cross into other region) // Stop at games boundary (don't cross into other region)
if case .games(_, let d) = items[i], d == day { break } if case .games(_, let d) = items[i], d == day { break }
if let v = movableSortOrder(i) { if let v = movableSortOrder(i) {
// Only return values in the correct region // Only return values in the correct region
if isBeforeGames { if isBeforeGames {
@@ -404,7 +376,7 @@ enum ItineraryReorderingLogic {
} }
return nil return nil
} }
/// Scan forward from start, stopping at boundaries, looking for movable items in the same region /// Scan forward from start, stopping at boundaries, looking for movable items in the same region
func scanForward(from start: Int) -> Double? { func scanForward(from start: Int) -> Double? {
var i = start var i = start
@@ -416,7 +388,7 @@ enum ItineraryReorderingLogic {
} }
// Stop at games boundary (don't cross into other region) // Stop at games boundary (don't cross into other region)
if case .games(_, let d) = items[i], d == day { break } if case .games(_, let d) = items[i], d == day { break }
if let v = movableSortOrder(i) { if let v = movableSortOrder(i) {
// Only return values in the correct region // Only return values in the correct region
if isBeforeGames { if isBeforeGames {
@@ -429,7 +401,8 @@ enum ItineraryReorderingLogic {
} }
return nil return nil
} }
let result: Double
if isBeforeGames { if isBeforeGames {
// Above games: sortOrder should be negative // Above games: sortOrder should be negative
let prev = scanBackward(from: row - 1) let prev = scanBackward(from: row - 1)
@@ -438,9 +411,9 @@ enum ItineraryReorderingLogic {
let upperBound: Double = 0.0 // Games boundary let upperBound: Double = 0.0 // Games boundary
switch (prev, next) { switch (prev, next) {
case (nil, nil): case (nil, nil):
return -1.0 result = -1.0
case (let p?, nil): case (let p?, nil):
return (p + upperBound) / 2.0 result = (p + upperBound) / 2.0
case (nil, let n?): case (nil, let n?):
// First item before games: place it before the next item. // First item before games: place it before the next item.
// n should always be negative (scanForward filters for region). // n should always be negative (scanForward filters for region).
@@ -448,12 +421,13 @@ enum ItineraryReorderingLogic {
// This shouldn't happen - scanForward should only return negative values // This shouldn't happen - scanForward should only return negative values
// in before-games region. Return safe default and assert in debug. // in before-games region. Return safe default and assert in debug.
assertionFailure("Before-games region has non-negative sortOrder: \(n)") assertionFailure("Before-games region has non-negative sortOrder: \(n)")
return -1.0 result = -1.0
} else {
// Place before n by subtracting 1.0 (simpler and more consistent than min(n/2, n-1))
result = n - 1.0
} }
// Place before n by subtracting 1.0 (simpler and more consistent than min(n/2, n-1))
return n - 1.0
case (let p?, let n?): case (let p?, let n?):
return (p + n) / 2.0 result = (p + n) / 2.0
} }
} else { } else {
// Below games: sortOrder should be >= 0 // Below games: sortOrder should be >= 0
@@ -462,15 +436,18 @@ enum ItineraryReorderingLogic {
switch next { switch next {
case nil: case nil:
return (prev == 0.0) ? 1.0 : (prev + 1.0) result = (prev == 0.0) ? 1.0 : (prev + 1.0)
case let n?: case let n?:
return (prev + n) / 2.0 result = (prev + n) / 2.0
} }
} }
print("🔢 [calculateSortOrder] RESULT: \(result) (isBeforeGames=\(isBeforeGames))")
return result
} }
// MARK: - Valid Drop Computation // MARK: - Valid Drop Computation
/// Computes all valid destination rows in **proposed** coordinate space. /// Computes all valid destination rows in **proposed** coordinate space.
/// ///
/// For BOTH travel and custom items, we: /// For BOTH travel and custom items, we:
@@ -503,7 +480,7 @@ enum ItineraryReorderingLogic {
) -> [Int] { ) -> [Int] {
let maxProposed = max(0, flatItems.count - 1) let maxProposed = max(0, flatItems.count - 1)
guard maxProposed > 0 else { return [] } guard maxProposed > 0 else { return [] }
switch dragged { switch dragged {
case .customItem(let customItem): case .customItem(let customItem):
// Custom items use the same simulation+validation approach as travel // Custom items use the same simulation+validation approach as travel
@@ -519,24 +496,24 @@ enum ItineraryReorderingLogic {
return true return true
} }
} }
var valid: [Int] = [] var valid: [Int] = []
valid.reserveCapacity(maxProposed) valid.reserveCapacity(maxProposed)
for proposedRow in 1...maxProposed { for proposedRow in 1...maxProposed {
let simulated = simulateMove(original: flatItems, sourceRow: sourceRow, destinationProposedRow: proposedRow) let simulated = simulateMove(original: flatItems, sourceRow: sourceRow, destinationProposedRow: proposedRow)
guard simulated.didMove else { continue } guard simulated.didMove else { continue }
let destRowInSim = simulated.destinationRowInNewArray let destRowInSim = simulated.destinationRowInNewArray
// Don't allow dropping ON a day header // Don't allow dropping ON a day header
if case .dayHeader = simulated.items[destRowInSim] { if case .dayHeader = simulated.items[destRowInSim] {
continue continue
} }
let day = dayNumber(in: simulated.items, forRow: destRowInSim) let day = dayNumber(in: simulated.items, forRow: destRowInSim)
let sortOrder = calculateSortOrder(in: simulated.items, at: destRowInSim, findTravelSortOrder: findTravelSortOrder) let sortOrder = calculateSortOrder(in: simulated.items, at: destRowInSim, findTravelSortOrder: findTravelSortOrder)
// Create a temporary item model with the computed position // Create a temporary item model with the computed position
let testItem = ItineraryItem( let testItem = ItineraryItem(
id: customItem.id, id: customItem.id,
@@ -545,21 +522,21 @@ enum ItineraryReorderingLogic {
sortOrder: sortOrder, sortOrder: sortOrder,
kind: customItem.kind kind: customItem.kind
) )
if constraints.isValidPosition(for: testItem, day: day, sortOrder: sortOrder) { if constraints.isValidPosition(for: testItem, day: day, sortOrder: sortOrder) {
valid.append(proposedRow) valid.append(proposedRow)
} }
} }
return valid return valid
case .travel(let segment, _): case .travel(let segment, _):
let travelId = "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())" let travelId = "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())"
let validDayRange = travelValidRanges[travelId] let validDayRange = travelValidRanges[travelId]
// Use existing model if available, otherwise create a default // Use existing model if available, otherwise create a default
let model = findTravelItem(segment) ?? makeTravelItem(segment) let model = findTravelItem(segment) ?? makeTravelItem(segment)
guard let constraints = constraints else { guard let constraints = constraints else {
// No constraint engine, allow all rows except 0 and day headers // No constraint engine, allow all rows except 0 and day headers
return (1...maxProposed).filter { proposedRow in return (1...maxProposed).filter { proposedRow in
@@ -571,31 +548,31 @@ enum ItineraryReorderingLogic {
return true return true
} }
} }
var valid: [Int] = [] var valid: [Int] = []
valid.reserveCapacity(maxProposed) valid.reserveCapacity(maxProposed)
for proposedRow in 1...maxProposed { for proposedRow in 1...maxProposed {
let simulated = simulateMove(original: flatItems, sourceRow: sourceRow, destinationProposedRow: proposedRow) let simulated = simulateMove(original: flatItems, sourceRow: sourceRow, destinationProposedRow: proposedRow)
guard simulated.didMove else { continue } guard simulated.didMove else { continue }
let destRowInSim = simulated.destinationRowInNewArray let destRowInSim = simulated.destinationRowInNewArray
// Don't allow dropping ON a day header // Don't allow dropping ON a day header
if case .dayHeader = simulated.items[destRowInSim] { if case .dayHeader = simulated.items[destRowInSim] {
continue continue
} }
let day = dayNumber(in: simulated.items, forRow: destRowInSim) let day = dayNumber(in: simulated.items, forRow: destRowInSim)
// Check day range constraint (quick rejection) // Check day range constraint (quick rejection)
if let range = validDayRange, !range.contains(day) { if let range = validDayRange, !range.contains(day) {
continue continue
} }
// Check sortOrder constraint // Check sortOrder constraint
let sortOrder = calculateSortOrder(in: simulated.items, at: destRowInSim, findTravelSortOrder: findTravelSortOrder) let sortOrder = calculateSortOrder(in: simulated.items, at: destRowInSim, findTravelSortOrder: findTravelSortOrder)
// Create a testItem with computed day/sortOrder (like custom items do) // Create a testItem with computed day/sortOrder (like custom items do)
// This ensures constraints.isValidPosition sees the actual proposed position // This ensures constraints.isValidPosition sees the actual proposed position
let testItem = ItineraryItem( let testItem = ItineraryItem(
@@ -605,22 +582,22 @@ enum ItineraryReorderingLogic {
sortOrder: sortOrder, sortOrder: sortOrder,
kind: model.kind kind: model.kind
) )
if constraints.isValidPosition(for: testItem, day: day, sortOrder: sortOrder) { if constraints.isValidPosition(for: testItem, day: day, sortOrder: sortOrder) {
valid.append(proposedRow) valid.append(proposedRow)
} }
} }
return valid return valid
default: default:
// Day headers and games can't be moved // Day headers and games can't be moved
return [] return []
} }
} }
// MARK: - Drag Zones // MARK: - Drag Zones
/// Result of calculating drag zones for visual feedback. /// Result of calculating drag zones for visual feedback.
/// ///
/// **COORDINATE SPACE**: All indices are in ORIGINAL coordinate space (current flatItems indices). /// **COORDINATE SPACE**: All indices are in ORIGINAL coordinate space (current flatItems indices).
@@ -633,7 +610,7 @@ enum ItineraryReorderingLogic {
/// Game IDs that act as barriers for this drag /// Game IDs that act as barriers for this drag
let barrierGameIds: Set<String> let barrierGameIds: Set<String>
} }
/// Calculates drag zones for a travel segment using simulation+validation. /// Calculates drag zones for a travel segment using simulation+validation.
/// ///
/// This ensures UI feedback matches what will actually be accepted on drop. /// This ensures UI feedback matches what will actually be accepted on drop.
@@ -670,11 +647,11 @@ enum ItineraryReorderingLogic {
makeTravelItem: makeTravelItem, makeTravelItem: makeTravelItem,
findTravelSortOrder: findTravelSortOrder findTravelSortOrder: findTravelSortOrder
) )
// Convert valid rows from proposed to original coordinate space // Convert valid rows from proposed to original coordinate space
let validRowsOriginal = validRowsProposed.map { proposedToOriginal($0, sourceRow: sourceRow) } let validRowsOriginal = validRowsProposed.map { proposedToOriginal($0, sourceRow: sourceRow) }
let validSet = Set(validRowsOriginal) let validSet = Set(validRowsOriginal)
// Compute invalid rows in original coordinate space // Compute invalid rows in original coordinate space
var invalidRows = Set<Int>() var invalidRows = Set<Int>()
for i in 0..<flatItems.count { for i in 0..<flatItems.count {
@@ -686,7 +663,7 @@ enum ItineraryReorderingLogic {
invalidRows.insert(i) invalidRows.insert(i)
} }
} }
// Find barrier games using constraints // Find barrier games using constraints
var barrierGameIds = Set<String>() var barrierGameIds = Set<String>()
if let travelItem = findTravelItem(segment), if let travelItem = findTravelItem(segment),
@@ -694,14 +671,14 @@ enum ItineraryReorderingLogic {
let barriers = constraints.barrierGames(for: travelItem) let barriers = constraints.barrierGames(for: travelItem)
barrierGameIds = Set(barriers.compactMap { $0.gameId }) barrierGameIds = Set(barriers.compactMap { $0.gameId })
} }
return DragZones( return DragZones(
invalidRowIndices: invalidRows, invalidRowIndices: invalidRows,
validDropRows: validRowsOriginal, validDropRows: validRowsOriginal,
barrierGameIds: barrierGameIds barrierGameIds: barrierGameIds
) )
} }
/// Calculates drag zones for a custom item using simulation+validation. /// Calculates drag zones for a custom item using simulation+validation.
/// ///
/// This ensures UI feedback matches what will actually be accepted on drop. /// This ensures UI feedback matches what will actually be accepted on drop.
@@ -735,11 +712,11 @@ enum ItineraryReorderingLogic {
}, },
findTravelSortOrder: findTravelSortOrder findTravelSortOrder: findTravelSortOrder
) )
// Convert valid rows from proposed to original coordinate space // Convert valid rows from proposed to original coordinate space
let validRowsOriginal = validRowsProposed.map { proposedToOriginal($0, sourceRow: sourceRow) } let validRowsOriginal = validRowsProposed.map { proposedToOriginal($0, sourceRow: sourceRow) }
let validSet = Set(validRowsOriginal) let validSet = Set(validRowsOriginal)
// Compute invalid rows in original coordinate space // Compute invalid rows in original coordinate space
var invalidRows = Set<Int>() var invalidRows = Set<Int>()
for i in 0..<flatItems.count { for i in 0..<flatItems.count {
@@ -751,16 +728,16 @@ enum ItineraryReorderingLogic {
invalidRows.insert(i) invalidRows.insert(i)
} }
} }
return DragZones( return DragZones(
invalidRowIndices: invalidRows, invalidRowIndices: invalidRows,
validDropRows: validRowsOriginal, validDropRows: validRowsOriginal,
barrierGameIds: [] // No barrier highlighting for custom items barrierGameIds: [] // No barrier highlighting for custom items
) )
} }
// MARK: - Legacy Compatibility // MARK: - Legacy Compatibility
/// Legacy version of calculateTravelDragZones that doesn't require sourceRow. /// Legacy version of calculateTravelDragZones that doesn't require sourceRow.
/// Uses day-range-based calculation only. /// Uses day-range-based calculation only.
/// ///
@@ -774,14 +751,14 @@ enum ItineraryReorderingLogic {
findTravelItem: (TravelSegment) -> ItineraryItem? findTravelItem: (TravelSegment) -> ItineraryItem?
) -> DragZones { ) -> DragZones {
let travelId = "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())" let travelId = "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())"
guard let validRange = travelValidRanges[travelId] else { guard let validRange = travelValidRanges[travelId] else {
return DragZones(invalidRowIndices: [], validDropRows: [], barrierGameIds: []) return DragZones(invalidRowIndices: [], validDropRows: [], barrierGameIds: [])
} }
var invalidRows = Set<Int>() var invalidRows = Set<Int>()
var validRows: [Int] = [] var validRows: [Int] = []
for (index, rowItem) in flatItems.enumerated() { for (index, rowItem) in flatItems.enumerated() {
let dayNum: Int let dayNum: Int
switch rowItem { switch rowItem {
@@ -794,14 +771,14 @@ enum ItineraryReorderingLogic {
case .customItem(let item): case .customItem(let item):
dayNum = item.day dayNum = item.day
} }
if validRange.contains(dayNum) { if validRange.contains(dayNum) {
validRows.append(index) validRows.append(index)
} else { } else {
invalidRows.insert(index) invalidRows.insert(index)
} }
} }
// Find barrier games using constraints // Find barrier games using constraints
var barrierGameIds = Set<String>() var barrierGameIds = Set<String>()
if let travelItem = findTravelItem(segment), if let travelItem = findTravelItem(segment),
@@ -809,14 +786,14 @@ enum ItineraryReorderingLogic {
let barriers = constraints.barrierGames(for: travelItem) let barriers = constraints.barrierGames(for: travelItem)
barrierGameIds = Set(barriers.compactMap { $0.gameId }) barrierGameIds = Set(barriers.compactMap { $0.gameId })
} }
return DragZones( return DragZones(
invalidRowIndices: invalidRows, invalidRowIndices: invalidRows,
validDropRows: validRows, validDropRows: validRows,
barrierGameIds: barrierGameIds barrierGameIds: barrierGameIds
) )
} }
/// Legacy version of calculateCustomItemDragZones that doesn't require sourceRow. /// Legacy version of calculateCustomItemDragZones that doesn't require sourceRow.
/// ///
/// - Note: Prefer the version with sourceRow for accurate validation. /// - Note: Prefer the version with sourceRow for accurate validation.
@@ -827,7 +804,7 @@ enum ItineraryReorderingLogic {
) -> DragZones { ) -> DragZones {
var invalidRows = Set<Int>() var invalidRows = Set<Int>()
var validRows: [Int] = [] var validRows: [Int] = []
for (index, rowItem) in flatItems.enumerated() { for (index, rowItem) in flatItems.enumerated() {
if case .dayHeader = rowItem { if case .dayHeader = rowItem {
invalidRows.insert(index) invalidRows.insert(index)
@@ -835,16 +812,16 @@ enum ItineraryReorderingLogic {
validRows.append(index) validRows.append(index)
} }
} }
return DragZones( return DragZones(
invalidRowIndices: invalidRows, invalidRowIndices: invalidRows,
validDropRows: validRows, validDropRows: validRows,
barrierGameIds: [] barrierGameIds: []
) )
} }
// MARK: - Utility Functions // MARK: - Utility Functions
/// Finds the nearest value in a sorted array using binary search. /// Finds the nearest value in a sorted array using binary search.
/// ///
/// - Parameters: /// - Parameters:
@@ -853,10 +830,10 @@ enum ItineraryReorderingLogic {
/// - Returns: The nearest value, or nil if array is empty /// - Returns: The nearest value, or nil if array is empty
static func nearestValue(in sorted: [Int], to target: Int) -> Int? { static func nearestValue(in sorted: [Int], to target: Int) -> Int? {
guard !sorted.isEmpty else { return nil } guard !sorted.isEmpty else { return nil }
var low = 0 var low = 0
var high = sorted.count var high = sorted.count
// Binary search for insertion point // Binary search for insertion point
while low < high { while low < high {
let mid = (low + high) / 2 let mid = (low + high) / 2
@@ -866,10 +843,10 @@ enum ItineraryReorderingLogic {
high = mid high = mid
} }
} }
let after = (low < sorted.count) ? sorted[low] : nil let after = (low < sorted.count) ? sorted[low] : nil
let before = (low > 0) ? sorted[low - 1] : nil let before = (low > 0) ? sorted[low - 1] : nil
switch (before, after) { switch (before, after) {
case let (b?, a?): case let (b?, a?):
// Both exist, return the closer one // Both exist, return the closer one
@@ -882,7 +859,7 @@ enum ItineraryReorderingLogic {
return nil return nil
} }
} }
/// Calculates target destination with constraint snapping. /// Calculates target destination with constraint snapping.
/// ///
/// If the proposed row is valid, returns it. Otherwise, snaps to nearest valid row. /// If the proposed row is valid, returns it. Otherwise, snaps to nearest valid row.
@@ -909,12 +886,12 @@ enum ItineraryReorderingLogic {
// UX rule: forbid dropping at absolute top (row 0 is always a day header) // UX rule: forbid dropping at absolute top (row 0 is always a day header)
var row = proposedRow var row = proposedRow
if row <= 0 { row = 1 } if row <= 0 { row = 1 }
// If already valid, use it // If already valid, use it
if validDestinationRows.contains(row) { if validDestinationRows.contains(row) {
return row return row
} }
// Snap to nearest valid destination (validDestinationRows must be sorted for binary search) // Snap to nearest valid destination (validDestinationRows must be sorted for binary search)
return nearestValue(in: validDestinationRows, to: row) ?? sourceRow return nearestValue(in: validDestinationRows, to: row) ?? sourceRow
} }

View File

@@ -80,14 +80,14 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresent
hostingController.view.translatesAutoresizingMaskIntoConstraints = true hostingController.view.translatesAutoresizingMaskIntoConstraints = true
controller.setTableHeader(hostingController.view) controller.setTableHeader(hostingController.view)
// Load initial data // Load initial data
let (days, validRanges, allItemsForConstraints) = buildItineraryData() let (days, validRanges, allItemsForConstraints) = buildItineraryData()
controller.reloadData(days: days, travelValidRanges: validRanges, itineraryItems: allItemsForConstraints) controller.reloadData(days: days, travelValidRanges: validRanges, itineraryItems: allItemsForConstraints)
return controller return controller
} }
func updateUIViewController(_ controller: ItineraryTableViewController, context: Context) { func updateUIViewController(_ controller: ItineraryTableViewController, context: Context) {
controller.colorScheme = colorScheme controller.colorScheme = colorScheme
controller.onTravelMoved = onTravelMoved controller.onTravelMoved = onTravelMoved
@@ -95,37 +95,46 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresent
controller.onCustomItemTapped = onCustomItemTapped controller.onCustomItemTapped = onCustomItemTapped
controller.onCustomItemDeleted = onCustomItemDeleted controller.onCustomItemDeleted = onCustomItemDeleted
controller.onAddButtonTapped = onAddButtonTapped controller.onAddButtonTapped = onAddButtonTapped
// Update header content by updating the hosting controller's rootView // Update header content by updating the hosting controller's rootView
// 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, allItemsForConstraints) = buildItineraryData() let (days, validRanges, allItemsForConstraints) = buildItineraryData()
controller.reloadData(days: days, travelValidRanges: validRanges, itineraryItems: allItemsForConstraints) controller.reloadData(days: days, travelValidRanges: validRanges, itineraryItems: allItemsForConstraints)
} }
// MARK: - Build Itinerary Data // MARK: - Build Itinerary Data
private func buildItineraryData() -> ([ItineraryDayData], [String: ClosedRange<Int>], [ItineraryItem]) { private func buildItineraryData() -> ([ItineraryDayData], [String: ClosedRange<Int>], [ItineraryItem]) {
let tripDays = calculateTripDays() let tripDays = calculateTripDays()
var travelValidRanges: [String: ClosedRange<Int>] = [:] var travelValidRanges: [String: ClosedRange<Int>] = [:]
// 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) // Build travel as semantic items with (day, sortOrder)
var travelItems: [ItineraryItem] = [] var travelItems: [ItineraryItem] = []
travelItems.reserveCapacity(trip.travelSegments.count) 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] { func gamesIn(city: String, day: Int) -> [ItineraryItem] {
itineraryItems.filter { item in gameItems.filter { item in
guard item.day == day else { return false } guard item.day == day else { return false }
guard case .game(let gid) = item.kind else { return false } guard let gameCity = item.gameCity else { return false }
guard let c = cityFromGameId(gid) else { return false } return cityMatches(gameCity, searchCity: city)
return cityMatches(c, searchCity: city)
} }
} }
@@ -249,7 +258,7 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresent
days.append(dayData) days.append(dayData)
} }
return (days, travelValidRanges, itineraryItems + travelItems) return (days, travelValidRanges, gameItems + itineraryItems + travelItems)
} }
// MARK: - Helper Methods // MARK: - Helper Methods

View File

@@ -203,7 +203,7 @@ struct TripDetailView: View {
withAnimation { withAnimation {
travelOverrides[travelId] = TravelOverride(day: newDay, sortOrder: newSortOrder) travelOverrides[travelId] = TravelOverride(day: newDay, sortOrder: newSortOrder)
} }
await saveTravelDayOverride(travelAnchorId: travelId, displayDay: newDay) await saveTravelDayOverride(travelAnchorId: travelId, displayDay: newDay, sortOrder: newSortOrder)
} }
}, },
onCustomItemMoved: { itemId, day, sortOrder in onCustomItemMoved: { itemId, day, sortOrder in
@@ -1371,7 +1371,8 @@ struct TripDetailView: View {
// Persist to CloudKit as a travel ItineraryItem // Persist to CloudKit as a travel ItineraryItem
await self.saveTravelDayOverride( await self.saveTravelDayOverride(
travelAnchorId: droppedId, travelAnchorId: droppedId,
displayDay: dayNumber displayDay: dayNumber,
sortOrder: newSortOrder
) )
return return
} }
@@ -1393,8 +1394,8 @@ struct TripDetailView: View {
return false return false
} }
private func saveTravelDayOverride(travelAnchorId: String, displayDay: Int) async { private func saveTravelDayOverride(travelAnchorId: String, displayDay: Int, sortOrder: Double) async {
print("💾 [TravelOverrides] Saving override: \(travelAnchorId) -> day \(displayDay)") print("💾 [TravelOverrides] Saving override: \(travelAnchorId) -> day \(displayDay), sortOrder \(sortOrder)")
// Parse travel ID to extract cities (format: "travel:city1->city2") // Parse travel ID to extract cities (format: "travel:city1->city2")
let stripped = travelAnchorId.replacingOccurrences(of: "travel:", with: "") let stripped = travelAnchorId.replacingOccurrences(of: "travel:", with: "")
@@ -1414,6 +1415,7 @@ struct TripDetailView: View {
// Update existing // Update existing
var updated = itineraryItems[existingIndex] var updated = itineraryItems[existingIndex]
updated.day = displayDay updated.day = displayDay
updated.sortOrder = sortOrder
updated.modifiedAt = Date() updated.modifiedAt = Date()
itineraryItems[existingIndex] = updated itineraryItems[existingIndex] = updated
await ItineraryItemService.shared.updateItem(updated) await ItineraryItemService.shared.updateItem(updated)
@@ -1423,7 +1425,7 @@ struct TripDetailView: View {
let item = ItineraryItem( let item = ItineraryItem(
tripId: trip.id, tripId: trip.id,
day: displayDay, day: displayDay,
sortOrder: 0, // Travel always comes first in day sortOrder: sortOrder,
kind: .travel(travelInfo) kind: .travel(travelInfo)
) )
itineraryItems.append(item) itineraryItems.append(item)

View File

@@ -243,24 +243,27 @@ final class ItineraryReorderingLogicTests: XCTestCase {
// MARK: - travelRow Tests // MARK: - travelRow Tests
func test_travelRow_findsCorrectRow() { func test_travelRow_findsCorrectRow() {
// Semantic model: travelRow finds travel in the section AFTER the day header
// Travel must be positioned within its correct day section
let items = buildFlatItems([ let items = buildFlatItems([
.day(1), .day(1),
.game("Detroit", day: 1), .game("Detroit", day: 1),
.travel(from: "Detroit", to: "Chicago", day: 2),
.day(2), .day(2),
.travel(from: "Chicago", to: "Milwaukee", day: 3), .travel(from: "Detroit", to: "Chicago", day: 2), // Row 3: in day 2 section
.day(3) .day(3),
.travel(from: "Chicago", to: "Milwaukee", day: 3) // Row 5: in day 3 section
]) ])
XCTAssertEqual(Logic.travelRow(in: items, forDay: 2), 2) XCTAssertEqual(Logic.travelRow(in: items, forDay: 2), 3)
XCTAssertEqual(Logic.travelRow(in: items, forDay: 3), 4) XCTAssertEqual(Logic.travelRow(in: items, forDay: 3), 5)
} }
func test_travelRow_noTravelOnDay_returnsNil() { func test_travelRow_noTravelOnDay_returnsNil() {
// Travel is in day 2 section, so day 1 has no travel
let items = buildFlatItems([ let items = buildFlatItems([
.day(1), .day(1),
.travel(from: "Detroit", to: "Chicago", day: 2), .day(2),
.day(2) .travel(from: "Detroit", to: "Chicago", day: 2) // In day 2 section
]) ])
XCTAssertNil(Logic.travelRow(in: items, forDay: 1)) XCTAssertNil(Logic.travelRow(in: items, forDay: 1))

View File

@@ -108,7 +108,7 @@ enum ItineraryTestHelpers {
tripId: testTripId, tripId: testTripId,
day: day, day: day,
sortOrder: sortOrder, sortOrder: sortOrder,
kind: .game(gameId: "game-\(city)-\(UUID().uuidString.prefix(4))") kind: .game(gameId: "game-\(city)-\(UUID().uuidString.prefix(4))", city: city)
) )
} }

View File

@@ -194,11 +194,15 @@ final class ItineraryTravelConstraintTests: XCTestCase {
// MARK: - Travel Movement Tests // MARK: - Travel Movement Tests
func test_travel_moveToValidDay_callsCallback() { func test_travel_moveToValidDay_callsCallback() {
// Given: Travel with valid range 2-4 // Given: Travel with valid range 2-3
let travel = H.makeTravelSegment(from: "Chicago", to: "Detroit") let travel = H.makeTravelSegment(from: "Chicago", to: "Detroit")
let travelItem = ItineraryRowItem.travel(travel, dayNumber: 2)
// Travel model item for sortOrder lookup
let travelModelItem = H.makeTravelItem(from: "Chicago", to: "Detroit", day: 2, sortOrder: 1.0)
let day1 = ItineraryDayData(id: 1, dayNumber: 1, date: testDate, games: [], items: [], travelBefore: nil) let day1 = ItineraryDayData(id: 1, dayNumber: 1, date: testDate, games: [], items: [], travelBefore: nil)
let day2 = ItineraryDayData(id: 2, dayNumber: 2, date: H.dayAfter(testDate), games: [], items: [], travelBefore: travel) let day2 = ItineraryDayData(id: 2, dayNumber: 2, date: H.dayAfter(testDate), games: [], items: [travelItem], travelBefore: nil)
let day3 = ItineraryDayData(id: 3, dayNumber: 3, date: H.dayAfter(H.dayAfter(testDate)), games: [], items: [], travelBefore: nil) let day3 = ItineraryDayData(id: 3, dayNumber: 3, date: H.dayAfter(H.dayAfter(testDate)), games: [], items: [], travelBefore: nil)
var capturedTravelId: String = "" var capturedTravelId: String = ""
@@ -211,12 +215,12 @@ final class ItineraryTravelConstraintTests: XCTestCase {
controller.reloadData( controller.reloadData(
days: [day1, day2, day3], days: [day1, day2, day3],
travelValidRanges: ["travel:chicago->detroit": 2...3], travelValidRanges: ["travel:chicago->detroit": 2...3],
itineraryItems: [] itineraryItems: [travelModelItem]
) )
// Rows: 0=Day1 header, 1=travel, 2=Day2 header, 3=Day3 header // Rows: 0=Day1 header, 1=Day2 header, 2=travel, 3=Day3 header
// Move travel (row 1) to row 3 (after Day2, before Day3 header means Day 3) // Move travel (row 2) to row 3 (after Day3 header = Day 3)
controller.tableView(controller.tableView, moveRowAt: IndexPath(row: 1, section: 0), to: IndexPath(row: 3, section: 0)) controller.tableView(controller.tableView, moveRowAt: IndexPath(row: 2, section: 0), to: IndexPath(row: 3, section: 0))
XCTAssertEqual(capturedTravelId, "travel:chicago->detroit") XCTAssertEqual(capturedTravelId, "travel:chicago->detroit")
XCTAssertEqual(capturedDay, 3, "Travel should now be on Day 3") XCTAssertEqual(capturedDay, 3, "Travel should now be on Day 3")
@@ -228,18 +232,21 @@ final class ItineraryTravelConstraintTests: XCTestCase {
// Given: Travel with valid range Days 2-3 // Given: Travel with valid range Days 2-3
let travel = H.makeTravelSegment(from: "Chicago", to: "Detroit") let travel = H.makeTravelSegment(from: "Chicago", to: "Detroit")
let travelId = "travel:chicago->detroit" let travelId = "travel:chicago->detroit"
let travelItem = ItineraryRowItem.travel(travel, dayNumber: 2)
let travelModelItem = H.makeTravelItem(from: "Chicago", to: "Detroit", day: 2, sortOrder: 1.0)
let day1 = ItineraryDayData(id: 1, dayNumber: 1, date: testDate, games: [], items: [], travelBefore: nil) let day1 = ItineraryDayData(id: 1, dayNumber: 1, date: testDate, games: [], items: [], travelBefore: nil)
let day2 = ItineraryDayData(id: 2, dayNumber: 2, date: H.dayAfter(testDate), games: [], items: [], travelBefore: travel) let day2 = ItineraryDayData(id: 2, dayNumber: 2, date: H.dayAfter(testDate), games: [], items: [travelItem], travelBefore: nil)
let day3 = ItineraryDayData(id: 3, dayNumber: 3, date: H.dayAfter(H.dayAfter(testDate)), games: [], items: [], travelBefore: nil) let day3 = ItineraryDayData(id: 3, dayNumber: 3, date: H.dayAfter(H.dayAfter(testDate)), games: [], items: [], travelBefore: nil)
let controller = ItineraryTableViewController(style: .plain) let controller = ItineraryTableViewController(style: .plain)
let validRanges = [travelId: 2...3] let validRanges = [travelId: 2...3]
controller.reloadData(days: [day1, day2, day3], travelValidRanges: validRanges) controller.reloadData(days: [day1, day2, day3], travelValidRanges: validRanges, itineraryItems: [travelModelItem])
// Travel is at row 1 (after Day1 header at row 0) // Rows: 0=Day1 header, 1=Day2 header, 2=travel, 3=Day3 header
// Try to move it to Day 1 area (row 0 or 1) - should snap back to valid range // Travel is at row 2 (after Day2 header at row 1)
let source = IndexPath(row: 1, section: 0) // Try to move it to Day 1 area (row 0) - should snap back to valid range
let source = IndexPath(row: 2, section: 0)
let proposed = IndexPath(row: 0, section: 0) let proposed = IndexPath(row: 0, section: 0)
let result = controller.tableView(controller.tableView, targetIndexPathForMoveFromRowAt: source, toProposedIndexPath: proposed) let result = controller.tableView(controller.tableView, targetIndexPathForMoveFromRowAt: source, toProposedIndexPath: proposed)

View File

@@ -49,33 +49,35 @@ final class ItineraryConstraintsTests: XCTestCase {
} }
func test_travel_mustBeAfterDepartureGames() { func test_travel_mustBeAfterDepartureGames() {
// Given: Chicago game on Day 1 at sortOrder 100 // Given: Chicago game on Day 1
// Visual boundary is 0: sortOrder < 0 = before games, sortOrder >= 0 = after games
let constraints = makeConstraints( let constraints = makeConstraints(
tripDays: 3, tripDays: 3,
games: [makeGameItem(city: "Chicago", day: 1, sortOrder: 100)] games: [makeGameItem(city: "Chicago", day: 1)]
) )
let travel = makeTravelItem(from: "Chicago", to: "Detroit", day: 1, sortOrder: 50) let travel = makeTravelItem(from: "Chicago", to: "Detroit", day: 1, sortOrder: -1)
// When/Then: Travel before game is invalid // When/Then: Travel before game (negative sortOrder) is invalid on departure day
XCTAssertFalse(constraints.isValidPosition(for: travel, day: 1, sortOrder: 50)) XCTAssertFalse(constraints.isValidPosition(for: travel, day: 1, sortOrder: -1))
// Travel after game is valid // Travel after game (non-negative sortOrder) is valid
XCTAssertTrue(constraints.isValidPosition(for: travel, day: 1, sortOrder: 150)) XCTAssertTrue(constraints.isValidPosition(for: travel, day: 1, sortOrder: 1))
} }
func test_travel_mustBeBeforeArrivalGames() { func test_travel_mustBeBeforeArrivalGames() {
// Given: Detroit game on Day 3 at sortOrder 100 // Given: Detroit game on Day 3
// Visual boundary is 0: sortOrder < 0 = before games, sortOrder >= 0 = after games
let constraints = makeConstraints( let constraints = makeConstraints(
tripDays: 3, tripDays: 3,
games: [makeGameItem(city: "Detroit", day: 3, sortOrder: 100)] games: [makeGameItem(city: "Detroit", day: 3)]
) )
let travel = makeTravelItem(from: "Chicago", to: "Detroit", day: 3, sortOrder: 150) let travel = makeTravelItem(from: "Chicago", to: "Detroit", day: 3, sortOrder: 1)
// When/Then: Travel after arrival game is invalid // When/Then: Travel after arrival game (non-negative sortOrder) is invalid on arrival day
XCTAssertFalse(constraints.isValidPosition(for: travel, day: 3, sortOrder: 150)) XCTAssertFalse(constraints.isValidPosition(for: travel, day: 3, sortOrder: 1))
// Travel before game is valid // Travel before game (negative sortOrder) is valid
XCTAssertTrue(constraints.isValidPosition(for: travel, day: 3, sortOrder: 50)) XCTAssertTrue(constraints.isValidPosition(for: travel, day: 3, sortOrder: -1))
} }
func test_travel_canBeAnywhereOnRestDays() { func test_travel_canBeAnywhereOnRestDays() {
@@ -217,13 +219,12 @@ final class ItineraryConstraintsTests: XCTestCase {
return ItineraryConstraints(tripDayCount: tripDays, items: games) return ItineraryConstraints(tripDayCount: tripDays, items: games)
} }
private func makeGameItem(city: String, day: Int, sortOrder: Double = 100) -> ItineraryItem { private func makeGameItem(city: String, day: Int, sortOrder: Double = 0) -> ItineraryItem {
// For tests, we use gameId to encode the city
return ItineraryItem( return ItineraryItem(
tripId: testTripId, tripId: testTripId,
day: day, day: day,
sortOrder: sortOrder, sortOrder: sortOrder,
kind: .game(gameId: "game-\(city)-\(UUID().uuidString.prefix(4))") kind: .game(gameId: "game-\(city)-\(UUID().uuidString.prefix(4))", city: city)
) )
} }