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>
6.1 KiB
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:
- Travel (if arriving this day) - appears BEFORE day header
- Day Header + Add button
- Games (bundled together)
- 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:
// 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:
// 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
@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.IDfor 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
- User drags travel to a new position
- Calculate which day that position falls under
- If day is within valid range → allow, update day + sortOrder
- 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():
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)
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:
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:
- Custom items can be placed anywhere - before games, after games, between travel and games, any day
- Travel can be reordered within a day and moved between days, constrained to valid day range
- Games have sortOrder for positioning but cannot be dragged
- 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.