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>
331 lines
12 KiB
Markdown
331 lines
12 KiB
Markdown
# 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) |
|