// // ItineraryReorderingLogic.swift // SportsTime // // Pure functions for itinerary reordering logic. // Extracted from ItineraryTableViewController for testability. // // SEMANTIC TRAVEL MODEL: // - Travel items are positioned semantically via (day, sortOrder), not structurally. // - Travel can appear before games (sortOrder < 0) or after games (sortOrder >= 0). // - The legacy `travelBefore` field on ItineraryDayData is IGNORED by flattenDays. // - All movable items (custom + travel) use the same day computation: backward scan to nearest dayHeader. // // COORDINATE SPACE CONVENTIONS: // - "Original indices": Row indices in the current flatItems array (0..= 0 (after games), sorted by sortOrder ascending /// /// - Parameters: /// - days: Array of ItineraryDayData from the wrapper /// - findTravelSortOrder: Closure to look up sortOrder for a travel segment /// - Returns: Flattened array of ItineraryRowItem static func flattenDays( _ days: [ItineraryDayData], findTravelSortOrder: (TravelSegment) -> Double? ) -> [ItineraryRowItem] { var flatItems: [ItineraryRowItem] = [] for day in days { // NOTE: day.travelBefore is IGNORED under semantic travel model. // Travel must be in day.items with a sortOrder to appear. // 1. Day header (structural anchor) flatItems.append(.dayHeader(dayNumber: day.dayNumber, date: day.date)) // 2. Partition movable items around games boundary // Tuple includes tiebreaker for stable sorting when sortOrders are equal var beforeGames: [(sortOrder: Double, tiebreaker: Int, item: ItineraryRowItem)] = [] var afterGames: [(sortOrder: Double, tiebreaker: Int, item: ItineraryRowItem)] = [] var insertionOrder = 0 for row in day.items { let sortOrder: Double let tiebreaker = insertionOrder insertionOrder += 1 switch row { case .customItem(let item): sortOrder = item.sortOrder case .travel(let segment, _): // Use provided sortOrder if available, otherwise default to after-games position. // nil is valid during initial display before travel is persisted. let lookedUp = findTravelSortOrder(segment) sortOrder = lookedUp ?? defaultTravelSortOrder #if DEBUG print("๐Ÿ“‹ [flattenDays] Travel \(segment.fromLocation.name)->\(segment.toLocation.name) on day \(day.dayNumber): lookedUp=\(String(describing: lookedUp)), using sortOrder=\(sortOrder)") #endif case .games, .dayHeader: // These item types are not movable and handled separately. continue } if sortOrder < 0 { beforeGames.append((sortOrder, tiebreaker, row)) } else { afterGames.append((sortOrder, tiebreaker, row)) } } beforeGames.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 }) // 3. Games for this day (bundled as one row) if !day.games.isEmpty { flatItems.append(.games(day.games, dayNumber: day.dayNumber)) } // 4. Items after games flatItems.append(contentsOf: afterGames.map { $0.item }) } return flatItems } // MARK: - Day Number Lookup /// Finds which day a row at the given index belongs to. /// /// Scans backwards from the row to find a `.dayHeader`. /// Returns 1 as fallback if no header is found. /// /// - Parameters: /// - items: The flat array of row items /// - row: The row index to look up /// - Returns: The day number (1-indexed) static 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 } /// Finds the row index of the day header for a specific day number. /// /// - Parameters: /// - items: The flat array of row items /// - day: The day number to find /// - Returns: The row index, or nil if not found static func dayHeaderRow(in items: [ItineraryRowItem], forDay day: Int) -> Int? { for (index, item) in items.enumerated() { if case .dayHeader(let dayNum, _) = item, dayNum == day { return index } } return nil } /// Finds the row index of the travel segment on a specific day. /// /// **SEMANTIC MODEL**: Does NOT use the embedded dayNumber in .travel(). /// Instead, scans the day section (between dayHeader(day) and dayHeader(day+1)) /// and returns the first travel row found. /// /// - Parameters: /// - items: The flat array of row items /// - day: The day number to find /// - Returns: The row index, or nil if no travel on that day static func travelRow(in items: [ItineraryRowItem], forDay day: Int) -> Int? { guard let headerRow = dayHeaderRow(in: items, forDay: day) else { return nil } for i in (headerRow + 1).. Int? { for (index, item) in items.enumerated() { if case .travel(_, let dayNum) = item, dayNum == day { return index } } return nil } /// Determines which day a travel segment belongs to at a given row position. /// /// **SEMANTIC MODEL**: Uses backward scan to find the nearest preceding dayHeader. /// This is consistent with how all movable items determine their day. /// /// - Parameters: /// - row: The row index of the travel /// - items: The flat array of row items /// - Returns: The day number the travel belongs to static func dayForTravelAt(row: Int, in items: [ItineraryRowItem]) -> Int { return dayNumber(in: items, forRow: row) } // MARK: - Move Simulation /// Result of simulating a move operation. struct SimulatedMove { let items: [ItineraryRowItem] let destinationRowInNewArray: Int let didMove: Bool // false if move was invalid/no-op } /// Simulates UITableView move semantics with bounds safety. /// /// UITableView moves work as: remove at sourceRow from ORIGINAL array, /// then insert at destinationProposedRow in the NEW array (post-removal coordinate space). /// /// - Parameters: /// - original: The original flat items array /// - sourceRow: Where the item is being moved from /// - destinationProposedRow: Where it's being moved to (in post-removal space) /// - Returns: The new array, the actual destination row, and whether the move occurred static func simulateMove( original: [ItineraryRowItem], sourceRow: Int, destinationProposedRow: Int ) -> SimulatedMove { guard sourceRow >= 0 && sourceRow < original.count else { return SimulatedMove(items: original, destinationRowInNewArray: sourceRow, didMove: false) } 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, didMove: true) } // MARK: - Coordinate Space Conversion /// Converts a proposed destination index to the equivalent original index. /// /// UITableView move semantics: remove at sourceRow first, then insert at proposed position. /// This means proposed indices >= sourceRow map to original indices + 1. /// /// - Parameters: /// - proposed: Index in post-removal coordinate space /// - sourceRow: The row being moved (in original space) /// - Returns: Equivalent index in original coordinate space static func proposedToOriginal(_ proposed: Int, sourceRow: Int) -> Int { if proposed >= sourceRow { return proposed + 1 } else { return proposed } } /// Converts an original index to the equivalent proposed destination index. /// /// - Parameters: /// - original: Index in original coordinate space /// - sourceRow: The row being moved (in original space) /// - Returns: Equivalent index in post-removal coordinate space, or nil if original == sourceRow static func originalToProposed(_ original: Int, sourceRow: Int) -> Int? { if original == sourceRow { // The dragged item itself has no proposed equivalent return nil } else if original > sourceRow { return original - 1 } else { return original } } // MARK: - Sort Order Calculation /// Calculates the sortOrder for an item dropped at the given row position. /// /// Uses **midpoint insertion** algorithm to avoid renumbering existing items: /// - Between items A(1.0) and B(2.0): new sortOrder = 1.5 /// - First item in empty day: sortOrder = 1.0 /// - After last item: sortOrder = last + 1.0 /// - Before first item: sortOrder = first / 2.0 /// /// **Region classification**: /// - `row < gamesRow` => before-games region => sortOrder < 0 /// - `row > gamesRow` => after-games region => sortOrder >= 0 /// - `row == gamesRow` => treated as after-games (cannot drop ON games row) /// - No games on day => after-games region (sortOrder >= 0) /// /// - Parameters: /// - items: The flat array of row items (with moved item already in place) /// - row: The row index where the item was dropped /// - findTravelSortOrder: Closure to look up sortOrder for travel segments /// - Returns: The calculated sortOrder static func calculateSortOrder( in items: [ItineraryRowItem], at row: Int, findTravelSortOrder: (TravelSegment) -> Double? ) -> Double { let day = dayNumber(in: items, forRow: row) // Find games row for this day (if any) var gamesRow: Int? = nil for i in 0.. day { break } } // DEBUG: Log the row positions #if DEBUG 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)") } #endif // Strict region classification: // - row < gamesRow => before-games (negative sortOrder) // - row >= gamesRow OR no games => after-games (positive sortOrder) let isBeforeGames: Bool if let gr = gamesRow { isBeforeGames = row < gr #if DEBUG print("๐Ÿ”ข [calculateSortOrder] row(\(row)) < gamesRow(\(gr)) = \(isBeforeGames) โ†’ isBeforeGames=\(isBeforeGames)") #endif } else { isBeforeGames = false // No games means everything is "after games" #if DEBUG print("๐Ÿ”ข [calculateSortOrder] No games on day \(day) โ†’ isBeforeGames=false") #endif } /// Get sortOrder from a movable item (custom item or travel) func movableSortOrder(_ idx: Int) -> Double? { guard idx >= 0 && idx < items.count else { return nil } switch items[idx] { case .customItem(let item): return item.sortOrder case .travel(let segment, _): return findTravelSortOrder(segment) default: return nil } } /// Scan backward from start, stopping at boundaries, looking for movable items in the same region func scanBackward(from start: Int) -> Double? { var i = start while i >= 0 { // Stop at day boundaries if case .dayHeader(let d, _) = items[i] { if d != day { break } break // Stop at own day header too } // Stop at games boundary (don't cross into other region) if case .games(_, let d) = items[i], d == day { break } if let v = movableSortOrder(i) { // Only return values in the correct region if isBeforeGames { if v < 0 { return v } } else { if v >= 0 { return v } } } i -= 1 } return nil } /// Scan forward from start, stopping at boundaries, looking for movable items in the same region func scanForward(from start: Int) -> Double? { var i = start while i < items.count { // Stop at day boundaries if case .dayHeader(let d, _) = items[i] { if d != day { break } break // Stop at any day header } // Stop at games boundary (don't cross into other region) if case .games(_, let d) = items[i], d == day { break } if let v = movableSortOrder(i) { // Only return values in the correct region if isBeforeGames { if v < 0 { return v } } else { if v >= 0 { return v } } } i += 1 } return nil } let result: Double if isBeforeGames { // Above games: sortOrder should be negative let prev = scanBackward(from: row - 1) let next = scanForward(from: row + 1) let upperBound: Double = 0.0 // Games boundary switch (prev, next) { case (nil, nil): result = -1.0 case (let p?, nil): result = (p + upperBound) / 2.0 case (nil, let n?): // First item before games: place it before the next item. // n should always be negative (scanForward filters for region). if n >= 0 { // This shouldn't happen - scanForward should only return negative values // in before-games region. Return safe default and assert in debug. assertionFailure("Before-games region has non-negative sortOrder: \(n)") 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 } case (let p?, let n?): result = (p + n) / 2.0 } } else { // Below games: sortOrder should be >= 0 let prev = scanBackward(from: row - 1) ?? 0.0 let next = scanForward(from: row + 1) switch next { case nil: result = (prev == 0.0) ? 1.0 : (prev + 1.0) case let n?: result = (prev + n) / 2.0 } } #if DEBUG print("๐Ÿ”ข [calculateSortOrder] RESULT: \(result) (isBeforeGames=\(isBeforeGames))") #endif return result } // MARK: - Valid Drop Computation /// Computes all valid destination rows in **proposed** coordinate space. /// /// For BOTH travel and custom items, we: /// 1. Simulate the move /// 2. Compute the resulting (day, sortOrder) /// 3. Validate with ItineraryConstraints /// /// This ensures drop targets match what will actually be persisted. /// /// - Parameters: /// - flatItems: The current flat items array /// - sourceRow: The row being moved /// - dragged: The item being dragged /// - travelValidRanges: Valid day ranges for travel segments /// - constraints: The constraint system for validation /// - findTravelItem: Closure to find ItineraryItem for a travel segment /// - makeTravelItem: Closure to create a default ItineraryItem for travel /// - findCustomItem: Closure to find ItineraryItem for a custom item row /// - findTravelSortOrder: Closure to find sortOrder for travel segments /// - Returns: Array of valid row indices in proposed coordinate space static func computeValidDestinationRowsProposed( flatItems: [ItineraryRowItem], sourceRow: Int, dragged: ItineraryRowItem, travelValidRanges: [String: ClosedRange], constraints: ItineraryConstraints?, findTravelItem: (TravelSegment) -> ItineraryItem?, makeTravelItem: (TravelSegment) -> ItineraryItem, findTravelSortOrder: @escaping (TravelSegment) -> Double? ) -> [Int] { let maxProposed = max(0, flatItems.count - 1) guard maxProposed > 0 else { return [] } switch dragged { case .customItem(let customItem): // Custom items use the same simulation+validation approach as travel guard let constraints = constraints else { // No constraint engine: allow all rows except 0 and day headers return (1...maxProposed).filter { proposedRow in let simulated = simulateMove(original: flatItems, sourceRow: sourceRow, destinationProposedRow: proposedRow) guard simulated.didMove else { return false } // Don't allow dropping ON a day header if case .dayHeader = simulated.items[simulated.destinationRowInNewArray] { return false } return true } } var valid: [Int] = [] valid.reserveCapacity(maxProposed) for proposedRow in 1...maxProposed { let simulated = simulateMove(original: flatItems, sourceRow: sourceRow, destinationProposedRow: proposedRow) guard simulated.didMove else { continue } let destRowInSim = simulated.destinationRowInNewArray // Don't allow dropping ON a day header if case .dayHeader = simulated.items[destRowInSim] { continue } let day = dayNumber(in: simulated.items, forRow: destRowInSim) let sortOrder = calculateSortOrder(in: simulated.items, at: destRowInSim, findTravelSortOrder: findTravelSortOrder) // Create a temporary item model with the computed position let testItem = ItineraryItem( id: customItem.id, tripId: customItem.tripId, day: day, sortOrder: sortOrder, kind: customItem.kind ) if constraints.isValidPosition(for: testItem, day: day, sortOrder: sortOrder) { valid.append(proposedRow) } } return valid case .travel(let segment, _): // Use existing model if available, otherwise create a default let model = findTravelItem(segment) ?? makeTravelItem(segment) let travelId = travelIdForSegment(segment, in: travelValidRanges, model: model) let validDayRange = travelValidRanges[travelId] guard let constraints = constraints else { // No constraint engine, allow all rows except 0 and day headers return (1...maxProposed).filter { proposedRow in let simulated = simulateMove(original: flatItems, sourceRow: sourceRow, destinationProposedRow: proposedRow) guard simulated.didMove else { return false } if case .dayHeader = simulated.items[simulated.destinationRowInNewArray] { return false } return true } } var valid: [Int] = [] valid.reserveCapacity(maxProposed) for proposedRow in 1...maxProposed { let simulated = simulateMove(original: flatItems, sourceRow: sourceRow, destinationProposedRow: proposedRow) guard simulated.didMove else { continue } let destRowInSim = simulated.destinationRowInNewArray // Don't allow dropping ON a day header if case .dayHeader = simulated.items[destRowInSim] { continue } let day = dayNumber(in: simulated.items, forRow: destRowInSim) // Check day range constraint (quick rejection) if let range = validDayRange, !range.contains(day) { continue } // Check sortOrder constraint let sortOrder = calculateSortOrder(in: simulated.items, at: destRowInSim, findTravelSortOrder: findTravelSortOrder) // Create a testItem with computed day/sortOrder (like custom items do) // This ensures constraints.isValidPosition sees the actual proposed position let testItem = ItineraryItem( id: model.id, tripId: model.tripId, day: day, sortOrder: sortOrder, kind: model.kind ) if constraints.isValidPosition(for: testItem, day: day, sortOrder: sortOrder) { valid.append(proposedRow) } } return valid default: // Day headers and games can't be moved return [] } } // MARK: - Drag Zones /// Result of calculating drag zones for visual feedback. /// /// **COORDINATE SPACE**: All indices are in ORIGINAL coordinate space (current flatItems indices). /// This is what the UI needs for highlighting rows before the move occurs. struct DragZones { /// Rows that should be dimmed/disabled in the UI (original indices) let invalidRowIndices: Set /// Rows where drop is allowed (original indices) let validDropRows: [Int] /// Game IDs that act as barriers for this drag let barrierGameIds: Set } /// Calculates drag zones for a travel segment using simulation+validation. /// /// This ensures UI feedback matches what will actually be accepted on drop. /// Returns indices in ORIGINAL coordinate space for direct use in UI highlighting. /// /// - Parameters: /// - segment: The travel segment being dragged /// - sourceRow: The current row of the travel (original index) /// - flatItems: The current flat items array /// - travelValidRanges: Valid day ranges for travel segments /// - constraints: The constraint system /// - findTravelItem: Closure to find ItineraryItem for travel /// - makeTravelItem: Closure to create a default ItineraryItem for travel /// - findTravelSortOrder: Closure to find sortOrder for travel /// - Returns: Drag zones with invalid rows, valid rows, and barrier game IDs (all in original space) static func calculateTravelDragZones( segment: TravelSegment, sourceRow: Int, flatItems: [ItineraryRowItem], travelValidRanges: [String: ClosedRange], constraints: ItineraryConstraints?, findTravelItem: (TravelSegment) -> ItineraryItem?, makeTravelItem: (TravelSegment) -> ItineraryItem, findTravelSortOrder: @escaping (TravelSegment) -> Double? ) -> DragZones { // Get valid rows in PROPOSED coordinate space let validRowsProposed = computeValidDestinationRowsProposed( flatItems: flatItems, sourceRow: sourceRow, dragged: .travel(segment, dayNumber: 0), // dayNumber doesn't matter for validation travelValidRanges: travelValidRanges, constraints: constraints, findTravelItem: findTravelItem, makeTravelItem: makeTravelItem, findTravelSortOrder: findTravelSortOrder ) // Convert valid rows from proposed to original coordinate space let validRowsOriginal = validRowsProposed.map { proposedToOriginal($0, sourceRow: sourceRow) } let validSet = Set(validRowsOriginal) // Compute invalid rows in original coordinate space var invalidRows = Set() for i in 0..() if let travelItem = findTravelItem(segment), let constraints = constraints { let barriers = constraints.barrierGames(for: travelItem) barrierGameIds = Set(barriers.compactMap { $0.gameId }) } return DragZones( invalidRowIndices: invalidRows, validDropRows: validRowsOriginal, barrierGameIds: barrierGameIds ) } /// Calculates drag zones for a custom item using simulation+validation. /// /// This ensures UI feedback matches what will actually be accepted on drop. /// Returns indices in ORIGINAL coordinate space for direct use in UI highlighting. /// /// - Parameters: /// - item: The custom item being dragged /// - sourceRow: The current row of the item (original index) /// - flatItems: The current flat items array /// - constraints: The constraint system /// - findTravelSortOrder: Closure to find sortOrder for travel /// - Returns: Drag zones with invalid rows and valid rows (all in original space) static func calculateCustomItemDragZones( item: ItineraryItem, sourceRow: Int, flatItems: [ItineraryRowItem], constraints: ItineraryConstraints?, findTravelSortOrder: @escaping (TravelSegment) -> Double? ) -> DragZones { // Get valid rows in PROPOSED coordinate space let validRowsProposed = computeValidDestinationRowsProposed( flatItems: flatItems, sourceRow: sourceRow, dragged: .customItem(item), travelValidRanges: [:], // Custom items don't use travel ranges constraints: constraints, findTravelItem: { _ in nil }, makeTravelItem: { _ in // This won't be called for custom items fatalError("makeTravelItem called for custom item") }, findTravelSortOrder: findTravelSortOrder ) // Convert valid rows from proposed to original coordinate space let validRowsOriginal = validRowsProposed.map { proposedToOriginal($0, sourceRow: sourceRow) } let validSet = Set(validRowsOriginal) // Compute invalid rows in original coordinate space var invalidRows = Set() for i in 0..], constraints: ItineraryConstraints?, findTravelItem: (TravelSegment) -> ItineraryItem? ) -> DragZones { let model = findTravelItem(segment) let travelId = travelIdForSegment(segment, in: travelValidRanges, model: model) guard let validRange = travelValidRanges[travelId] else { return DragZones(invalidRowIndices: [], validDropRows: [], barrierGameIds: []) } var invalidRows = Set() var validRows: [Int] = [] for (index, rowItem) in flatItems.enumerated() { let dayNum: Int switch rowItem { case .dayHeader(let d, _): dayNum = d case .games(_, let d): dayNum = d case .travel(_, let d): dayNum = d case .customItem(let item): dayNum = item.day } if validRange.contains(dayNum) { validRows.append(index) } else { invalidRows.insert(index) } } // Find barrier games using constraints var barrierGameIds = Set() if let travelItem = findTravelItem(segment), let constraints = constraints { let barriers = constraints.barrierGames(for: travelItem) barrierGameIds = Set(barriers.compactMap { $0.gameId }) } return DragZones( invalidRowIndices: invalidRows, validDropRows: validRows, barrierGameIds: barrierGameIds ) } /// Legacy version of calculateCustomItemDragZones that doesn't require sourceRow. /// /// - Note: Prefer the version with sourceRow for accurate validation. @available(*, deprecated, message: "Use calculateCustomItemDragZones(item:sourceRow:...) for accurate validation") static func calculateCustomItemDragZones( item: ItineraryItem, flatItems: [ItineraryRowItem] ) -> DragZones { var invalidRows = Set() var validRows: [Int] = [] for (index, rowItem) in flatItems.enumerated() { if case .dayHeader = rowItem { invalidRows.insert(index) } else { validRows.append(index) } } return DragZones( invalidRowIndices: invalidRows, validDropRows: validRows, barrierGameIds: [] ) } // MARK: - Travel ID Lookup /// Find the travel ID key for a segment in the travelValidRanges dictionary. /// Keys are formatted as "travel:INDEX:from->to". /// When multiple keys share the same city pair (repeat visits), matches by /// checking all keys and preferring the one whose index matches the model's segmentIndex. private static func travelIdForSegment( _ segment: TravelSegment, in travelValidRanges: [String: ClosedRange], model: ItineraryItem? = nil ) -> String { let from = TravelInfo.normalizeCityName(segment.fromLocation.name) let to = TravelInfo.normalizeCityName(segment.toLocation.name) let suffix = "\(from)->\(to)" let matchingKeys = travelValidRanges.keys.filter { $0.hasSuffix(suffix) } if let segIdx = model?.travelInfo?.segmentIndex { let indexedKey = "travel:\(segIdx):\(suffix)" if matchingKeys.contains(indexedKey) { return indexedKey } return indexedKey } if matchingKeys.count == 1, let key = matchingKeys.first { return key } // Fallback: return first match or construct without index return matchingKeys.first ?? "travel:\(suffix)" } // MARK: - Utility Functions /// Finds the nearest value in a sorted array using binary search. /// /// - Parameters: /// - sorted: A sorted array of integers /// - target: The target value to find the nearest match for /// - Returns: The nearest value, or nil if array is empty static func nearestValue(in sorted: [Int], to target: Int) -> Int? { guard !sorted.isEmpty else { return nil } var low = 0 var high = sorted.count // Binary search for insertion point 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?): // Both exist, return the closer one return (target - b) <= (a - target) ? b : a case let (b?, nil): return b case let (nil, a?): return a default: return nil } } /// Calculates target destination with constraint snapping. /// /// If the proposed row is valid, returns it. Otherwise, snaps to nearest valid row. /// /// **COORDINATE SPACE**: This function expects all indices in PROPOSED coordinate space. /// The caller must ensure validDestinationRows comes from computeValidDestinationRowsProposed. /// /// **UX RULE**: Row 0 is forbidden (always a day header). If proposedRow <= 0, it's clamped to 1. /// This is a UX-level rule, not a semantic constraint - day headers cannot receive drops. /// /// - Parameters: /// - proposedRow: The user's proposed drop position (in proposed coordinate space) /// - validDestinationRows: Pre-computed valid rows from computeValidDestinationRowsProposed /// - sourceRow: The original row (fallback if no valid destination found) /// - Returns: The target row to use (in proposed coordinate space) /// /// - Note: Uses O(n) contains check. For repeated calls, consider passing a Set instead. /// However, validDestinationRows is typically small (< 50 items), so this is fine. static func calculateTargetRow( proposedRow: Int, validDestinationRows: [Int], sourceRow: Int ) -> Int { // UX rule: forbid dropping at absolute top (row 0 is always a day header) var row = proposedRow if row <= 0 { row = 1 } // If already valid, use it if validDestinationRows.contains(row) { return row } // Snap to nearest valid destination (validDestinationRows must be sorted for binary search) return nearestValue(in: validDestinationRows, to: row) ?? sourceRow } }