docs: add flexible itinerary ordering design

Design for fully customizable item ordering in trip itineraries:
- Custom items can go anywhere (before/after games, any day)
- Travel constrained to valid day range but freely positioned within days
- Games get sortOrder for positioning but remain immovable
- TravelPosition stored in SwiftData, synced to CloudKit

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-17 11:01:16 -06:00
parent c658d5f9f4
commit 6e9b9f728b

View File

@@ -0,0 +1,205 @@
# Flexible Itinerary Ordering Design
## Overview
Enable fully flexible ordering of itinerary items while maintaining logical constraints. Users can arrange their day however they want - custom items can go before or after games, travel can be positioned anywhere within its valid day range.
## Current State
The current implementation has a fixed rendering order within each day:
1. Travel (if arriving this day) - appears BEFORE day header
2. Day Header + Add button
3. Games (bundled together)
4. Custom Items
Custom items are constrained to appear after games and cannot be placed before travel.
## New Behavior
### Ordering Rules
| Item Type | Drag Handle | Can Move To |
|-----------|-------------|-------------|
| Day Header | No | Fixed at top of day |
| Game | No | Has sortOrder for position, but immovable |
| Travel | Yes | Any position, day constrained to game cities' range |
| Custom Item | Yes | Any position, any day |
### Rendering Order Within a Day
```
1. Day Header (fixed, always first)
2. All orderable items sorted by sortOrder:
- Games (sortOrder assigned, drag disabled)
- Travel (sortOrder, drag enabled with day constraints)
- Custom Items (sortOrder, drag enabled anywhere)
```
### Visual Example
User arranges their day:
```
Day 3 · Sunday, Mar 8 + Add
🏨 Hotel checkout (sortOrder: 50.0) ← custom, before game
🚗 Detroit → Milwaukee (sortOrder: 75.0) ← travel, before game
Milwaukee: NBA ORL @ MIL 8PM (sortOrder: 100.0) ← game (no drag handle)
🍽️ Post-game dinner (sortOrder: 150.0) ← custom, after game
```
## Data Model Changes
### Travel Position Storage
Replace simple day override with full position:
```swift
// Before:
@State private var travelDayOverrides: [String: Int] = [:]
// After:
@State private var travelPositions: [String: TravelPosition] = [:]
struct TravelPosition: Codable {
var day: Int
var sortOrder: Double
}
```
### Game SortOrder Assignment
Games receive automatic sortOrder values when the trip is loaded/created:
```swift
// Games for each day get sortOrder 100.0, 101.0, 102.0...
// This leaves room below (0-99) and above (103+) for user items
```
### SwiftData Model for Persistence
```swift
@Model
final class TravelPositionModel {
@Attribute(.unique) var id: String // "travel:detroit->milwaukee"
var tripId: UUID
var day: Int
var sortOrder: Double
var modifiedAt: Date
// CloudKit sync fields
var ckRecordID: String?
var ckModifiedAt: Date?
}
```
### CloudKit Sync
Travel positions sync to CloudKit so all trip participants see updates:
- Record Type: `TravelPosition`
- Fields: `id`, `tripId`, `day`, `sortOrder`, `modifiedAt`
- Reference to trip's `CKRecord.ID` for sharing
## Travel Movement Constraints
Travel segments can be dragged to any position, but their **day** is constrained to the range between the departure and arrival cities' games.
### Example
```
Day 1: Game in Detroit
Day 2: Rest day
Day 3: Rest day
Day 4: Game in Milwaukee
```
Travel "Detroit → Milwaukee" valid day range: **Day 2, 3, or 4**
- Can't be Day 1 (game in Detroit hasn't happened yet)
- Can be Day 4 (arrive morning of Milwaukee game)
### Constraint Behavior
1. User drags travel to a new position
2. Calculate which day that position falls under
3. If day is within valid range → allow, update day + sortOrder
4. If day is outside valid range → snap to nearest valid day
## Implementation Changes
### 1. ItineraryTableViewWrapper.swift
Update `buildItineraryData()` to:
- Assign sortOrder to games (100.0, 101.0, etc. per day)
- Include travel in the sortable items list
- Sort all items (games + travel + custom) by sortOrder within each day
### 2. ItineraryTableViewController.swift
Update flattening in `reloadData()`:
```swift
for day in days {
// 1. Day header (fixed)
flatItems.append(.dayHeader(...))
// 2. All orderable items sorted together
let orderableItems = (day.games + day.travel + day.customItems)
.sorted(by: \.sortOrder)
for item in orderableItems {
flatItems.append(item)
}
}
```
Update `targetIndexPathForMoveFromRowAt`:
- Remove constraints that prevent custom items from going before games/travel
- Keep travel day-range constraints
Update `calculateSortOrder()`:
- Scan for ANY orderable item (not just custom items) when finding neighbors
### 3. TravelPositionService.swift (new)
```swift
actor TravelPositionService {
func save(_ position: TravelPosition, for tripId: UUID) async throws
func fetch(for tripId: UUID) async throws -> [TravelPosition]
func sync() async throws // Push local changes to CloudKit
}
```
## Edge Cases
| Scenario | Behavior |
|----------|----------|
| Trip replanned, games change | Reassign game sortOrders (100, 101...). Custom items keep their sortOrder |
| Game removed from trip | Items stay at their sortOrder positions |
| Game added to trip | Gets next available sortOrder (102, 103...) |
| Travel no longer valid (cities changed) | Remove orphaned TravelPosition records during replan |
| Conflict: two items same sortOrder | Stable sort by `modifiedAt` as tiebreaker |
## Migration
Existing trips have `travelDayOverrides: [String: Int]`. On first load:
```swift
for (travelId, day) in travelDayOverrides {
let position = TravelPosition(
day: day,
sortOrder: 50.0 // Before games (which start at 100)
)
travelPositions[travelId] = position
}
```
Existing custom items already have `day` and `sortOrder` - no migration needed.
## Summary
This design enables fully flexible itinerary ordering where:
1. **Custom items** can be placed anywhere - before games, after games, between travel and games, any day
2. **Travel** can be reordered within a day and moved between days, constrained to valid day range
3. **Games** have sortOrder for positioning but cannot be dragged
4. **Day headers** remain fixed anchors at the top of each day
The key insight is that all orderable content (games, travel, custom items) shares a single sortOrder namespace within each day, allowing arbitrary interleaving while maintaining the structural constraint that games can't be moved by users.