Files
SportstimeAPI/.planning/research/ARCHITECTURE.md
Trey t 6f09c0abcb docs: complete project research for itinerary editor
Files:
- STACK.md - UITableView drag-drop APIs, SwiftUI bridging patterns
- FEATURES.md - Table stakes UX (lift, insertion line, haptics), polish features
- ARCHITECTURE.md - 5-layer semantic positioning architecture
- PITFALLS.md - Critical pitfalls (row vs semantic, travel as structural)
- SUMMARY.md - Executive synthesis with roadmap implications

Key findings:
- Stack: UITableView + UIHostingConfiguration (existing pattern validated)
- Architecture: Semantic (day, sortOrder) model, not row indices
- Critical pitfall: Row indices are ephemeral; semantic positions are truth

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 13:16:07 -06:00

16 KiB

Architecture Research: Semantic Drag-Drop

Project: SportsTime Itinerary Editor Researched: 2026-01-18 Confidence: HIGH (based on existing implementation analysis)

Executive Summary

The SportsTime codebase already contains a well-architected semantic drag-drop system. This document captures the existing architecture, identifies the key design decisions that make it work, and recommends refinements for maintainability.

The core insight: UITableView operates on row indices, but the semantic model uses (day: Int, sortOrder: Double). The architecture must cleanly separate these coordinate systems while maintaining bidirectional mapping.


Component Layers

Layer 1: Semantic Position Model

Responsibility: Own the source of truth for item positions using semantic coordinates.

Location: ItineraryItem.swift

ItineraryItem {
    day: Int           // 1-indexed day number
    sortOrder: Double  // Position within day (fractional for unlimited insertion)
    kind: ItemKind     // .game, .travel, .custom
}

Key Design Decisions:

  1. Day-based positioning - Items belong to days, not absolute positions
  2. Fractional sortOrder - Enables midpoint insertion without renumbering
  3. sortOrder convention - < 0 = before games, >= 0 = after games

Why this works: The semantic model is independent of visual representation. Moving an item means updating (day, sortOrder), not recalculating row indices.


Layer 2: Constraint Validation

Responsibility: Determine valid positions for each item type.

Location: ItineraryConstraints.swift

ItineraryConstraints {
    isValidPosition(for item, day, sortOrder) -> Bool
    validDayRange(for item) -> ClosedRange<Int>?
    barrierGames(for item) -> [ItineraryItem]
}

Constraint Rules:

Item Type Day Constraint sortOrder Constraint
Game Fixed (immovable) Fixed
Travel After last from-city game, before first to-city game After from-city games on same day, before to-city games on same day
Custom Any day (1...tripDayCount) Any position

Why this layer exists: Drag operations need real-time validation. Having a dedicated constraint engine enables:

  • Pre-computing valid drop zones at drag start
  • Haptic feedback when entering/exiting valid zones
  • Visual dimming of invalid targets

Layer 3: Visual Flattening

Responsibility: Transform semantic model into flat row array for UITableView.

Location: ItineraryTableViewWrapper.swift (buildItineraryData) + ItineraryTableViewController.swift (reloadData)

Data Transformation:

[ItineraryItem] (semantic)
       |
       v
[ItineraryDayData] (grouped by day, with travel/custom items)
       |
       v
[ItineraryRowItem] (flat row array for UITableView)

Row Ordering (per day):

  1. Day header + Add button (merged into one row)
  2. Items with sortOrder < 0 (before games)
  3. Games row (all games bundled)
  4. Items with sortOrder >= 0 (after games)

Why flattening is separate: The UITableView needs contiguous row indices. By keeping flattening in its own layer:

  • Semantic model stays clean
  • Row calculation is centralized
  • Changes to visual layout don't affect data model

Layer 4: Drop Slot Calculation

Responsibility: Translate row indices back to semantic positions during drag.

Location: ItineraryTableViewController.swift (calculateSortOrder, dayNumber)

Key Functions:

// Row -> Semantic Day
dayNumber(forRow:) -> Int
// Scans backward to find dayHeader

// Row -> Semantic sortOrder
calculateSortOrder(at row:) -> Double
// Uses midpoint insertion algorithm

Midpoint Insertion Algorithm:

Existing: A (sortOrder: 1.0), B (sortOrder: 2.0)
Drop between A and B:
  newSortOrder = (1.0 + 2.0) / 2 = 1.5

