# Flexible Itinerary Ordering Design ## Overview Enable fully flexible ordering of itinerary items while maintaining logical constraints. Users can arrange their day however they want - custom items can go before or after games, travel can be positioned anywhere within its valid day range. ## Current State The current implementation has a fixed rendering order within each day: 1. Travel (if arriving this day) - appears BEFORE day header 2. Day Header + Add button 3. Games (bundled together) 4. Custom Items Custom items are constrained to appear after games and cannot be placed before travel. ## New Behavior ### Ordering Rules | Item Type | Drag Handle | Can Move To | |-----------|-------------|-------------| | Day Header | No | Fixed at top of day | | Game | No | Has sortOrder for position, but immovable | | Travel | Yes | Any position, day constrained to game cities' range | | Custom Item | Yes | Any position, any day | ### Rendering Order Within a Day ``` 1. Day Header (fixed, always first) 2. All orderable items sorted by sortOrder: - Games (sortOrder assigned, drag disabled) - Travel (sortOrder, drag enabled with day constraints) - Custom Items (sortOrder, drag enabled anywhere) ``` ### Visual Example User arranges their day: ``` Day 3 ยท Sunday, Mar 8 + Add ๐Ÿจ Hotel checkout (sortOrder: 50.0) โ† custom, before game ๐Ÿš— Detroit โ†’ Milwaukee (sortOrder: 75.0) โ† travel, before game Milwaukee: NBA ORL @ MIL 8PM (sortOrder: 100.0) โ† game (no drag handle) ๐Ÿฝ๏ธ Post-game dinner (sortOrder: 150.0) โ† custom, after game ``` ## Data Model Changes ### Travel Position Storage Replace simple day override with full position: ```swift // Before: @State private var travelDayOverrides: [String: Int] = [:] // After: @State private var travelPositions: [String: TravelPosition] = [:] struct TravelPosition: Codable { var day: Int var sortOrder: Double } ``` ### Game SortOrder Assignment Games receive automatic sortOrder values when the trip is loaded/created: ```swift // Games for each day get sortOrder 100.0, 101.0, 102.0... // This leaves room below (0-99) and above (103+) for user items ``` ### SwiftData Model for Persistence ```swift @Model final class TravelPositionModel { @Attribute(.unique) var id: String // "travel:detroit->milwaukee" var tripId: UUID var day: Int var sortOrder: Double var modifiedAt: Date // CloudKit sync fields var ckRecordID: String? var ckModifiedAt: Date? } ``` ### CloudKit Sync Travel positions sync to CloudKit so all trip participants see updates: - Record Type: `TravelPosition` - Fields: `id`, `tripId`, `day`, `sortOrder`, `modifiedAt` - Reference to trip's `CKRecord.ID` for sharing ## Travel Movement Constraints Travel segments can be dragged to any position, but their **day** is constrained to the range between the departure and arrival cities' games. ### Example ``` Day 1: Game in Detroit Day 2: Rest day Day 3: Rest day Day 4: Game in Milwaukee ``` Travel "Detroit โ†’ Milwaukee" valid day range: **Day 2, 3, or 4** - Can't be Day 1 (game in Detroit hasn't happened yet) - Can be Day 4 (arrive morning of Milwaukee game) ### Constraint Behavior 1. User drags travel to a new position 2. Calculate which day that position falls under 3. If day is within valid range โ†’ allow, update day + sortOrder 4. If day is outside valid range โ†’ snap to nearest valid day ## Implementation Changes ### 1. ItineraryTableViewWrapper.swift Update `buildItineraryData()` to: - Assign sortOrder to games (100.0, 101.0, etc. per day) - Include travel in the sortable items list - Sort all items (games + travel + custom) by sortOrder within each day ### 2. ItineraryTableViewController.swift Update flattening in `reloadData()`: ```swift for day in days { // 1. Day header (fixed) flatItems.append(.dayHeader(...)) // 2. All orderable items sorted together let orderableItems = (day.games + day.travel + day.customItems) .sorted(by: \.sortOrder) for item in orderableItems { flatItems.append(item) } } ``` Update `targetIndexPathForMoveFromRowAt`: - Remove constraints that prevent custom items from going before games/travel - Keep travel day-range constraints Update `calculateSortOrder()`: - Scan for ANY orderable item (not just custom items) when finding neighbors ### 3. TravelPositionService.swift (new) ```swift actor TravelPositionService { func save(_ position: TravelPosition, for tripId: UUID) async throws func fetch(for tripId: UUID) async throws -> [TravelPosition] func sync() async throws // Push local changes to CloudKit } ``` ## Edge Cases | Scenario | Behavior | |----------|----------| | Trip replanned, games change | Reassign game sortOrders (100, 101...). Custom items keep their sortOrder | | Game removed from trip | Items stay at their sortOrder positions | | Game added to trip | Gets next available sortOrder (102, 103...) | | Travel no longer valid (cities changed) | Remove orphaned TravelPosition records during replan | | Conflict: two items same sortOrder | Stable sort by `modifiedAt` as tiebreaker | ## Migration Existing trips have `travelDayOverrides: [String: Int]`. On first load: ```swift for (travelId, day) in travelDayOverrides { let position = TravelPosition( day: day, sortOrder: 50.0 // Before games (which start at 100) ) travelPositions[travelId] = position } ``` Existing custom items already have `day` and `sortOrder` - no migration needed. ## Summary This design enables fully flexible itinerary ordering where: 1. **Custom items** can be placed anywhere - before games, after games, between travel and games, any day 2. **Travel** can be reordered within a day and moved between days, constrained to valid day range 3. **Games** have sortOrder for positioning but cannot be dragged 4. **Day headers** remain fixed anchors at the top of each day The key insight is that all orderable content (games, travel, custom items) shares a single sortOrder namespace within each day, allowing arbitrary interleaving while maintaining the structural constraint that games can't be moved by users.