Files
Sportstime/docs/plans/2026-01-17-flexible-itinerary-ordering-design.md
Trey t 6e9b9f728b 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>
2026-01-17 11:01:16 -06:00

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:

  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:

// 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.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():

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:

  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.