Files
Sportstime/SportsTime/Features/Trip/Views/ItineraryReorderingLogic.swift
Trey t c94e373e33 fix: comprehensive codebase hardening — crashes, silent failures, performance, and security
Fixes ~95 issues from deep audit across 12 categories in 82 files:

- Crash prevention: double-resume in PhotoMetadataExtractor, force unwraps in
  DateRangePicker, array bounds checks in polls/achievements, ProGate hit-test
  bypass, Dictionary(uniqueKeysWithValues:) → uniquingKeysWith in 4 files
- Silent failure elimination: all 34 try? sites replaced with do/try/catch +
  logging (SavedTrip, TripDetailView, CanonicalSyncService, BootstrapService,
  CanonicalModels, CKModels, SportsTimeApp, and more)
- Performance: cached DateFormatters (7 files), O(1) team lookups via
  AppDataProvider, achievement definition dictionary, AnimatedBackground
  consolidated from 19 Tasks to 1, task cancellation in SharePreviewView
- Concurrency: UIKit drawing → MainActor.run, background fetch timeout guard,
  @MainActor on ThemeManager/AppearanceManager, SyncLogger read/write race fix
- Planning engine: game end time in travel feasibility, state-aware city
  normalization, exact city matching, DrivingConstraints parameter propagation
- IAP: unknown subscription states → expired, unverified transaction logging,
  entitlements updated before paywall dismiss, restore visible to all users
- Security: API key to Info.plist lookup, filename sanitization in PDF export,
  honest User-Agent, removed stale "Feels" analytics super properties
- Navigation: consolidated competing navigationDestination, boolean → value-based
- Testing: 8 sleep() → waitForExistence, duplicates extracted, Swift 6 compat
- Service bugs: infinite retry cap, duplicate achievement prevention, TOCTOU vote
  fix, PollVote.odg → voterId rename, deterministic placeholder IDs, parallel
  MKDirections, Sendable-safe POI struct

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 17:03:09 -06:00

940 lines
38 KiB
Swift

//
// 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..<flatItems.count)
// - "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.
// - simulateMove: Takes proposed index, returns post-move array + actual destination
// - computeValidDestinationRowsProposed: Returns PROPOSED indices (for UITableView delegate)
// - DragZones: invalidRowIndices and validDropRows are in ORIGINAL space (for UI highlighting)
// - To convert: proposedToOriginal(proposed, sourceRow) and originalToProposed(original, sourceRow)
//
import Foundation
// MARK: - Pure Functions for Itinerary Reordering
/// Container for all pure reordering logic.
/// Using an enum (no cases) as a namespace for static functions.
enum ItineraryReorderingLogic {
// MARK: - Row Flattening
/// Default sortOrder for travel when lookup returns nil.
/// Travel defaults to after-games region (positive value).
private static let defaultTravelSortOrder: Double = 1.0
/// Flattens hierarchical day data into a single array of row items.
///
/// **SEMANTIC MODEL**: This function ignores `day.travelBefore` entirely.
/// Travel segments must be included in `day.items` with appropriate sortOrder.
///
/// For each day, rows are added in this order:
/// 1. Day header - "Day N · Date"
/// 2. Items with sortOrder < 0 (before games), sorted by sortOrder ascending
/// 3. Games - all games for this day (grouped as one row)
/// 4. Items with sortOrder >= 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)..<items.count {
switch items[i] {
case .dayHeader:
return nil
case .travel:
return i
default:
continue
}
}
return nil
}
/// Legacy version that uses embedded dayNumber (unreliable under semantic model).
@available(*, deprecated, message: "Use travelRow(in:forDay:) which uses semantic day lookup")
static func travelRowByEmbeddedDay(in items: [ItineraryRowItem], forDay day: Int) -> 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..<items.count {
if case .games(_, let d) = items[i], d == day {
gamesRow = i
break
}
if case .dayHeader(let d, _) = items[i], d > 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<Int>],
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<Int>
/// Rows where drop is allowed (original indices)
let validDropRows: [Int]
/// Game IDs that act as barriers for this drag
let barrierGameIds: Set<String>
}
/// 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<Int>],
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<Int>()
for i in 0..<flatItems.count {
if i == sourceRow {
// The source row itself is neither valid nor invalid - it's being dragged
continue
}
if !validSet.contains(i) {
invalidRows.insert(i)
}
}
// Find barrier games using constraints
var barrierGameIds = Set<String>()
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 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<Int>()
for i in 0..<flatItems.count {
if i == sourceRow {
// The source row itself is neither valid nor invalid - it's being dragged
continue
}
if !validSet.contains(i) {
invalidRows.insert(i)
}
}
return DragZones(
invalidRowIndices: invalidRows,
validDropRows: validRowsOriginal,
barrierGameIds: [] // No barrier highlighting for custom items
)
}
// MARK: - Legacy Compatibility
/// Legacy version of calculateTravelDragZones that doesn't require sourceRow.
/// Uses day-range-based calculation only.
///
/// - Note: Prefer the version with sourceRow for accurate validation.
@available(*, deprecated, message: "Use calculateTravelDragZones(segment:sourceRow:...) for accurate validation")
static func calculateTravelDragZones(
segment: TravelSegment,
flatItems: [ItineraryRowItem],
travelValidRanges: [String: ClosedRange<Int>],
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<Int>()
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<String>()
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<Int>()
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.
/// Falls back to using segment UUID to ensure unique keys for different segments.
private static func travelIdForSegment(
_ segment: TravelSegment,
in travelValidRanges: [String: ClosedRange<Int>],
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
}
// Include segment UUID to make keys unique when multiple segments share
// the same from/to city pair (e.g., repeat visits like A->B, C->B)
return matchingKeys.first ?? "travel:\(segment.id.uuidString):\(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 a Set for O(1) containment check on validDestinationRows.
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 }
// Use Set for O(1) containment check instead of O(n) Array.contains
let validDestinationSet = Set(validDestinationRows)
if validDestinationSet.contains(row) {
return row
}
// Snap to nearest valid destination (validDestinationRows must be sorted for binary search)
return nearestValue(in: validDestinationRows, to: row) ?? sourceRow
}
}