# Itinerary Reorder Refactor Design ## Overview Refactor the itinerary drag-and-drop system to use a unified data model, fix current bugs, and implement proper constraint validation with visual feedback. ## Current Problems 1. Can't move custom item below a game 2. Can't move custom item to empty/rest days 3. Travel positioning is inconsistent - always appears above custom items regardless of drop position 4. General weirdness with drag targeting and sort order calculation ## Goals - **Custom items can go anywhere** - any position on any day, including empty rest days - **Travel has hard constraints** - day range based on game cities, position constraints on edge days - **Games are fixed** - no drag handle, sorted by game time - **Red zone feedback** - invalid drop zones are visually highlighted - **Unified data model** - single `ItineraryItem` type for all positionable items --- ## Data Model ### Unified ItineraryItem Replace `CustomItineraryItem` and `TravelDayOverride` with a single model: ```swift struct ItineraryItem: Identifiable, Codable, Hashable { let id: UUID let tripId: UUID var category: ItemCategory var day: Int // 1-indexed var sortOrder: Double var title: String let createdAt: Date var modifiedAt: Date // Location (for mappable items) var latitude: Double? var longitude: Double? var address: String? // Travel-specific (only when category == .travel) var travelFromCity: String? var travelToCity: String? var travelDistanceMeters: Double? var travelDurationSeconds: Double? var isMappable: Bool { latitude != nil && longitude != nil } var isTravel: Bool { category == .travel } } enum ItemCategory: String, Codable, CaseIterable { case restaurant case hotel case activity case note case travel var icon: String { switch self { case .restaurant: return "🍽️" case .hotel: return "🏨" case .activity: return "🎯" case .note: return "📝" case .travel: return "🚗" } } var label: String { switch self { case .restaurant: return "Restaurant" case .hotel: return "Hotel" case .activity: return "Activity" case .note: return "Note" case .travel: return "Travel" } } } ``` ### Games Remain Derived Games are NOT stored as `ItineraryItem`. They're computed from trip data and rendered with assigned sortOrder values: | SortOrder Range | Usage | |-----------------|-------| | 0 - 99 | Items BEFORE games | | 100 - 199 | Games (100 + index by game time) | | 200+ | Items AFTER games | --- ## Constraint Rules ### Custom Items (non-travel) - **Can go anywhere** within trip date range - Any day (1 to tripDayCount), including empty rest days - Any sortOrder position ### Travel Items **Day constraint:** - Earliest: Day of last game in departure city (can leave after game) - Latest: Day of first game in arrival city (must arrive before game) **Position constraint on edge days:** - On departure day: Must be AFTER all games (sortOrder > max game sortOrder) - On arrival day: Must be BEFORE all games (sortOrder < min game sortOrder) - On rest days: Can be anywhere **Example:** ``` Day 1: Game in Detroit (sortOrder 100) Day 2: Rest day Day 3: Rest day Day 4: Game in Milwaukee (sortOrder 100) Travel "Detroit → Milwaukee" valid positions: - Day 1: sortOrder > 100 (after the game) - Day 2: any sortOrder - Day 3: any sortOrder - Day 4: sortOrder < 100 (before the game) ``` ### Games - Fixed position, no drag handle - Sorted by game time within a day - Multiple games on same day: travel must be after ALL on departure, before ALL on arrival --- ## Constraint Validation Layer ```swift struct ItineraryConstraints { let trip: Trip let games: [RichGame] private var tripDayCount: Int { // Calculate from trip.startDate to trip.endDate } /// Check if position is valid for an item func isValidPosition(for item: ItineraryItem, day: Int, sortOrder: Double) -> Bool { // Day must be within trip range guard day >= 1 && day <= tripDayCount else { return false } switch item.category { case .travel: return isValidTravelPosition(item: item, day: day, sortOrder: sortOrder) default: return true // Custom items can go anywhere } } /// Get valid day range for travel func validDayRange(for item: ItineraryItem) -> ClosedRange? { guard item.category == .travel, let fromCity = item.travelFromCity, let toCity = item.travelToCity else { return nil } let departureDays = gameDays(in: fromCity) let arrivalDays = gameDays(in: toCity) let minDay = departureDays.max() ?? 1 // Day of last game in departure city let maxDay = arrivalDays.min() ?? tripDayCount // Day of first game in arrival city return minDay...maxDay } /// Get valid sortOrder range for travel on a specific day func validSortOrderRange(for item: ItineraryItem, on day: Int) -> ClosedRange { guard item.category == .travel, let fromCity = item.travelFromCity, let toCity = item.travelToCity else { return 0...Double.greatestFiniteMagnitude } let lastDepartureGameDay = gameDays(in: fromCity).max() ?? 0 let firstArrivalGameDay = gameDays(in: toCity).min() ?? tripDayCount + 1 if day == lastDepartureGameDay { // Must be AFTER all games let maxGameSortOrder = maxGameSortOrder(on: day) return (maxGameSortOrder + 0.001)...Double.greatestFiniteMagnitude } else if day == firstArrivalGameDay { // Must be BEFORE all games let minGameSortOrder = minGameSortOrder(on: day) return 0...(minGameSortOrder - 0.001) } else { // Rest day - anywhere return 0...Double.greatestFiniteMagnitude } } private func isValidTravelPosition(item: ItineraryItem, day: Int, sortOrder: Double) -> Bool { guard let dayRange = validDayRange(for: item) else { return false } guard dayRange.contains(day) else { return false } let sortOrderRange = validSortOrderRange(for: item, on: day) return sortOrderRange.contains(sortOrder) } private func gameDays(in city: String) -> [Int] { // Return day numbers that have games in this city } private func maxGameSortOrder(on day: Int) -> Double { let dayGames = games.filter { /* game is on this day */ } return 100.0 + Double(dayGames.count - 1) } private func minGameSortOrder(on day: Int) -> Double { return 100.0 } } ``` --- ## Flattening Logic Simplified approach - group by day, sort by sortOrder: ```swift enum ItineraryRowItem { case dayHeader(dayNumber: Int, date: Date) case game(RichGame, sortOrder: Double) case item(ItineraryItem) var sortOrder: Double? { switch self { case .dayHeader: return nil case .game(_, let order): return order case .item(let item): return item.sortOrder } } var isReorderable: Bool { switch self { case .dayHeader, .game: return false case .item: return true } } var id: String { switch self { case .dayHeader(let day, _): return "day:\(day)" case .game(let game, _): return "game:\(game.game.id)" case .item(let item): return "item:\(item.id)" } } } func buildFlatList(trip: Trip, games: [RichGame], items: [ItineraryItem]) -> [ItineraryRowItem] { var result: [ItineraryRowItem] = [] for dayNumber in 1...tripDayCount { let dayDate = dateFor(dayNumber) // 1. Day header (always first) result.append(.dayHeader(dayNumber: dayNumber, date: dayDate)) // 2. Collect all orderable content var dayContent: [ItineraryRowItem] = [] // Games with assigned sortOrder (100, 101, 102...) let dayGames = games .filter { Calendar.current.isDate($0.game.dateTime, inSameDayAs: dayDate) } .sorted { $0.game.dateTime < $1.game.dateTime } for (index, game) in dayGames.enumerated() { dayContent.append(.game(game, sortOrder: 100.0 + Double(index))) } // Items (travel + custom) for item in items.filter({ $0.day == dayNumber }) { dayContent.append(.item(item)) } // 3. Sort by sortOrder dayContent.sort { ($0.sortOrder ?? 0) < ($1.sortOrder ?? 0) } result.append(contentsOf: dayContent) } return result } ``` --- ## Drag Handling with Red Zone Feedback ### Pre-calculate Invalid Zones on Drag Start ```swift final class ItineraryTableViewController: UITableViewController { private var constraints: ItineraryConstraints! private var draggingItem: ItineraryItem? private var invalidRows: Set = [] override func tableView(_ tableView: UITableView, dragSessionWillBegin session: UIDragSession) { guard let indexPath = tableView.indexPathForRow(at: session.location(in: tableView)), case .item(let item) = flatItems[indexPath.row] else { return } draggingItem = item invalidRows = calculateInvalidRows(for: item) // Refresh cells to show red zones tableView.reloadData() } private func calculateInvalidRows(for item: ItineraryItem) -> Set { var invalid = Set() for (index, row) in flatItems.enumerated() { // Day headers are always invalid drop targets if case .dayHeader = row { invalid.insert(index) continue } // For travel items, check constraints if item.category == .travel { let day = dayNumber(forRow: index) let sortOrder = calculateSortOrder(at: index) if !constraints.isValidPosition(for: item, day: day, sortOrder: sortOrder) { invalid.insert(index) } } } return invalid } override func tableView(_ tableView: UITableView, dragSessionDidEnd session: UIDragSession) { draggingItem = nil invalidRows.removeAll() tableView.reloadData() } } ``` ### Apply Red Zone Styling ```swift override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = // ... configure normally // Apply red zone styling during drag if draggingItem != nil && invalidRows.contains(indexPath.row) { cell.contentView.alpha = 0.3 cell.backgroundColor = UIColor.systemRed.withAlphaComponent(0.1) } else { cell.contentView.alpha = 1.0 cell.backgroundColor = .clear } return cell } ``` ### Block Invalid Drops ```swift override func tableView(_ tableView: UITableView, targetIndexPathForMoveFromRowAt source: IndexPath, toProposedIndexPath proposed: IndexPath) -> IndexPath { guard case .item(let item) = flatItems[source.row] else { return source } // Can't drop on position 0 var targetRow = max(1, proposed.row) targetRow = min(targetRow, flatItems.count - 1) // Can't drop ON a day header - go after it if case .dayHeader = flatItems[targetRow] { targetRow += 1 } let targetDay = dayNumber(forRow: targetRow) let targetSortOrder = calculateSortOrder(at: targetRow) // If valid, allow it if constraints.isValidPosition(for: item, day: targetDay, sortOrder: targetSortOrder) { return IndexPath(row: targetRow, section: 0) } // Otherwise, find nearest valid position return findNearestValidPosition(for: item, from: targetRow) } private func findNearestValidPosition(for item: ItineraryItem, from row: Int) -> IndexPath { // Search outward from proposed position to find nearest valid var searchRadius = 1 while searchRadius < flatItems.count { // Check row + radius let upRow = row + searchRadius if upRow < flatItems.count { let day = dayNumber(forRow: upRow) let sortOrder = calculateSortOrder(at: upRow) if constraints.isValidPosition(for: item, day: day, sortOrder: sortOrder) { return IndexPath(row: upRow, section: 0) } } // Check row - radius let downRow = row - searchRadius if downRow >= 1 { let day = dayNumber(forRow: downRow) let sortOrder = calculateSortOrder(at: downRow) if constraints.isValidPosition(for: item, day: day, sortOrder: sortOrder) { return IndexPath(row: downRow, section: 0) } } searchRadius += 1 } // Fallback to source (shouldn't happen) return IndexPath(row: row, section: 0) } ``` --- ## Travel Auto-Generation When a trip is created, generate travel items for each segment: ```swift func generateTravelItems(for trip: Trip, games: [RichGame]) -> [ItineraryItem] { let constraints = ItineraryConstraints(trip: trip, games: games) var items: [ItineraryItem] = [] for segment in trip.travelSegments { let fromCity = segment.fromLocation.name let toCity = segment.toLocation.name // Calculate default day (prefer first valid day) let validRange = constraints.validDayRange(for: tempTravelItem) ?? 1...trip.dayCount let defaultDay = validRange.lowerBound // Calculate default sortOrder based on day let defaultSortOrder: Double if defaultDay == validRange.lowerBound && gamesExist(on: defaultDay, in: fromCity) { defaultSortOrder = 200.0 // After games on departure day } else if defaultDay == validRange.upperBound && gamesExist(on: defaultDay, in: toCity) { defaultSortOrder = 50.0 // Before games on arrival day } else { defaultSortOrder = 50.0 // Rest day, default to morning } let item = ItineraryItem( id: UUID(), tripId: trip.id, category: .travel, day: defaultDay, sortOrder: defaultSortOrder, title: "\(fromCity) → \(toCity)", createdAt: Date(), modifiedAt: Date(), travelFromCity: fromCity, travelToCity: toCity, travelDistanceMeters: segment.distanceMeters, travelDurationSeconds: segment.durationSeconds ) items.append(item) } return items } ``` ### Smart Merge on Re-plan ```swift func mergeTravelItems( existing: [ItineraryItem], newSegments: [TravelSegment], trip: Trip, games: [RichGame] ) -> [ItineraryItem] { let constraints = ItineraryConstraints(trip: trip, games: games) var result: [ItineraryItem] = [] // Index existing travel by route let existingByRoute = Dictionary( grouping: existing.filter { $0.category == .travel } ) { "\($0.travelFromCity ?? "")->\($0.travelToCity ?? "")" } for segment in newSegments { let routeKey = "\(segment.fromLocation.name)->\(segment.toLocation.name)" if var existingItem = existingByRoute[routeKey]?.first { // Route still exists - preserve position if valid if constraints.isValidPosition(for: existingItem, day: existingItem.day, sortOrder: existingItem.sortOrder) { existingItem.modifiedAt = Date() result.append(existingItem) } else { // Position no longer valid - reset to default let newItems = generateTravelItems(for: trip, games: games) if let newItem = newItems.first(where: { $0.travelFromCity == segment.fromLocation.name && $0.travelToCity == segment.toLocation.name }) { result.append(newItem) } } } else { // New segment - generate fresh let newItems = generateTravelItems(for: trip, games: games) if let newItem = newItems.first(where: { $0.travelFromCity == segment.fromLocation.name && $0.travelToCity == segment.toLocation.name }) { result.append(newItem) } } } // Orphaned travel items (route removed) are NOT included return result } ``` --- ## Persistence ### CloudKit Record: `ItineraryItem` ``` Fields: - itemId: String (UUID) - tripId: String (UUID) - category: String - day: Int64 - sortOrder: Double - title: String - createdAt: Date - modifiedAt: Date - latitude: Double? - longitude: Double? - address: String? - travelFromCity: String? - travelToCity: String? - travelDistanceMeters: Double? - travelDurationSeconds: Double? ``` ### SwiftData Model ```swift @Model final class ItineraryItemModel { @Attribute(.unique) var id: UUID var tripId: UUID var category: String var day: Int var sortOrder: Double var title: String var createdAt: Date var modifiedAt: Date var latitude: Double? var longitude: Double? var address: String? var travelFromCity: String? var travelToCity: String? var travelDistanceMeters: Double? var travelDurationSeconds: Double? var ckRecordName: String? var ckModifiedAt: Date? } ``` ### Service Consolidation - Delete `TravelOverrideService` - Rename `CustomItemService` → `ItineraryItemService` - Single service handles all item types --- ## Files to Change ### Create - `SportsTime/Core/Models/Domain/ItineraryItem.swift` - `SportsTime/Core/Models/Domain/ItineraryConstraints.swift` ### Modify - `SportsTime/Features/Trip/Views/ItineraryTableViewController.swift` - Simplified flattening, red zone feedback - `SportsTime/Features/Trip/Views/ItineraryTableViewWrapper.swift` - Use unified items - `SportsTime/Features/Trip/Views/TripDetailView.swift` - Single items state, unified service - `SportsTime/Core/Models/CloudKit/CKModels.swift` - Add `CKItineraryItem` - `SportsTime/Core/Services/CustomItemService.swift` → rename to `ItineraryItemService.swift` ### Delete - `SportsTime/Core/Models/Domain/TravelDayOverride.swift` - `SportsTime/Core/Models/Domain/CustomItineraryItem.swift` - `SportsTime/Core/Services/TravelOverrideService.swift` --- ## Summary | Aspect | Current | New | |--------|---------|-----| | Data models | `CustomItineraryItem` + `TravelDayOverride` | Single `ItineraryItem` | | Travel storage | Separate override model | `.travel` category | | Custom item placement | Only after games | Anywhere | | Travel constraints | Day range only | Day range + position on edge days | | Invalid drop feedback | Snap (buggy) | Red zone highlighting | | Services | Two services | One `ItineraryItemService` | | Flattening | Complex special-casing | Simple group + sort |