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>
This commit is contained in:
Trey t
2026-01-18 13:16:07 -06:00
parent fc6943a6f9
commit 6f09c0abcb
5 changed files with 1515 additions and 0 deletions

View File

@@ -0,0 +1,423 @@
# 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:**
```swift
// 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:
```swift
// 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
```swift
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
```swift
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
```swift
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:**
```swift
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:**
```swift
// 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:**
```swift
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
- Existing codebase analysis: `ItineraryTableViewController.swift`, `ItineraryTableViewWrapper.swift`, `ItineraryItem.swift`, `ItineraryConstraints.swift`
- [SwiftUI + UIKit Hybrid Architecture Guide](https://ravi6997.medium.com/swiftui-uikit-hybrid-app-architecture-b17d8be139d8)
- [Modern iOS Frontend Architecture (2025)](https://medium.com/@bhumibhuva18/modern-ios-frontend-architecture-swiftui-uikit-and-the-patterns-that-scale-in-2025-c7ba5c35f55e)
- [SwiftReorder Library](https://github.com/adamshin/SwiftReorder) - Reference implementation
- [Drag and Drop UX Design Best Practices](https://www.pencilandpaper.io/articles/ux-pattern-drag-and-drop)