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:
423
.planning/research/ARCHITECTURE.md
Normal file
423
.planning/research/ARCHITECTURE.md
Normal 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)
|
||||
Reference in New Issue
Block a user