Edge cases:
- First in day: existing_min / 2
- Last in day: existing_max + 1.0
- Empty day: 1.0

Why this is complex: UITableView's moveRowAt:to: gives us destination row indices, but we need to fire callbacks with semantic (day, sortOrder). This layer bridges the gap.


Layer 5: Drag Interaction

Responsibility: Handle UITableView drag-and-drop with constraints.

Location: ItineraryTableViewController.swift (targetIndexPathForMoveFromRowAt)

Key Behaviors:

  1. Drag Start: Compute valid destination rows (proposed coordinate space)
  2. During Drag: Snap to nearest valid position if proposed is invalid
  3. Drag End: Calculate semantic position, fire callback

Coordinate System Challenge:

UITableView's targetIndexPathForMoveFromRowAt:toProposedIndexPath: uses "proposed" coordinates (array with source row removed). This requires:

// At drag start, precompute valid destinations in proposed space
validDestinationRowsProposed = computeValidDestinationRowsProposed(...)

// During drag, snap to nearest valid
if !validDestinationRowsProposed.contains(proposedRow) {
    return nearestValue(in: validDestinationRowsProposed, to: proposedRow)
}

Data Flow Diagram

┌─────────────────────────────────────────────────────────────────────┐
│                    TripDetailView (SwiftUI)                         │
│                                                                     │
│  State:                                                             │
│  - trip: Trip                                                       │
│  - itineraryItems: [ItineraryItem]  <- Source of truth             │
│  - travelOverrides: [String: TravelOverride]                        │
│                                                                     │
│  Callbacks:                                                         │
│  - onTravelMoved(travelId, newDay, newSortOrder)                   │
│  - onCustomItemMoved(itemId, newDay, newSortOrder)                 │
└───────────────────────────────┬─────────────────────────────────────┘
                                │
                                ▼
┌─────────────────────────────────────────────────────────────────────┐
│              ItineraryTableViewWrapper (UIViewControllerRepresentable)│
│                                                                     │
│  Transform:                                                         │
│  - buildItineraryData() -> ([ItineraryDayData], validRanges)       │
│  - Passes callbacks to controller                                   │
└───────────────────────────────┬─────────────────────────────────────┘
                                │
                                ▼
┌─────────────────────────────────────────────────────────────────────┐
│            ItineraryTableViewController (UIKit)                     │
│                                                                     │
│  Flattening:                                                        │
│  - reloadData(days) -> flatItems: [ItineraryRowItem]               │
│                                                                     │
│  Drag Logic:                                                        │
│  - targetIndexPathForMoveFromRowAt (constraint validation)          │
│  - moveRowAt:to: (fire callback with semantic position)             │
│                                                                     │
│  Drop Slot Calculation:                                             │
│  - dayNumber(forRow:) + calculateSortOrder(at:)                     │
└───────────────────────────────┬─────────────────────────────────────┘
                                │
                                ▼
┌─────────────────────────────────────────────────────────────────────┐
│                    Callbacks to Parent                              │
│                                                                     │
│  onCustomItemMoved(itemId, day: 3, sortOrder: 1.5)                 │
│                     │                                               │
│                     ▼                                               │
│  TripDetailView updates itineraryItems -> SwiftUI re-renders       │
│  ItineraryItemService syncs to CloudKit                             │
└─────────────────────────────────────────────────────────────────────┘

Key Interfaces

Semantic Model Interface

protocol SemanticPosition {
    var day: Int { get }
    var sortOrder: Double { get }
}

protocol PositionConstraint {
    func isValidPosition(for item: ItineraryItem, day: Int, sortOrder: Double) -> Bool
    func validDayRange(for item: ItineraryItem) -> ClosedRange<Int>?
}

Flattening Interface

protocol ItineraryFlattener {
    func flatten(days: [ItineraryDayData]) -> [ItineraryRowItem]
    func dayNumber(forRow row: Int, in items: [ItineraryRowItem]) -> Int
    func calculateSortOrder(at row: Int, in items: [ItineraryRowItem]) -> Double
}

Drag Interaction Interface

protocol DragConstraintValidator {
    func computeValidDestinationRows(
        sourceRow: Int,
        item: ItineraryRowItem,
        constraints: ItineraryConstraints
    ) -> [Int]

    func nearestValidRow(to proposed: Int, in validRows: [Int]) -> Int
}

