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>
This commit is contained in:
@@ -1,632 +1,330 @@
|
||||
# Itinerary Reorder Refactor Design
|
||||
|
||||
**Date:** 2026-01-17
|
||||
**Status:** Approved
|
||||
**Scope:** Complete rewrite of TripDetailView itinerary with constrained drag-and-drop
|
||||
|
||||
## Overview
|
||||
|
||||
Refactor the itinerary drag-and-drop system to use a unified data model, fix current bugs, and implement proper constraint validation with visual feedback.
|
||||
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
|
||||
|
||||
## Current Problems
|
||||
## Core Data Model
|
||||
|
||||
1. Can't move custom item below a game
|
||||
2. Can't move custom item to empty/rest days
|
||||
3. Travel positioning is inconsistent - always appears above custom items regardless of drop position
|
||||
4. General weirdness with drag targeting and sort order calculation
|
||||
|
||||
## Goals
|
||||
|
||||
- **Custom items can go anywhere** - any position on any day, including empty rest days
|
||||
- **Travel has hard constraints** - day range based on game cities, position constraints on edge days
|
||||
- **Games are fixed** - no drag handle, sorted by game time
|
||||
- **Red zone feedback** - invalid drop zones are visually highlighted
|
||||
- **Unified data model** - single `ItineraryItem` type for all positionable items
|
||||
|
||||
---
|
||||
|
||||
## Data Model
|
||||
|
||||
### Unified ItineraryItem
|
||||
|
||||
Replace `CustomItineraryItem` and `TravelDayOverride` with a single model:
|
||||
The itinerary is a flat list of items grouped by day headers. Each item has:
|
||||
|
||||
```swift
|
||||
struct ItineraryItem: Identifiable, Codable, Hashable {
|
||||
struct ItineraryItem: Identifiable, Codable {
|
||||
let id: UUID
|
||||
let tripId: UUID
|
||||
var category: ItemCategory
|
||||
var day: Int // 1-indexed
|
||||
var sortOrder: Double
|
||||
var title: String
|
||||
let createdAt: Date
|
||||
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
|
||||
|
||||
// Location (for mappable items)
|
||||
var latitude: Double?
|
||||
var longitude: Double?
|
||||
var address: String?
|
||||
|
||||
// Travel-specific (only when category == .travel)
|
||||
var travelFromCity: String?
|
||||
var travelToCity: String?
|
||||
var travelDistanceMeters: Double?
|
||||
var travelDurationSeconds: Double?
|
||||
|
||||
var isMappable: Bool {
|
||||
latitude != nil && longitude != nil
|
||||
}
|
||||
|
||||
var isTravel: Bool {
|
||||
category == .travel
|
||||
}
|
||||
}
|
||||
|
||||
enum ItemCategory: String, Codable, CaseIterable {
|
||||
case restaurant
|
||||
case hotel
|
||||
case activity
|
||||
case note
|
||||
case travel
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .restaurant: return "🍽️"
|
||||
case .hotel: return "🏨"
|
||||
case .activity: return "🎯"
|
||||
case .note: return "📝"
|
||||
case .travel: return "🚗"
|
||||
}
|
||||
}
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .restaurant: return "Restaurant"
|
||||
case .hotel: return "Hotel"
|
||||
case .activity: return "Activity"
|
||||
case .note: return "Note"
|
||||
case .travel: return "Travel"
|
||||
}
|
||||
}
|
||||
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?)
|
||||
}
|
||||
```
|
||||
|
||||
### Games Remain Derived
|
||||
### Key Points
|
||||
|
||||
Games are NOT stored as `ItineraryItem`. They're computed from trip data and rendered with assigned sortOrder values:
|
||||
- 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
|
||||
|
||||
| SortOrder Range | Usage |
|
||||
|-----------------|-------|
|
||||
| 0 - 99 | Items BEFORE games |
|
||||
| 100 - 199 | Games (100 + index by game time) |
|
||||
| 200+ | Items AFTER games |
|
||||
### 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 Rules
|
||||
## Constraint Logic
|
||||
|
||||
### Custom Items (non-travel)
|
||||
A separate `ItineraryConstraints` type handles all validation, making it testable in isolation.
|
||||
|
||||
- **Can go anywhere** within trip date range
|
||||
- Any day (1 to tripDayCount), including empty rest days
|
||||
- Any sortOrder position
|
||||
### Game Constraints
|
||||
|
||||
### Travel Items
|
||||
- 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
|
||||
|
||||
**Day constraint:**
|
||||
- Earliest: Day of last game in departure city (can leave after game)
|
||||
- Latest: Day of first game in arrival city (must arrive before game)
|
||||
### Travel Constraints
|
||||
|
||||
**Position constraint on edge days:**
|
||||
- On departure day: Must be AFTER all games (sortOrder > max game sortOrder)
|
||||
- On arrival day: Must be BEFORE all games (sortOrder < min game sortOrder)
|
||||
- On rest days: Can be anywhere
|
||||
- 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: Game in Detroit (sortOrder 100)
|
||||
Day 2: Rest day
|
||||
Day 3: Rest day
|
||||
Day 4: Game in Milwaukee (sortOrder 100)
|
||||
- Day 1: Chicago game
|
||||
- Day 2: Rest day
|
||||
- Day 3: Detroit game
|
||||
|
||||
Travel "Detroit → Milwaukee" valid positions:
|
||||
- Day 1: sortOrder > 100 (after the game)
|
||||
- Day 2: any sortOrder
|
||||
- Day 3: any sortOrder
|
||||
- Day 4: sortOrder < 100 (before the game)
|
||||
```
|
||||
Chicago->Detroit travel is valid on:
|
||||
- Day 1 (after the game)
|
||||
- Day 2 (anywhere)
|
||||
- Day 3 (before the game)
|
||||
|
||||
### Games
|
||||
### Custom Item Constraints
|
||||
|
||||
- Fixed position, no drag handle
|
||||
- Sorted by game time within a day
|
||||
- Multiple games on same day: travel must be after ALL on departure, before ALL on arrival
|
||||
- **No constraints** — can be placed on any day, in any position
|
||||
- Location (if set) is for map display only, doesn't affect placement
|
||||
|
||||
---
|
||||
|
||||
## Constraint Validation Layer
|
||||
### Constraint API
|
||||
|
||||
```swift
|
||||
struct ItineraryConstraints {
|
||||
let trip: Trip
|
||||
let games: [RichGame]
|
||||
/// Returns all valid drop zones for an item being dragged
|
||||
func validDropZones(for item: ItineraryItem, in itinerary: [ItineraryItem]) -> [DropZone]
|
||||
|
||||
private var tripDayCount: Int {
|
||||
// Calculate from trip.startDate to trip.endDate
|
||||
}
|
||||
/// Validates if a specific position is valid for an item
|
||||
func isValidPosition(item: ItineraryItem, day: Int, sortOrder: Double, in itinerary: [ItineraryItem]) -> Bool
|
||||
|
||||
/// Check if position is valid for an item
|
||||
func isValidPosition(for item: ItineraryItem, day: Int, sortOrder: Double) -> Bool {
|
||||
// Day must be within trip range
|
||||
guard day >= 1 && day <= tripDayCount else { return false }
|
||||
|
||||
switch item.category {
|
||||
case .travel:
|
||||
return isValidTravelPosition(item: item, day: day, sortOrder: sortOrder)
|
||||
default:
|
||||
return true // Custom items can go anywhere
|
||||
}
|
||||
}
|
||||
|
||||
/// Get valid day range for travel
|
||||
func validDayRange(for item: ItineraryItem) -> ClosedRange<Int>? {
|
||||
guard item.category == .travel,
|
||||
let fromCity = item.travelFromCity,
|
||||
let toCity = item.travelToCity else { return nil }
|
||||
|
||||
let departureDays = gameDays(in: fromCity)
|
||||
let arrivalDays = gameDays(in: toCity)
|
||||
|
||||
let minDay = departureDays.max() ?? 1 // Day of last game in departure city
|
||||
let maxDay = arrivalDays.min() ?? tripDayCount // Day of first game in arrival city
|
||||
|
||||
return minDay...maxDay
|
||||
}
|
||||
|
||||
/// Get valid sortOrder range for travel on a specific day
|
||||
func validSortOrderRange(for item: ItineraryItem, on day: Int) -> ClosedRange<Double> {
|
||||
guard item.category == .travel,
|
||||
let fromCity = item.travelFromCity,
|
||||
let toCity = item.travelToCity else {
|
||||
return 0...Double.greatestFiniteMagnitude
|
||||
}
|
||||
|
||||
let lastDepartureGameDay = gameDays(in: fromCity).max() ?? 0
|
||||
let firstArrivalGameDay = gameDays(in: toCity).min() ?? tripDayCount + 1
|
||||
|
||||
if day == lastDepartureGameDay {
|
||||
// Must be AFTER all games
|
||||
let maxGameSortOrder = maxGameSortOrder(on: day)
|
||||
return (maxGameSortOrder + 0.001)...Double.greatestFiniteMagnitude
|
||||
} else if day == firstArrivalGameDay {
|
||||
// Must be BEFORE all games
|
||||
let minGameSortOrder = minGameSortOrder(on: day)
|
||||
return 0...(minGameSortOrder - 0.001)
|
||||
} else {
|
||||
// Rest day - anywhere
|
||||
return 0...Double.greatestFiniteMagnitude
|
||||
}
|
||||
}
|
||||
|
||||
private func isValidTravelPosition(item: ItineraryItem, day: Int, sortOrder: Double) -> Bool {
|
||||
guard let dayRange = validDayRange(for: item) else { return false }
|
||||
guard dayRange.contains(day) else { return false }
|
||||
|
||||
let sortOrderRange = validSortOrderRange(for: item, on: day)
|
||||
return sortOrderRange.contains(sortOrder)
|
||||
}
|
||||
|
||||
private func gameDays(in city: String) -> [Int] {
|
||||
// Return day numbers that have games in this city
|
||||
}
|
||||
|
||||
private func maxGameSortOrder(on day: Int) -> Double {
|
||||
let dayGames = games.filter { /* game is on this day */ }
|
||||
return 100.0 + Double(dayGames.count - 1)
|
||||
}
|
||||
|
||||
private func minGameSortOrder(on day: Int) -> Double {
|
||||
return 100.0
|
||||
}
|
||||
/// 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
|
||||
|
||||
## Flattening Logic
|
||||
### Layout Structure
|
||||
|
||||
Simplified approach - group by day, sort by sortOrder:
|
||||
- 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)
|
||||
|
||||
```swift
|
||||
enum ItineraryRowItem {
|
||||
case dayHeader(dayNumber: Int, date: Date)
|
||||
case game(RichGame, sortOrder: Double)
|
||||
case item(ItineraryItem)
|
||||
### Day Header
|
||||
|
||||
var sortOrder: Double? {
|
||||
switch self {
|
||||
case .dayHeader: return nil
|
||||
case .game(_, let order): return order
|
||||
case .item(let item): return item.sortOrder
|
||||
}
|
||||
}
|
||||
- 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)
|
||||
|
||||
var isReorderable: Bool {
|
||||
switch self {
|
||||
case .dayHeader, .game: return false
|
||||
case .item: return true
|
||||
}
|
||||
}
|
||||
### Game Rows (Prominent)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .dayHeader(let day, _): return "day:\(day)"
|
||||
case .game(let game, _): return "game:\(game.game.id)"
|
||||
case .item(let item): return "item:\(item.id)"
|
||||
}
|
||||
}
|
||||
}
|
||||
- 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
|
||||
|
||||
func buildFlatList(trip: Trip, games: [RichGame], items: [ItineraryItem]) -> [ItineraryRowItem] {
|
||||
var result: [ItineraryRowItem] = []
|
||||
### Travel Rows (Distinct)
|
||||
|
||||
for dayNumber in 1...tripDayCount {
|
||||
let dayDate = dateFor(dayNumber)
|
||||
- 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)
|
||||
|
||||
// 1. Day header (always first)
|
||||
result.append(.dayHeader(dayNumber: dayNumber, date: dayDate))
|
||||
### Custom Item Rows (Minimal)
|
||||
|
||||
// 2. Collect all orderable content
|
||||
var dayContent: [ItineraryRowItem] = []
|
||||
|
||||
// Games with assigned sortOrder (100, 101, 102...)
|
||||
let dayGames = games
|
||||
.filter { Calendar.current.isDate($0.game.dateTime, inSameDayAs: dayDate) }
|
||||
.sorted { $0.game.dateTime < $1.game.dateTime }
|
||||
- Compact: icon + title only
|
||||
- Time shown on right if set (e.g., "7:00 PM")
|
||||
- Drag handle on left (always visible)
|
||||
|
||||
for (index, game) in dayGames.enumerated() {
|
||||
dayContent.append(.game(game, sortOrder: 100.0 + Double(index)))
|
||||
}
|
||||
### Removed from Current Design
|
||||
|
||||
// Items (travel + custom)
|
||||
for item in items.filter({ $0.day == dayNumber }) {
|
||||
dayContent.append(.item(item))
|
||||
}
|
||||
- Overview section (stats row, score card) — focus on itinerary
|
||||
- EV charger expandable section in travel
|
||||
|
||||
// 3. Sort by sortOrder
|
||||
dayContent.sort { ($0.sortOrder ?? 0) < ($1.sortOrder ?? 0) }
|
||||
## Drag & Drop Interaction
|
||||
|
||||
result.append(contentsOf: dayContent)
|
||||
}
|
||||
### Initiating Drag
|
||||
|
||||
return result
|
||||
}
|
||||
```
|
||||
- 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
|
||||
|
||||
## Drag Handling with Red Zone Feedback
|
||||
- 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
|
||||
|
||||
### Pre-calculate Invalid Zones on Drag Start
|
||||
### Drop Behavior
|
||||
|
||||
```swift
|
||||
final class ItineraryTableViewController: UITableViewController {
|
||||
- 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
|
||||
|
||||
private var constraints: ItineraryConstraints!
|
||||
private var draggingItem: ItineraryItem?
|
||||
private var invalidRows: Set<Int> = []
|
||||
### Invalid Drop Prevention
|
||||
|
||||
override func tableView(_ tableView: UITableView,
|
||||
dragSessionWillBegin session: UIDragSession) {
|
||||
|
||||
guard let indexPath = tableView.indexPathForRow(at: session.location(in: tableView)),
|
||||
case .item(let item) = flatItems[indexPath.row] else { return }
|
||||
|
||||
draggingItem = item
|
||||
invalidRows = calculateInvalidRows(for: item)
|
||||
|
||||
// Refresh cells to show red zones
|
||||
tableView.reloadData()
|
||||
}
|
||||
|
||||
private func calculateInvalidRows(for item: ItineraryItem) -> Set<Int> {
|
||||
var invalid = Set<Int>()
|
||||
|
||||
for (index, row) in flatItems.enumerated() {
|
||||
// Day headers are always invalid drop targets
|
||||
if case .dayHeader = row {
|
||||
invalid.insert(index)
|
||||
continue
|
||||
}
|
||||
|
||||
// For travel items, check constraints
|
||||
if item.category == .travel {
|
||||
let day = dayNumber(forRow: index)
|
||||
let sortOrder = calculateSortOrder(at: index)
|
||||
|
||||
if !constraints.isValidPosition(for: item, day: day, sortOrder: sortOrder) {
|
||||
invalid.insert(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return invalid
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView,
|
||||
dragSessionDidEnd session: UIDragSession) {
|
||||
|
||||
draggingItem = nil
|
||||
invalidRows.removeAll()
|
||||
tableView.reloadData()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Apply Red Zone Styling
|
||||
- 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
|
||||
|
||||
```swift
|
||||
override func tableView(_ tableView: UITableView,
|
||||
cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
|
||||
let cell = // ... configure normally
|
||||
|
||||
// Apply red zone styling during drag
|
||||
if draggingItem != nil && invalidRows.contains(indexPath.row) {
|
||||
cell.contentView.alpha = 0.3
|
||||
cell.backgroundColor = UIColor.systemRed.withAlphaComponent(0.1)
|
||||
} else {
|
||||
cell.contentView.alpha = 1.0
|
||||
cell.backgroundColor = .clear
|
||||
}
|
||||
|
||||
return cell
|
||||
}
|
||||
```
|
||||
|
||||
### Block Invalid Drops
|
||||
|
||||
```swift
|
||||
override func tableView(_ tableView: UITableView,
|
||||
targetIndexPathForMoveFromRowAt source: IndexPath,
|
||||
toProposedIndexPath proposed: IndexPath) -> IndexPath {
|
||||
|
||||
guard case .item(let item) = flatItems[source.row] else { return source }
|
||||
|
||||
// Can't drop on position 0
|
||||
var targetRow = max(1, proposed.row)
|
||||
targetRow = min(targetRow, flatItems.count - 1)
|
||||
|
||||
// Can't drop ON a day header - go after it
|
||||
if case .dayHeader = flatItems[targetRow] {
|
||||
targetRow += 1
|
||||
}
|
||||
|
||||
let targetDay = dayNumber(forRow: targetRow)
|
||||
let targetSortOrder = calculateSortOrder(at: targetRow)
|
||||
|
||||
// If valid, allow it
|
||||
if constraints.isValidPosition(for: item, day: targetDay, sortOrder: targetSortOrder) {
|
||||
return IndexPath(row: targetRow, section: 0)
|
||||
}
|
||||
|
||||
// Otherwise, find nearest valid position
|
||||
return findNearestValidPosition(for: item, from: targetRow)
|
||||
}
|
||||
|
||||
private func findNearestValidPosition(for item: ItineraryItem, from row: Int) -> IndexPath {
|
||||
// Search outward from proposed position to find nearest valid
|
||||
var searchRadius = 1
|
||||
while searchRadius < flatItems.count {
|
||||
// Check row + radius
|
||||
let upRow = row + searchRadius
|
||||
if upRow < flatItems.count {
|
||||
let day = dayNumber(forRow: upRow)
|
||||
let sortOrder = calculateSortOrder(at: upRow)
|
||||
if constraints.isValidPosition(for: item, day: day, sortOrder: sortOrder) {
|
||||
return IndexPath(row: upRow, section: 0)
|
||||
}
|
||||
}
|
||||
|
||||
// Check row - radius
|
||||
let downRow = row - searchRadius
|
||||
if downRow >= 1 {
|
||||
let day = dayNumber(forRow: downRow)
|
||||
let sortOrder = calculateSortOrder(at: downRow)
|
||||
if constraints.isValidPosition(for: item, day: day, sortOrder: sortOrder) {
|
||||
return IndexPath(row: downRow, section: 0)
|
||||
}
|
||||
}
|
||||
|
||||
searchRadius += 1
|
||||
}
|
||||
|
||||
// Fallback to source (shouldn't happen)
|
||||
return IndexPath(row: row, section: 0)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Travel Auto-Generation
|
||||
|
||||
When a trip is created, generate travel items for each segment:
|
||||
|
||||
```swift
|
||||
func generateTravelItems(for trip: Trip, games: [RichGame]) -> [ItineraryItem] {
|
||||
let constraints = ItineraryConstraints(trip: trip, games: games)
|
||||
var items: [ItineraryItem] = []
|
||||
|
||||
for segment in trip.travelSegments {
|
||||
let fromCity = segment.fromLocation.name
|
||||
let toCity = segment.toLocation.name
|
||||
|
||||
// Calculate default day (prefer first valid day)
|
||||
let validRange = constraints.validDayRange(for: tempTravelItem) ?? 1...trip.dayCount
|
||||
let defaultDay = validRange.lowerBound
|
||||
|
||||
// Calculate default sortOrder based on day
|
||||
let defaultSortOrder: Double
|
||||
if defaultDay == validRange.lowerBound && gamesExist(on: defaultDay, in: fromCity) {
|
||||
defaultSortOrder = 200.0 // After games on departure day
|
||||
} else if defaultDay == validRange.upperBound && gamesExist(on: defaultDay, in: toCity) {
|
||||
defaultSortOrder = 50.0 // Before games on arrival day
|
||||
} else {
|
||||
defaultSortOrder = 50.0 // Rest day, default to morning
|
||||
}
|
||||
|
||||
let item = ItineraryItem(
|
||||
id: UUID(),
|
||||
tripId: trip.id,
|
||||
category: .travel,
|
||||
day: defaultDay,
|
||||
sortOrder: defaultSortOrder,
|
||||
title: "\(fromCity) → \(toCity)",
|
||||
createdAt: Date(),
|
||||
modifiedAt: Date(),
|
||||
travelFromCity: fromCity,
|
||||
travelToCity: toCity,
|
||||
travelDistanceMeters: segment.distanceMeters,
|
||||
travelDurationSeconds: segment.durationSeconds
|
||||
)
|
||||
items.append(item)
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
```
|
||||
|
||||
### Smart Merge on Re-plan
|
||||
|
||||
```swift
|
||||
func mergeTravelItems(
|
||||
existing: [ItineraryItem],
|
||||
newSegments: [TravelSegment],
|
||||
trip: Trip,
|
||||
games: [RichGame]
|
||||
) -> [ItineraryItem] {
|
||||
|
||||
let constraints = ItineraryConstraints(trip: trip, games: games)
|
||||
var result: [ItineraryItem] = []
|
||||
|
||||
// Index existing travel by route
|
||||
let existingByRoute = Dictionary(
|
||||
grouping: existing.filter { $0.category == .travel }
|
||||
) { "\($0.travelFromCity ?? "")->\($0.travelToCity ?? "")" }
|
||||
|
||||
for segment in newSegments {
|
||||
let routeKey = "\(segment.fromLocation.name)->\(segment.toLocation.name)"
|
||||
|
||||
if var existingItem = existingByRoute[routeKey]?.first {
|
||||
// Route still exists - preserve position if valid
|
||||
if constraints.isValidPosition(for: existingItem, day: existingItem.day, sortOrder: existingItem.sortOrder) {
|
||||
existingItem.modifiedAt = Date()
|
||||
result.append(existingItem)
|
||||
} else {
|
||||
// Position no longer valid - reset to default
|
||||
let newItems = generateTravelItems(for: trip, games: games)
|
||||
if let newItem = newItems.first(where: { $0.travelFromCity == segment.fromLocation.name && $0.travelToCity == segment.toLocation.name }) {
|
||||
result.append(newItem)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// New segment - generate fresh
|
||||
let newItems = generateTravelItems(for: trip, games: games)
|
||||
if let newItem = newItems.first(where: { $0.travelFromCity == segment.fromLocation.name && $0.travelToCity == segment.toLocation.name }) {
|
||||
result.append(newItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Orphaned travel items (route removed) are NOT included
|
||||
return result
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Persistence
|
||||
|
||||
### CloudKit Record: `ItineraryItem`
|
||||
|
||||
```
|
||||
Fields:
|
||||
- itemId: String (UUID)
|
||||
- tripId: String (UUID)
|
||||
- category: String
|
||||
- day: Int64
|
||||
- sortOrder: Double
|
||||
- title: String
|
||||
- createdAt: Date
|
||||
- modifiedAt: Date
|
||||
- latitude: Double?
|
||||
- longitude: Double?
|
||||
- address: String?
|
||||
- travelFromCity: String?
|
||||
- travelToCity: String?
|
||||
- travelDistanceMeters: Double?
|
||||
- travelDurationSeconds: Double?
|
||||
```
|
||||
|
||||
### SwiftData Model
|
||||
|
||||
```swift
|
||||
@Model
|
||||
final class ItineraryItemModel {
|
||||
@Attribute(.unique) var id: UUID
|
||||
var tripId: UUID
|
||||
var category: String
|
||||
var day: Int
|
||||
var sortOrder: Double
|
||||
var title: String
|
||||
var createdAt: Date
|
||||
var modifiedAt: Date
|
||||
|
||||
var latitude: Double?
|
||||
var longitude: Double?
|
||||
var address: String?
|
||||
var travelFromCity: String?
|
||||
var travelToCity: String?
|
||||
var travelDistanceMeters: Double?
|
||||
var travelDurationSeconds: Double?
|
||||
|
||||
var ckRecordName: String?
|
||||
var ckModifiedAt: Date?
|
||||
}
|
||||
```
|
||||
|
||||
### Service Consolidation
|
||||
|
||||
- Delete `TravelOverrideService`
|
||||
- Rename `CustomItemService` → `ItineraryItemService`
|
||||
- Single service handles all item types
|
||||
|
||||
---
|
||||
|
||||
## Files to Change
|
||||
|
||||
### Create
|
||||
- `SportsTime/Core/Models/Domain/ItineraryItem.swift`
|
||||
- `SportsTime/Core/Models/Domain/ItineraryConstraints.swift`
|
||||
|
||||
### Modify
|
||||
- `SportsTime/Features/Trip/Views/ItineraryTableViewController.swift` - Simplified flattening, red zone feedback
|
||||
- `SportsTime/Features/Trip/Views/ItineraryTableViewWrapper.swift` - Use unified items
|
||||
- `SportsTime/Features/Trip/Views/TripDetailView.swift` - Single items state, unified service
|
||||
- `SportsTime/Core/Models/CloudKit/CKModels.swift` - Add `CKItineraryItem`
|
||||
- `SportsTime/Core/Services/CustomItemService.swift` → rename to `ItineraryItemService.swift`
|
||||
|
||||
### Delete
|
||||
- `SportsTime/Core/Models/Domain/TravelDayOverride.swift`
|
||||
- `SportsTime/Core/Models/Domain/CustomItineraryItem.swift`
|
||||
- `SportsTime/Core/Services/TravelOverrideService.swift`
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Aspect | Current | New |
|
||||
|--------|---------|-----|
|
||||
| Data models | `CustomItineraryItem` + `TravelDayOverride` | Single `ItineraryItem` |
|
||||
| Travel storage | Separate override model | `.travel` category |
|
||||
| Custom item placement | Only after games | Anywhere |
|
||||
| Travel constraints | Day range only | Day range + position on edge days |
|
||||
| Invalid drop feedback | Snap (buggy) | Red zone highlighting |
|
||||
| Services | Two services | One `ItineraryItemService` |
|
||||
| Flattening | Complex special-casing | Simple group + sort |
|
||||
### 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) |
|
||||
|
||||
Reference in New Issue
Block a user