Files
Sportstime/docs/plans/2026-01-17-itinerary-reorder-design.md
Trey t 05a3efd9c8 docs: comprehensive itinerary reorder refactor design
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>
2026-01-17 20:29:28 -06:00

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)
  • 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

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)