Build Order (Dependencies)

Phase structure based on what depends on what:

Phase 1: Semantic Position Model

Build:

  • ItineraryItem struct with day, sortOrder, kind
  • Unit tests for fractional sortOrder behavior

No dependencies. This is the foundation.

Phase 2: Constraint Validation

Build:

  • ItineraryConstraints with validation rules
  • Unit tests for travel constraint edge cases

Depends on: Phase 1 (ItineraryItem)

Phase 3: Visual Flattening

Build:

  • ItineraryRowItem enum (row types)
  • ItineraryDayData structure
  • Flattening algorithm
  • Unit tests for row ordering

Depends on: Phase 1 (ItineraryItem)

Phase 4: Drop Slot Calculation

Build:

  • dayNumber(forRow:) implementation
  • calculateSortOrder(at:) with midpoint insertion
  • Unit tests for sortOrder calculation

Depends on: Phase 3 (flattened row array)

Phase 5: Drag Interaction

Build:

  • targetIndexPathForMoveFromRowAt with constraint snapping
  • Drag state management (visual feedback, haptics)
  • Integration with UITableView

Depends on: Phase 2 (constraints), Phase 4 (drop slot calculation)

Phase 6: Integration

Build:

  • ItineraryTableViewWrapper bridge
  • SwiftUI parent view with state and callbacks
  • CloudKit persistence

Depends on: All previous phases


The Reload Problem

Problem Statement:

Data reloads frequently from SwiftUI/SwiftData. Previous attempts failed because row logic and semantic logic were tangled.

How This Architecture Solves It:

  1. Semantic state is authoritative. SwiftUI's itineraryItems: [ItineraryItem] is the source of truth. Reloads always regenerate the flat row array from semantic state.

  2. Flattening is deterministic. Given the same [ItineraryItem], flattening produces the same [ItineraryRowItem]. No state is stored in the row array.

  3. Drag callbacks return semantic positions. When drag completes, onCustomItemMoved(id, day, sortOrder) returns semantic coordinates. The parent updates itineraryItems, which triggers a reload.

  4. UITableView.reloadData() is safe. Because semantic state survives, calling reloadData() after any external update just re-flattens. Scroll position may need preservation, but data integrity is maintained.

Pattern:

External Update -> itineraryItems changes
                -> ItineraryTableViewWrapper.updateUIViewController
                -> buildItineraryData() re-flattens
                -> controller.reloadData()
                -> UITableView renders new state

Anti-Patterns to Avoid

Anti-Pattern 1: Storing Semantic State in Row Indices

Bad:

var itemPositions: [UUID: Int]  // Item ID -> row index

Why bad: Row indices change when items are added/removed/reordered. This creates sync issues.

Good: Store (day: Int, sortOrder: Double) which is independent of row count.

Anti-Pattern 2: Calculating sortOrder from Row Index at Rest

Bad:

// In the semantic model
item.sortOrder = Double(rowIndex)  // Re-assign on every reload

Why bad: Causes sortOrder drift. After multiple reloads, sortOrders become meaningless.

Good: Only calculate sortOrder at drop time using midpoint insertion.

Anti-Pattern 3: Mixing Coordinate Systems in Constraint Validation

Bad:

func isValidDropTarget(proposedRow: Int) -> Bool {
    // Directly checks row index against day header positions
    return proposedRow > dayHeaderRow
}

Why bad: Mixes proposed coordinate space with current array indices.

Good: Convert proposed row to semantic (day, sortOrder) first, then validate semantically.


Scalability Considerations

Concern Current (10-20 rows) At 100 rows At 500+ rows
Flattening Instant Fast (<10ms) Consider caching
Constraint validation Per-drag Per-drag Pre-compute at load
UITableView performance Native Native Cell recycling critical
sortOrder precision Perfect Perfect Consider normalization after 1000s of edits

Existing Implementation Quality

The SportsTime codebase already implements this architecture well. Key observations:

Strengths:

  • Clear separation between semantic model and row array
  • ItineraryConstraints is a dedicated validation layer
  • Midpoint insertion is correctly implemented
  • Coordinate system translation (proposed vs current) is handled

Areas for Refinement:

  • Consider extracting flattening into a dedicated ItineraryFlattener type
  • Unit tests for edge cases in sortOrder calculation
  • Documentation of the "proposed coordinate space" behavior

Sources