Complete design for TripDetailView refactor with constrained drag-and-drop: - Games are fixed anchors (immovable, ordered by time) - Travel is movable with hard constraints (after departure games, before arrival games) - Custom items are freely movable anywhere Key decisions from 100-question brainstorming session: - Unified ItineraryItem model (replaces CustomItineraryItem + TravelDayOverride) - Separate testable ItineraryConstraints type - Full structure stored (not derived) - Debounced sync, local-first, last-write-wins - Invalid zones dim during drag, barrier games highlight - Gap opens at drop position, haptic feedback throughout Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
12 KiB
12 KiB
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:
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)
sortOrderuses 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
CustomItineraryItemandTravelDayOverriderecords
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
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
ItineraryItemis a CloudKit record linked to trip bytripId - Clean break from old format — existing
CustomItineraryItemandTravelDayOverridedata 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 logicItineraryItem— unified model for games, travel, and custom itemsItineraryItemService— handles persistence, sync, and CRUD operations- View layer uses the constraint API to determine valid drop zones
Implementation Order
- Data model (
ItineraryItem,ItemKind) - Constraint logic (
ItineraryConstraintswith unit tests) - Persistence layer (
ItineraryItemServicewith CloudKit sync) - UI components (day header, item rows, drag handles)
- Drag & drop integration with constraint validation
- 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 modelCore/Models/Domain/ItineraryConstraints.swift— constraint logicCore/Services/ItineraryItemService.swift— persistence and sync
Modified Files
Features/Trip/Views/TripDetailView.swift— complete rewrite of itinerary sectionFeatures/Trip/Views/AddItemSheet.swift— adapt to new modelExport/PDFGenerator.swift— respect user's itinerary order
Deleted Files
Core/Models/Domain/CustomItineraryItem.swiftCore/Models/Domain/TravelDayOverride.swiftCore/Services/CustomItemService.swiftCore/Services/CustomItemSubscriptionService.swiftCore/Services/TravelOverrideService.swiftFeatures/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) |