diff --git a/docs/plans/2026-01-17-flexible-itinerary-ordering-design.md b/docs/plans/2026-01-17-flexible-itinerary-ordering-design.md new file mode 100644 index 0000000..0468782 --- /dev/null +++ b/docs/plans/2026-01-17-flexible-itinerary-ordering-design.md @@ -0,0 +1,205 @@ +# 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.