# Itinerary Reorder Refactor Design **Date:** 2026-01-17 **Status:** Approved **Scope:** Complete rewrite of TripDetailView itinerary with constrained drag-and-drop ## Overview Refactor TripDetailView to support drag-and-drop reordering where: - **Games** are fixed anchors (immovable, ordered by time) - **Travel** is movable with hard constraints (must be after departure games, before arrival games) - **Custom items** are freely movable anywhere ## Core Data Model The itinerary is a flat list of items grouped by day headers. Each item has: ```swift struct ItineraryItem: Identifiable, Codable { let id: UUID let tripId: UUID var day: Int // Which day (1, 2, 3...) var sortOrder: Double // Position within the day (fractional) let kind: ItemKind // game, travel, or custom var modifiedAt: Date } enum ItemKind: Codable { case game(gameId: String) // Fixed, ordered by game time case travel(fromCity: String, toCity: String) // Constrained by games case custom(title: String, icon: String, time: Date?, location: CLLocationCoordinate2D?) } ``` ### Key Points - Games and travel are stored as items (not derived) — full structure persisted - Travel segments are system-generated when cities change, but stored with positions - Custom items have optional time and location (location for map display only, no constraints) - `sortOrder` uses fractional values for efficient insertion without reindexing ### Storage - Items synced to CloudKit with debounced writes (1-2 seconds after last change) - Local-first: changes persist locally immediately, sync retries silently on failure - Clean break from existing `CustomItineraryItem` and `TravelDayOverride` records ## Constraint Logic A separate `ItineraryConstraints` type handles all validation, making it testable in isolation. ### Game Constraints - Games are **immovable** — fixed to their day, ordered by game time within the day - Multiple games on same day sort by time (1pm above 7pm) - Games act as **barriers** that travel cannot cross ### Travel Constraints - Travel has a start city (departure) and end city (arrival) - Must be **below** ALL games in the departure city (on any day) - Must be **above** ALL games in the arrival city (on any day) - Can be placed on any day within this valid window **Example:** - Day 1: Chicago game - Day 2: Rest day - Day 3: Detroit game Chicago->Detroit travel is valid on: - Day 1 (after the game) - Day 2 (anywhere) - Day 3 (before the game) ### Custom Item Constraints - **No constraints** — can be placed on any day, in any position - Location (if set) is for map display only, doesn't affect placement ### Constraint API ```swift struct ItineraryConstraints { /// Returns all valid drop zones for an item being dragged func validDropZones(for item: ItineraryItem, in itinerary: [ItineraryItem]) -> [DropZone] /// Validates if a specific position is valid for an item func isValidPosition(item: ItineraryItem, day: Int, sortOrder: Double, in itinerary: [ItineraryItem]) -> Bool /// Returns the games that act as barriers for a travel item (for visual highlighting) func barrierGames(for travelItem: ItineraryItem, in itinerary: [ItineraryItem]) -> [ItineraryItem] } ``` ## Visual Design ### Layout Structure - Full map header at top (current design, with save/heart button overlay) - Flat list below — day headers are separators, not containers - Day headers scroll with content (not sticky) ### Day Header - Format: "Day 1 - Friday, January 17" - Plus (+) button on the right to add custom items - Empty state text when no items: "No items yet, tap + to add" - Same styling for rest days (just no games) ### Game Rows (Prominent) - Larger card style to show importance - Sport badge, team matchup, stadium, time (current detailed design) - **No drag handle** — absence signals they're immovable - Ordered by game time within the day ### Travel Rows (Distinct) - Gold/route-colored border and accent - Shows "Chicago -> Detroit - 280 mi - 4h 30m" - Drag handle on left (always visible) - No EV charger info (removed for cleaner UI) ### Custom Item Rows (Minimal) - Compact: icon + title only - Time shown on right if set (e.g., "7:00 PM") - Drag handle on left (always visible) ### Removed from Current Design - Overview section (stats row, score card) — focus on itinerary - EV charger expandable section in travel ## Drag & Drop Interaction ### Initiating Drag - Drag handle (grip icon) on left side of travel and custom items - Games have no handle — visually signals they're locked - Standard iOS drag threshold (system default) ### During Drag - Standard iOS drag appearance (system handles lift/shadow) - **Invalid zones dim** — valid stays normal, invalid fades out - **Barrier games highlight** (gold border) when dragging travel — shows which games create the constraint - **Gap opens** between items at valid drop positions - Auto-scroll when dragging near top/bottom edge - Haptic feedback: on pickup, when hovering valid zone, and on drop ### Drop Behavior - Drop to exact position between items (even across days) - Dropping outside valid zones: item snaps back to original position - After drop: smooth reflow animation as items settle ### Invalid Drop Prevention - Travel dropped above a departure game -> prevented (no drop zone shown) - Travel dropped below an arrival game -> prevented (no drop zone shown) - Custom items have no restrictions — all zones valid ### Day-to-Day Movement - Drag item to different day by scrolling (auto-scroll at edges) - Drop at exact position within target day ## Persistence & Sync ### Storage Model - Full itinerary structure stored (not derived) - Each `ItineraryItem` is a CloudKit record linked to trip by `tripId` - Clean break from old format — existing `CustomItineraryItem` and `TravelDayOverride` data not migrated ### Sync Behavior - **Debounced writes**: Wait 1-2 seconds after last change, then sync - **Local-first**: Changes persist locally immediately - **Silent retry**: If sync fails, keep retrying in background - **Last write wins**: No complex conflict resolution, most recent change overwrites ### Offline - **Read-only offline**: Can view itinerary but editing requires connection - Sync resumes automatically when back online ### Trip Changes (Game Added/Removed Externally) - **Auto-update**: Detect changes to underlying trip, merge into stored itinerary - New games inserted in time order on correct day - Removed games simply disappear, other items stay in place - If removing a game makes travel invalid (no games left in that city), auto-remove the travel segment ### Error Handling - Corrupted data (e.g., invalid day reference): Show error, offer reset option ## Item Operations ### Adding Custom Items - Tap (+) in day header -> opens AddItemSheet (current behavior) - New item appears at end of day (user can drag to reorder) - Sheet allows setting: title, icon/category, optional time, optional location ### Editing Custom Items - Tap item -> opens edit sheet (current behavior) - Can modify title, icon, time, location - Delete button available in edit sheet ### Deleting Custom Items - Multiple options: swipe to delete, long-press context menu, or delete button in edit sheet - **Confirmation dialog** before delete: "Are you sure?" - Games and travel cannot be deleted (system-managed) ### Reordering - Drag handle to reorder within day or move to different day - Single item drag only (no multi-select) - No undo feature — moves are simple enough to redo manually ### Map Integration - Route lines follow itinerary order (includes custom items with locations) - Quick fade/flash animation when route updates after reorder - Tap map marker -> list scrolls to that item - Tapping list items does NOT affect map (list is for editing, map is overview) ## Implementation Approach ### Architecture - `ItineraryConstraints` — separate testable type for all constraint logic - `ItineraryItem` — unified model for games, travel, and custom items - `ItineraryItemService` — handles persistence, sync, and CRUD operations - View layer uses the constraint API to determine valid drop zones ### Implementation Order 1. Data model (`ItineraryItem`, `ItemKind`) 2. Constraint logic (`ItineraryConstraints` with unit tests) 3. Persistence layer (`ItineraryItemService` with CloudKit sync) 4. UI components (day header, item rows, drag handles) 5. Drag & drop integration with constraint validation 6. Map route updates ### Code Strategy - Branch replacement — new code replaces old in a feature branch - Clean break from existing `CustomItineraryItem`, `TravelDayOverride`, and related services - Lazy rendering for performance (variable trip length, potentially hundreds of items) - PDF export updated to respect user's itinerary order ### Testing - Unit tests for `ItineraryConstraints` — all constraint rules - Integration tests for drag/drop behavior - Edge cases: multiple games same day, games in multiple cities same day, rest days ### Not in Scope - Accessibility/VoiceOver reordering (touch first) - Keyboard shortcuts - Multi-select drag - Undo feature ## Files to Create/Modify ### New Files - `Core/Models/Domain/ItineraryItem.swift` — unified item model - `Core/Models/Domain/ItineraryConstraints.swift` — constraint logic - `Core/Services/ItineraryItemService.swift` — persistence and sync ### Modified Files - `Features/Trip/Views/TripDetailView.swift` — complete rewrite of itinerary section - `Features/Trip/Views/AddItemSheet.swift` — adapt to new model - `Export/PDFGenerator.swift` — respect user's itinerary order ### Deleted Files - `Core/Models/Domain/CustomItineraryItem.swift` - `Core/Models/Domain/TravelDayOverride.swift` - `Core/Services/CustomItemService.swift` - `Core/Services/CustomItemSubscriptionService.swift` - `Core/Services/TravelOverrideService.swift` - `Features/Trip/Views/CustomItemRow.swift` ## Decision Log | Question | Decision | |----------|----------| | Core model | List of days, each containing ordered items | | Item order within day | User-controlled via drag | | Item types | Games (fixed), Travel (constrained), Custom (free) | | Travel constraints | After ALL departure games, before ALL arrival games | | Custom item location | Optional, for map only, no placement constraints | | Invalid drop handling | Don't show invalid drop zones (dim them) | | Visual feedback for barriers | Highlight barrier games during travel drag | | Persistence | Full structure stored, not derived | | Sync strategy | Debounced (1-2s), local-first, silent retry | | Conflict resolution | Last write wins | | Offline | Read-only | | Trip changes | Auto-merge, auto-remove invalid travel | | Delete confirmation | Yes, dialog | | Undo | No | | Keyboard/accessibility | Not in scope (touch first) | | Day header format | "Day 1 - Friday, January 17" | | Add button location | In day header (+) | | New item position | End of day | | Game row style | Prominent card (current detailed design) | | Travel row style | Distinct gold accent | | Custom item row style | Minimal (icon + title + optional time) | | EV charger info | Removed | | Overview section | Removed (focus on itinerary) | | Map at top | Yes, full map | | Drag initiation | Drag handle (grip icon) | | Haptic feedback | Yes (pickup, hover, drop) | | Drop indicator | Gap opens between items | | Scroll during drag | Auto-scroll at edges | | Rest day styling | Same as game days | | Empty day text | "No items yet, tap + to add" | | PDF export | Respects user's itinerary order | | Max trip length | Variable (optimize for long trips) | | Max custom items | Unlimited (hundreds possible) |