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:
205
docs/plans/2026-01-17-flexible-itinerary-ordering-design.md
Normal file
205
docs/plans/2026-01-17-flexible-itinerary-ordering-design.md
Normal 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.
|
||||
Reference in New Issue
Block a user