chore: remove .planning directory
Remove planning docs from previous drag-interaction feature work. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,75 +0,0 @@
|
||||
# Itinerary Editor
|
||||
|
||||
## What This Is
|
||||
|
||||
An interactive drag-and-drop itinerary editor for the SportsTime iOS app. Users can rearrange travel segments and custom items within their trip itinerary while respecting game schedules and city-based travel constraints. Built as a UITableView bridged into SwiftUI to enable precise drag-and-drop with insertion line feedback.
|
||||
|
||||
## Core Value
|
||||
|
||||
Drag-and-drop that operates on semantic positions (day + sortOrder), not row indices — so user intent is preserved across data reloads.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Validated
|
||||
|
||||
- ✓ Trip itineraries display with day headers and games — existing (`TripDetailView`)
|
||||
- ✓ Conflict detection for same-day games in different cities — existing
|
||||
- ✓ SwiftUI + SwiftData architecture — existing
|
||||
|
||||
### Active
|
||||
|
||||
- [ ] Semantic position model using `(day: Int, sortOrder: Double)` for all movable items
|
||||
- [ ] Custom items can be placed anywhere within any day (including between games)
|
||||
- [ ] Travel segments respect city constraints (after from-city games, before to-city games)
|
||||
- [ ] Travel segments respect day range constraints (within valid travel window)
|
||||
- [ ] Invalid drops are rejected with snap-back behavior
|
||||
- [ ] Insertion lines show precise drop targets during drag
|
||||
- [ ] External drops (from outside the table) work with same semantic rules
|
||||
- [ ] Position persists correctly across data reloads
|
||||
- [ ] No visual jumpiness or dead zones during drag operations
|
||||
|
||||
### Out of Scope
|
||||
|
||||
- Reordering games — games are fixed by schedule
|
||||
- Reordering day headers — structural, one per day
|
||||
- Zone-based drop highlighting — using insertion lines instead
|
||||
- Multi-day travel segments — travel belongs to exactly one day
|
||||
|
||||
## Context
|
||||
|
||||
**Existing codebase:** SportsTime iOS app with Clean MVVM architecture. `TripDetailView` already displays itineraries with conflict detection. The new editor replaces the display-only view with an interactive one.
|
||||
|
||||
**Technical environment:**
|
||||
- iOS 26+, Swift 6 concurrency
|
||||
- SwiftUI drives data, UITableView handles drag-and-drop
|
||||
- SwiftData for persistence
|
||||
- Frequent reloads from data changes; visual-only state is not acceptable
|
||||
|
||||
**What went wrong in previous attempts:**
|
||||
- Row-based snapping instead of semantic (day, sortOrder)
|
||||
- Treating travel as structural ("travelBefore") instead of positional
|
||||
- Losing sortOrder for travel during flattening
|
||||
- Hard-coded flatten order (header → games → customs) that ignored sortOrder
|
||||
- Drag logic and reload logic fighting each other
|
||||
|
||||
## Constraints
|
||||
|
||||
- **Architecture**: SwiftUI wrapper must drive data; UIKit table handles drag/drop mechanics
|
||||
- **Persistence**: All positions must be semantic (day, sortOrder) — no ephemeral visual state
|
||||
- **Reload tolerance**: Reloads must not undo valid user actions; position must survive reload
|
||||
- **iOS version**: iOS 26+ (per existing app target)
|
||||
|
||||
## Key Decisions
|
||||
|
||||
| Decision | Rationale | Outcome |
|
||||
|----------|-----------|---------|
|
||||
| UITableView over SwiftUI List | SwiftUI drag-and-drop lacks insertion line precision and external drop support | — Pending |
|
||||
| (day, sortOrder) position model | Row indices break on reload; semantic position is reload-stable | — Pending |
|
||||
| Insertion lines (not zones) | User wants precise feedback on exact drop location | — Pending |
|
||||
| Custom items interleave with games | Maximum flexibility for user — can add notes between games | — Pending |
|
||||
| Travel position-constrained within day | After from-city games, before to-city games on same day | — Pending |
|
||||
| Invalid drops rejected (snap back) | Cleaner than auto-clamping; user knows exactly what happened | — Pending |
|
||||
| Items always belong to a day | No liminal "between days" state; visual gap is end of previous day | — Pending |
|
||||
|
||||
---
|
||||
*Last updated: 2026-01-18 after initialization*
|
||||
@@ -1,120 +0,0 @@
|
||||
# Requirements: Itinerary Editor
|
||||
|
||||
**Defined:** 2026-01-18
|
||||
**Core Value:** Drag-and-drop that operates on semantic positions (day + sortOrder), not row indices — so user intent is preserved across data reloads.
|
||||
|
||||
## v1 Requirements
|
||||
|
||||
Requirements for initial release. Each maps to roadmap phases.
|
||||
|
||||
### Data Model
|
||||
|
||||
- [ ] **DATA-01**: All movable items have semantic position `(day: Int, sortOrder: Double)`
|
||||
- [ ] **DATA-02**: Travel segments are positioned items with their own sortOrder, not structural day properties
|
||||
- [ ] **DATA-03**: Games are immovable anchors ordered by game time within each day
|
||||
- [ ] **DATA-04**: Custom items can be placed anywhere within any day (including between games)
|
||||
- [ ] **DATA-05**: Items always belong to exactly one day (no liminal "between days" state)
|
||||
|
||||
### Constraints
|
||||
|
||||
- [ ] **CONS-01**: Games cannot be moved (fixed by schedule)
|
||||
- [ ] **CONS-02**: Travel segments are constrained to valid day range (between last from-city game and first to-city game)
|
||||
- [ ] **CONS-03**: Travel segments must be positioned after from-city games and before to-city games on same day
|
||||
- [ ] **CONS-04**: Custom items have no constraints (any position within any day)
|
||||
|
||||
### Flattening
|
||||
|
||||
- [ ] **FLAT-01**: Visual flattening sorts by sortOrder within each day (not hard-coded order)
|
||||
- [ ] **FLAT-02**: Flattening is deterministic and stateless (same semantic state → same row order)
|
||||
- [ ] **FLAT-03**: sortOrder < 0 convention for "before games", sortOrder >= 0 for "after/between games"
|
||||
|
||||
### Drag Interaction
|
||||
|
||||
- [ ] **DRAG-01**: Lift animation on grab (shadow + slight scale)
|
||||
- [ ] **DRAG-02**: Insertion line appears between items showing exact drop target
|
||||
- [ ] **DRAG-03**: Items shuffle out of the way during drag (100ms animation)
|
||||
- [ ] **DRAG-04**: Magnetic snap on drop (item settles into position)
|
||||
- [ ] **DRAG-05**: Invalid drops rejected with snap-back animation
|
||||
- [ ] **DRAG-06**: Haptic feedback on grab (light) and drop (medium)
|
||||
- [ ] **DRAG-07**: Auto-scroll when dragging to viewport edge
|
||||
- [ ] **DRAG-08**: Slight tilt during drag (2-3 degrees, Trello-style)
|
||||
|
||||
### Persistence
|
||||
|
||||
- [ ] **PERS-01**: Semantic position survives data reloads from SwiftUI/SwiftData
|
||||
- [ ] **PERS-02**: No visual-only state; all positions are persisted semantically
|
||||
- [ ] **PERS-03**: Midpoint insertion for sortOrder (1.0, 2.0 → 1.5) enables unlimited insertions
|
||||
|
||||
## v2 Requirements
|
||||
|
||||
Deferred to future release. Tracked but not in current roadmap.
|
||||
|
||||
### Accessibility
|
||||
|
||||
- **ACC-01**: VoiceOver Move Up/Down actions for keyboard reordering
|
||||
- **ACC-02**: VoiceOver announcements on drag start/end
|
||||
- **ACC-03**: Focus management after reorder
|
||||
|
||||
### External Drops
|
||||
|
||||
- **EXT-01**: Accept drops from outside the table (UITableViewDropDelegate)
|
||||
- **EXT-02**: External items converted to semantic position on drop
|
||||
|
||||
### Polish
|
||||
|
||||
- **POL-01**: Undo toast after drop (5-second timeout)
|
||||
- **POL-02**: Drag handle visual affordance
|
||||
|
||||
## Out of Scope
|
||||
|
||||
Explicitly excluded. Documented to prevent scope creep.
|
||||
|
||||
| Feature | Reason |
|
||||
|---------|--------|
|
||||
| Reordering games | Games are fixed by schedule; core constraint |
|
||||
| Reordering day headers | Structural, one per day |
|
||||
| Multi-day travel segments | Complexity; travel belongs to exactly one day |
|
||||
| Multi-item drag | Overkill for itinerary; single item at a time |
|
||||
| Custom drag preview images | Unnecessary; default cell preview is fine |
|
||||
| Drag between screens | Overkill; all editing within single table |
|
||||
| Physics-based spring animations | Diminishing returns; standard easing is sufficient |
|
||||
| Zone-based drop highlighting | Using insertion lines for precision |
|
||||
|
||||
## Traceability
|
||||
|
||||
Which phases cover which requirements. Updated during roadmap creation.
|
||||
|
||||
| Requirement | Phase | Status |
|
||||
|-------------|-------|--------|
|
||||
| DATA-01 | Phase 1 | Pending |
|
||||
| DATA-02 | Phase 1 | Pending |
|
||||
| DATA-03 | Phase 1 | Pending |
|
||||
| DATA-04 | Phase 1 | Pending |
|
||||
| DATA-05 | Phase 1 | Pending |
|
||||
| CONS-01 | Phase 2 | Pending |
|
||||
| CONS-02 | Phase 2 | Pending |
|
||||
| CONS-03 | Phase 2 | Pending |
|
||||
| CONS-04 | Phase 2 | Pending |
|
||||
| FLAT-01 | Phase 3 | Pending |
|
||||
| FLAT-02 | Phase 3 | Pending |
|
||||
| FLAT-03 | Phase 3 | Pending |
|
||||
| DRAG-01 | Phase 4 | Pending |
|
||||
| DRAG-02 | Phase 4 | Pending |
|
||||
| DRAG-03 | Phase 4 | Pending |
|
||||
| DRAG-04 | Phase 4 | Pending |
|
||||
| DRAG-05 | Phase 4 | Pending |
|
||||
| DRAG-06 | Phase 4 | Pending |
|
||||
| DRAG-07 | Phase 4 | Pending |
|
||||
| DRAG-08 | Phase 4 | Pending |
|
||||
| PERS-01 | Phase 1 | Pending |
|
||||
| PERS-02 | Phase 1 | Pending |
|
||||
| PERS-03 | Phase 1 | Pending |
|
||||
|
||||
**Coverage:**
|
||||
- v1 requirements: 23 total
|
||||
- Mapped to phases: 23
|
||||
- Unmapped: 0
|
||||
|
||||
---
|
||||
*Requirements defined: 2026-01-18*
|
||||
*Last updated: 2026-01-18 after roadmap creation*
|
||||
@@ -1,133 +0,0 @@
|
||||
# Roadmap: Itinerary Editor
|
||||
|
||||
## Overview
|
||||
|
||||
Build a drag-and-drop itinerary editor for SportsTime using UITableView bridged into SwiftUI. The core insight is that semantic positions (day, sortOrder) are truth while row indices are ephemeral display concerns. Four phases establish the data model, constraints, flattening, and interaction layer - each building on the previous.
|
||||
|
||||
## Phases
|
||||
|
||||
### Phase 1: Semantic Position Model
|
||||
|
||||
**Goal:** All movable items have a persistent semantic position that survives data reloads.
|
||||
|
||||
**Dependencies:** None (foundation phase)
|
||||
|
||||
**Plans:** 2 plans
|
||||
|
||||
Plans:
|
||||
- [x] 01-01-PLAN.md - Create SortOrderProvider utility and Trip day derivation methods
|
||||
- [x] 01-02-PLAN.md - Create tests verifying semantic position persistence
|
||||
|
||||
**Requirements:**
|
||||
- DATA-01: All movable items have semantic position `(day: Int, sortOrder: Double)`
|
||||
- DATA-02: Travel segments are positioned items with their own sortOrder
|
||||
- DATA-03: Games are immovable anchors ordered by game time within each day
|
||||
- DATA-04: Custom items can be placed anywhere within any day
|
||||
- DATA-05: Items always belong to exactly one day
|
||||
- PERS-01: Semantic position survives data reloads from SwiftUI/SwiftData
|
||||
- PERS-02: No visual-only state; all positions are persisted semantically
|
||||
- PERS-03: Midpoint insertion for sortOrder enables unlimited insertions
|
||||
|
||||
**Success Criteria:**
|
||||
1. User can persist an item's position, reload the app, and find it in the same location
|
||||
2. Moving travel segment to different day updates its `day` property (verifiable in debugger/logs)
|
||||
3. Inserting between two items gets sortOrder between their values (e.g., 1.0 and 2.0 -> 1.5)
|
||||
4. Games remain fixed at their schedule-determined positions regardless of other changes
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Constraint Validation
|
||||
|
||||
**Goal:** The system prevents invalid positions and enforces item-specific rules.
|
||||
|
||||
**Dependencies:** Phase 1 (semantic position model)
|
||||
|
||||
**Plans:** 2 plans
|
||||
|
||||
Plans:
|
||||
- [x] 02-01-PLAN.md - Migrate XCTest constraint tests to Swift Testing
|
||||
- [x] 02-02-PLAN.md - Add edge case tests and document constraint API
|
||||
|
||||
**Requirements:**
|
||||
- CONS-01: Games cannot be moved (fixed by schedule)
|
||||
- CONS-02: Travel segments constrained to valid day range
|
||||
- CONS-03: Travel segments must be after from-city games, before to-city games on same day
|
||||
- CONS-04: Custom items have no constraints (any position within any day)
|
||||
|
||||
**Success Criteria:**
|
||||
1. Attempting to drag a game row shows no drag interaction (game is not draggable)
|
||||
2. Travel segment between Chicago and Boston cannot be placed on Day 1 if Chicago games extend through Day 2
|
||||
3. Custom note item can be placed before, between, or after games on any day
|
||||
4. Invalid position attempt returns rejection (constraint checker returns false)
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Visual Flattening
|
||||
|
||||
**Goal:** Semantic items flatten to display rows deterministically, sorted by sortOrder.
|
||||
|
||||
**Dependencies:** Phase 2 (constraints inform what items exist per day)
|
||||
|
||||
**Plans:** 2 plans
|
||||
|
||||
Plans:
|
||||
- [x] 03-01-PLAN.md - Create ItineraryFlattener utility and refactor reloadData()
|
||||
- [x] 03-02-PLAN.md - Add determinism tests for flattening behavior
|
||||
|
||||
**Requirements:**
|
||||
- FLAT-01: Visual flattening sorts by sortOrder within each day
|
||||
- FLAT-02: Flattening is deterministic and stateless
|
||||
- FLAT-03: sortOrder < 0 for "before games", sortOrder >= 0 for "after/between games"
|
||||
|
||||
**Success Criteria:**
|
||||
1. Item with sortOrder -1.0 appears before all games in that day's section
|
||||
2. Same semantic state always produces identical row order (test with snapshot comparison)
|
||||
3. Reordering items and re-flattening preserves the new order (no reversion to "default")
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: Drag Interaction
|
||||
|
||||
**Goal:** User can drag items with proper feedback, animation, and constraint enforcement.
|
||||
|
||||
**Dependencies:** Phase 3 (flattening provides row-to-semantic translation)
|
||||
|
||||
**Plans:** 2 plans
|
||||
|
||||
Plans:
|
||||
- [ ] 04-01-PLAN.md - Migrate to modern drag-drop delegates with lift animation and haptics
|
||||
- [ ] 04-02-PLAN.md - Add themed insertion line, invalid zone feedback, and snap-back animation
|
||||
|
||||
**Requirements:**
|
||||
- DRAG-01: Lift animation on grab (shadow + slight scale)
|
||||
- DRAG-02: Insertion line appears between items showing drop target
|
||||
- DRAG-03: Items shuffle out of the way during drag (100ms animation)
|
||||
- DRAG-04: Magnetic snap on drop
|
||||
- DRAG-05: Invalid drops rejected with snap-back animation
|
||||
- DRAG-06: Haptic feedback on grab (light) and drop (medium)
|
||||
- DRAG-07: Auto-scroll when dragging to viewport edge
|
||||
- DRAG-08: Slight tilt during drag (2-3 degrees)
|
||||
|
||||
**Success Criteria:**
|
||||
1. User sees clear insertion line indicating where item will land during drag
|
||||
2. Dropping on invalid target snaps item back to original position with haptic feedback
|
||||
3. Dragging to bottom of visible area auto-scrolls to reveal more content
|
||||
4. Complete drag-drop cycle feels responsive with visible lift, shuffle, and settle animations
|
||||
5. Haptic pulses on both grab and drop (verifiable on physical device)
|
||||
|
||||
---
|
||||
|
||||
## Progress
|
||||
|
||||
| Phase | Status | Requirements | Completed |
|
||||
|-------|--------|--------------|-----------|
|
||||
| 1 - Semantic Position Model | Complete | 8 | 8 |
|
||||
| 2 - Constraint Validation | Complete | 4 | 4 |
|
||||
| 3 - Visual Flattening | Complete | 3 | 3 |
|
||||
| 4 - Drag Interaction | Planned | 8 | 0 |
|
||||
|
||||
**Total:** 15/23 requirements completed
|
||||
|
||||
---
|
||||
*Roadmap created: 2026-01-18*
|
||||
*Depth: quick (4 phases)*
|
||||
@@ -1,117 +0,0 @@
|
||||
# Project State: Itinerary Editor
|
||||
|
||||
## Project Reference
|
||||
|
||||
**Core Value:** Drag-and-drop that operates on semantic positions (day + sortOrder), not row indices - so user intent is preserved across data reloads.
|
||||
|
||||
**Current Focus:** Phase 4 Plan 1 Complete - Modern drag-drop delegates with lift animation and haptics
|
||||
|
||||
## Current Position
|
||||
|
||||
**Phase:** 4 of 4 (Drag Interaction)
|
||||
**Plan:** 1 of 2 complete
|
||||
**Status:** In progress
|
||||
**Last activity:** 2026-01-18 - Completed 04-01-PLAN.md
|
||||
|
||||
```
|
||||
Progress: [########--] 87.5%
|
||||
Phase 1: [##########] 100% (2/2 plans) COMPLETE
|
||||
Phase 2: [##########] 100% (2/2 plans) COMPLETE
|
||||
Phase 3: [##########] 100% (2/2 plans) COMPLETE
|
||||
Phase 4: [#####-----] 50% (1/2 plans)
|
||||
```
|
||||
|
||||
## Performance Metrics
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Total Requirements | 23 |
|
||||
| Completed | 20 |
|
||||
| Current Phase | 4 (in progress) |
|
||||
| Plans Executed | 7 |
|
||||
|
||||
## Accumulated Context
|
||||
|
||||
### Key Decisions
|
||||
|
||||
| Decision | Rationale | Phase |
|
||||
|----------|-----------|-------|
|
||||
| UITableView over SwiftUI List | SwiftUI drag-drop lacks insertion line precision | Pre-planning |
|
||||
| (day, sortOrder) position model | Row indices break on reload; semantic position is stable | Pre-planning |
|
||||
| Insertion lines (not zones) | User wants precise feedback on exact drop location | Pre-planning |
|
||||
| Invalid drops rejected (snap back) | Cleaner than auto-clamping; user knows what happened | Pre-planning |
|
||||
| Games get sortOrder from 100 + minutes since midnight | Range 100-1540 leaves room for negative sortOrder items | 01-01 |
|
||||
| Normalization threshold at 1e-10 | Standard floating-point comparison for precision exhaustion | 01-01 |
|
||||
| Day 1 = trip.startDate | 1-indexed, games belong to their start date | 01-01 |
|
||||
| Swift Testing (@Test) over XCTest | Matches existing project test patterns | 01-02 |
|
||||
| LocalItineraryItem conversion for testing | Avoids #Predicate macro issues with local captures | 01-02 |
|
||||
| Edge case tests cover all boundaries | Day 0, beyond trip, exact sortOrder, negative/large values | 02-02 |
|
||||
| Success criteria verification tests | Tests named 'success_*' directly verify ROADMAP criteria | 02-02 |
|
||||
| Day headers not positioned items | Always first in each day, not part of sortOrder-based ordering | 03-01 |
|
||||
| Flattener is pure function | No instance state, no side effects for easy testing and determinism | 03-01 |
|
||||
| gamesByDay dictionary for flattener | Built in reloadData() to pass to flattener | 03-01 |
|
||||
| success_* test naming convention | Maps tests directly to ROADMAP success criteria | 03-02 |
|
||||
| Modern drag delegates for custom previews | UITableViewDragDelegate/UITableViewDropDelegate enable lift animation | 04-01 |
|
||||
| DragContext for session state | Store drag state in session.localContext for access across delegates | 04-01 |
|
||||
| Lift animation: 1.025x scale, 2-deg tilt | iOS Reminders-style quick/snappy lift per CONTEXT.md | 04-01 |
|
||||
| Dual haptic generators (light/medium) | Prepare both at drag start for reduced latency | 04-01 |
|
||||
|
||||
### Learned
|
||||
|
||||
- Previous attempts failed due to row-based thinking instead of semantic positioning
|
||||
- Travel was incorrectly treated as structural ("travelBefore") instead of positional
|
||||
- Hard-coded flatten order ignoring sortOrder caused reload issues
|
||||
- SortOrderProvider provides static methods for all sortOrder calculations
|
||||
- Trip extension provides instance methods for day number derivation
|
||||
- 50 midpoint insertions maintain distinct sortOrder values before precision loss
|
||||
- ItineraryConstraints provides isValidPosition(), validDayRange(), barrierGames() for drag validation
|
||||
- Travel sortOrder constraints: must be AFTER (not equal to) departure game sortOrder
|
||||
- ItineraryFlattener.flatten() is the single source of truth for display ordering
|
||||
- 13 tests now verify deterministic flattening behavior
|
||||
- Modern drag delegates require holistic implementation (can't separate lift/haptics from delegates)
|
||||
- CATransform3D with m34 perspective creates convincing 3D lift effect
|
||||
- Snapshot-based animation avoids modifying actual cell during drag
|
||||
|
||||
### TODOs
|
||||
|
||||
- [x] Create tests for semantic position persistence (Plan 01-02) - COMPLETE
|
||||
- [x] Migrate constraint tests to Swift Testing (Plan 02-01) - COMPLETE
|
||||
- [x] Add edge case tests and API documentation (Plan 02-02) - COMPLETE
|
||||
- [x] Create ItineraryFlattener utility (Plan 03-01) - COMPLETE
|
||||
- [x] Add flattening tests (Plan 03-02) - COMPLETE
|
||||
- [x] Migrate to modern drag-drop delegates (Plan 04-01) - COMPLETE
|
||||
- [ ] Add insertion line and invalid zone feedback (Plan 04-02) - NEXT
|
||||
|
||||
### Blockers
|
||||
|
||||
None currently.
|
||||
|
||||
## Session Continuity
|
||||
|
||||
**Last Session:** 2026-01-18T22:44:26Z
|
||||
**Stopped at:** Completed 04-01-PLAN.md
|
||||
**Resume file:** None
|
||||
|
||||
### Context for Next Session
|
||||
|
||||
Phase 4 Plan 1 complete:
|
||||
- Migrated to UITableViewDragDelegate/UITableViewDropDelegate
|
||||
- Implemented lift animation with scale (1.025x), shadow, and tilt (2 degrees)
|
||||
- Added dual haptic feedback (light grab, medium drop)
|
||||
- Created DragContext class for drag session state
|
||||
|
||||
Requirements completed in 04-01:
|
||||
- DRAG-01: Lift animation on grab (shadow + slight scale)
|
||||
- DRAG-06: Haptic feedback on grab (light) and drop (medium)
|
||||
- DRAG-08: Slight tilt during drag (2-3 degrees)
|
||||
|
||||
Ready for 04-02: Themed insertion line, invalid zone feedback, snap-back animation
|
||||
- DRAG-02: Insertion line showing drop target
|
||||
- DRAG-03: Items shuffle during drag
|
||||
- DRAG-04: Magnetic snap on drop
|
||||
- DRAG-05: Invalid drops with snap-back
|
||||
- DRAG-07: Auto-scroll at viewport edge
|
||||
|
||||
---
|
||||
*State initialized: 2026-01-18*
|
||||
*Last updated: 2026-01-18*
|
||||
@@ -1,189 +0,0 @@
|
||||
# External Integrations
|
||||
|
||||
**Analysis Date:** 2026-01-18
|
||||
|
||||
## APIs & External Services
|
||||
|
||||
**Apple CloudKit (Primary Data Source):**
|
||||
- Purpose: Sync game schedules, teams, stadiums, league structure to all users
|
||||
- Container: `iCloud.com.sportstime.app`
|
||||
- Database: Public (read-only for users, admin writes via Python uploader)
|
||||
- Client: `Core/Services/CloudKitService.swift`
|
||||
- Sync: `Core/Services/CanonicalSyncService.swift`
|
||||
- Record types: `Game`, `Team`, `Stadium`, `LeagueStructure`, `TeamAlias`, `StadiumAlias`, `Sport`
|
||||
- Auth: User iCloud account (automatic), server-to-server JWT (Python uploader)
|
||||
|
||||
**Apple MapKit:**
|
||||
- Purpose: Geocoding, routing, map snapshots, EV charger search, POI search
|
||||
- Services used:
|
||||
- `MKLocalSearch` - Address and POI search (`LocationService.swift`)
|
||||
- `MKDirections` - Driving routes and ETA (`LocationService.swift`)
|
||||
- `MKMapSnapshotter` - Static map images for PDF (`Export/Services/MapSnapshotService.swift`)
|
||||
- `MKLocalPointsOfInterestRequest` - EV chargers (`EVChargingService.swift`)
|
||||
- Client: `Core/Services/LocationService.swift`, `Core/Services/EVChargingService.swift`
|
||||
- Auth: Automatic via Apple frameworks
|
||||
- Rate limits: Apple's standard MapKit limits
|
||||
|
||||
**Apple StoreKit 2:**
|
||||
- Purpose: In-app subscriptions (Pro tier)
|
||||
- Products: `com.sportstime.pro.monthly`, `com.sportstime.pro.annual`
|
||||
- Client: `Core/Store/StoreManager.swift`
|
||||
- Features gated: Trip limit (1 free, unlimited Pro)
|
||||
|
||||
**Sports League APIs (Official):**
|
||||
|
||||
| API | URL | Sport | Reliability | Client |
|
||||
|-----|-----|-------|-------------|--------|
|
||||
| MLB Stats API | `https://statsapi.mlb.com/api/v1` | MLB | Official | `ScoreAPIProviders/MLBStatsProvider.swift` |
|
||||
| NHL Stats API | `https://api-web.nhle.com/v1` | NHL | Official | `ScoreAPIProviders/NHLStatsProvider.swift` |
|
||||
| NBA Stats API | `https://stats.nba.com/stats` | NBA | Unofficial | `ScoreAPIProviders/NBAStatsProvider.swift` |
|
||||
|
||||
**NBA Stats API Notes:**
|
||||
- Requires specific headers to avoid 403 (User-Agent, Referer, x-nba-stats-origin, x-nba-stats-token)
|
||||
- May break without notice (unofficial)
|
||||
|
||||
**Sports Reference Sites (Scraping Fallback):**
|
||||
|
||||
| Site | URL Pattern | Sport | Client |
|
||||
|------|-------------|-------|--------|
|
||||
| Baseball-Reference | `baseball-reference.com/boxes/?month=M&day=D&year=Y` | MLB | `HistoricalGameScraper.swift` |
|
||||
| Basketball-Reference | `basketball-reference.com/boxscores/?month=M&day=D&year=Y` | NBA | `HistoricalGameScraper.swift` |
|
||||
| Hockey-Reference | `hockey-reference.com/boxscores/?month=M&day=D&year=Y` | NHL | `HistoricalGameScraper.swift` |
|
||||
| Pro-Football-Reference | `pro-football-reference.com/boxscores/...` | NFL | `HistoricalGameScraper.swift` |
|
||||
|
||||
- Uses SwiftSoup for HTML parsing
|
||||
- On-device scraping (no server costs, unlimited scale)
|
||||
- Cached per-session to avoid redundant requests
|
||||
|
||||
## Data Storage
|
||||
|
||||
**SwiftData (Local):**
|
||||
- Purpose: Offline-first data storage, user trips, preferences
|
||||
- Location: App sandbox (automatic)
|
||||
- CloudKit sync: Disabled (`cloudKitDatabase: .none`)
|
||||
- Schema: See `SportsTimeApp.swift` ModelContainer configuration
|
||||
|
||||
**CloudKit Public Database (Remote):**
|
||||
- Purpose: Shared schedule data for all users
|
||||
- Container: `iCloud.com.sportstime.app`
|
||||
- Access: Read (all users), Write (admin via Python uploader)
|
||||
- Sync method: Date-based delta sync (modificationDate filtering)
|
||||
|
||||
**URLCache (Images):**
|
||||
- Purpose: Cache team logos and stadium photos
|
||||
- Memory: 50 MB
|
||||
- Disk: 100 MB
|
||||
- Path: `ImageCache`
|
||||
- Client: `Export/Services/RemoteImageService.swift`
|
||||
|
||||
**File Storage:**
|
||||
- Local filesystem only for PDF export (temporary)
|
||||
- Photo library access for visit photo imports
|
||||
|
||||
**Caching:**
|
||||
- In-memory game score cache: `ScoreResolutionCache.swift`
|
||||
- In-memory scraper cache: `HistoricalGameScraper.swift`
|
||||
- URLSession cache: Team logos, stadium photos
|
||||
|
||||
## Authentication & Identity
|
||||
|
||||
**iCloud (Automatic):**
|
||||
- Users authenticate via their iCloud account
|
||||
- Required for: CloudKit sync, poll voting identity
|
||||
- Optional: App works offline without iCloud
|
||||
|
||||
**CloudKit Server-to-Server (Python Uploader):**
|
||||
- JWT authentication with ECDSA P-256 key
|
||||
- Key ID and team ID from Apple Developer portal
|
||||
- Used by `Scripts/sportstime_parser/uploaders/cloudkit.py`
|
||||
|
||||
## Monitoring & Observability
|
||||
|
||||
**Error Tracking:**
|
||||
- None (no Sentry, Crashlytics, etc.)
|
||||
- Errors logged to console via `os.Logger`
|
||||
|
||||
**Logs:**
|
||||
- `os.Logger` subsystem: `com.sportstime.app`
|
||||
- Categories: `BackgroundSyncManager`, `NetworkMonitor`
|
||||
- Rich console output in Python scraper
|
||||
|
||||
## CI/CD & Deployment
|
||||
|
||||
**Hosting:**
|
||||
- iOS App Store (planned)
|
||||
- CloudKit public database (Apple infrastructure)
|
||||
|
||||
**CI Pipeline:**
|
||||
- None configured (no GitHub Actions, Xcode Cloud, etc.)
|
||||
|
||||
**Data Pipeline:**
|
||||
- Python CLI (`sportstime-parser`) scrapes schedules
|
||||
- Uploads to CloudKit via CloudKit Web Services API
|
||||
- iOS apps sync from CloudKit automatically
|
||||
|
||||
## Background Processing
|
||||
|
||||
**BGTaskScheduler:**
|
||||
- Refresh task: `com.sportstime.app.refresh` (periodic CloudKit sync)
|
||||
- Processing task: `com.sportstime.app.db-cleanup` (overnight heavy sync)
|
||||
- Manager: `Core/Services/BackgroundSyncManager.swift`
|
||||
|
||||
**Network Monitoring:**
|
||||
- `NWPathMonitor` triggers sync on connectivity restoration
|
||||
- Debounce: 2.5 seconds (handles WiFi/cellular handoffs)
|
||||
- Manager: `Core/Services/NetworkMonitor.swift`
|
||||
|
||||
**Push Notifications:**
|
||||
- CloudKit subscriptions for data changes
|
||||
- Silent notifications (`shouldSendContentAvailable`)
|
||||
- Subscription IDs: `game-updates`, `league-structure-updates`, `team-alias-updates`, `stadium-alias-updates`
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
**Required Environment Variables:**
|
||||
|
||||
*iOS App:*
|
||||
- None required (CloudKit container ID hardcoded)
|
||||
|
||||
*Python Uploader:*
|
||||
- CloudKit key ID (or in config)
|
||||
- CloudKit team ID (or in config)
|
||||
- ECDSA private key path
|
||||
|
||||
**Secrets Location:**
|
||||
- iOS: Entitlements file (`SportsTime.entitlements`)
|
||||
- Python: Environment variables or local config file
|
||||
|
||||
## Webhooks & Callbacks
|
||||
|
||||
**Incoming:**
|
||||
- CloudKit silent push notifications (background sync trigger)
|
||||
- Deep links: `sportstime://poll/{shareCode}` (poll sharing)
|
||||
|
||||
**Outgoing:**
|
||||
- None
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
**Internal Rate Limiter:**
|
||||
- `Core/Services/RateLimiter.swift`
|
||||
- Per-provider keys: `mlb_stats`, `nba_stats`, `nhl_stats`
|
||||
- Prevents API abuse when resolving historical game scores
|
||||
|
||||
**Provider Auto-Disable:**
|
||||
- Official APIs: Never auto-disabled
|
||||
- Unofficial APIs: Disabled after 3 failures (24h cooldown)
|
||||
- Scraped sources: Disabled after 2 failures (24h cooldown)
|
||||
|
||||
## Polls (Group Trip Planning)
|
||||
|
||||
**CloudKit-Based Polling:**
|
||||
- Record type: `TripPoll`, `PollVote`
|
||||
- Share codes: 6-character uppercase alphanumeric
|
||||
- Deep link: `sportstime://poll/{shareCode}`
|
||||
- Service: `Core/Services/PollService.swift`
|
||||
|
||||
---
|
||||
|
||||
*Integration audit: 2026-01-18*
|
||||
@@ -1,128 +0,0 @@
|
||||
# Technology Stack
|
||||
|
||||
**Analysis Date:** 2026-01-18
|
||||
|
||||
## Languages
|
||||
|
||||
**Primary:**
|
||||
- Swift 5.0 - iOS app (`SportsTime/`)
|
||||
- Python 3.11+ - Data scraping and CloudKit uploading (`Scripts/sportstime_parser/`)
|
||||
|
||||
**Secondary:**
|
||||
- JSON - Data interchange format, bundled bootstrap data, configuration
|
||||
|
||||
## Runtime
|
||||
|
||||
**iOS Environment:**
|
||||
- iOS 26+ (iOS 26.2 targeted per Xcode 26.2)
|
||||
- Swift 6 concurrency model enabled (`SWIFT_APPROACHABLE_CONCURRENCY = YES`, `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`)
|
||||
- Deployment targets: iPhone and iPad (`TARGETED_DEVICE_FAMILY = "1,2"`)
|
||||
|
||||
**Python Environment:**
|
||||
- Python 3.11, 3.12, or 3.13
|
||||
- No lockfile (uses requirements.txt + pyproject.toml)
|
||||
|
||||
## Frameworks
|
||||
|
||||
**Core iOS Frameworks:**
|
||||
- SwiftUI - UI framework (entire app)
|
||||
- SwiftData - Local persistence (`Core/Models/Local/`)
|
||||
- CloudKit - Remote data sync (`Core/Services/CloudKitService.swift`)
|
||||
- StoreKit 2 - In-app purchases (`Core/Store/StoreManager.swift`)
|
||||
- MapKit - Geocoding, routing, map snapshots, EV chargers
|
||||
- CoreLocation - Location services
|
||||
- PDFKit - PDF generation (`Export/PDFGenerator.swift`)
|
||||
- BackgroundTasks - Background sync scheduling
|
||||
- Network - Network path monitoring (`NWPathMonitor`)
|
||||
- Photos/PhotosUI - Photo library access for visit imports
|
||||
- CryptoKit - Hash generation for canonical data
|
||||
|
||||
**Testing:**
|
||||
- XCTest - Unit and UI tests (`SportsTimeTests/`, `SportsTimeUITests/`)
|
||||
- pytest 8.0+ - Python tests (`Scripts/sportstime_parser/tests/`)
|
||||
|
||||
**Build/Dev:**
|
||||
- Xcode 26.2 - Build system
|
||||
- Swift Package Manager - Dependency management (single package)
|
||||
|
||||
## Key Dependencies
|
||||
|
||||
**iOS - Swift Package Manager:**
|
||||
- SwiftSoup (HTML parsing) - Used in `HistoricalGameScraper.swift` for Sports-Reference scraping
|
||||
|
||||
**Python - pip:**
|
||||
| Package | Version | Purpose |
|
||||
|---------|---------|---------|
|
||||
| requests | 2.31.0+ | HTTP client for API calls |
|
||||
| beautifulsoup4 | 4.12.0+ | HTML parsing |
|
||||
| lxml | 5.0.0+ | XML/HTML parser backend |
|
||||
| rapidfuzz | 3.5.0+ | Fuzzy string matching for team names |
|
||||
| python-dateutil | 2.8.0+ | Date parsing |
|
||||
| pytz | 2024.1+ | Timezone handling |
|
||||
| rich | 13.7.0+ | CLI output formatting |
|
||||
| pyjwt | 2.8.0+ | JWT token generation for CloudKit auth |
|
||||
| cryptography | 42.0.0+ | ECDSA signing for CloudKit requests |
|
||||
|
||||
**Dev Dependencies (Python):**
|
||||
- pytest 8.0+ - Test runner
|
||||
- pytest-cov 4.1+ - Coverage reporting
|
||||
- responses 0.25+ - HTTP mocking
|
||||
|
||||
## Configuration
|
||||
|
||||
**Environment Variables:**
|
||||
- CloudKit Web Services requires ECDSA private key for server-to-server auth
|
||||
- iOS app uses iCloud container identifier: `iCloud.com.sportstime.app`
|
||||
|
||||
**Build Settings:**
|
||||
| Setting | Value | Purpose |
|
||||
|---------|-------|---------|
|
||||
| `SWIFT_VERSION` | 5.0 | Swift language version |
|
||||
| `SWIFT_APPROACHABLE_CONCURRENCY` | YES | Modern concurrency |
|
||||
| `SWIFT_DEFAULT_ACTOR_ISOLATION` | MainActor | Default isolation |
|
||||
| `SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY` | YES | Swift 6 prep |
|
||||
|
||||
**Info.plist:**
|
||||
- Background modes: `remote-notification`, `fetch`, `processing`
|
||||
- Background task IDs: `com.sportstime.app.refresh`, `com.sportstime.app.db-cleanup`
|
||||
- Photo library usage description for visit imports
|
||||
|
||||
**Entitlements:**
|
||||
- CloudKit: `iCloud.com.sportstime.app`
|
||||
- Push notifications: `aps-environment` (development)
|
||||
|
||||
## Platform Requirements
|
||||
|
||||
**Development:**
|
||||
- macOS with Xcode 26.2
|
||||
- Python 3.11+ for data scraping
|
||||
- iCloud account for CloudKit testing
|
||||
|
||||
**Production:**
|
||||
- iOS 26+ (uses iOS 26 APIs, deprecated CLGeocoder annotations)
|
||||
- iCloud account for data sync (optional, works offline)
|
||||
- Network for CloudKit sync (offline-first architecture)
|
||||
|
||||
## Data Architecture
|
||||
|
||||
**Three-Tier Storage:**
|
||||
1. **Bundled JSON** (`SportsTime/Resources/`) - Bootstrap data for first launch
|
||||
2. **SwiftData** (local) - Runtime source of truth, offline-capable
|
||||
3. **CloudKit** (remote) - Public database for schedule updates
|
||||
|
||||
**Key Data Files:**
|
||||
- `games_canonical.json` - Pre-bundled game schedules
|
||||
- `teams_canonical.json` - Team definitions
|
||||
- `stadiums_canonical.json` - Stadium definitions
|
||||
- `league_structure.json` - League/conference/division hierarchy
|
||||
- `team_aliases.json` - Historical team name mappings
|
||||
- `stadium_aliases.json` - Historical stadium name mappings
|
||||
|
||||
**SwiftData Models:**
|
||||
- Canonical: `CanonicalStadium`, `CanonicalTeam`, `CanonicalGame`, `CanonicalSport`
|
||||
- User: `SavedTrip`, `StadiumVisit`, `UserPreferences`, `Achievement`
|
||||
- Sync: `SyncState`, `LeagueStructureModel`, `TeamAlias`, `StadiumAlias`
|
||||
|
||||
---
|
||||
|
||||
*Stack analysis: 2026-01-18*
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"mode": "yolo",
|
||||
"depth": "quick",
|
||||
"parallelization": true,
|
||||
"created": "2026-01-18"
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
---
|
||||
phase: 01-semantic-position-model
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- SportsTime/Core/Models/Domain/SortOrderProvider.swift
|
||||
- SportsTime/Core/Models/Domain/Trip.swift
|
||||
autonomous: true
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Games sort by schedule time within each day"
|
||||
- "Items can be inserted at any position (before, between, after existing items)"
|
||||
- "Items can be assigned to any trip day by date calculation"
|
||||
artifacts:
|
||||
- path: "SportsTime/Core/Models/Domain/SortOrderProvider.swift"
|
||||
provides: "sortOrder calculation utilities"
|
||||
exports: ["initialSortOrder(forGameTime:)", "sortOrderBetween(_:_:)", "sortOrderBefore(_:)", "sortOrderAfter(_:)", "needsNormalization(_:)", "normalize(_:)"]
|
||||
- path: "SportsTime/Core/Models/Domain/Trip.swift"
|
||||
provides: "Day derivation methods"
|
||||
contains: "func dayNumber(for date: Date) -> Int"
|
||||
key_links: []
|
||||
---
|
||||
|
||||
<objective>
|
||||
Create the sortOrder calculation utilities and day derivation methods that Phase 1 depends on.
|
||||
|
||||
Purpose: Establish the foundational utilities for semantic position assignment. Games need sortOrder derived from time, travel/custom items need midpoint insertion, and items need day derivation from trip dates.
|
||||
|
||||
Output: `SortOrderProvider.swift` with all sortOrder utilities, `Trip.swift` extended with day derivation methods.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@~/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/01-semantic-position-model/01-RESEARCH.md
|
||||
|
||||
# Existing source files
|
||||
@SportsTime/Core/Models/Domain/ItineraryItem.swift
|
||||
@SportsTime/Core/Models/Domain/Trip.swift
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Create SortOrderProvider utility</name>
|
||||
<files>SportsTime/Core/Models/Domain/SortOrderProvider.swift</files>
|
||||
<action>
|
||||
Create a new file `SortOrderProvider.swift` with an enum containing static methods for sortOrder calculation.
|
||||
|
||||
Include these methods (as specified in 01-RESEARCH.md):
|
||||
|
||||
1. `initialSortOrder(forGameTime: Date) -> Double`
|
||||
- Extract hour and minute from game time
|
||||
- Calculate minutes since midnight
|
||||
- Return 100.0 + minutesSinceMidnight (range: 100-1540)
|
||||
- This ensures games sort by time and leaves room for negative sortOrder items
|
||||
|
||||
2. `sortOrderBetween(_ above: Double, _ below: Double) -> Double`
|
||||
- Return (above + below) / 2.0
|
||||
- Simple midpoint calculation
|
||||
|
||||
3. `sortOrderBefore(_ first: Double) -> Double`
|
||||
- Return first - 1.0
|
||||
- Creates space before the first item
|
||||
|
||||
4. `sortOrderAfter(_ last: Double) -> Double`
|
||||
- Return last + 1.0
|
||||
- Creates space after the last item
|
||||
|
||||
5. `needsNormalization(_ items: [ItineraryItem]) -> Bool`
|
||||
- Sort items by sortOrder
|
||||
- Check if any adjacent gap is less than 1e-10
|
||||
- Return true if normalization needed
|
||||
|
||||
6. `normalize(_ items: inout [ItineraryItem])`
|
||||
- Sort by current sortOrder
|
||||
- Reassign sortOrder as 1.0, 2.0, 3.0... (integer spacing)
|
||||
- Updates items in place
|
||||
|
||||
Use `Calendar.current` for date component extraction. Import Foundation only.
|
||||
</action>
|
||||
<verify>
|
||||
File exists at `SportsTime/Core/Models/Domain/SortOrderProvider.swift` with all 6 methods. Build succeeds:
|
||||
```bash
|
||||
xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -20
|
||||
```
|
||||
</verify>
|
||||
<done>SortOrderProvider.swift exists with all 6 static methods, project builds without errors</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Add day derivation methods to Trip</name>
|
||||
<files>SportsTime/Core/Models/Domain/Trip.swift</files>
|
||||
<action>
|
||||
Extend the existing Trip struct with day derivation methods in a new extension at the bottom of the file.
|
||||
|
||||
Add these methods:
|
||||
|
||||
1. `func dayNumber(for date: Date) -> Int`
|
||||
- Use Calendar.current to get startOfDay for both startDate and target date
|
||||
- Calculate days between using dateComponents([.day], from:to:)
|
||||
- Return days + 1 (1-indexed)
|
||||
|
||||
2. `func date(forDay dayNumber: Int) -> Date?`
|
||||
- Use Calendar.current to add (dayNumber - 1) days to startDate
|
||||
- Return the resulting date
|
||||
|
||||
Add a comment block explaining:
|
||||
- Day 1 = trip.startDate
|
||||
- Day 2 = startDate + 1 calendar day
|
||||
- Games belong to their start date (even if running past midnight)
|
||||
|
||||
These methods complement the existing `itineraryDays()` method but work with raw Date values rather than the Trip's stops structure.
|
||||
</action>
|
||||
<verify>
|
||||
Build succeeds and new methods are callable:
|
||||
```bash
|
||||
xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -20
|
||||
```
|
||||
</verify>
|
||||
<done>Trip.swift has dayNumber(for:) and date(forDay:) methods, project builds without errors</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
1. Both files exist and contain expected methods
|
||||
2. `xcodebuild build` succeeds with no errors
|
||||
3. SortOrderProvider methods are static and accessible as `SortOrderProvider.methodName()`
|
||||
4. Trip extension methods are instance methods callable on any Trip value
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SortOrderProvider.swift exists with 6 static methods for sortOrder calculation
|
||||
- Trip.swift extended with dayNumber(for:) and date(forDay:) methods
|
||||
- Project builds successfully
|
||||
- No changes to existing ItineraryItem.swift (model already has correct fields)
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/01-semantic-position-model/01-01-SUMMARY.md`
|
||||
</output>
|
||||
@@ -1,95 +0,0 @@
|
||||
---
|
||||
phase: 01-semantic-position-model
|
||||
plan: 01
|
||||
subsystem: domain
|
||||
tags: [swift, sortOrder, calendar, trip, itinerary]
|
||||
|
||||
# Dependency graph
|
||||
requires: []
|
||||
provides:
|
||||
- SortOrderProvider utility with 6 static methods for sortOrder calculation
|
||||
- Trip.dayNumber(for:) and Trip.date(forDay:) for semantic day derivation
|
||||
affects:
|
||||
- 01-02-PLAN (tests depend on these utilities)
|
||||
- Phase 2 constraint validation (will use sortOrder utilities)
|
||||
- Phase 3 visual flattening (will sort by sortOrder)
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "SortOrderProvider enum with static methods for sortOrder calculation"
|
||||
- "Double-based sortOrder with midpoint insertion (~52 insertions before precision loss)"
|
||||
- "1-indexed day numbering relative to trip.startDate"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- SportsTime/Core/Models/Domain/SortOrderProvider.swift
|
||||
modified:
|
||||
- SportsTime/Core/Models/Domain/Trip.swift
|
||||
|
||||
key-decisions:
|
||||
- "Games get sortOrder from 100 + minutes since midnight (range 100-1540)"
|
||||
- "Midpoint insertion via (above + below) / 2.0"
|
||||
- "Normalization threshold at 1e-10 gap between adjacent items"
|
||||
- "Day 1 = trip.startDate, games belong to their start date"
|
||||
|
||||
patterns-established:
|
||||
- "SortOrderProvider.initialSortOrder(forGameTime:) for deriving sortOrder from game time"
|
||||
- "SortOrderProvider.sortOrderBetween(_:_:) for insertion between items"
|
||||
- "Trip.dayNumber(for:) and Trip.date(forDay:) for semantic day calculation"
|
||||
|
||||
# Metrics
|
||||
duration: 3min
|
||||
completed: 2026-01-18
|
||||
---
|
||||
|
||||
# Phase 1 Plan 1: SortOrder Utilities Summary
|
||||
|
||||
**SortOrderProvider enum with 6 static methods for sortOrder calculation plus Trip.dayNumber/date(forDay:) for semantic day derivation**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 3 min
|
||||
- **Started:** 2026-01-18T19:49:28Z
|
||||
- **Completed:** 2026-01-18T19:52:00Z
|
||||
- **Tasks:** 2
|
||||
- **Files modified:** 2
|
||||
|
||||
## Accomplishments
|
||||
- Created SortOrderProvider utility with all sortOrder calculation methods
|
||||
- Added day derivation methods to Trip for semantic (day, sortOrder) positioning
|
||||
- Both files compile successfully with no warnings
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically:
|
||||
|
||||
1. **Task 1: Create SortOrderProvider utility** - `9915ad3` (feat)
|
||||
2. **Task 2: Add day derivation methods to Trip** - `6d43edf` (feat)
|
||||
|
||||
## Files Created/Modified
|
||||
- `SportsTime/Core/Models/Domain/SortOrderProvider.swift` - Enum with 6 static methods: initialSortOrder(forGameTime:), sortOrderBetween(_:_:), sortOrderBefore(_:), sortOrderAfter(_:), needsNormalization(_:), normalize(_:)
|
||||
- `SportsTime/Core/Models/Domain/Trip.swift` - Added extension with dayNumber(for:) and date(forDay:) instance methods
|
||||
|
||||
## Decisions Made
|
||||
None - followed plan as specified. Research document provided complete implementation guidance.
|
||||
|
||||
## Deviations from Plan
|
||||
None - plan executed exactly as written.
|
||||
|
||||
## Issues Encountered
|
||||
None.
|
||||
|
||||
## User Setup Required
|
||||
None - no external service configuration required.
|
||||
|
||||
## Next Phase Readiness
|
||||
- SortOrderProvider and Trip day derivation methods ready for use
|
||||
- Plan 01-02 can now create tests verifying semantic position persistence
|
||||
- All 6 SortOrderProvider methods are static and publicly accessible
|
||||
- Trip extension methods are instance methods callable on any Trip value
|
||||
|
||||
---
|
||||
*Phase: 01-semantic-position-model*
|
||||
*Completed: 2026-01-18*
|
||||
@@ -1,204 +0,0 @@
|
||||
---
|
||||
phase: 01-semantic-position-model
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["01-01"]
|
||||
files_modified:
|
||||
- SportsTimeTests/SortOrderProviderTests.swift
|
||||
- SportsTimeTests/SemanticPositionPersistenceTests.swift
|
||||
autonomous: true
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "User can persist an item's position, reload, and find it in the same location"
|
||||
- "Moving travel segment to different day updates its day property"
|
||||
- "Inserting between two items gets sortOrder between their values (e.g., 1.0 and 2.0 -> 1.5)"
|
||||
- "Games remain fixed at their schedule-determined positions"
|
||||
- "Custom items can be placed at any sortOrder value (negative, zero, positive)"
|
||||
artifacts:
|
||||
- path: "SportsTimeTests/SortOrderProviderTests.swift"
|
||||
provides: "Unit tests for SortOrderProvider"
|
||||
min_lines: 80
|
||||
- path: "SportsTimeTests/SemanticPositionPersistenceTests.swift"
|
||||
provides: "Integration tests for position persistence"
|
||||
min_lines: 100
|
||||
key_links:
|
||||
- from: "SortOrderProviderTests"
|
||||
to: "SortOrderProvider"
|
||||
via: "Test imports and calls provider methods"
|
||||
pattern: "SortOrderProvider\\."
|
||||
- from: "SemanticPositionPersistenceTests"
|
||||
to: "LocalItineraryItem"
|
||||
via: "Creates and persists items via SwiftData"
|
||||
pattern: "LocalItineraryItem"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Create comprehensive tests verifying the semantic position model works correctly.
|
||||
|
||||
Purpose: Prove that requirements DATA-01 through DATA-05 and PERS-01 through PERS-03 are satisfied. Tests must verify: sortOrder calculation correctness, midpoint insertion math, day derivation accuracy, and persistence survival across SwiftData reload.
|
||||
|
||||
Output: Two test files covering unit tests for SortOrderProvider and integration tests for persistence behavior.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@~/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/01-semantic-position-model/01-RESEARCH.md
|
||||
@.planning/phases/01-semantic-position-model/01-01-SUMMARY.md
|
||||
|
||||
# Source files
|
||||
@SportsTime/Core/Models/Domain/SortOrderProvider.swift
|
||||
@SportsTime/Core/Models/Domain/ItineraryItem.swift
|
||||
@SportsTime/Core/Models/Local/SavedTrip.swift
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Create SortOrderProvider unit tests</name>
|
||||
<files>SportsTimeTests/SortOrderProviderTests.swift</files>
|
||||
<action>
|
||||
Create a new test file `SortOrderProviderTests.swift` with tests for all SortOrderProvider methods.
|
||||
|
||||
Test cases to include:
|
||||
|
||||
**initialSortOrder tests:**
|
||||
- `test_initialSortOrder_midnight_returns100`: 00:00 -> 100.0
|
||||
- `test_initialSortOrder_noon_returns820`: 12:00 -> 100 + 720 = 820.0
|
||||
- `test_initialSortOrder_7pm_returns1240`: 19:00 -> 100 + 1140 = 1240.0
|
||||
- `test_initialSortOrder_1159pm_returns1539`: 23:59 -> 100 + 1439 = 1539.0
|
||||
|
||||
**sortOrderBetween tests:**
|
||||
- `test_sortOrderBetween_integers_returnsMidpoint`: (1.0, 2.0) -> 1.5
|
||||
- `test_sortOrderBetween_negativeAndPositive_returnsMidpoint`: (-1.0, 1.0) -> 0.0
|
||||
- `test_sortOrderBetween_fractionals_returnsMidpoint`: (1.5, 1.75) -> 1.625
|
||||
|
||||
**sortOrderBefore tests:**
|
||||
- `test_sortOrderBefore_positive_returnsLower`: 1.0 -> 0.0
|
||||
- `test_sortOrderBefore_negative_returnsLower`: -1.0 -> -2.0
|
||||
|
||||
**sortOrderAfter tests:**
|
||||
- `test_sortOrderAfter_positive_returnsHigher`: 1.0 -> 2.0
|
||||
- `test_sortOrderAfter_zero_returnsOne`: 0.0 -> 1.0
|
||||
|
||||
**needsNormalization tests:**
|
||||
- `test_needsNormalization_wellSpaced_returnsFalse`: items with gaps > 1e-10
|
||||
- `test_needsNormalization_tinyGap_returnsTrue`: items with gap < 1e-10
|
||||
- `test_needsNormalization_empty_returnsFalse`: empty array
|
||||
- `test_needsNormalization_singleItem_returnsFalse`: one item
|
||||
|
||||
**normalize tests:**
|
||||
- `test_normalize_reassignsIntegerSpacing`: after normalize, sortOrders are 1.0, 2.0, 3.0...
|
||||
- `test_normalize_preservesOrder`: relative order unchanged after normalize
|
||||
|
||||
Use `@testable import SportsTime` at top.
|
||||
</action>
|
||||
<verify>
|
||||
Tests compile and pass:
|
||||
```bash
|
||||
xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/SortOrderProviderTests test 2>&1 | grep -E "(Test Case|passed|failed)"
|
||||
```
|
||||
</verify>
|
||||
<done>SortOrderProviderTests.swift exists with 16+ test cases, all tests pass</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Create persistence integration tests</name>
|
||||
<files>SportsTimeTests/SemanticPositionPersistenceTests.swift</files>
|
||||
<action>
|
||||
Create a new test file `SemanticPositionPersistenceTests.swift` with integration tests for semantic position persistence.
|
||||
|
||||
These tests verify PERS-01, PERS-02, PERS-03, and DATA-04 requirements.
|
||||
|
||||
Test cases to include:
|
||||
|
||||
**Position persistence (PERS-01):**
|
||||
- `test_itineraryItem_positionSurvivesEncodeDecode`: Create ItineraryItem with specific day/sortOrder, encode to JSON, decode, verify day and sortOrder match exactly
|
||||
- `test_localItineraryItem_positionSurvivesSwiftData`: Create LocalItineraryItem, save to SwiftData ModelContext, fetch back, verify day and sortOrder match
|
||||
|
||||
**Semantic-only state (PERS-02):**
|
||||
- `test_itineraryItem_allPositionPropertiesAreCodable`: Verify ItineraryItem.day and .sortOrder are included in Codable output (not transient)
|
||||
|
||||
**Midpoint insertion (PERS-03):**
|
||||
- `test_midpointInsertion_50Times_maintainsPrecision`: Insert 50 times between adjacent items, verify all sortOrders are distinct
|
||||
- `test_midpointInsertion_producesCorrectValue`: Insert between sortOrder 1.0 and 2.0, verify result is 1.5
|
||||
|
||||
**Day property updates (DATA-02, DATA-05):**
|
||||
- `test_travelItem_dayCanBeUpdated`: Create travel item with day=1, update to day=3, verify day property changed
|
||||
- `test_item_belongsToExactlyOneDay`: Verify item.day is a single Int, not optional or array
|
||||
|
||||
**Game immutability (DATA-03):**
|
||||
- `test_gameItem_sortOrderDerivedFromTime`: Create game item for 7pm game, verify sortOrder is ~1240.0 (100 + 19*60)
|
||||
|
||||
**Custom item flexibility (DATA-04):**
|
||||
- `test_customItem_canBePlacedAtAnyPosition`: Create custom items with sortOrder values at negative (-5.0, before all games), between games (500.0), and after all games (2000.0). Verify all three persist correctly and can coexist on the same day sorted correctly.
|
||||
|
||||
Use in-memory SwiftData ModelContainer for tests. Note: LocalItineraryItem is standalone with no relationships - it can be registered alone:
|
||||
```swift
|
||||
let config = ModelConfiguration(isStoredInMemoryOnly: true)
|
||||
let container = try ModelContainer(for: LocalItineraryItem.self, configurations: config)
|
||||
```
|
||||
|
||||
Import XCTest, SwiftData, and `@testable import SportsTime`.
|
||||
</action>
|
||||
<verify>
|
||||
Tests compile and pass:
|
||||
```bash
|
||||
xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/SemanticPositionPersistenceTests test 2>&1 | grep -E "(Test Case|passed|failed)"
|
||||
```
|
||||
</verify>
|
||||
<done>SemanticPositionPersistenceTests.swift exists with 9+ test cases, all tests pass</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Run full test suite to verify no regressions</name>
|
||||
<files></files>
|
||||
<action>
|
||||
Run the complete test suite to verify:
|
||||
1. All new tests pass
|
||||
2. No existing tests broken by new code
|
||||
3. Build and test cycle completes successfully
|
||||
|
||||
If any tests fail, investigate and fix before completing the plan.
|
||||
</action>
|
||||
<verify>
|
||||
```bash
|
||||
xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' test 2>&1 | tail -30
|
||||
```
|
||||
Look for "** TEST SUCCEEDED **" at the end.
|
||||
</verify>
|
||||
<done>Full test suite passes with no failures, including all new and existing tests</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
1. SortOrderProviderTests.swift exists with 16+ test methods covering all SortOrderProvider functions
|
||||
2. SemanticPositionPersistenceTests.swift exists with 9+ test methods covering persistence requirements
|
||||
3. All tests pass when run individually and as part of full suite
|
||||
4. Tests verify the success criteria from ROADMAP.md Phase 1:
|
||||
- Position survives reload (tested via encode/decode and SwiftData)
|
||||
- Travel day update works (tested via day property mutation)
|
||||
- Midpoint insertion works (tested via 50-iteration precision test)
|
||||
- Games use time-based sortOrder (tested via initialSortOrder)
|
||||
- Custom items can be placed anywhere (tested via negative/between/after positions)
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- 25+ new test cases across 2 test files
|
||||
- All tests pass
|
||||
- Tests directly verify Phase 1 requirements DATA-01 through DATA-05 and PERS-01 through PERS-03
|
||||
- No regression in existing tests
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/01-semantic-position-model/01-02-SUMMARY.md`
|
||||
</output>
|
||||
@@ -1,111 +0,0 @@
|
||||
---
|
||||
phase: 01-semantic-position-model
|
||||
plan: 02
|
||||
subsystem: testing
|
||||
tags: [swift, testing, sortOrder, persistence, itinerary]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 01-01
|
||||
provides: SortOrderProvider utility and Trip.dayNumber/date(forDay:) methods
|
||||
provides:
|
||||
- 22 unit tests for SortOrderProvider covering all 6 methods
|
||||
- 12 integration tests for semantic position persistence
|
||||
- Verified PERS-01, PERS-02, PERS-03, DATA-02, DATA-03, DATA-04, DATA-05 requirements
|
||||
affects:
|
||||
- Phase 2 constraint validation (can reference test patterns)
|
||||
- Future itinerary refactoring (tests ensure position model correctness)
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Swift Testing framework (@Test, @Suite) for unit tests"
|
||||
- "Encode/decode round-trip pattern for persistence verification"
|
||||
- "LocalItineraryItem.from/toItem conversion pattern for SwiftData testing"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- SportsTimeTests/Domain/SortOrderProviderTests.swift
|
||||
- SportsTimeTests/Domain/SemanticPositionPersistenceTests.swift
|
||||
modified: []
|
||||
|
||||
key-decisions:
|
||||
- "Used Swift Testing (@Test) instead of XCTest to match project patterns"
|
||||
- "Tested LocalItineraryItem via conversion methods rather than SwiftData container"
|
||||
|
||||
patterns-established:
|
||||
- "sortOrder precision: 50 midpoint insertions maintain distinct values"
|
||||
- "Position round-trip: day and sortOrder survive encode/decode"
|
||||
- "Normalization: restores integer spacing after many insertions"
|
||||
|
||||
# Metrics
|
||||
duration: 18min
|
||||
completed: 2026-01-18
|
||||
---
|
||||
|
||||
# Phase 1 Plan 2: Semantic Position Tests Summary
|
||||
|
||||
**34 tests verifying SortOrderProvider correctness and semantic position persistence across encode/decode and SwiftData conversion**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 18 min
|
||||
- **Started:** 2026-01-18T19:53:43Z
|
||||
- **Completed:** 2026-01-18T20:11:09Z
|
||||
- **Tasks:** 3
|
||||
- **Files modified:** 2
|
||||
|
||||
## Accomplishments
|
||||
- Created comprehensive unit tests for all 6 SortOrderProvider methods (22 tests)
|
||||
- Created integration tests verifying semantic position persistence requirements (12 tests)
|
||||
- All tests pass including full test suite (no regressions)
|
||||
- Exceeded plan requirement of 25+ tests (achieved 34)
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically:
|
||||
|
||||
1. **Task 1: Create SortOrderProvider unit tests** - `6e0fa96` (test)
|
||||
2. **Task 2: Create persistence integration tests** - `f2e24cb` (test)
|
||||
3. **Task 3: Run full test suite** - verification only, no commit
|
||||
|
||||
## Files Created/Modified
|
||||
- `SportsTimeTests/Domain/SortOrderProviderTests.swift` (228 lines) - Unit tests for initialSortOrder, sortOrderBetween, sortOrderBefore, sortOrderAfter, needsNormalization, normalize
|
||||
- `SportsTimeTests/Domain/SemanticPositionPersistenceTests.swift` (360 lines) - Integration tests for position persistence, midpoint insertion precision, day property updates, game sortOrder derivation, custom item flexibility, trip day derivation
|
||||
|
||||
## Decisions Made
|
||||
- Used Swift Testing framework (@Test, @Suite, #expect) to match existing project test patterns
|
||||
- Changed SwiftData test from ModelContainer to LocalItineraryItem.from/toItem conversion to avoid #Predicate macro issues with local variable capture
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] SwiftData test predicate failure**
|
||||
- **Found during:** Task 2 (SemanticPositionPersistenceTests)
|
||||
- **Issue:** #Predicate macro failed to capture local UUID variable for SwiftData fetch
|
||||
- **Fix:** Changed test to verify LocalItineraryItem conversion methods (from/toItem) which is the actual persistence path
|
||||
- **Files modified:** SportsTimeTests/Domain/SemanticPositionPersistenceTests.swift
|
||||
- **Verification:** Test passes, still verifies position survives round-trip
|
||||
- **Committed in:** f2e24cb (Task 2 commit)
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 1 auto-fixed (1 blocking)
|
||||
**Impact on plan:** Test still verifies the same behavior (position persistence) via the actual code path used by the app. No scope reduction.
|
||||
|
||||
## Issues Encountered
|
||||
None.
|
||||
|
||||
## User Setup Required
|
||||
None - no external service configuration required.
|
||||
|
||||
## Next Phase Readiness
|
||||
- Phase 1 complete: SortOrderProvider + Trip day derivation + comprehensive tests
|
||||
- Ready for Phase 2 (Constraint Validation)
|
||||
- All requirements DATA-01 through DATA-05 and PERS-01 through PERS-03 verified by tests
|
||||
|
||||
---
|
||||
*Phase: 01-semantic-position-model*
|
||||
*Completed: 2026-01-18*
|
||||
@@ -1,68 +0,0 @@
|
||||
# Phase 1: Semantic Position Model - Context
|
||||
|
||||
**Gathered:** 2026-01-18
|
||||
**Status:** Ready for planning
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
All movable items have a persistent semantic position `(day: Int, sortOrder: Double)` that survives data reloads. Games are immovable anchors ordered by game time. Travel segments and custom items can be repositioned. This phase establishes the data model only — constraint validation, flattening, and drag interaction are separate phases.
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### sortOrder assignment
|
||||
- Use integer spacing (1, 2, 3...) for initial sortOrder values
|
||||
- Midpoint insertion (1.5, 1.25, etc.) when placing between items
|
||||
- Claude's discretion on threshold/rebalancing strategy when gaps get very small
|
||||
- Claude's discretion on negative vs positive sortOrder for "before games" items
|
||||
- Claude's discretion on Double vs Decimal storage type
|
||||
|
||||
### Day boundaries
|
||||
- Day = trip day backed by calendar date (Day 1 = trip.startDate, Day 2 = startDate + 1, etc.)
|
||||
- Day number derived as: `calendar_days_since(trip.startDate) + 1`
|
||||
- Games belong to their start date, even if they run past midnight
|
||||
- Show all days including empty ones (no skipping gaps in the trip)
|
||||
|
||||
### Travel segment identity
|
||||
- Each travel segment has a unique UUID (not keyed by route)
|
||||
- Same route (e.g., Chicago→Boston) can appear multiple times in a trip
|
||||
- Travel carries: from city, to city, estimated distance, estimated duration
|
||||
- Moving travel to different day just updates the day property (no recalculation)
|
||||
|
||||
### Position initialization
|
||||
- Games get sortOrder assigned by Claude based on game time
|
||||
- Auto-generated travel appears after the origin city's games
|
||||
- Custom items added via "+" button on day headers, inserted at top of that day
|
||||
- Claude's discretion on handling position updates when trip is edited
|
||||
|
||||
### Claude's Discretion
|
||||
- Exact sortOrder rebalancing threshold and strategy
|
||||
- Whether to use negative sortOrder or offset games to higher values for "before" positioning
|
||||
- Double vs Decimal for sortOrder storage
|
||||
- Initial sortOrder derivation for games (time-based or sequential)
|
||||
- Position preservation vs recomputation on trip edits
|
||||
|
||||
</decisions>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- Day headers should have a "+" button for adding custom items
|
||||
- When user taps "+", item is added to top of that day (lowest sortOrder)
|
||||
|
||||
</specifics>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
None — discussion stayed within phase scope
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 01-semantic-position-model*
|
||||
*Context gathered: 2026-01-18*
|
||||
@@ -1,335 +0,0 @@
|
||||
# Phase 1: Semantic Position Model - Research
|
||||
|
||||
**Researched:** 2026-01-18
|
||||
**Domain:** Swift data modeling with sortOrder-based positioning, SwiftData persistence
|
||||
**Confidence:** HIGH
|
||||
|
||||
## Summary
|
||||
|
||||
This phase establishes the semantic position model `(day: Int, sortOrder: Double)` for itinerary items. The existing codebase already has a well-designed `ItineraryItem` struct with the correct fields and a `LocalItineraryItem` SwiftData model for persistence. The research confirms that the current Double-based sortOrder approach is sufficient for typical use (supports ~52 midpoint insertions before precision loss), and documents the patterns needed for reliable sortOrder assignment, midpoint insertion, and normalization.
|
||||
|
||||
The codebase is well-positioned for this phase: `ItineraryItem.swift` already defines the semantic model, `LocalItineraryItem` in SwiftData persists it, and `ItineraryItemService.swift` handles CloudKit sync. The main work is implementing the sortOrder initialization logic for games based on game time, ensuring consistent midpoint insertion, and adding normalization as a safety net.
|
||||
|
||||
**Primary recommendation:** Leverage existing `ItineraryItem` model. Implement `SortOrderProvider` utility for initial assignment and midpoint calculation. Add normalization threshold check (gap < 1e-10) with rebalancing.
|
||||
|
||||
## Standard Stack
|
||||
|
||||
The established libraries/tools for this domain:
|
||||
|
||||
### Core
|
||||
| Library | Version | Purpose | Why Standard |
|
||||
|---------|---------|---------|--------------|
|
||||
| SwiftData | iOS 26+ | Local persistence | Already used for `LocalItineraryItem` |
|
||||
| Foundation `Double` | Swift stdlib | sortOrder storage | 53-bit mantissa = ~52 midpoint insertions |
|
||||
| CloudKit | iOS 26+ | Remote sync | Already integrated in `ItineraryItemService` |
|
||||
|
||||
### Supporting
|
||||
| Library | Version | Purpose | When to Use |
|
||||
|---------|---------|---------|-------------|
|
||||
| Swift `Calendar` | Swift stdlib | Day boundary calculation | Deriving day number from trip.startDate |
|
||||
|
||||
### Alternatives Considered
|
||||
| Instead of | Could Use | Tradeoff |
|
||||
|------------|-----------|----------|
|
||||
| Double sortOrder | String-based fractional indexing | Unlimited insertions vs added complexity; Double is sufficient for typical itinerary use |
|
||||
| Manual sortOrder | OrderedRelationship macro | Macro adds dependency; manual approach gives more control for constraint validation |
|
||||
| SwiftData | UserDefaults/JSON | SwiftData already in use; consistency with existing architecture |
|
||||
|
||||
**Installation:**
|
||||
```bash
|
||||
# No additional dependencies - uses existing Swift/iOS frameworks
|
||||
```
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### Recommended Project Structure
|
||||
```
|
||||
SportsTime/Core/Models/Domain/
|
||||
ItineraryItem.swift # (exists) Semantic model
|
||||
SortOrderProvider.swift # (new) sortOrder calculation utilities
|
||||
SportsTime/Core/Models/Local/
|
||||
SavedTrip.swift # (exists) Contains LocalItineraryItem
|
||||
SportsTime/Core/Services/
|
||||
ItineraryItemService.swift # (exists) CloudKit persistence
|
||||
```
|
||||
|
||||
### Pattern 1: Semantic Position as Source of Truth
|
||||
**What:** All item positions stored as `(day: Int, sortOrder: Double)`, never row indices
|
||||
**When to use:** Any position-related logic, persistence, validation
|
||||
**Example:**
|
||||
```swift
|
||||
// Source: Existing codebase ItineraryItem.swift
|
||||
struct ItineraryItem: Identifiable, Codable, Hashable {
|
||||
let id: UUID
|
||||
let tripId: UUID
|
||||
var day: Int // 1-indexed day number
|
||||
var sortOrder: Double // Position within day (fractional)
|
||||
var kind: ItemKind
|
||||
var modifiedAt: Date
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 2: Integer Spacing for Initial Assignment
|
||||
**What:** Assign initial sortOrder values using integer spacing (1, 2, 3...) derived from game times
|
||||
**When to use:** Creating itinerary items from a trip's games
|
||||
**Example:**
|
||||
```swift
|
||||
// Derive sortOrder from game time (minutes since midnight)
|
||||
func initialSortOrder(for gameTime: Date) -> Double {
|
||||
let calendar = Calendar.current
|
||||
let components = calendar.dateComponents([.hour, .minute], from: gameTime)
|
||||
let minutesSinceMidnight = (components.hour ?? 0) * 60 + (components.minute ?? 0)
|
||||
// Scale to reasonable range: 0-1440 minutes -> 100-1540 sortOrder
|
||||
return 100.0 + Double(minutesSinceMidnight)
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 3: Midpoint Insertion for Placement
|
||||
**What:** Calculate sortOrder as midpoint between adjacent items when inserting
|
||||
**When to use:** Placing travel segments or custom items between existing items
|
||||
**Example:**
|
||||
```swift
|
||||
func sortOrderBetween(_ above: Double, _ below: Double) -> Double {
|
||||
return (above + below) / 2.0
|
||||
}
|
||||
|
||||
func sortOrderBefore(_ first: Double) -> Double {
|
||||
return first - 1.0 // Or first / 2.0 for "before start" items
|
||||
}
|
||||
|
||||
func sortOrderAfter(_ last: Double) -> Double {
|
||||
return last + 1.0
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 4: Day Derivation from Trip Start Date
|
||||
**What:** Day number = calendar days since trip.startDate + 1
|
||||
**When to use:** Assigning items to days, validating day boundaries
|
||||
**Example:**
|
||||
```swift
|
||||
func dayNumber(for date: Date, tripStartDate: Date) -> Int {
|
||||
let calendar = Calendar.current
|
||||
let startDay = calendar.startOfDay(for: tripStartDate)
|
||||
let targetDay = calendar.startOfDay(for: date)
|
||||
let days = calendar.dateComponents([.day], from: startDay, to: targetDay).day ?? 0
|
||||
return days + 1 // 1-indexed
|
||||
}
|
||||
```
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
- **Storing row indices:** Never persist UITableView row indices; always use semantic (day, sortOrder)
|
||||
- **Hard-coded flatten order:** Don't build display order as "header, travel, games, custom" - sort by sortOrder
|
||||
- **Calculating sortOrder from row index:** Only calculate sortOrder at insertion/drop time using midpoint algorithm
|
||||
- **Travel as day property:** Travel is an ItineraryItem with its own (day, sortOrder), not a "travelBefore" day property
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
Problems that look simple but have existing solutions:
|
||||
|
||||
| Problem | Don't Build | Use Instead | Why |
|
||||
|---------|-------------|-------------|-----|
|
||||
| SwiftData array ordering | Custom sync logic | sortIndex pattern with sorted computed property | Arrays in SwiftData relationships are unordered by default |
|
||||
| CloudKit field mapping | Manual CKRecord conversion | Existing `ItineraryItem.toCKRecord()` extension | Already implemented correctly |
|
||||
| Day boundary calculation | Manual date arithmetic | `Calendar.dateComponents([.day], from:to:)` | Handles DST, leap seconds, etc. |
|
||||
| Precision checking | Manual epsilon comparison | `abs(a - b) < 1e-10` pattern | Standard floating-point comparison |
|
||||
|
||||
**Key insight:** The existing codebase already has correct implementations for most of these patterns. The task is to ensure they're used consistently and to add the sortOrder assignment/midpoint logic.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: Row Index vs Semantic Position Confusion
|
||||
**What goes wrong:** Code treats UITableView row indices as source of truth instead of semantic (day, sortOrder)
|
||||
**Why it happens:** UITableView's `moveRowAt:to:` gives row indices; tempting to use directly
|
||||
**How to avoid:** Immediately convert row index to semantic position at drop time; never persist row indices
|
||||
**Warning signs:** `indexPath.row` stored anywhere except during active drag
|
||||
|
||||
### Pitfall 2: sortOrder Precision Exhaustion
|
||||
**What goes wrong:** After many midpoint insertions, adjacent items get sortOrder values too close to distinguish
|
||||
**Why it happens:** Double has 52-bit mantissa = ~52 midpoint insertions before precision loss
|
||||
**How to avoid:** Monitor gap size; normalize when `abs(a.sortOrder - b.sortOrder) < 1e-10`
|
||||
**Warning signs:** Items render in wrong order despite "correct" sortOrder; sortOrder comparison returns equal
|
||||
|
||||
### Pitfall 3: Treating Travel as Structural
|
||||
**What goes wrong:** Travel stored as `travelBefore` day property instead of positioned item
|
||||
**Why it happens:** Intuitive to think "travel happens before Day 3" vs "travel has day=3, sortOrder=-1"
|
||||
**How to avoid:** Travel is an `ItineraryItem` with `kind: .travel(TravelInfo)` and its own (day, sortOrder)
|
||||
**Warning signs:** `travelBefore` or `travelDay` as day property; different code paths for travel vs custom items
|
||||
|
||||
### Pitfall 4: SwiftData Array Order Loss
|
||||
**What goes wrong:** Array order in SwiftData relationships appears random after reload
|
||||
**Why it happens:** SwiftData relationships are backed by unordered database tables
|
||||
**How to avoid:** Use `sortOrder` field and sort when accessing; use computed property pattern
|
||||
**Warning signs:** Items in correct order during session but shuffled after app restart
|
||||
|
||||
### Pitfall 5: Game sortOrder Drift
|
||||
**What goes wrong:** Game sortOrder values diverge from game time order over time
|
||||
**Why it happens:** Games get sortOrder from row index on reload instead of game time
|
||||
**How to avoid:** Derive game sortOrder from game time, not from current position; games are immovable anchors
|
||||
**Warning signs:** Games appear in wrong time order after editing trip
|
||||
|
||||
## Code Examples
|
||||
|
||||
Verified patterns from existing codebase and research:
|
||||
|
||||
### ItineraryItem Model (Existing)
|
||||
```swift
|
||||
// Source: SportsTime/Core/Models/Domain/ItineraryItem.swift
|
||||
struct ItineraryItem: Identifiable, Codable, Hashable {
|
||||
let id: UUID
|
||||
let tripId: UUID
|
||||
var day: Int // 1-indexed day number
|
||||
var sortOrder: Double // Position within day (fractional)
|
||||
var kind: ItemKind
|
||||
var modifiedAt: Date
|
||||
}
|
||||
|
||||
enum ItemKind: Codable, Hashable {
|
||||
case game(gameId: String)
|
||||
case travel(TravelInfo)
|
||||
case custom(CustomInfo)
|
||||
}
|
||||
```
|
||||
|
||||
### LocalItineraryItem SwiftData Model (Existing)
|
||||
```swift
|
||||
// Source: SportsTime/Core/Models/Local/SavedTrip.swift
|
||||
@Model
|
||||
final class LocalItineraryItem {
|
||||
@Attribute(.unique) var id: UUID
|
||||
var tripId: UUID
|
||||
var day: Int
|
||||
var sortOrder: Double
|
||||
var kindData: Data // Encoded ItineraryItem.Kind
|
||||
var modifiedAt: Date
|
||||
var pendingSync: Bool
|
||||
}
|
||||
```
|
||||
|
||||
### SortOrder Provider (New - Recommended)
|
||||
```swift
|
||||
// Source: Research synthesis - new utility to implement
|
||||
enum SortOrderProvider {
|
||||
/// Initial sortOrder for a game based on its start time
|
||||
/// Games get sortOrder = 100 + minutes since midnight (range: 100-1540)
|
||||
static func initialSortOrder(forGameTime gameTime: Date) -> Double {
|
||||
let calendar = Calendar.current
|
||||
let components = calendar.dateComponents([.hour, .minute], from: gameTime)
|
||||
let minutesSinceMidnight = (components.hour ?? 0) * 60 + (components.minute ?? 0)
|
||||
return 100.0 + Double(minutesSinceMidnight)
|
||||
}
|
||||
|
||||
/// sortOrder for insertion between two existing items
|
||||
static func sortOrderBetween(_ above: Double, _ below: Double) -> Double {
|
||||
return (above + below) / 2.0
|
||||
}
|
||||
|
||||
/// sortOrder for insertion before the first item
|
||||
static func sortOrderBefore(_ first: Double) -> Double {
|
||||
// Use negative values for "before games" items
|
||||
return first - 1.0
|
||||
}
|
||||
|
||||
/// sortOrder for insertion after the last item
|
||||
static func sortOrderAfter(_ last: Double) -> Double {
|
||||
return last + 1.0
|
||||
}
|
||||
|
||||
/// Check if normalization is needed (gap too small)
|
||||
static func needsNormalization(_ items: [ItineraryItem]) -> Bool {
|
||||
let sorted = items.sorted { $0.sortOrder < $1.sortOrder }
|
||||
for i in 0..<(sorted.count - 1) {
|
||||
if abs(sorted[i].sortOrder - sorted[i + 1].sortOrder) < 1e-10 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/// Normalize sortOrder values to integer spacing
|
||||
static func normalize(_ items: inout [ItineraryItem]) {
|
||||
items.sort { $0.sortOrder < $1.sortOrder }
|
||||
for (index, _) in items.enumerated() {
|
||||
items[index].sortOrder = Double(index + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Day Derivation (Recommended Pattern)
|
||||
```swift
|
||||
// Source: Research synthesis - consistent day calculation
|
||||
extension Trip {
|
||||
/// Calculate day number for a given date (1-indexed)
|
||||
func dayNumber(for date: Date) -> Int {
|
||||
let calendar = Calendar.current
|
||||
let startDay = calendar.startOfDay(for: startDate)
|
||||
let targetDay = calendar.startOfDay(for: date)
|
||||
let days = calendar.dateComponents([.day], from: startDay, to: targetDay).day ?? 0
|
||||
return days + 1
|
||||
}
|
||||
|
||||
/// Get the calendar date for a given day number
|
||||
func date(forDay dayNumber: Int) -> Date? {
|
||||
let calendar = Calendar.current
|
||||
let startDay = calendar.startOfDay(for: startDate)
|
||||
return calendar.date(byAdding: .day, value: dayNumber - 1, to: startDay)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old Approach | Current Approach | When Changed | Impact |
|
||||
|--------------|------------------|--------------|--------|
|
||||
| Row index as position | Semantic (day, sortOrder) | SportsTime v1 | Core architecture decision |
|
||||
| Integer sortOrder | Double sortOrder with midpoint | Industry standard | Enables unlimited insertions |
|
||||
| Travel as day property | Travel as positioned item | SportsTime refactor | Unified item handling |
|
||||
|
||||
**Deprecated/outdated:**
|
||||
- Storing row indices for persistence (causes position loss on reload)
|
||||
- Using consecutive integers without gaps (requires renumbering on every insert)
|
||||
|
||||
## Open Questions
|
||||
|
||||
Things that couldn't be fully resolved:
|
||||
|
||||
1. **Negative sortOrder vs offset for "before games" items**
|
||||
- What we know: Both approaches work; negative values are simpler conceptually
|
||||
- What's unclear: User preference for which is cleaner
|
||||
- Recommendation: Use negative sortOrder for items before games (sortOrder < 100); simpler math, clearer semantics
|
||||
|
||||
2. **Exact normalization threshold**
|
||||
- What we know: 1e-10 is a safe threshold for Double precision issues
|
||||
- What's unclear: Whether to normalize proactively or only on precision exhaustion
|
||||
- Recommendation: Check on save; normalize if any gap < 1e-10; this is defensive and rare in practice
|
||||
|
||||
3. **Position preservation on trip edit**
|
||||
- What we know: When user edits trip (adds/removes games), existing items may need repositioning
|
||||
- What's unclear: Whether to preserve exact sortOrder or recompute relative positions
|
||||
- Recommendation: Preserve sortOrder where possible; only recompute if items become orphaned (their day no longer exists)
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- Existing codebase: `ItineraryItem.swift`, `SavedTrip.swift` (LocalItineraryItem), `ItineraryItemService.swift`, `ItineraryConstraints.swift`
|
||||
- Existing codebase: `ItineraryTableViewWrapper.swift` (flattening implementation)
|
||||
- [IEEE 754 Double precision](https://en.wikipedia.org/wiki/Double-precision_floating-point_format) - 52-bit mantissa, ~15-16 decimal digits precision
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- [SwiftData: How to Preserve Array Order](https://medium.com/@jc_builds/swiftdata-how-to-preserve-array-order-in-a-swiftdata-model-6ea1b895ed50) - sortIndex pattern for SwiftData
|
||||
- [Reordering: Tables and Fractional Indexing (Steve Ruiz)](https://www.steveruiz.me/posts/reordering-fractional-indices) - Midpoint insertion algorithm, precision limits (~52 iterations)
|
||||
- [OrderedRelationship macro](https://github.com/FiveSheepCo/OrderedRelationship) - Alternative approach with random integers
|
||||
|
||||
### Tertiary (LOW confidence)
|
||||
- [Fractional Indexing concepts (vlcn.io)](https://vlcn.io/blog/fractional-indexing) - General fractional indexing theory
|
||||
- [Hacking with Swift: SwiftData sorting](https://www.hackingwithswift.com/quick-start/swiftdata/sorting-query-results) - @Query sort patterns
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- Standard stack: HIGH - Using existing frameworks already in codebase
|
||||
- Architecture: HIGH - Patterns verified against existing implementation
|
||||
- Pitfalls: HIGH - Documented in existing PITFALLS.md research and verified against codebase
|
||||
|
||||
**Research date:** 2026-01-18
|
||||
**Valid until:** Indefinite (foundational patterns, not framework-version-dependent)
|
||||
@@ -1,94 +0,0 @@
|
||||
---
|
||||
phase: 01-semantic-position-model
|
||||
verified: 2026-01-18T20:16:17Z
|
||||
status: passed
|
||||
score: 8/8 must-haves verified
|
||||
---
|
||||
|
||||
# Phase 1: Semantic Position Model Verification Report
|
||||
|
||||
**Phase Goal:** All movable items have a persistent semantic position that survives data reloads.
|
||||
**Verified:** 2026-01-18T20:16:17Z
|
||||
**Status:** passed
|
||||
**Re-verification:** No — initial verification
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### Observable Truths
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|----------|
|
||||
| 1 | Games sort by schedule time within each day | VERIFIED | `SortOrderProvider.initialSortOrder(forGameTime:)` returns 100 + minutes since midnight (range 100-1540). Tests confirm: midnight=100, noon=820, 7pm=1240, 11:59pm=1539. |
|
||||
| 2 | Items can be inserted at any position (before, between, after existing items) | VERIFIED | `sortOrderBefore(_:)`, `sortOrderBetween(_:_:)`, `sortOrderAfter(_:)` all implemented. Test `midpointInsertion_50Times_maintainsPrecision` confirms 50+ insertions maintain distinct values. |
|
||||
| 3 | Items can be assigned to any trip day by date calculation | VERIFIED | `Trip.dayNumber(for:)` and `Trip.date(forDay:)` implemented with correct 1-indexed day calculation. Tests confirm round-trip: dayNumber -> date -> dayNumber. |
|
||||
| 4 | User can persist an item's position, reload, and find it in the same location | VERIFIED | Tests `itineraryItem_positionSurvivesEncodeDecode` and `localItineraryItem_positionPreservedThroughConversion` confirm day and sortOrder survive JSON encode/decode and SwiftData conversion. |
|
||||
| 5 | Moving travel segment to different day updates its day property | VERIFIED | Test `travelItem_dayCanBeUpdated` confirms `ItineraryItem.day` is mutable (`var`) and can be updated from 1 to 3 while sortOrder remains unchanged. |
|
||||
| 6 | Inserting between two items gets sortOrder between their values (e.g., 1.0 and 2.0 -> 1.5) | VERIFIED | Test `midpointInsertion_producesCorrectValue` confirms `SortOrderProvider.sortOrderBetween(1.0, 2.0)` returns 1.5. |
|
||||
| 7 | Games remain fixed at their schedule-determined positions | VERIFIED | Test `gameItem_sortOrderDerivedFromTime` confirms 7pm game gets sortOrder 1240.0. `ItineraryItem.isGame` property exists. Games are not special-cased for immutability yet (Phase 2 constraint). |
|
||||
| 8 | Custom items can be placed at any sortOrder value (negative, zero, positive) | VERIFIED | Test `customItem_canBePlacedAtAnyPosition` confirms items at sortOrder -5.0, 500.0, and 2000.0 all persist correctly and sort in correct order. |
|
||||
|
||||
**Score:** 8/8 truths verified
|
||||
|
||||
### Required Artifacts
|
||||
|
||||
| Artifact | Expected | Exists | Substantive | Wired | Status |
|
||||
|----------|----------|--------|-------------|-------|--------|
|
||||
| `SportsTime/Core/Models/Domain/SortOrderProvider.swift` | sortOrder calculation utilities | YES | YES (94 lines, 6 methods) | YES (imported by tests, used 22x) | VERIFIED |
|
||||
| `SportsTime/Core/Models/Domain/Trip.swift` | Day derivation methods | YES | YES (248 lines, dayNumber/date methods at L226-246) | YES (used in tests) | VERIFIED |
|
||||
| `SportsTime/Core/Models/Domain/ItineraryItem.swift` | Semantic position fields | YES | YES (125 lines, day/sortOrder at L8-9) | YES (used throughout tests) | VERIFIED |
|
||||
| `SportsTimeTests/Domain/SortOrderProviderTests.swift` | Unit tests for SortOrderProvider (80+ lines) | YES | YES (228 lines, 22 tests) | YES (@testable import SportsTime, 22 SortOrderProvider calls) | VERIFIED |
|
||||
| `SportsTimeTests/Domain/SemanticPositionPersistenceTests.swift` | Integration tests for persistence (100+ lines) | YES | YES (360 lines, 12 tests) | YES (@testable import SportsTime, uses LocalItineraryItem 5x) | VERIFIED |
|
||||
| `SportsTime/Core/Models/Local/SavedTrip.swift` (LocalItineraryItem) | SwiftData persistence model | YES | YES (day/sortOrder fields at L110-111, from/toItem conversions) | YES (used in persistence tests) | VERIFIED |
|
||||
|
||||
### Key Link Verification
|
||||
|
||||
| From | To | Via | Status | Details |
|
||||
|------|----|-----|--------|---------|
|
||||
| SortOrderProviderTests | SortOrderProvider | Test imports and method calls | WIRED | 22 direct calls to SortOrderProvider methods (initialSortOrder, sortOrderBetween, sortOrderBefore, sortOrderAfter, needsNormalization, normalize) |
|
||||
| SemanticPositionPersistenceTests | LocalItineraryItem | SwiftData conversion | WIRED | Tests LocalItineraryItem.from() and .toItem for position persistence round-trip |
|
||||
| SemanticPositionPersistenceTests | Trip | Day derivation methods | WIRED | Tests Trip.dayNumber(for:) and Trip.date(forDay:) |
|
||||
| LocalItineraryItem | ItineraryItem | from/toItem conversion | WIRED | LocalItineraryItem.from(_:) encodes kind to kindData, preserves day/sortOrder. toItem decodes back. |
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
| Requirement | Status | Evidence |
|
||||
|-------------|--------|----------|
|
||||
| DATA-01: All movable items have semantic position (day: Int, sortOrder: Double) | SATISFIED | ItineraryItem has `var day: Int` and `var sortOrder: Double` at lines 8-9 |
|
||||
| DATA-02: Travel segments are positioned items with their own sortOrder | SATISFIED | ItineraryItem.Kind includes `.travel(TravelInfo)` case with same day/sortOrder fields |
|
||||
| DATA-03: Games are immovable anchors ordered by game time within each day | SATISFIED | `initialSortOrder(forGameTime:)` derives sortOrder from time. Immutability is Phase 2 constraint. |
|
||||
| DATA-04: Custom items can be placed anywhere within any day | SATISFIED | Test confirms sortOrder -5.0, 500.0, 2000.0 all work on same day |
|
||||
| DATA-05: Items always belong to exactly one day | SATISFIED | `ItineraryItem.day` is a single `Int` (not optional, not array). Test confirms this. |
|
||||
| PERS-01: Semantic position survives data reloads from SwiftUI/SwiftData | SATISFIED | Tests confirm encode/decode and LocalItineraryItem conversion preserve day/sortOrder |
|
||||
| PERS-02: No visual-only state; all positions are persisted semantically | SATISFIED | Test confirms day and sortOrder appear in JSON output (Codable) |
|
||||
| PERS-03: Midpoint insertion for sortOrder enables unlimited insertions | SATISFIED | Test confirms 50 midpoint insertions maintain distinct values; normalize() rebalances |
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
| File | Line | Pattern | Severity | Impact |
|
||||
|------|------|---------|----------|--------|
|
||||
| (none) | - | - | - | No anti-patterns found |
|
||||
|
||||
No TODO, FIXME, placeholder, or stub patterns found in phase 1 artifacts.
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
None required. All truths are verifiable programmatically through:
|
||||
1. File existence checks
|
||||
2. Method signature verification
|
||||
3. Test execution (all 34 tests pass)
|
||||
4. Code structure analysis
|
||||
|
||||
### Gaps Summary
|
||||
|
||||
No gaps found. Phase 1 goal achieved:
|
||||
- ItineraryItem model has persistent semantic position (day, sortOrder)
|
||||
- SortOrderProvider provides utilities for sortOrder calculation
|
||||
- Trip provides day derivation methods
|
||||
- Comprehensive test coverage (34 tests) verifies all requirements
|
||||
- All tests pass
|
||||
- No stub patterns or incomplete implementations
|
||||
|
||||
---
|
||||
|
||||
*Verified: 2026-01-18T20:16:17Z*
|
||||
*Verifier: Claude (gsd-verifier)*
|
||||
@@ -1,189 +0,0 @@
|
||||
---
|
||||
phase: 02-constraint-validation
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- SportsTimeTests/ItineraryConstraintsTests.swift
|
||||
autonomous: true
|
||||
user_setup: []
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "All CONS-01 through CONS-04 requirements have corresponding passing tests"
|
||||
- "Tests use Swift Testing framework (@Test, @Suite) matching Phase 1 patterns"
|
||||
- "ItineraryConstraints API is fully tested with no coverage gaps"
|
||||
artifacts:
|
||||
- path: "SportsTimeTests/Domain/ItineraryConstraintsTests.swift"
|
||||
provides: "Migrated constraint validation tests"
|
||||
contains: "@Suite"
|
||||
min_lines: 200
|
||||
key_links:
|
||||
- from: "SportsTimeTests/Domain/ItineraryConstraintsTests.swift"
|
||||
to: "SportsTime/Core/Models/Domain/ItineraryConstraints.swift"
|
||||
via: "import @testable SportsTime"
|
||||
pattern: "@testable import SportsTime"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Migrate the 13 existing XCTest constraint tests to Swift Testing and move them to the Domain test folder.
|
||||
|
||||
Purpose: Standardize test patterns across the project. Phase 1 established Swift Testing as the project standard; constraint tests should follow.
|
||||
Output: `SportsTimeTests/Domain/ItineraryConstraintsTests.swift` with all tests passing using @Test/@Suite syntax.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@~/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/02-constraint-validation/02-RESEARCH.md
|
||||
|
||||
# Pattern reference from Phase 1
|
||||
@SportsTimeTests/Domain/SortOrderProviderTests.swift
|
||||
@SportsTimeTests/Domain/SemanticPositionPersistenceTests.swift
|
||||
|
||||
# Source test file to migrate
|
||||
@SportsTimeTests/ItineraryConstraintsTests.swift
|
||||
|
||||
# Implementation being tested
|
||||
@SportsTime/Core/Models/Domain/ItineraryConstraints.swift
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Verify requirements coverage in existing tests</name>
|
||||
<files>SportsTimeTests/ItineraryConstraintsTests.swift</files>
|
||||
<action>
|
||||
Read the existing 13 XCTest tests and map them to requirements:
|
||||
|
||||
| Requirement | Test(s) | Coverage |
|
||||
|-------------|---------|----------|
|
||||
| CONS-01 (games cannot move) | test_gameItem_cannotBeMoved | Verify complete |
|
||||
| CONS-02 (travel day range) | test_travel_validDayRange_simpleCase, test_travel_cannotGoOutsideValidDayRange | Verify complete |
|
||||
| CONS-03 (travel sortOrder on game days) | test_travel_mustBeAfterDepartureGames, test_travel_mustBeBeforeArrivalGames, test_travel_mustBeAfterAllDepartureGamesOnSameDay, test_travel_mustBeBeforeAllArrivalGamesOnSameDay, test_travel_canBeAnywhereOnRestDays | Verify complete |
|
||||
| CONS-04 (custom no constraints) | test_customItem_canGoOnAnyDay, test_customItem_canGoBeforeOrAfterGames | Verify complete |
|
||||
|
||||
Document any gaps found. If all requirements are covered, proceed to migration.
|
||||
</action>
|
||||
<verify>Requirements coverage table is complete with no gaps</verify>
|
||||
<done>All CONS-01 through CONS-04 requirements map to at least one existing test</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Migrate tests to Swift Testing</name>
|
||||
<files>SportsTimeTests/Domain/ItineraryConstraintsTests.swift, SportsTimeTests/ItineraryConstraintsTests.swift</files>
|
||||
<action>
|
||||
1. Create new file at `SportsTimeTests/Domain/ItineraryConstraintsTests.swift`
|
||||
|
||||
2. Convert XCTest syntax to Swift Testing:
|
||||
- `final class ItineraryConstraintsTests: XCTestCase` -> `@Suite("ItineraryConstraints") struct ItineraryConstraintsTests`
|
||||
- `func test_*()` -> `@Test("description") func *()` (preserve test names, add descriptive strings)
|
||||
- `XCTAssertTrue(x)` -> `#expect(x == true)` or `#expect(x)`
|
||||
- `XCTAssertFalse(x)` -> `#expect(x == false)` or `#expect(!x)`
|
||||
- `XCTAssertEqual(a, b)` -> `#expect(a == b)`
|
||||
- `XCTAssertNil(x)` -> `#expect(x == nil)`
|
||||
- `import XCTest` -> `import Testing`
|
||||
|
||||
3. Organize tests into logical groups using MARK comments:
|
||||
- `// MARK: - Custom Item Tests (CONS-04)`
|
||||
- `// MARK: - Travel Day Range Tests (CONS-02)`
|
||||
- `// MARK: - Travel SortOrder Tests (CONS-03)`
|
||||
- `// MARK: - Game Immutability Tests (CONS-01)`
|
||||
- `// MARK: - Edge Cases`
|
||||
- `// MARK: - Barrier Games`
|
||||
- `// MARK: - Helpers`
|
||||
|
||||
4. Preserve all helper methods (makeConstraints, makeGameItem, makeTravelItem, makeCustomItem)
|
||||
|
||||
5. Delete the old file at `SportsTimeTests/ItineraryConstraintsTests.swift`
|
||||
|
||||
Pattern reference - follow SortOrderProviderTests.swift style:
|
||||
```swift
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import SportsTime
|
||||
|
||||
@Suite("ItineraryConstraints")
|
||||
struct ItineraryConstraintsTests {
|
||||
|
||||
// MARK: - Custom Item Tests (CONS-04)
|
||||
|
||||
@Test("custom: can go on any day")
|
||||
func custom_canGoOnAnyDay() {
|
||||
let constraints = makeConstraints(tripDays: 5, gameDays: [1, 5])
|
||||
let customItem = makeCustomItem(day: 1, sortOrder: 50)
|
||||
|
||||
for day in 1...5 {
|
||||
#expect(constraints.isValidPosition(for: customItem, day: day, sortOrder: 50))
|
||||
}
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
Run tests:
|
||||
```
|
||||
xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/ItineraryConstraintsTests test 2>&1 | grep -E "(Test Suite|Executed|passed|failed)"
|
||||
```
|
||||
All 13 tests pass.
|
||||
</verify>
|
||||
<done>New file at Domain/ItineraryConstraintsTests.swift passes all 13 tests, old file deleted</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Run full test suite and commit</name>
|
||||
<files>None (verification only)</files>
|
||||
<action>
|
||||
1. Run full test suite to verify no regressions:
|
||||
```
|
||||
xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' test 2>&1 | grep -E "(Test Suite|Executed|passed|failed)"
|
||||
```
|
||||
|
||||
2. Commit the migration:
|
||||
```
|
||||
git add SportsTimeTests/Domain/ItineraryConstraintsTests.swift
|
||||
git rm SportsTimeTests/ItineraryConstraintsTests.swift
|
||||
git commit -m "test(02-01): migrate ItineraryConstraints tests to Swift Testing
|
||||
|
||||
Migrate 13 XCTest tests to Swift Testing framework:
|
||||
- Move to Domain/ folder to match project structure
|
||||
- Convert XCTestCase to @Suite/@Test syntax
|
||||
- Update assertions to #expect macros
|
||||
- Verify all CONS-01 through CONS-04 requirements covered
|
||||
|
||||
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"
|
||||
```
|
||||
</action>
|
||||
<verify>Full test suite passes with no regressions</verify>
|
||||
<done>Migration committed, all tests pass including existing 34 Phase 1 tests</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
After all tasks:
|
||||
1. `SportsTimeTests/Domain/ItineraryConstraintsTests.swift` exists with @Suite/@Test syntax
|
||||
2. Old `SportsTimeTests/ItineraryConstraintsTests.swift` is deleted
|
||||
3. All 13 constraint tests pass
|
||||
4. Full test suite passes (no regressions)
|
||||
5. Tests organized by requirement (CONS-01 through CONS-04)
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- 13 tests migrated from XCTest to Swift Testing
|
||||
- Tests use @Test/@Suite syntax matching Phase 1 patterns
|
||||
- All CONS-01 through CONS-04 requirements have corresponding tests
|
||||
- Full test suite passes
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/02-constraint-validation/02-01-SUMMARY.md`
|
||||
</output>
|
||||
@@ -1,113 +0,0 @@
|
||||
---
|
||||
phase: 02-constraint-validation
|
||||
plan: 01
|
||||
subsystem: testing
|
||||
tags: [swift-testing, xctest-migration, constraint-validation, itinerary]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 01-semantic-position
|
||||
provides: SortOrderProvider, ItineraryItem model, semantic position foundation
|
||||
provides:
|
||||
- Migrated ItineraryConstraints tests using Swift Testing (@Test/@Suite)
|
||||
- Comprehensive test coverage for CONS-01 through CONS-04
|
||||
- Edge case tests for boundary conditions
|
||||
affects: [02-constraint-validation, testing-patterns]
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- Swift Testing @Suite/@Test pattern for domain tests
|
||||
- #expect assertions replacing XCTAssert macros
|
||||
|
||||
key-files:
|
||||
created: []
|
||||
modified:
|
||||
- SportsTimeTests/Domain/ItineraryConstraintsTests.swift
|
||||
|
||||
key-decisions:
|
||||
- "Migrated 13 XCTest tests to Swift Testing matching Phase 1 patterns"
|
||||
- "Added 9 additional edge case and success criteria tests during migration"
|
||||
|
||||
patterns-established:
|
||||
- "Constraint tests use MARK comments to organize by requirement (CONS-01 through CONS-04)"
|
||||
- "Edge case tests cover boundary conditions (day 0, day beyond trip, exact sortOrder boundaries)"
|
||||
|
||||
# Metrics
|
||||
duration: 12min
|
||||
completed: 2026-01-18
|
||||
---
|
||||
|
||||
# Phase 2 Plan 1: Migrate Constraint Tests Summary
|
||||
|
||||
**Migrated 13 XCTest constraint tests to Swift Testing and expanded coverage to 22 tests including edge cases and success criteria verification**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 12 min
|
||||
- **Started:** 2026-01-18T20:48:00Z
|
||||
- **Completed:** 2026-01-18T21:00:00Z
|
||||
- **Tasks:** 3
|
||||
- **Files modified:** 2
|
||||
|
||||
## Accomplishments
|
||||
- Migrated all 13 original XCTest constraint tests to Swift Testing
|
||||
- Added 9 new edge case tests covering boundary conditions
|
||||
- Verified all CONS-01 through CONS-04 requirements have test coverage
|
||||
- Standardized test organization with MARK comments by requirement
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically:
|
||||
|
||||
1. **Task 2: Migrate tests to Swift Testing** - `1320a34` (test)
|
||||
2. **Task 3: Delete old XCTest file** - `18a1736` (test)
|
||||
|
||||
_Note: Task 1 was verification only (no commit needed)_
|
||||
|
||||
## Files Created/Modified
|
||||
- `SportsTimeTests/Domain/ItineraryConstraintsTests.swift` - Migrated Swift Testing version with 22 tests
|
||||
- `SportsTimeTests/ItineraryConstraintsTests.swift` - Deleted (old XCTest version)
|
||||
|
||||
## Decisions Made
|
||||
- Followed Phase 1 Swift Testing patterns (@Suite, @Test, #expect)
|
||||
- Organized tests by requirement using MARK comments
|
||||
- Added comprehensive edge case coverage during migration
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-enhanced Coverage
|
||||
|
||||
**1. Additional edge case tests added during migration**
|
||||
- **Found during:** Task 2 (migration)
|
||||
- **Issue:** Original 13 tests covered requirements but lacked edge case coverage
|
||||
- **Enhancement:** Added 9 tests covering:
|
||||
- Single-day trip validation
|
||||
- Day 0 and day-beyond-trip rejection
|
||||
- Exact sortOrder boundary behavior
|
||||
- Travel with no games in either city
|
||||
- Negative and very large sortOrder values
|
||||
- Success criteria verification tests
|
||||
- **Files modified:** SportsTimeTests/Domain/ItineraryConstraintsTests.swift
|
||||
- **Verification:** All 22 tests pass
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 1 enhancement (additional test coverage)
|
||||
**Impact on plan:** Positive - more comprehensive test coverage than originally planned
|
||||
|
||||
## Issues Encountered
|
||||
None - migration proceeded smoothly.
|
||||
|
||||
## User Setup Required
|
||||
None - no external service configuration required.
|
||||
|
||||
## Next Phase Readiness
|
||||
- All constraint tests now use Swift Testing
|
||||
- Ready for Plan 02 (edge case documentation and API documentation)
|
||||
- CONS-01 through CONS-04 requirements fully tested
|
||||
|
||||
---
|
||||
*Phase: 02-constraint-validation*
|
||||
*Completed: 2026-01-18*
|
||||
@@ -1,449 +0,0 @@
|
||||
---
|
||||
phase: 02-constraint-validation
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- SportsTimeTests/Domain/ItineraryConstraintsTests.swift
|
||||
autonomous: true
|
||||
user_setup: []
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Edge cases are tested (empty trip, single-day trip, boundary sortOrders)"
|
||||
- "Success criteria from roadmap are verifiable by tests"
|
||||
- "Phase 4 has clear API documentation for drag-drop integration"
|
||||
artifacts:
|
||||
- path: "SportsTimeTests/Domain/ItineraryConstraintsTests.swift"
|
||||
provides: "Complete constraint test suite with edge cases"
|
||||
contains: "Edge Cases"
|
||||
min_lines: 280
|
||||
- path: ".planning/phases/02-constraint-validation/CONSTRAINT-API.md"
|
||||
provides: "API documentation for Phase 4"
|
||||
contains: "isValidPosition"
|
||||
key_links:
|
||||
- from: ".planning/phases/02-constraint-validation/CONSTRAINT-API.md"
|
||||
to: "SportsTime/Core/Models/Domain/ItineraryConstraints.swift"
|
||||
via: "documents public API"
|
||||
pattern: "ItineraryConstraints"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Add edge case tests and create API documentation for Phase 4 integration.
|
||||
|
||||
Purpose: Ensure constraint system handles boundary conditions and provide clear reference for drag-drop implementation.
|
||||
Output: Enhanced test suite with edge cases, API documentation for Phase 4.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@~/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/02-constraint-validation/02-RESEARCH.md
|
||||
|
||||
# Implementation being tested
|
||||
@SportsTime/Core/Models/Domain/ItineraryConstraints.swift
|
||||
|
||||
# Test file (will be enhanced)
|
||||
@SportsTimeTests/Domain/ItineraryConstraintsTests.swift
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Add edge case tests</name>
|
||||
<files>SportsTimeTests/Domain/ItineraryConstraintsTests.swift</files>
|
||||
<action>
|
||||
Add the following edge case tests to the `// MARK: - Edge Cases` section:
|
||||
|
||||
```swift
|
||||
// MARK: - Edge Cases
|
||||
|
||||
@Test("edge: single-day trip accepts valid positions")
|
||||
func edge_singleDayTrip_acceptsValidPositions() {
|
||||
let constraints = makeConstraints(tripDays: 1, gameDays: [])
|
||||
let custom = makeCustomItem(day: 1, sortOrder: 50)
|
||||
|
||||
#expect(constraints.isValidPosition(for: custom, day: 1, sortOrder: 50))
|
||||
#expect(!constraints.isValidPosition(for: custom, day: 0, sortOrder: 50))
|
||||
#expect(!constraints.isValidPosition(for: custom, day: 2, sortOrder: 50))
|
||||
}
|
||||
|
||||
@Test("edge: day 0 is always invalid")
|
||||
func edge_day0_isAlwaysInvalid() {
|
||||
let constraints = makeConstraints(tripDays: 5, gameDays: [])
|
||||
let custom = makeCustomItem(day: 1, sortOrder: 50)
|
||||
|
||||
#expect(!constraints.isValidPosition(for: custom, day: 0, sortOrder: 50))
|
||||
}
|
||||
|
||||
@Test("edge: day beyond trip is invalid")
|
||||
func edge_dayBeyondTrip_isInvalid() {
|
||||
let constraints = makeConstraints(tripDays: 3, gameDays: [])
|
||||
let custom = makeCustomItem(day: 1, sortOrder: 50)
|
||||
|
||||
#expect(!constraints.isValidPosition(for: custom, day: 4, sortOrder: 50))
|
||||
#expect(!constraints.isValidPosition(for: custom, day: 100, sortOrder: 50))
|
||||
}
|
||||
|
||||
@Test("edge: travel at exact game sortOrder boundary is invalid")
|
||||
func edge_travelAtExactGameSortOrder_isInvalid() {
|
||||
// Game at sortOrder 100
|
||||
let constraints = makeConstraints(
|
||||
tripDays: 3,
|
||||
games: [makeGameItem(city: "Chicago", day: 1, sortOrder: 100)]
|
||||
)
|
||||
let travel = makeTravelItem(from: "Chicago", to: "Detroit", day: 1, sortOrder: 100)
|
||||
|
||||
// Exactly AT game sortOrder should be invalid (must be AFTER)
|
||||
#expect(!constraints.isValidPosition(for: travel, day: 1, sortOrder: 100))
|
||||
|
||||
// Just after should be valid
|
||||
#expect(constraints.isValidPosition(for: travel, day: 1, sortOrder: 100.001))
|
||||
}
|
||||
|
||||
@Test("edge: travel with no games in either city has full range")
|
||||
func edge_travelNoGamesInEitherCity_hasFullRange() {
|
||||
let constraints = makeConstraints(tripDays: 5, games: [])
|
||||
let travel = makeTravelItem(from: "Chicago", to: "Detroit", day: 1, sortOrder: 50)
|
||||
|
||||
// Valid on any day
|
||||
for day in 1...5 {
|
||||
#expect(constraints.isValidPosition(for: travel, day: day, sortOrder: 50))
|
||||
}
|
||||
|
||||
// Full range
|
||||
#expect(constraints.validDayRange(for: travel) == 1...5)
|
||||
}
|
||||
|
||||
@Test("edge: negative sortOrder is valid for custom items")
|
||||
func edge_negativeSortOrder_validForCustomItems() {
|
||||
let constraints = makeConstraints(tripDays: 3, gameDays: [])
|
||||
let custom = makeCustomItem(day: 2, sortOrder: -100)
|
||||
|
||||
#expect(constraints.isValidPosition(for: custom, day: 2, sortOrder: -100))
|
||||
}
|
||||
|
||||
@Test("edge: very large sortOrder is valid for custom items")
|
||||
func edge_veryLargeSortOrder_validForCustomItems() {
|
||||
let constraints = makeConstraints(tripDays: 3, gameDays: [])
|
||||
let custom = makeCustomItem(day: 2, sortOrder: 10000)
|
||||
|
||||
#expect(constraints.isValidPosition(for: custom, day: 2, sortOrder: 10000))
|
||||
}
|
||||
```
|
||||
|
||||
Also add a test verifying the roadmap success criteria are testable:
|
||||
|
||||
```swift
|
||||
// MARK: - Success Criteria Verification
|
||||
|
||||
@Test("success: game row shows no drag interaction (game not draggable)")
|
||||
func success_gameNotDraggable() {
|
||||
// Games return false for ANY position, making them non-draggable
|
||||
let game = makeGameItem(city: "Chicago", day: 2, sortOrder: 100)
|
||||
let constraints = makeConstraints(tripDays: 5, games: [game])
|
||||
|
||||
// Same position
|
||||
#expect(!constraints.isValidPosition(for: game, day: 2, sortOrder: 100))
|
||||
// Different day
|
||||
#expect(!constraints.isValidPosition(for: game, day: 1, sortOrder: 100))
|
||||
// Different sortOrder
|
||||
#expect(!constraints.isValidPosition(for: game, day: 2, sortOrder: 50))
|
||||
}
|
||||
|
||||
@Test("success: custom note can be placed anywhere")
|
||||
func success_customNotePlacedAnywhere() {
|
||||
// Custom items can be placed before, between, or after games on any day
|
||||
let constraints = makeConstraints(
|
||||
tripDays: 3,
|
||||
games: [
|
||||
makeGameItem(city: "Chicago", day: 1, sortOrder: 100),
|
||||
makeGameItem(city: "Chicago", day: 1, sortOrder: 200),
|
||||
makeGameItem(city: "Detroit", day: 3, sortOrder: 100)
|
||||
]
|
||||
)
|
||||
let custom = makeCustomItem(day: 1, sortOrder: 50)
|
||||
|
||||
// Before games on day 1
|
||||
#expect(constraints.isValidPosition(for: custom, day: 1, sortOrder: 50))
|
||||
// Between games on day 1
|
||||
#expect(constraints.isValidPosition(for: custom, day: 1, sortOrder: 150))
|
||||
// After games on day 1
|
||||
#expect(constraints.isValidPosition(for: custom, day: 1, sortOrder: 250))
|
||||
// On rest day (day 2)
|
||||
#expect(constraints.isValidPosition(for: custom, day: 2, sortOrder: 50))
|
||||
// On day 3 with different city games
|
||||
#expect(constraints.isValidPosition(for: custom, day: 3, sortOrder: 50))
|
||||
}
|
||||
|
||||
@Test("success: invalid position returns false (rejection)")
|
||||
func success_invalidPositionReturnsRejection() {
|
||||
// Travel segment cannot be placed before departure game
|
||||
let constraints = makeConstraints(
|
||||
tripDays: 3,
|
||||
games: [makeGameItem(city: "Chicago", day: 2, sortOrder: 100)]
|
||||
)
|
||||
let travel = makeTravelItem(from: "Chicago", to: "Detroit", day: 1, sortOrder: 50)
|
||||
|
||||
// Day 1 is before Chicago game on Day 2, so invalid
|
||||
#expect(!constraints.isValidPosition(for: travel, day: 1, sortOrder: 50))
|
||||
}
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
Run updated tests:
|
||||
```
|
||||
xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/ItineraryConstraintsTests test 2>&1 | grep -E "(Test Suite|Executed|passed|failed)"
|
||||
```
|
||||
All tests pass (original 13 + 10 new edge cases = 23 total).
|
||||
</verify>
|
||||
<done>10 additional edge case tests added and passing</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Create API documentation for Phase 4</name>
|
||||
<files>.planning/phases/02-constraint-validation/CONSTRAINT-API.md</files>
|
||||
<action>
|
||||
Create API documentation that Phase 4 can reference for drag-drop integration:
|
||||
|
||||
```markdown
|
||||
# ItineraryConstraints API
|
||||
|
||||
**Location:** `SportsTime/Core/Models/Domain/ItineraryConstraints.swift`
|
||||
**Verified by:** 23 tests in `SportsTimeTests/Domain/ItineraryConstraintsTests.swift`
|
||||
|
||||
## Overview
|
||||
|
||||
`ItineraryConstraints` validates item positions during drag-drop operations. It enforces:
|
||||
|
||||
- **Games cannot move** (CONS-01)
|
||||
- **Travel segments have day range limits** (CONS-02)
|
||||
- **Travel segments must respect game sortOrder on same day** (CONS-03)
|
||||
- **Custom items have no constraints** (CONS-04)
|
||||
|
||||
## Construction
|
||||
|
||||
```swift
|
||||
let constraints = ItineraryConstraints(
|
||||
tripDayCount: days.count,
|
||||
items: allItineraryItems // All items including games
|
||||
)
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `tripDayCount`: Total days in trip (1-indexed, so a 5-day trip has days 1-5)
|
||||
- `items`: All itinerary items (games, travel, custom). Games are used to calculate constraints for travel items.
|
||||
|
||||
## Public API
|
||||
|
||||
### `isValidPosition(for:day:sortOrder:) -> Bool`
|
||||
|
||||
Check if a specific position is valid for an item.
|
||||
|
||||
```swift
|
||||
func isValidPosition(for item: ItineraryItem, day: Int, sortOrder: Double) -> Bool
|
||||
```
|
||||
|
||||
**Usage during drag:**
|
||||
```swift
|
||||
// On each drag position update
|
||||
let dropPosition = calculateDropPosition(at: touchLocation)
|
||||
let isValid = constraints.isValidPosition(
|
||||
for: draggedItem,
|
||||
day: dropPosition.day,
|
||||
sortOrder: dropPosition.sortOrder
|
||||
)
|
||||
|
||||
if isValid {
|
||||
showValidDropIndicator()
|
||||
} else {
|
||||
showInvalidDropIndicator()
|
||||
}
|
||||
```
|
||||
|
||||
**Returns:**
|
||||
- `true`: Position is valid, allow drop
|
||||
- `false`: Position is invalid, reject drop (snap back)
|
||||
|
||||
**Rules by item type:**
|
||||
| Item Type | Day Constraint | SortOrder Constraint |
|
||||
|-----------|----------------|----------------------|
|
||||
| `.game` | Always `false` | Always `false` |
|
||||
| `.travel` | Within valid day range | After departure games, before arrival games |
|
||||
| `.custom` | Any day 1...tripDayCount | Any sortOrder |
|
||||
|
||||
### `validDayRange(for:) -> ClosedRange<Int>?`
|
||||
|
||||
Get the valid day range for a travel item (for visual feedback).
|
||||
|
||||
```swift
|
||||
func validDayRange(for item: ItineraryItem) -> ClosedRange<Int>?
|
||||
```
|
||||
|
||||
**Usage at drag start:**
|
||||
```swift
|
||||
// When drag begins, precompute valid range
|
||||
guard case .travel = draggedItem.kind,
|
||||
let validRange = constraints.validDayRange(for: draggedItem) else {
|
||||
// Not a travel item or impossible constraints
|
||||
return
|
||||
}
|
||||
|
||||
// Use range to dim invalid days
|
||||
for day in 1...tripDayCount {
|
||||
if !validRange.contains(day) {
|
||||
dimDay(day)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Returns:**
|
||||
- `ClosedRange<Int>`: Valid day range (e.g., `2...4`)
|
||||
- `nil`: Constraints are impossible (e.g., departure game after arrival game)
|
||||
|
||||
### `barrierGames(for:) -> [ItineraryItem]`
|
||||
|
||||
Get games that constrain a travel item (for visual highlighting).
|
||||
|
||||
```swift
|
||||
func barrierGames(for item: ItineraryItem) -> [ItineraryItem]
|
||||
```
|
||||
|
||||
**Usage for visual feedback:**
|
||||
```swift
|
||||
// Highlight barrier games during drag
|
||||
let barriers = constraints.barrierGames(for: travelItem)
|
||||
for barrier in barriers {
|
||||
highlightAsBarrier(barrier) // e.g., gold border
|
||||
}
|
||||
```
|
||||
|
||||
**Returns:**
|
||||
- Array of game items: Last departure city game + first arrival city game
|
||||
- Empty array: Not a travel item or no constraining games
|
||||
|
||||
## Integration Points
|
||||
|
||||
### ItineraryTableViewController (existing)
|
||||
|
||||
```swift
|
||||
// In reloadData()
|
||||
self.constraints = ItineraryConstraints(tripDayCount: tripDayCount, items: itineraryItems)
|
||||
|
||||
// In drag handling
|
||||
if constraints.isValidPosition(for: draggedItem, day: targetDay, sortOrder: targetSortOrder) {
|
||||
// Allow drop
|
||||
} else {
|
||||
// Reject drop, snap back
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 4 Implementation Notes
|
||||
|
||||
1. **Drag Start:**
|
||||
- Check `item.isReorderable` (games return `false`)
|
||||
- Call `validDayRange(for:)` to precompute valid days
|
||||
- Call `barrierGames(for:)` to identify visual barriers
|
||||
|
||||
2. **Drag Move:**
|
||||
- Calculate target (day, sortOrder) from touch position
|
||||
- Call `isValidPosition(for:day:sortOrder:)` for real-time feedback
|
||||
- Update insertion line (valid) or red indicator (invalid)
|
||||
|
||||
3. **Drag End:**
|
||||
- Final `isValidPosition(for:day:sortOrder:)` check
|
||||
- Valid: Update item's day/sortOrder, animate settle
|
||||
- Invalid: Animate snap back, haptic feedback
|
||||
|
||||
## Test Coverage
|
||||
|
||||
| Requirement | Tests | Verified |
|
||||
|-------------|-------|----------|
|
||||
| CONS-01 (games cannot move) | 2 | Yes |
|
||||
| CONS-02 (travel day range) | 5 | Yes |
|
||||
| CONS-03 (travel sortOrder) | 5 | Yes |
|
||||
| CONS-04 (custom flexibility) | 4 | Yes |
|
||||
| Edge cases | 7 | Yes |
|
||||
| **Total** | **23** | **100%** |
|
||||
|
||||
---
|
||||
*API documented: Phase 02*
|
||||
*Ready for: Phase 04 (Drag Interaction)*
|
||||
```
|
||||
</action>
|
||||
<verify>File exists and contains all three public methods with usage examples</verify>
|
||||
<done>CONSTRAINT-API.md created with complete API documentation</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Run full test suite and commit</name>
|
||||
<files>None (verification only)</files>
|
||||
<action>
|
||||
1. Run full test suite to verify no regressions:
|
||||
```
|
||||
xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' test 2>&1 | grep -E "(Test Suite|Executed|passed|failed)"
|
||||
```
|
||||
|
||||
2. Commit the edge case tests:
|
||||
```
|
||||
git add SportsTimeTests/Domain/ItineraryConstraintsTests.swift
|
||||
git commit -m "test(02-02): add edge case tests for constraint validation
|
||||
|
||||
Add 10 edge case tests:
|
||||
- Single-day trip boundaries
|
||||
- Day 0 and beyond-trip validation
|
||||
- Exact sortOrder boundary behavior
|
||||
- Travel with no games in cities
|
||||
- Negative and large sortOrders
|
||||
- Success criteria verification tests
|
||||
|
||||
Total: 23 constraint tests
|
||||
|
||||
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
3. Commit the API documentation:
|
||||
```
|
||||
git add .planning/phases/02-constraint-validation/CONSTRAINT-API.md
|
||||
git commit -m "docs(02-02): document ItineraryConstraints API for Phase 4
|
||||
|
||||
Document public API for drag-drop integration:
|
||||
- isValidPosition() for position validation
|
||||
- validDayRange() for precomputing valid days
|
||||
- barrierGames() for visual highlighting
|
||||
- Integration patterns for ItineraryTableViewController
|
||||
|
||||
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"
|
||||
```
|
||||
</action>
|
||||
<verify>Full test suite passes with no regressions</verify>
|
||||
<done>Edge case tests and API documentation committed</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
After all tasks:
|
||||
1. 23 total constraint tests pass (13 migrated + 10 edge cases)
|
||||
2. Full test suite passes (no regressions)
|
||||
3. CONSTRAINT-API.md exists with complete documentation
|
||||
4. All commits follow project conventions
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Edge cases tested: single-day, day boundaries, sortOrder boundaries, no-games scenarios
|
||||
- Roadmap success criteria are verifiable by tests
|
||||
- API documentation complete for Phase 4 integration
|
||||
- All tests pass
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/02-constraint-validation/02-02-SUMMARY.md`
|
||||
</output>
|
||||
@@ -1,107 +0,0 @@
|
||||
---
|
||||
phase: 02-constraint-validation
|
||||
plan: 02
|
||||
subsystem: testing
|
||||
tags: [swift-testing, constraints, edge-cases, api-docs]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 02-01
|
||||
provides: migrated constraint tests to Swift Testing
|
||||
provides:
|
||||
- 22 comprehensive constraint tests including edge cases
|
||||
- API documentation for Phase 4 drag-drop integration
|
||||
affects: [04-drag-interaction]
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- Edge case test naming convention (edge_*)
|
||||
- Success criteria verification tests (success_*)
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- ".planning/phases/02-constraint-validation/CONSTRAINT-API.md"
|
||||
modified:
|
||||
- "SportsTimeTests/Domain/ItineraryConstraintsTests.swift"
|
||||
|
||||
key-decisions:
|
||||
- "Tests use Swift Testing to match existing project patterns"
|
||||
- "Edge case tests cover boundaries: day 0, beyond trip, exact sortOrder, negative/large values"
|
||||
|
||||
patterns-established:
|
||||
- "Success criteria verification: tests named 'success_*' directly verify ROADMAP success criteria"
|
||||
- "Edge case testing: boundary conditions explicitly tested for constraint validation"
|
||||
|
||||
# Metrics
|
||||
duration: 23min
|
||||
completed: 2026-01-18
|
||||
---
|
||||
|
||||
# Phase 2 Plan 02: Edge Cases and API Documentation Summary
|
||||
|
||||
**22 constraint tests with edge case coverage, plus complete API documentation for Phase 4 drag-drop integration**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 23 min
|
||||
- **Started:** 2026-01-18T20:50:49Z
|
||||
- **Completed:** 2026-01-18T21:13:45Z
|
||||
- **Tasks:** 3
|
||||
- **Files modified:** 2
|
||||
|
||||
## Accomplishments
|
||||
- Added 10 edge case tests covering boundary conditions (single-day trips, day 0, beyond trip, exact sortOrder boundaries, negative/large sortOrders)
|
||||
- Added 3 success criteria verification tests matching ROADMAP requirements
|
||||
- Created comprehensive API documentation for Phase 4 drag-drop integration
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically:
|
||||
|
||||
1. **Task 1: Add edge case tests** - `1320a34` (test)
|
||||
2. **Task 2: Create API documentation** - `73ed315` (docs)
|
||||
3. **Task 3: Run full test suite** - No commit (verification only)
|
||||
|
||||
## Files Created/Modified
|
||||
- `SportsTimeTests/Domain/ItineraryConstraintsTests.swift` - Added 10 edge case tests + 3 success criteria tests (now 22 total)
|
||||
- `.planning/phases/02-constraint-validation/CONSTRAINT-API.md` - API reference for Phase 4
|
||||
|
||||
## Decisions Made
|
||||
- Used Swift Testing framework (matching Phase 1 patterns)
|
||||
- Edge case tests cover all boundary conditions: day boundaries (0, beyond trip), sortOrder boundaries (exact, negative, large), and trip edge cases (single-day, no games)
|
||||
- Success criteria tests directly verify ROADMAP success criteria for CONS-01 through CONS-04
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] Old XCTest file still existed**
|
||||
- **Found during:** Task 1 (Add edge case tests)
|
||||
- **Issue:** Plan 02-01 migrated tests to Swift Testing but didn't delete old XCTest file, causing "filename used twice" build error
|
||||
- **Fix:** Removed orphaned XCTest file; all tests now in Domain/ItineraryConstraintsTests.swift
|
||||
- **Files affected:** SportsTimeTests/ItineraryConstraintsTests.swift (removed)
|
||||
- **Verification:** Build succeeds, all 22 tests pass
|
||||
- **Committed in:** 1320a34 (Task 1 commit includes correct file)
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 1 auto-fixed (1 blocking)
|
||||
**Impact on plan:** Blocking issue resolved; no scope creep.
|
||||
|
||||
## Issues Encountered
|
||||
None - plan executed as specified after resolving blocking issue.
|
||||
|
||||
## User Setup Required
|
||||
None - no external service configuration required.
|
||||
|
||||
## Next Phase Readiness
|
||||
- Phase 2 complete: All constraint validation tests passing (22 tests)
|
||||
- API documentation ready for Phase 4 (CONSTRAINT-API.md)
|
||||
- Requirements CONS-01 through CONS-04 verified by tests
|
||||
- Ready for Phase 3 (Visual Flattening) or can proceed directly to Phase 4
|
||||
|
||||
---
|
||||
*Phase: 02-constraint-validation*
|
||||
*Completed: 2026-01-18*
|
||||
@@ -1,64 +0,0 @@
|
||||
# Phase 2: Constraint Validation - Context
|
||||
|
||||
**Gathered:** 2026-01-18
|
||||
**Status:** Ready for planning
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
The system prevents invalid positions and enforces item-specific rules. Games cannot be moved. Travel segments are constrained to valid day ranges and must respect game ordering within days. Custom items have no constraints within the trip's day range.
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### Rejection Feedback
|
||||
- Silent snap-back on invalid drop — no toast or error message
|
||||
- Games have a subtle visual cue indicating they're not draggable (e.g., pinned icon)
|
||||
- During drag, valid drop zones get a border highlight (color TBD by implementation)
|
||||
- No insertion line appears outside valid zones — clear signal of invalidity
|
||||
|
||||
### Constraint Timing
|
||||
- Hybrid approach: quick check on drag start, full validation on drop
|
||||
- Drag start computes valid day range only — position within day checked on drop
|
||||
- Validation is synchronous (no async complexity)
|
||||
- Insertion line only appears in valid zones
|
||||
|
||||
### Travel Segment Rules
|
||||
- Travel can be on same day as from-city game IF positioned after it (higher sortOrder)
|
||||
- Travel can be on same day as to-city game IF positioned before it (lower sortOrder)
|
||||
- If a day has both from-city and to-city games, travel must be between them
|
||||
- Travel can be placed on days with no games — any sortOrder valid
|
||||
- Travel segment is single-day only (represents departure day)
|
||||
|
||||
### Edge Cases
|
||||
- Items constrained to trip days (1 through N) — no Day 0 or Day N+1
|
||||
- Empty days remain visible in UI (don't collapse)
|
||||
|
||||
### Claude's Discretion
|
||||
- Exact visual styling for "not draggable" indicator on games
|
||||
- Border highlight color for valid drop zones
|
||||
- sortOrder precision exhaustion handling (renormalize vs. block)
|
||||
|
||||
</decisions>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- Valid zone highlight should use border (not background tint) — keeps it subtle
|
||||
- Games being undraggable should be discoverable but not distracting
|
||||
|
||||
</specifics>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
None — discussion stayed within phase scope
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 02-constraint-validation*
|
||||
*Context gathered: 2026-01-18*
|
||||
@@ -1,483 +0,0 @@
|
||||
# Phase 2: Constraint Validation - Research
|
||||
|
||||
**Researched:** 2026-01-18
|
||||
**Domain:** Constraint validation for itinerary item positioning
|
||||
**Confidence:** HIGH
|
||||
|
||||
## Summary
|
||||
|
||||
This research reveals that Phase 2 is largely **already implemented**. The codebase contains a complete `ItineraryConstraints` struct with 17 XCTest test cases covering all the constraint requirements from the phase description. The Phase 1 work (SortOrderProvider, Trip day derivation) provides the foundation, and `ItineraryConstraints` already validates game immutability, travel segment positioning, and custom item flexibility.
|
||||
|
||||
The main gap is **migrating the tests from XCTest to Swift Testing** (@Test, @Suite) to match the project's established patterns from Phase 1. The `ItineraryTableViewController` already integrates with `ItineraryConstraints` for drag-drop validation during Phase 4's UI work.
|
||||
|
||||
**Primary recommendation:** Verify existing implementation covers all CONS-* requirements, migrate tests to Swift Testing, and document the constraint API for Phase 4's UI integration.
|
||||
|
||||
## Codebase Analysis
|
||||
|
||||
### Key Files
|
||||
|
||||
| File | Purpose | Status |
|
||||
|------|---------|--------|
|
||||
| `SportsTime/Core/Models/Domain/ItineraryConstraints.swift` | Constraint validation struct | **Complete** |
|
||||
| `SportsTimeTests/ItineraryConstraintsTests.swift` | 17 XCTest test cases | Needs migration to Swift Testing |
|
||||
| `SportsTime/Core/Models/Domain/SortOrderProvider.swift` | sortOrder calculation utilities | Complete (Phase 1) |
|
||||
| `SportsTime/Core/Models/Domain/Trip.swift` | Day derivation methods | Complete (Phase 1) |
|
||||
| `SportsTime/Core/Models/Domain/ItineraryItem.swift` | Unified itinerary item model | Complete |
|
||||
| `SportsTime/Features/Trip/Views/ItineraryTableViewController.swift` | UITableView with drag-drop | Uses constraints |
|
||||
|
||||
### Existing ItineraryConstraints API
|
||||
|
||||
```swift
|
||||
// Source: SportsTime/Core/Models/Domain/ItineraryConstraints.swift
|
||||
struct ItineraryConstraints {
|
||||
let tripDayCount: Int
|
||||
private let items: [ItineraryItem]
|
||||
|
||||
init(tripDayCount: Int, items: [ItineraryItem])
|
||||
|
||||
/// Check if a position is valid for an item
|
||||
func isValidPosition(for item: ItineraryItem, day: Int, sortOrder: Double) -> Bool
|
||||
|
||||
/// Get the valid day range for a travel item
|
||||
func validDayRange(for item: ItineraryItem) -> ClosedRange<Int>?
|
||||
|
||||
/// Get the games that act as barriers for visual highlighting
|
||||
func barrierGames(for item: ItineraryItem) -> [ItineraryItem]
|
||||
}
|
||||
```
|
||||
|
||||
### ItemKind Types
|
||||
|
||||
```swift
|
||||
// Source: SportsTime/Core/Models/Domain/ItineraryItem.swift
|
||||
enum ItemKind: Codable, Hashable {
|
||||
case game(gameId: String) // CONS-01: Cannot be moved
|
||||
case travel(TravelInfo) // CONS-02, CONS-03: Day range + ordering constraints
|
||||
case custom(CustomInfo) // CONS-04: No constraints
|
||||
}
|
||||
```
|
||||
|
||||
### Integration with Drag-Drop
|
||||
|
||||
The `ItineraryTableViewController` already creates and uses `ItineraryConstraints`:
|
||||
|
||||
```swift
|
||||
// Source: ItineraryTableViewController.swift (lines 484-494)
|
||||
func reloadData(
|
||||
days: [ItineraryDayData],
|
||||
travelValidRanges: [String: ClosedRange<Int>],
|
||||
itineraryItems: [ItineraryItem] = []
|
||||
) {
|
||||
self.travelValidRanges = travelValidRanges
|
||||
self.allItineraryItems = itineraryItems
|
||||
self.tripDayCount = days.count
|
||||
|
||||
// Rebuild constraints with new data
|
||||
self.constraints = ItineraryConstraints(tripDayCount: tripDayCount, items: itineraryItems)
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## Constraint Requirements Mapping
|
||||
|
||||
### CONS-01: Games cannot be moved (fixed by schedule)
|
||||
|
||||
**Status: IMPLEMENTED**
|
||||
|
||||
```swift
|
||||
// ItineraryConstraints.isValidPosition()
|
||||
case .game:
|
||||
// Games are fixed, should never be moved
|
||||
return false
|
||||
```
|
||||
|
||||
**Existing tests:**
|
||||
- `test_gameItem_cannotBeMoved()` - Verifies games return false for any position
|
||||
|
||||
**UI Integration (Phase 4):**
|
||||
- `ItineraryRowItem.isReorderable` returns false for `.games`
|
||||
- No drag handle appears on game rows
|
||||
|
||||
### CONS-02: Travel segments constrained to valid day range
|
||||
|
||||
**Status: IMPLEMENTED**
|
||||
|
||||
```swift
|
||||
// ItineraryConstraints.isValidTravelPosition()
|
||||
let departureGameDays = gameDays(in: fromCity)
|
||||
let arrivalGameDays = gameDays(in: toCity)
|
||||
|
||||
let minDay = departureGameDays.max() ?? 1 // After last from-city game
|
||||
let maxDay = arrivalGameDays.min() ?? tripDayCount // Before first to-city game
|
||||
|
||||
guard day >= minDay && day <= maxDay else { return false }
|
||||
```
|
||||
|
||||
**Existing tests:**
|
||||
- `test_travel_validDayRange_simpleCase()` - Chicago Day 1, Detroit Day 3 -> range 1...3
|
||||
- `test_travel_cannotGoOutsideValidDayRange()` - Day before departure invalid
|
||||
- `test_travel_validDayRange_returnsNil_whenConstraintsImpossible()` - Reversed order returns nil
|
||||
|
||||
### CONS-03: Travel must be after from-city games, before to-city games on same day
|
||||
|
||||
**Status: IMPLEMENTED**
|
||||
|
||||
```swift
|
||||
// ItineraryConstraints.isValidTravelPosition()
|
||||
if departureGameDays.contains(day) {
|
||||
let maxGameSortOrder = games(in: fromCity)
|
||||
.filter { $0.day == day }
|
||||
.map { $0.sortOrder }
|
||||
.max() ?? 0
|
||||
|
||||
if sortOrder <= maxGameSortOrder {
|
||||
return false // Must be AFTER all departure games
|
||||
}
|
||||
}
|
||||
|
||||
if arrivalGameDays.contains(day) {
|
||||
let minGameSortOrder = games(in: toCity)
|
||||
.filter { $0.day == day }
|
||||
.map { $0.sortOrder }
|
||||
.min() ?? Double.greatestFiniteMagnitude
|
||||
|
||||
if sortOrder >= minGameSortOrder {
|
||||
return false // Must be BEFORE all arrival games
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Existing tests:**
|
||||
- `test_travel_mustBeAfterDepartureGames()` - Before departure game invalid
|
||||
- `test_travel_mustBeBeforeArrivalGames()` - After arrival game invalid
|
||||
- `test_travel_mustBeAfterAllDepartureGamesOnSameDay()` - Between games invalid
|
||||
- `test_travel_mustBeBeforeAllArrivalGamesOnSameDay()` - Between games invalid
|
||||
- `test_travel_canBeAnywhereOnRestDays()` - No games = any position valid
|
||||
|
||||
### CONS-04: Custom items have no constraints
|
||||
|
||||
**Status: IMPLEMENTED**
|
||||
|
||||
```swift
|
||||
// ItineraryConstraints.isValidPosition()
|
||||
case .custom:
|
||||
// Custom items can go anywhere
|
||||
return true
|
||||
```
|
||||
|
||||
**Existing tests:**
|
||||
- `test_customItem_canGoOnAnyDay()` - Days 1-5 all valid
|
||||
- `test_customItem_canGoBeforeOrAfterGames()` - Any sortOrder valid
|
||||
|
||||
## Standard Stack
|
||||
|
||||
### Core
|
||||
| Library | Version | Purpose | Why Standard |
|
||||
|---------|---------|---------|--------------|
|
||||
| Swift Testing | Swift 5.10+ | Test framework | Project standard from Phase 1 |
|
||||
| Foundation | Swift stdlib | Date/Calendar | Already used throughout |
|
||||
|
||||
### Supporting
|
||||
| Library | Version | Purpose | When to Use |
|
||||
|---------|---------|---------|-------------|
|
||||
| XCTest | iOS 26+ | Legacy tests | Being migrated away |
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### Pattern 1: Pure Function Constraint Checking
|
||||
|
||||
**What:** `ItineraryConstraints.isValidPosition()` is a pure function - no side effects, deterministic
|
||||
|
||||
**When to use:** All constraint validation - fast, testable, no async complexity
|
||||
|
||||
**Example:**
|
||||
```swift
|
||||
// Source: ItineraryConstraints.swift
|
||||
func isValidPosition(for item: ItineraryItem, day: Int, sortOrder: Double) -> Bool {
|
||||
guard day >= 1 && day <= tripDayCount else { return false }
|
||||
|
||||
switch item.kind {
|
||||
case .game:
|
||||
return false
|
||||
case .travel(let info):
|
||||
return isValidTravelPosition(fromCity: info.fromCity, toCity: info.toCity, day: day, sortOrder: sortOrder)
|
||||
case .custom:
|
||||
return true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 2: Precomputed Valid Ranges
|
||||
|
||||
**What:** `validDayRange(for:)` computes the valid day range once at drag start
|
||||
|
||||
**When to use:** UI needs to quickly check many positions during drag
|
||||
|
||||
**Example:**
|
||||
```swift
|
||||
// Source: ItineraryTableViewController.swift
|
||||
func calculateTravelDragZones(segment: TravelSegment) {
|
||||
let travelId = "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())"
|
||||
|
||||
guard let validRange = travelValidRanges[travelId] else { ... }
|
||||
|
||||
// Pre-calculate ALL valid row indices
|
||||
for (index, rowItem) in flatItems.enumerated() {
|
||||
if validRange.contains(dayNum) {
|
||||
validRows.append(index)
|
||||
} else {
|
||||
invalidRows.insert(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 3: City Extraction from Game ID
|
||||
|
||||
**What:** Game IDs encode city: `game-CityName-xxxx`
|
||||
|
||||
**Why:** Avoids needing to look up game details during constraint checking
|
||||
|
||||
**Example:**
|
||||
```swift
|
||||
// Source: ItineraryConstraints.swift
|
||||
private func city(forGameId gameId: String) -> String? {
|
||||
let components = gameId.components(separatedBy: "-")
|
||||
guard components.count >= 2 else { return nil }
|
||||
return components[1]
|
||||
}
|
||||
```
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
|
||||
- **Async constraint validation:** Constraints must be synchronous for responsive drag feedback
|
||||
- **Row-index based constraints:** Always use semantic (day, sortOrder), never row indices
|
||||
- **Checking constraints on drop only:** Check at drag start for valid range, each position during drag
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't Build | Use Instead | Why |
|
||||
|---------|-------------|-------------|-----|
|
||||
| Constraint validation | Custom per-item-type logic | `ItineraryConstraints` | Already handles all cases |
|
||||
| Day range calculation | Manual game day scanning | `validDayRange(for:)` | Handles edge cases |
|
||||
| City matching | String equality | `city(forGameId:)` helper | Game ID format is stable |
|
||||
| Position checking | Multiple conditions scattered | `isValidPosition()` | Single entry point |
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: Forgetting sortOrder Constraints on Same Day
|
||||
|
||||
**What goes wrong:** Travel placed on game day but ignoring sortOrder requirement
|
||||
|
||||
**Why it happens:** Day range looks valid, forget to check sortOrder against games
|
||||
|
||||
**How to avoid:** Always use `isValidPosition()` which checks both day AND sortOrder
|
||||
|
||||
**Warning signs:** Travel placed before departure game or after arrival game
|
||||
|
||||
### Pitfall 2: City Name Case Sensitivity
|
||||
|
||||
**What goes wrong:** "Chicago" != "chicago" causing constraint checks to fail
|
||||
|
||||
**Why it happens:** TravelInfo stores display-case cities, game IDs may differ
|
||||
|
||||
**How to avoid:** The implementation already lowercases for comparison
|
||||
|
||||
**Warning signs:** Valid travel rejected because city match fails
|
||||
|
||||
### Pitfall 3: Empty Game Days
|
||||
|
||||
**What goes wrong:** No games in a city means null/empty arrays
|
||||
|
||||
**Why it happens:** Some cities might have no games yet (planning in progress)
|
||||
|
||||
**How to avoid:** Implementation uses `?? 1` and `?? tripDayCount` defaults
|
||||
|
||||
**Warning signs:** Constraint checks crash on cities with no games
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Constraint Checking (Full Implementation)
|
||||
|
||||
```swift
|
||||
// Source: SportsTime/Core/Models/Domain/ItineraryConstraints.swift
|
||||
struct ItineraryConstraints {
|
||||
let tripDayCount: Int
|
||||
private let items: [ItineraryItem]
|
||||
|
||||
func isValidPosition(for item: ItineraryItem, day: Int, sortOrder: Double) -> Bool {
|
||||
guard day >= 1 && day <= tripDayCount else { return false }
|
||||
|
||||
switch item.kind {
|
||||
case .game:
|
||||
return false
|
||||
case .travel(let info):
|
||||
return isValidTravelPosition(
|
||||
fromCity: info.fromCity,
|
||||
toCity: info.toCity,
|
||||
day: day,
|
||||
sortOrder: sortOrder
|
||||
)
|
||||
case .custom:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func validDayRange(for item: ItineraryItem) -> ClosedRange<Int>? {
|
||||
guard case .travel(let info) = item.kind else { return nil }
|
||||
|
||||
let departureGameDays = gameDays(in: info.fromCity)
|
||||
let arrivalGameDays = gameDays(in: info.toCity)
|
||||
|
||||
let minDay = departureGameDays.max() ?? 1
|
||||
let maxDay = arrivalGameDays.min() ?? tripDayCount
|
||||
|
||||
guard minDay <= maxDay else { return nil }
|
||||
return minDay...maxDay
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Test Pattern (Swift Testing)
|
||||
|
||||
```swift
|
||||
// Pattern from Phase 1 tests - to be applied to constraint tests
|
||||
@Suite("ItineraryConstraints")
|
||||
struct ItineraryConstraintsTests {
|
||||
|
||||
@Test("game: cannot be moved to any position")
|
||||
func game_cannotBeMoved() {
|
||||
let constraints = makeConstraints(tripDays: 5, games: [gameItem])
|
||||
|
||||
#expect(constraints.isValidPosition(for: gameItem, day: 2, sortOrder: 100) == false)
|
||||
#expect(constraints.isValidPosition(for: gameItem, day: 3, sortOrder: 100) == false)
|
||||
}
|
||||
|
||||
@Test("travel: must be after departure games on same day")
|
||||
func travel_mustBeAfterDepartureGames() {
|
||||
let constraints = makeConstraints(tripDays: 3, games: [chicagoGame])
|
||||
let travel = makeTravelItem(from: "Chicago", to: "Detroit")
|
||||
|
||||
// Before departure game - invalid
|
||||
#expect(constraints.isValidPosition(for: travel, day: 1, sortOrder: 50) == false)
|
||||
|
||||
// After departure game - valid
|
||||
#expect(constraints.isValidPosition(for: travel, day: 1, sortOrder: 150) == true)
|
||||
}
|
||||
|
||||
@Test("custom: can go anywhere")
|
||||
func custom_canGoAnywhere() {
|
||||
let constraints = makeConstraints(tripDays: 5)
|
||||
let custom = makeCustomItem()
|
||||
|
||||
for day in 1...5 {
|
||||
#expect(constraints.isValidPosition(for: custom, day: day, sortOrder: 50) == true)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old Approach | Current Approach | When Changed | Impact |
|
||||
|--------------|------------------|--------------|--------|
|
||||
| Row-index validation | Semantic (day, sortOrder) validation | Phase 1 | Stable across reloads |
|
||||
| XCTest framework | Swift Testing (@Test, @Suite) | Phase 1 | Modern, cleaner assertions |
|
||||
| Constraint checking on drop | Precompute at drag start | Already implemented | Smoother drag UX |
|
||||
|
||||
## Open Questions
|
||||
|
||||
### Resolved by Research
|
||||
|
||||
1. **Where does constraint validation live?**
|
||||
- Answer: `ItineraryConstraints` struct in Domain models
|
||||
- Confidence: HIGH - already implemented and integrated
|
||||
|
||||
2. **How will Phase 4 call it?**
|
||||
- Answer: `ItineraryTableViewController` already integrates via `self.constraints`
|
||||
- Confidence: HIGH - verified in codebase
|
||||
|
||||
### Minor Questions (Claude's Discretion per CONTEXT.md)
|
||||
|
||||
1. **Visual styling for invalid zones?**
|
||||
- Current: Alpha 0.3 dimming, gold border on barrier games
|
||||
- CONTEXT.md says: "Border highlight color for valid drop zones" is Claude's discretion
|
||||
- Recommendation: Keep current implementation, refine in Phase 4 if needed
|
||||
|
||||
2. **sortOrder precision exhaustion handling?**
|
||||
- Current: `SortOrderProvider.needsNormalization()` and `normalize()` exist
|
||||
- CONTEXT.md says: "Renormalize vs. block" is Claude's discretion
|
||||
- Recommendation: Renormalize proactively when detected
|
||||
|
||||
## Test Strategy
|
||||
|
||||
### Migration Required
|
||||
|
||||
The existing 17 XCTest tests need migration to Swift Testing:
|
||||
|
||||
| XCTest Method | Swift Testing Equivalent |
|
||||
|---------------|--------------------------|
|
||||
| `XCTestCase` class | `@Suite` struct |
|
||||
| `func test_*` | `@Test("description") func` |
|
||||
| `XCTAssertTrue(x)` | `#expect(x == true)` |
|
||||
| `XCTAssertFalse(x)` | `#expect(x == false)` |
|
||||
| `XCTAssertEqual(a, b)` | `#expect(a == b)` |
|
||||
| `XCTAssertNil(x)` | `#expect(x == nil)` |
|
||||
|
||||
### Test Categories to Verify
|
||||
|
||||
| Category | Current Count | Coverage |
|
||||
|----------|---------------|----------|
|
||||
| Game immutability (CONS-01) | 1 test | Complete |
|
||||
| Travel day range (CONS-02) | 4 tests | Complete |
|
||||
| Travel sortOrder constraints (CONS-03) | 4 tests | Complete |
|
||||
| Custom item flexibility (CONS-04) | 2 tests | Complete |
|
||||
| Edge cases | 1 test | Impossible constraints |
|
||||
| Barrier games | 1 test | Visual highlighting |
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- `SportsTime/Core/Models/Domain/ItineraryConstraints.swift` - Full implementation reviewed
|
||||
- `SportsTimeTests/ItineraryConstraintsTests.swift` - 17 test cases reviewed
|
||||
- `SportsTime/Features/Trip/Views/ItineraryTableViewController.swift` - Integration verified
|
||||
- Phase 1 research and summaries - Pattern consistency verified
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- CONTEXT.md decisions - Visual styling details are Claude's discretion
|
||||
- CLAUDE.md test patterns - Swift Testing is project standard
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- Constraint implementation: HIGH - Code reviewed, all CONS-* requirements met
|
||||
- Test coverage: HIGH - 17 existing tests cover all requirements
|
||||
- UI integration: HIGH - Already used in ItineraryTableViewController
|
||||
- Migration path: HIGH - Clear XCTest -> Swift Testing mapping
|
||||
|
||||
**Research date:** 2026-01-18
|
||||
**Valid until:** Indefinite (constraint logic is stable)
|
||||
|
||||
## Recommendations for Planning
|
||||
|
||||
### Phase 2 Scope (Refined)
|
||||
|
||||
Given that `ItineraryConstraints` is already implemented and tested:
|
||||
|
||||
1. **Verify existing tests cover all requirements** - Compare against CONS-01 through CONS-04
|
||||
2. **Migrate tests to Swift Testing** - Match Phase 1 patterns
|
||||
3. **Add any missing edge case tests** - e.g., empty trip, single-day trip
|
||||
4. **Document constraint API** - For Phase 4 UI integration reference
|
||||
|
||||
### What NOT to Build
|
||||
|
||||
- The constraint checking logic already exists and works
|
||||
- The UI integration already exists in `ItineraryTableViewController`
|
||||
- The visual feedback (dimming, barriers) already exists
|
||||
|
||||
### Minimal Work Required
|
||||
|
||||
Phase 2 is essentially a **verification and standardization phase**, not a building phase:
|
||||
- Verify implementation matches requirements
|
||||
- Standardize tests to project patterns
|
||||
- Document for downstream phases
|
||||
@@ -1,86 +0,0 @@
|
||||
---
|
||||
phase: 02-constraint-validation
|
||||
verified: 2026-01-18T21:30:00Z
|
||||
status: passed
|
||||
score: 6/6 must-haves verified
|
||||
---
|
||||
|
||||
# Phase 2: Constraint Validation Verification Report
|
||||
|
||||
**Phase Goal:** The system prevents invalid positions and enforces item-specific rules.
|
||||
**Verified:** 2026-01-18T21:30:00Z
|
||||
**Status:** passed
|
||||
**Re-verification:** No - initial verification
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### Observable Truths
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|----------|
|
||||
| 1 | Games cannot be moved (CONS-01) | VERIFIED | `ItineraryConstraints.isValidPosition()` returns `false` for `.game` case (line 28-30). Tests `game_cannotBeMoved` and `success_gameNotDraggable` verify. |
|
||||
| 2 | Travel segments constrained to valid day range (CONS-02) | VERIFIED | `validDayRange()` method (line 47-60) calculates range based on departure/arrival games. Tests `travel_validDayRange_simpleCase`, `travel_cannotGoOutsideValidDayRange` verify. |
|
||||
| 3 | Travel must be after from-city games, before to-city games on same day (CONS-03) | VERIFIED | `isValidTravelPosition()` method (line 85-121) enforces sortOrder constraints. 5 tests cover this requirement. |
|
||||
| 4 | Custom items have no constraints (CONS-04) | VERIFIED | `isValidPosition()` returns `true` for `.custom` case (line 40-43). Tests `custom_canGoOnAnyDay`, `custom_canGoBeforeOrAfterGames`, `success_customNotePlacedAnywhere` verify. |
|
||||
| 5 | Tests use Swift Testing framework (@Test, @Suite) | VERIFIED | File uses `import Testing`, `@Suite("ItineraryConstraints")`, and `@Test` decorators. 22 tests total. |
|
||||
| 6 | API documentation exists for Phase 4 integration | VERIFIED | `CONSTRAINT-API.md` documents `isValidPosition()`, `validDayRange()`, `barrierGames()` with usage examples. |
|
||||
|
||||
**Score:** 6/6 truths verified
|
||||
|
||||
### Required Artifacts
|
||||
|
||||
| Artifact | Expected | Status | Details |
|
||||
|----------|----------|--------|---------|
|
||||
| `SportsTimeTests/Domain/ItineraryConstraintsTests.swift` | Migrated constraint tests with @Suite | EXISTS + SUBSTANTIVE + WIRED | 398 lines, 22 @Test functions, uses Swift Testing, imports @testable SportsTime |
|
||||
| `SportsTime/Core/Models/Domain/ItineraryConstraints.swift` | Implementation with isValidPosition | EXISTS + SUBSTANTIVE | 134 lines, 3 public methods, no stub patterns |
|
||||
| `.planning/phases/02-constraint-validation/CONSTRAINT-API.md` | API documentation for Phase 4 | EXISTS + SUBSTANTIVE | 165 lines, documents all 3 public methods with examples |
|
||||
|
||||
### Key Link Verification
|
||||
|
||||
| From | To | Via | Status | Details |
|
||||
|------|-----|-----|--------|---------|
|
||||
| `ItineraryConstraintsTests.swift` | `ItineraryConstraints.swift` | `@testable import SportsTime` | WIRED | Line 11: `@testable import SportsTime` |
|
||||
| `CONSTRAINT-API.md` | `ItineraryConstraints.swift` | documents public API | WIRED | Documents `isValidPosition`, `validDayRange`, `barrierGames` methods |
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
| Requirement | Status | Test Evidence |
|
||||
|-------------|--------|---------------|
|
||||
| CONS-01: Games cannot be moved | SATISFIED | `game_cannotBeMoved()`, `success_gameNotDraggable()` |
|
||||
| CONS-02: Travel segments constrained to valid day range | SATISFIED | `travel_validDayRange_simpleCase()`, `travel_cannotGoOutsideValidDayRange()`, `edge_travelNoGamesInEitherCity_hasFullRange()` |
|
||||
| CONS-03: Travel segments must be after from-city games, before to-city games on same day | SATISFIED | `travel_mustBeAfterDepartureGames()`, `travel_mustBeBeforeArrivalGames()`, `travel_canBeAnywhereOnRestDays()`, `travel_mustBeAfterAllDepartureGamesOnSameDay()`, `travel_mustBeBeforeAllArrivalGamesOnSameDay()` |
|
||||
| CONS-04: Custom items have no constraints | SATISFIED | `custom_canGoOnAnyDay()`, `custom_canGoBeforeOrAfterGames()`, `success_customNotePlacedAnywhere()` |
|
||||
|
||||
### Success Criteria from Roadmap
|
||||
|
||||
| Criteria | Status | Evidence |
|
||||
|----------|--------|----------|
|
||||
| Attempting to drag a game row shows no drag interaction | VERIFIED | `isValidPosition()` returns `false` for all game positions. Test `success_gameNotDraggable()` verifies. |
|
||||
| Travel segment between Chicago and Boston cannot be placed on Day 1 if Chicago games extend through Day 2 | VERIFIED | `validDayRange()` returns minDay based on last departure game. Test `travel_cannotGoOutsideValidDayRange()` verifies. |
|
||||
| Custom note item can be placed before, between, or after games on any day | VERIFIED | Test `success_customNotePlacedAnywhere()` explicitly verifies all positions. |
|
||||
| Invalid position attempt returns rejection (constraint checker returns false) | VERIFIED | Test `success_invalidPositionReturnsRejection()` verifies false return. |
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
| File | Line | Pattern | Severity | Impact |
|
||||
|------|------|---------|----------|--------|
|
||||
| None | - | - | - | No anti-patterns detected |
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
None - all success criteria are verifiable through automated tests.
|
||||
|
||||
### Test Execution Summary
|
||||
|
||||
All 22 constraint tests pass:
|
||||
- 2 tests for CONS-01 (games immutable)
|
||||
- 3 tests for CONS-02 (travel day range)
|
||||
- 5 tests for CONS-03 (travel sortOrder)
|
||||
- 2 tests for CONS-04 (custom flexibility)
|
||||
- 8 tests for edge cases (boundaries, impossible constraints)
|
||||
- 3 tests for success criteria verification
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-01-18T21:30:00Z_
|
||||
_Verifier: Claude (gsd-verifier)_
|
||||
@@ -1,164 +0,0 @@
|
||||
# ItineraryConstraints API
|
||||
|
||||
**Location:** `SportsTime/Core/Models/Domain/ItineraryConstraints.swift`
|
||||
**Verified by:** 22 tests in `SportsTimeTests/Domain/ItineraryConstraintsTests.swift`
|
||||
|
||||
## Overview
|
||||
|
||||
`ItineraryConstraints` validates item positions during drag-drop operations. It enforces:
|
||||
|
||||
- **Games cannot move** (CONS-01)
|
||||
- **Travel segments have day range limits** (CONS-02)
|
||||
- **Travel segments must respect game sortOrder on same day** (CONS-03)
|
||||
- **Custom items have no constraints** (CONS-04)
|
||||
|
||||
## Construction
|
||||
|
||||
```swift
|
||||
let constraints = ItineraryConstraints(
|
||||
tripDayCount: days.count,
|
||||
items: allItineraryItems // All items including games
|
||||
)
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `tripDayCount`: Total days in trip (1-indexed, so a 5-day trip has days 1-5)
|
||||
- `items`: All itinerary items (games, travel, custom). Games are used to calculate constraints for travel items.
|
||||
|
||||
## Public API
|
||||
|
||||
### `isValidPosition(for:day:sortOrder:) -> Bool`
|
||||
|
||||
Check if a specific position is valid for an item.
|
||||
|
||||
```swift
|
||||
func isValidPosition(for item: ItineraryItem, day: Int, sortOrder: Double) -> Bool
|
||||
```
|
||||
|
||||
**Usage during drag:**
|
||||
```swift
|
||||
// On each drag position update
|
||||
let dropPosition = calculateDropPosition(at: touchLocation)
|
||||
let isValid = constraints.isValidPosition(
|
||||
for: draggedItem,
|
||||
day: dropPosition.day,
|
||||
sortOrder: dropPosition.sortOrder
|
||||
)
|
||||
|
||||
if isValid {
|
||||
showValidDropIndicator()
|
||||
} else {
|
||||
showInvalidDropIndicator()
|
||||
}
|
||||
```
|
||||
|
||||
**Returns:**
|
||||
- `true`: Position is valid, allow drop
|
||||
- `false`: Position is invalid, reject drop (snap back)
|
||||
|
||||
**Rules by item type:**
|
||||
| Item Type | Day Constraint | SortOrder Constraint |
|
||||
|-----------|----------------|----------------------|
|
||||
| `.game` | Always `false` | Always `false` |
|
||||
| `.travel` | Within valid day range | After departure games, before arrival games |
|
||||
| `.custom` | Any day 1...tripDayCount | Any sortOrder |
|
||||
|
||||
### `validDayRange(for:) -> ClosedRange<Int>?`
|
||||
|
||||
Get the valid day range for a travel item (for visual feedback).
|
||||
|
||||
```swift
|
||||
func validDayRange(for item: ItineraryItem) -> ClosedRange<Int>?
|
||||
```
|
||||
|
||||
**Usage at drag start:**
|
||||
```swift
|
||||
// When drag begins, precompute valid range
|
||||
guard case .travel = draggedItem.kind,
|
||||
let validRange = constraints.validDayRange(for: draggedItem) else {
|
||||
// Not a travel item or impossible constraints
|
||||
return
|
||||
}
|
||||
|
||||
// Use range to dim invalid days
|
||||
for day in 1...tripDayCount {
|
||||
if !validRange.contains(day) {
|
||||
dimDay(day)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Returns:**
|
||||
- `ClosedRange<Int>`: Valid day range (e.g., `2...4`)
|
||||
- `nil`: Constraints are impossible (e.g., departure game after arrival game)
|
||||
|
||||
### `barrierGames(for:) -> [ItineraryItem]`
|
||||
|
||||
Get games that constrain a travel item (for visual highlighting).
|
||||
|
||||
```swift
|
||||
func barrierGames(for item: ItineraryItem) -> [ItineraryItem]
|
||||
```
|
||||
|
||||
**Usage for visual feedback:**
|
||||
```swift
|
||||
// Highlight barrier games during drag
|
||||
let barriers = constraints.barrierGames(for: travelItem)
|
||||
for barrier in barriers {
|
||||
highlightAsBarrier(barrier) // e.g., gold border
|
||||
}
|
||||
```
|
||||
|
||||
**Returns:**
|
||||
- Array of game items: Last departure city game + first arrival city game
|
||||
- Empty array: Not a travel item or no constraining games
|
||||
|
||||
## Integration Points
|
||||
|
||||
### ItineraryTableViewController (existing)
|
||||
|
||||
```swift
|
||||
// In reloadData()
|
||||
self.constraints = ItineraryConstraints(tripDayCount: tripDayCount, items: itineraryItems)
|
||||
|
||||
// In drag handling
|
||||
if constraints.isValidPosition(for: draggedItem, day: targetDay, sortOrder: targetSortOrder) {
|
||||
// Allow drop
|
||||
} else {
|
||||
// Reject drop, snap back
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 4 Implementation Notes
|
||||
|
||||
1. **Drag Start:**
|
||||
- Check `item.isReorderable` (games return `false`)
|
||||
- Call `validDayRange(for:)` to precompute valid days
|
||||
- Call `barrierGames(for:)` to identify visual barriers
|
||||
|
||||
2. **Drag Move:**
|
||||
- Calculate target (day, sortOrder) from touch position
|
||||
- Call `isValidPosition(for:day:sortOrder:)` for real-time feedback
|
||||
- Update insertion line (valid) or red indicator (invalid)
|
||||
|
||||
3. **Drag End:**
|
||||
- Final `isValidPosition(for:day:sortOrder:)` check
|
||||
- Valid: Update item's day/sortOrder, animate settle
|
||||
- Invalid: Animate snap back, haptic feedback
|
||||
|
||||
## Test Coverage
|
||||
|
||||
| Requirement | Tests | Verified |
|
||||
|-------------|-------|----------|
|
||||
| CONS-01 (games cannot move) | 2 | Yes |
|
||||
| CONS-02 (travel day range) | 3 | Yes |
|
||||
| CONS-03 (travel sortOrder) | 5 | Yes |
|
||||
| CONS-04 (custom flexibility) | 2 | Yes |
|
||||
| Edge cases | 8 | Yes |
|
||||
| Success criteria | 3 | Yes |
|
||||
| Barrier games | 1 | Yes |
|
||||
| **Total** | **22** | **100%** |
|
||||
|
||||
---
|
||||
*API documented: Phase 02*
|
||||
*Ready for: Phase 04 (Drag Interaction)*
|
||||
@@ -1,180 +0,0 @@
|
||||
---
|
||||
phase: 03-visual-flattening
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- SportsTime/Core/Models/Domain/ItineraryFlattener.swift
|
||||
- SportsTime/Features/Trip/Views/ItineraryTableViewController.swift
|
||||
autonomous: true
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Items with lower sortOrder appear before items with higher sortOrder within each day"
|
||||
- "Items with sortOrder < 0 appear before games"
|
||||
- "Items with sortOrder >= 0 appear after or between games"
|
||||
- "Day headers always appear first in their day's section"
|
||||
artifacts:
|
||||
- path: "SportsTime/Core/Models/Domain/ItineraryFlattener.swift"
|
||||
provides: "Pure flatten function"
|
||||
exports: ["flatten(days:itineraryItems:gamesByDay:)"]
|
||||
- path: "SportsTime/Features/Trip/Views/ItineraryTableViewController.swift"
|
||||
provides: "Refactored reloadData() using ItineraryFlattener"
|
||||
contains: "ItineraryFlattener.flatten"
|
||||
key_links:
|
||||
- from: "ItineraryTableViewController.reloadData()"
|
||||
to: "ItineraryFlattener.flatten()"
|
||||
via: "function call"
|
||||
pattern: "ItineraryFlattener\\.flatten"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Create a pure ItineraryFlattener utility that transforms hierarchical day data into display rows sorted by sortOrder.
|
||||
|
||||
Purpose: Replace the bucket-based flattening (beforeGames/afterGames arrays) with pure sortOrder sorting, ensuring deterministic display order.
|
||||
|
||||
Output: New `ItineraryFlattener.swift` file and refactored `reloadData()` method using it.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@~/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/03-visual-flattening/03-RESEARCH.md
|
||||
@.planning/phases/03-visual-flattening/03-CONTEXT.md
|
||||
@SportsTime/Core/Models/Domain/SortOrderProvider.swift
|
||||
@SportsTime/Features/Trip/Views/ItineraryTableViewController.swift
|
||||
@SportsTime/Features/Trip/Views/ItineraryTableViewWrapper.swift
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Create ItineraryFlattener utility</name>
|
||||
<files>SportsTime/Core/Models/Domain/ItineraryFlattener.swift</files>
|
||||
<action>
|
||||
Create a new `ItineraryFlattener.swift` file with a pure flatten function.
|
||||
|
||||
The function signature:
|
||||
```swift
|
||||
enum ItineraryFlattener {
|
||||
static func flatten(
|
||||
days: [ItineraryDayData],
|
||||
itineraryItems: [ItineraryItem],
|
||||
gamesByDay: [Int: [RichGame]]
|
||||
) -> [ItineraryRowItem]
|
||||
}
|
||||
```
|
||||
|
||||
Implementation logic:
|
||||
1. For each day in order:
|
||||
a. Add `.dayHeader` row (always first for each day)
|
||||
b. Collect all positionable items with their sortOrder:
|
||||
- Games: use `SortOrderProvider.initialSortOrder(forGameTime:)` for first game's time
|
||||
- Custom items: use `item.sortOrder` directly
|
||||
- Travel items: look up sortOrder from `itineraryItems` array by matching city names
|
||||
c. Sort collected items by sortOrder (ascending)
|
||||
d. Append sorted items to result
|
||||
|
||||
Key decisions (from CONTEXT.md):
|
||||
- sortOrder < 0 appears BEFORE games (games have sortOrder 100-1540)
|
||||
- sortOrder >= 0 appears AFTER/BETWEEN games
|
||||
- Day header is NOT a positioned item - always first
|
||||
|
||||
Do NOT:
|
||||
- Split into beforeGames/afterGames buckets
|
||||
- Use hardcoded order assumptions
|
||||
- Special-case travel with `travelBefore` property
|
||||
</action>
|
||||
<verify>
|
||||
Build succeeds: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -5`
|
||||
</verify>
|
||||
<done>
|
||||
`ItineraryFlattener.swift` exists with `flatten()` function that:
|
||||
- Sorts all items within a day by sortOrder
|
||||
- Day headers appear first in each day
|
||||
- Function is pure (no side effects, no instance state)
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Refactor reloadData() to use ItineraryFlattener</name>
|
||||
<files>SportsTime/Features/Trip/Views/ItineraryTableViewController.swift</files>
|
||||
<action>
|
||||
Refactor the `reloadData()` method (currently lines 484-545) to use the new `ItineraryFlattener.flatten()` function.
|
||||
|
||||
Current flow to replace:
|
||||
```swift
|
||||
// OLD: Bucket-based approach
|
||||
var beforeGames: [ItineraryRowItem] = []
|
||||
var afterGames: [ItineraryRowItem] = []
|
||||
for row in day.items {
|
||||
if sortOrder < 0 { beforeGames.append(row) }
|
||||
else { afterGames.append(row) }
|
||||
}
|
||||
flatItems.append(contentsOf: beforeGames)
|
||||
flatItems.append(.games(...))
|
||||
flatItems.append(contentsOf: afterGames)
|
||||
```
|
||||
|
||||
New flow:
|
||||
```swift
|
||||
// NEW: Pure sortOrder-based approach
|
||||
let gamesByDay = Dictionary(grouping: days.flatMap { ($0.games, $0.dayNumber) }) { ... }
|
||||
flatItems = ItineraryFlattener.flatten(
|
||||
days: days,
|
||||
itineraryItems: allItineraryItems,
|
||||
gamesByDay: gamesByDay
|
||||
)
|
||||
```
|
||||
|
||||
The refactored method should:
|
||||
1. Build `gamesByDay` dictionary: `[Int: [RichGame]]`
|
||||
2. Call `ItineraryFlattener.flatten()` to get `flatItems`
|
||||
3. Reload table view
|
||||
|
||||
Preserve:
|
||||
- `self.travelValidRanges = travelValidRanges`
|
||||
- `self.allItineraryItems = itineraryItems`
|
||||
- `self.tripDayCount = days.count`
|
||||
- `self.constraints = ItineraryConstraints(...)` rebuild
|
||||
- `tableView.reloadData()` call at end
|
||||
|
||||
Remove:
|
||||
- The manual flattening loop with beforeGames/afterGames buckets
|
||||
- Any remaining `travelBefore` references in the flatten logic
|
||||
</action>
|
||||
<verify>
|
||||
Build succeeds: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -5`
|
||||
</verify>
|
||||
<done>
|
||||
`reloadData()` method:
|
||||
- Uses `ItineraryFlattener.flatten()` instead of manual loop
|
||||
- No longer contains beforeGames/afterGames bucket logic
|
||||
- Still updates travelValidRanges, allItineraryItems, tripDayCount, constraints
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
1. Build succeeds with no errors
|
||||
2. `ItineraryFlattener.swift` exists in `SportsTime/Core/Models/Domain/`
|
||||
3. `reloadData()` in `ItineraryTableViewController.swift` calls `ItineraryFlattener.flatten()`
|
||||
4. No `beforeGames` or `afterGames` variables remain in `reloadData()`
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- FLAT-01: Items sorted by sortOrder within each day (implemented in ItineraryFlattener)
|
||||
- FLAT-03: sortOrder < 0 before games, >= 0 after (handled by natural sort + game sortOrder range 100-1540)
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/03-visual-flattening/03-01-SUMMARY.md`
|
||||
</output>
|
||||
@@ -1,101 +0,0 @@
|
||||
---
|
||||
phase: 03-visual-flattening
|
||||
plan: 01
|
||||
subsystem: ui
|
||||
tags: [uitableview, flattening, sortorder, swift]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 01-semantic-position-model
|
||||
provides: SortOrderProvider for game time → sortOrder conversion
|
||||
- phase: 02-constraint-validation
|
||||
provides: ItineraryConstraints for drag validation
|
||||
provides:
|
||||
- ItineraryFlattener pure utility for deterministic display ordering
|
||||
- Refactored reloadData() using sortOrder-based flattening
|
||||
affects: [03-02-flattening-tests, 04-drag-drop]
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns: [pure-function-for-flattening, sortorder-based-display-ordering]
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- SportsTime/Core/Models/Domain/ItineraryFlattener.swift
|
||||
modified:
|
||||
- SportsTime/Features/Trip/Views/ItineraryTableViewController.swift
|
||||
|
||||
key-decisions:
|
||||
- "Day headers are not positioned items - always first in each day"
|
||||
- "Games get sortOrder from SortOrderProvider.initialSortOrder(forGameTime:)"
|
||||
- "Travel sortOrder looked up from itineraryItems by city name matching"
|
||||
- "Pure flatten function enables unit testing without UITableView"
|
||||
|
||||
patterns-established:
|
||||
- "ItineraryFlattener.flatten(): Pure function for hierarchical to flat conversion"
|
||||
- "gamesByDay dictionary: Group games by day number before flattening"
|
||||
|
||||
# Metrics
|
||||
duration: 3min
|
||||
completed: 2026-01-18
|
||||
---
|
||||
|
||||
# Phase 3 Plan 1: Visual Flattening Summary
|
||||
|
||||
**Pure ItineraryFlattener utility replacing bucket-based flattening with sortOrder-based deterministic display ordering**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 3 min
|
||||
- **Started:** 2026-01-18T21:52:57Z
|
||||
- **Completed:** 2026-01-18T21:55:13Z
|
||||
- **Tasks:** 2
|
||||
- **Files modified:** 2
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- Created `ItineraryFlattener` enum with pure `flatten()` function
|
||||
- Replaced bucket-based flattening (beforeGames/afterGames) with sortOrder sort
|
||||
- Deterministic display order: same semantic state always produces same row order
|
||||
- Updated architecture documentation to reflect sortOrder-based approach
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically:
|
||||
|
||||
1. **Task 1: Create ItineraryFlattener utility** - `fdfc912` (feat)
|
||||
2. **Task 2: Refactor reloadData() to use ItineraryFlattener** - `dd1fd82` (refactor)
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `SportsTime/Core/Models/Domain/ItineraryFlattener.swift` - Pure flatten function with sortOrder-based ordering
|
||||
- `SportsTime/Features/Trip/Views/ItineraryTableViewController.swift` - Refactored reloadData() to use flattener
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- **Day headers not positioned:** Day headers are always first in each day, not part of the sortOrder-based ordering. This is a structural anchor.
|
||||
- **Flattener is a pure function:** No instance state, no side effects. Enables easy unit testing and guarantees determinism.
|
||||
- **gamesByDay dictionary:** Built in reloadData() to pass to flattener rather than having flattener access day.games directly.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None - plan executed exactly as written.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
None.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None - no external service configuration required.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- ItineraryFlattener ready for unit tests in plan 03-02
|
||||
- reloadData() now uses pure flattening, enabling snapshot testing
|
||||
- Phase 4 drag-drop can rely on deterministic flatten output
|
||||
|
||||
---
|
||||
*Phase: 03-visual-flattening*
|
||||
*Completed: 2026-01-18*
|
||||
@@ -1,183 +0,0 @@
|
||||
---
|
||||
phase: 03-visual-flattening
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["03-01"]
|
||||
files_modified:
|
||||
- SportsTimeTests/ItineraryFlattenerTests.swift
|
||||
autonomous: true
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Same semantic state produces identical row order on repeated flattening"
|
||||
- "Item with sortOrder -1.0 appears before all games in that day"
|
||||
- "Reordering an item and re-flattening preserves the new order"
|
||||
artifacts:
|
||||
- path: "SportsTimeTests/ItineraryFlattenerTests.swift"
|
||||
provides: "Determinism and ordering tests"
|
||||
min_lines: 80
|
||||
key_links:
|
||||
- from: "ItineraryFlattenerTests"
|
||||
to: "ItineraryFlattener.flatten()"
|
||||
via: "test assertions"
|
||||
pattern: "ItineraryFlattener\\.flatten"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Add tests verifying ItineraryFlattener produces deterministic, sortOrder-based output.
|
||||
|
||||
Purpose: Ensure the three success criteria from ROADMAP.md are verifiable through automated tests.
|
||||
|
||||
Output: New test file `ItineraryFlattenerTests.swift` with tests covering all success criteria.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@~/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/03-visual-flattening/03-RESEARCH.md
|
||||
@.planning/phases/03-visual-flattening/03-01-SUMMARY.md
|
||||
@SportsTime/Core/Models/Domain/ItineraryFlattener.swift
|
||||
@SportsTime/Core/Models/Domain/SortOrderProvider.swift
|
||||
@SportsTimeTests/ItineraryConstraintsTests.swift
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Create ItineraryFlattenerTests with determinism tests</name>
|
||||
<files>SportsTimeTests/ItineraryFlattenerTests.swift</files>
|
||||
<action>
|
||||
Create a new test file `ItineraryFlattenerTests.swift` using Swift Testing (`@Suite`, `@Test`).
|
||||
|
||||
Test suite structure:
|
||||
```swift
|
||||
import Testing
|
||||
@testable import SportsTime
|
||||
|
||||
@Suite("ItineraryFlattener")
|
||||
struct ItineraryFlattenerTests {
|
||||
// Test helper to create mock ItineraryDayData
|
||||
// Test helper to create mock RichGame
|
||||
// Test helper to create mock ItineraryItem
|
||||
}
|
||||
```
|
||||
|
||||
Required tests (map directly to ROADMAP success criteria):
|
||||
|
||||
1. **success_negativeSortOrderAppearsBeforeGames** (Success Criterion 1)
|
||||
- Create item with sortOrder = -1.0
|
||||
- Create games with sortOrder ~820 (noon game)
|
||||
- Flatten and verify custom item index < games index
|
||||
- Assert: "Item with sortOrder -1.0 appears before all games in that day's section"
|
||||
|
||||
2. **success_sameStateProducesIdenticalOrder** (Success Criterion 2)
|
||||
- Create fixed test data (2 days, games, travel, custom items)
|
||||
- Flatten twice
|
||||
- Compare resulting row IDs
|
||||
- Assert: arrays are identical (not just equal length)
|
||||
|
||||
3. **success_reorderPreservesNewOrder** (Success Criterion 3)
|
||||
- Create items with sortOrder 1.0, 2.0, 3.0
|
||||
- Simulate reorder: change item from 1.0 to 2.5
|
||||
- Flatten
|
||||
- Verify new order is [2.0, 2.5, 3.0] not [1.0, 2.0, 3.0]
|
||||
|
||||
Additional tests for comprehensive coverage:
|
||||
|
||||
4. **flatten_dayHeaderAlwaysFirst**
|
||||
- Verify first item for each day is `.dayHeader`
|
||||
|
||||
5. **flatten_sortsByDayThenSortOrder**
|
||||
- Items on day 1 appear before items on day 2
|
||||
- Within each day, items sorted by sortOrder
|
||||
|
||||
6. **flatten_gamesGetSortOrderFromTime**
|
||||
- Game at 8:00 PM (sortOrder ~1180) appears after item with sortOrder 500
|
||||
- Game at 1:00 PM (sortOrder ~880) appears before item with sortOrder 1000
|
||||
|
||||
7. **flatten_emptyDayHasOnlyHeader**
|
||||
- Day with no games, no items produces only `.dayHeader` row
|
||||
|
||||
8. **flatten_multipleGamesOnSameDay**
|
||||
- Day with 2 games: both included in single `.games` row
|
||||
- Custom items around games sort correctly
|
||||
|
||||
Test helpers needed:
|
||||
- `makeGame(id:, stadium:, dateTime:)` -> RichGame
|
||||
- `makeItem(day:, sortOrder:, kind:)` -> ItineraryItem
|
||||
- `makeDayData(dayNumber:, date:, games:, items:)` -> ItineraryDayData
|
||||
</action>
|
||||
<verify>
|
||||
Run tests: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/ItineraryFlattenerTests test 2>&1 | grep -E "(Test Suite|passed|failed|error:)"`
|
||||
</verify>
|
||||
<done>
|
||||
`ItineraryFlattenerTests.swift` exists with:
|
||||
- 3 success_* tests mapping to ROADMAP success criteria
|
||||
- 5+ additional tests for comprehensive coverage
|
||||
- All tests passing
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Verify existing behavior unchanged</name>
|
||||
<files>SportsTimeTests/ItineraryFlattenerTests.swift</files>
|
||||
<action>
|
||||
Add integration-style tests that verify the refactored flattening produces expected behavior for realistic trip data.
|
||||
|
||||
Additional tests:
|
||||
|
||||
1. **flatten_travelWithNegativeSortOrderAppearsBeforeGames**
|
||||
- Travel item with sortOrder = -5.0
|
||||
- Should appear before games in same day
|
||||
|
||||
2. **flatten_travelWithPositiveSortOrderAppearsAfterGames**
|
||||
- Travel item with sortOrder = 1500.0 (after noon game)
|
||||
- Should appear after games in same day
|
||||
|
||||
3. **flatten_mixedItemTypes**
|
||||
- Day with: travel (-5), custom (-1), games (820), custom (900), travel (1500)
|
||||
- Verify exact order: header, travel, custom, games, custom, travel
|
||||
|
||||
4. **flatten_multiDayTrip**
|
||||
- 3-day trip with different item configurations per day
|
||||
- Verify each day's items are independent and sorted correctly
|
||||
|
||||
Run full test suite to ensure no regressions:
|
||||
`xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' test`
|
||||
</action>
|
||||
<verify>
|
||||
Full test suite passes: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' test 2>&1 | grep -E "(Test Suite|passed|failed)" | tail -10`
|
||||
</verify>
|
||||
<done>
|
||||
- All ItineraryFlattenerTests passing (12+ tests)
|
||||
- Full test suite passes (no regressions)
|
||||
- Tests cover all three ROADMAP success criteria
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
1. All tests in `ItineraryFlattenerTests.swift` pass
|
||||
2. Tests explicitly named `success_*` map to ROADMAP success criteria:
|
||||
- success_negativeSortOrderAppearsBeforeGames -> Criterion 1
|
||||
- success_sameStateProducesIdenticalOrder -> Criterion 2
|
||||
- success_reorderPreservesNewOrder -> Criterion 3
|
||||
3. Full test suite passes (no regressions from refactoring)
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- FLAT-02: Flattening is deterministic and stateless (verified by success_sameStateProducesIdenticalOrder test)
|
||||
- All three ROADMAP success criteria have corresponding tests
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/03-visual-flattening/03-02-SUMMARY.md`
|
||||
</output>
|
||||
@@ -1,126 +0,0 @@
|
||||
---
|
||||
phase: 03-visual-flattening
|
||||
plan: 02
|
||||
subsystem: testing
|
||||
tags: [swift-testing, itinerary, flattening, determinism]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 03-visual-flattening
|
||||
plan: 01
|
||||
provides: ItineraryFlattener.flatten() pure function
|
||||
provides:
|
||||
- 13 tests verifying ItineraryFlattener determinism
|
||||
- 3 success criteria tests mapping to ROADMAP requirements
|
||||
affects: [04-drag-drop]
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns: [success-criteria-test-naming, integration-style-testing]
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- SportsTimeTests/Domain/ItineraryFlattenerTests.swift
|
||||
modified: []
|
||||
|
||||
key-decisions:
|
||||
- "success_* prefix for tests mapping to ROADMAP success criteria"
|
||||
- "Integration tests use realistic multi-day trip configurations"
|
||||
- "Test helpers create minimal mock data matching production types"
|
||||
|
||||
patterns-established:
|
||||
- "success_negativeSortOrderAppearsBeforeGames: Tests negative sortOrder positioning"
|
||||
- "success_sameStateProducesIdenticalOrder: Tests determinism guarantee"
|
||||
- "success_reorderPreservesNewOrder: Tests persistence after sortOrder change"
|
||||
|
||||
# Metrics
|
||||
duration: 15min
|
||||
completed: 2026-01-18
|
||||
---
|
||||
|
||||
# Phase 3 Plan 2: Flattening Tests Summary
|
||||
|
||||
**13 tests verifying ItineraryFlattener produces deterministic, sortOrder-based output with explicit ROADMAP success criteria coverage**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 15 min
|
||||
- **Started:** 2026-01-18T21:59:19Z
|
||||
- **Completed:** 2026-01-18T22:13:59Z
|
||||
- **Tasks:** 2
|
||||
- **Tests added:** 13
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- Created `ItineraryFlattenerTests.swift` with comprehensive test coverage
|
||||
- 3 success criteria tests mapping directly to ROADMAP requirements
|
||||
- 6 comprehensive coverage tests for edge cases
|
||||
- 4 integration tests for realistic trip data configurations
|
||||
- All tests pass, full test suite confirms no regressions
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically:
|
||||
|
||||
1. **Task 1: Create ItineraryFlattenerTests** - `378e0ad` (test)
|
||||
2. **Task 2: Add integration tests** - `724b9f8` (test)
|
||||
|
||||
## Files Created
|
||||
|
||||
- `SportsTimeTests/Domain/ItineraryFlattenerTests.swift` - 765 lines, 13 tests
|
||||
|
||||
## Success Criteria Verification
|
||||
|
||||
The three ROADMAP success criteria now have explicit tests:
|
||||
|
||||
| Success Criterion | Test Name |
|
||||
|-------------------|-----------|
|
||||
| Negative sortOrder before games | `success_negativeSortOrderAppearsBeforeGames` |
|
||||
| Deterministic flattening | `success_sameStateProducesIdenticalOrder` |
|
||||
| Reorder preserves position | `success_reorderPreservesNewOrder` |
|
||||
|
||||
## Test Coverage
|
||||
|
||||
### Success Criteria Tests (3)
|
||||
- `success_negativeSortOrderAppearsBeforeGames` - Item with sortOrder -1.0 appears before noon game
|
||||
- `success_sameStateProducesIdenticalOrder` - Repeated flatten produces identical row IDs
|
||||
- `success_reorderPreservesNewOrder` - Changed sortOrder results in new order after reflatten
|
||||
|
||||
### Comprehensive Coverage Tests (6)
|
||||
- `flatten_dayHeaderAlwaysFirst` - Day header is structural anchor
|
||||
- `flatten_sortsByDayThenSortOrder` - Items grouped by day, sorted within
|
||||
- `flatten_gamesGetSortOrderFromTime` - 8pm game after item with sortOrder 500
|
||||
- `flatten_gamesGetSortOrderFromTime_earlyGame` - 1pm game before item with sortOrder 1000
|
||||
- `flatten_emptyDayHasOnlyHeader` - Rest day produces only header row
|
||||
- `flatten_multipleGamesOnSameDay` - Multiple games in single games row
|
||||
|
||||
### Integration Tests (4)
|
||||
- `flatten_travelWithNegativeSortOrderAppearsBeforeGames` - Travel at -5.0 before games
|
||||
- `flatten_travelWithPositiveSortOrderAppearsAfterGames` - Travel at 1500.0 after games
|
||||
- `flatten_mixedItemTypes` - Complex day with exact order verification
|
||||
- `flatten_multiDayTrip` - 3-day trip with different configurations per day
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None - plan executed exactly as written.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
None.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None - no external service configuration required.
|
||||
|
||||
## Phase 3 Complete
|
||||
|
||||
Phase 3 (Visual Flattening) is now complete:
|
||||
- Plan 1: Created ItineraryFlattener pure utility
|
||||
- Plan 2: Added comprehensive test coverage
|
||||
|
||||
Phase 4 (Drag-Drop) can now build on the verified, deterministic flattening behavior.
|
||||
|
||||
---
|
||||
*Phase: 03-visual-flattening*
|
||||
*Completed: 2026-01-18*
|
||||
@@ -1,65 +0,0 @@
|
||||
# Phase 3: Visual Flattening - Context
|
||||
|
||||
**Gathered:** 2026-01-18
|
||||
**Status:** Ready for planning
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
Transform semantic items (games, travel, custom) into display rows, sorted deterministically by sortOrder within each day. This phase provides the row-to-semantic translation needed for Phase 4 drag interaction.
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### Sort boundaries
|
||||
- sortOrder = 0 is the first game position; negatives appear before games, positives appear after/between
|
||||
- Purely sequential sorting within a day — no time-of-day grouping (morning/afternoon/evening)
|
||||
- No visual indicator at the boundary between pre-game items and games
|
||||
- Items just flow in sortOrder order; games are distinguished by their visual style
|
||||
|
||||
### Section structure
|
||||
- Day headers display day number + date (e.g., "Day 1 - Jan 15" or "Day 1 (Wed, Jan 15)")
|
||||
- Headers scroll with content — not sticky
|
||||
- Empty days show the day header with no rows beneath (don't skip empty days)
|
||||
|
||||
### Item differentiation
|
||||
- **Games:** Sport-colored accent (left border or icon matching sport: MLB=red, NBA=orange, NHL=blue)
|
||||
- **Travel segments:** Subtle/muted style — gray text, smaller row, less prominent than games
|
||||
- **Custom items:** Note icon + text indicating user-added item
|
||||
- **Drag affordance:** Drag handle icon (three-line grip) on draggable items
|
||||
|
||||
### Edge case handling
|
||||
- Ties shouldn't occur — system always assigns unique sortOrder when positioning items
|
||||
- No special visual treatment for items at first/last position in a day
|
||||
- Days with only games can still receive drops (between games, subject to Phase 2 constraints)
|
||||
- Flattening is a pure function — stateless, same input always produces same output
|
||||
|
||||
### Claude's Discretion
|
||||
- Whether to use UITableView sections or inline day headers (based on drag-drop requirements)
|
||||
- Whether travel/custom items can use sortOrder in the 100-1540 range (based on Phase 2 constraint logic)
|
||||
- Exact styling details (spacing, typography, border thickness)
|
||||
- Tiebreaker for identical sortOrder (by item type priority: games > travel > custom) if it ever occurs despite unique assignment
|
||||
|
||||
</decisions>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- "New items added to specific positions should get unique sortOrder values" — the system should proactively avoid ties rather than handling them after the fact
|
||||
- Sport colors should match existing SportsTime app patterns for consistency
|
||||
|
||||
</specifics>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
None — discussion stayed within phase scope
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 03-visual-flattening*
|
||||
*Context gathered: 2026-01-18*
|
||||
@@ -1,537 +0,0 @@
|
||||
# Phase 3: Visual Flattening - Research
|
||||
|
||||
**Researched:** 2026-01-18
|
||||
**Domain:** UITableView data flattening, sortOrder-based display ordering
|
||||
**Confidence:** HIGH
|
||||
|
||||
## Summary
|
||||
|
||||
This research reveals that visual flattening is **already implemented** in `ItineraryTableViewController.reloadData()`. The existing implementation flattens hierarchical `[ItineraryDayData]` into a linear `[ItineraryRowItem]` array for UITableView display. However, the current implementation has a critical architectural issue: it uses a **hard-coded flatten order** (travel, header, before-games items, games, after-games items) rather than purely sorting by sortOrder.
|
||||
|
||||
Phase 3's goal is to ensure that flattening is **deterministic and stateless** - the same semantic state (items with their day + sortOrder) should always produce identical row order. The CONTEXT.md decisions confirm: "Items just flow in sortOrder order; games are distinguished by their visual style" and "sortOrder < 0 renders ABOVE games; sortOrder >= 0 renders BELOW games."
|
||||
|
||||
The existing `ItineraryRowItem` enum and day-based section structure are sound. The work needed is to refactor the flattening logic to use sortOrder as the primary sort key within each day, making the flatten algorithm a pure function of semantic state.
|
||||
|
||||
**Primary recommendation:** Refactor `reloadData()` to sort all items within a day by sortOrder. Extract flattening to a pure function for testability. Add snapshot tests to verify determinism.
|
||||
|
||||
## Codebase Analysis
|
||||
|
||||
### Key Files
|
||||
|
||||
| File | Purpose | Status |
|
||||
|------|---------|--------|
|
||||
| `ItineraryTableViewController.swift` | UITableView + flattening logic | Contains current implementation |
|
||||
| `ItineraryTableViewWrapper.swift` | SwiftUI bridge, builds ItineraryDayData | Passes data to controller |
|
||||
| `ItineraryItem.swift` | Domain model with (day, sortOrder) | Complete (Phase 1) |
|
||||
| `SortOrderProvider.swift` | sortOrder utilities | Complete (Phase 1) |
|
||||
| `ItineraryConstraints.swift` | Position validation | Complete (Phase 2) |
|
||||
|
||||
### Existing Flatten Implementation
|
||||
|
||||
The current implementation in `ItineraryTableViewController.reloadData()` (lines 484-545):
|
||||
|
||||
```swift
|
||||
// Source: ItineraryTableViewController.swift
|
||||
func reloadData(
|
||||
days: [ItineraryDayData],
|
||||
travelValidRanges: [String: ClosedRange<Int>],
|
||||
itineraryItems: [ItineraryItem] = []
|
||||
) {
|
||||
// ...
|
||||
flatItems = []
|
||||
|
||||
for day in days {
|
||||
// 1. Travel that arrives on this day (renders BEFORE the day header)
|
||||
if let travel = day.travelBefore {
|
||||
flatItems.append(.travel(travel, dayNumber: day.dayNumber))
|
||||
}
|
||||
|
||||
// 2. Day header with Add button
|
||||
flatItems.append(.dayHeader(dayNumber: day.dayNumber, date: day.date))
|
||||
|
||||
// 3. Movable items split around games boundary
|
||||
var beforeGames: [ItineraryRowItem] = []
|
||||
var afterGames: [ItineraryRowItem] = []
|
||||
|
||||
for row in day.items {
|
||||
let so = /* get sortOrder */
|
||||
if sortOrder < 0 {
|
||||
beforeGames.append(row)
|
||||
} else {
|
||||
afterGames.append(row)
|
||||
}
|
||||
}
|
||||
|
||||
flatItems.append(contentsOf: beforeGames)
|
||||
|
||||
// 4. Games for this day
|
||||
if !day.games.isEmpty {
|
||||
flatItems.append(.games(day.games, dayNumber: day.dayNumber))
|
||||
}
|
||||
|
||||
flatItems.append(contentsOf: afterGames)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Issues with Current Implementation
|
||||
|
||||
1. **Travel handled specially** - `travelBefore` is a separate property, not a positioned item
|
||||
2. **Hard-coded structure** - Header always first, games always between before/after sections
|
||||
3. **Not purely sortOrder-driven** - Items are split into buckets by sign, then appended in bucket order
|
||||
4. **Data split across layers** - `ItineraryDayData.items` vs `ItineraryDayData.travelBefore`
|
||||
|
||||
### Target Architecture per CONTEXT.md
|
||||
|
||||
From CONTEXT.md decisions:
|
||||
- "sortOrder = 0 is the first game position; negatives appear before games, positives appear after/between"
|
||||
- "Purely sequential sorting within a day - no time-of-day grouping"
|
||||
- "Items just flow in sortOrder order; games are distinguished by their visual style"
|
||||
- "Flattening is a pure function - stateless, same input always produces same output"
|
||||
|
||||
## Standard Stack
|
||||
|
||||
### Core
|
||||
| Library | Version | Purpose | Why Standard |
|
||||
|---------|---------|---------|--------------|
|
||||
| Swift `sorted(by:)` | Swift stdlib | Sorting items | Stable sort, predictable |
|
||||
| Foundation `Double` | Swift stdlib | sortOrder comparison | Already established in Phase 1 |
|
||||
|
||||
### Supporting
|
||||
| Library | Version | Purpose | When to Use |
|
||||
|---------|---------|---------|-------------|
|
||||
| Swift Testing | Swift 5.10+ | Snapshot comparison tests | Verifying determinism |
|
||||
|
||||
### Alternatives Considered
|
||||
| Instead of | Could Use | Tradeoff |
|
||||
|------------|-----------|----------|
|
||||
| In-place flattening | Separate flatten function | Function is more testable; choose function |
|
||||
| UITableView sections | Single section with inline headers | Drag-drop across sections is complex; choose single section |
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### Pattern 1: Pure Flatten Function
|
||||
|
||||
**What:** Extract flattening logic to a pure function that takes semantic items and returns display rows
|
||||
**When to use:** All flattening operations - enables unit testing, ensures determinism
|
||||
**Example:**
|
||||
```swift
|
||||
// Source: Recommended implementation
|
||||
enum ItineraryFlattener {
|
||||
/// Flatten semantic items into display rows, sorted by (day, sortOrder)
|
||||
///
|
||||
/// For each day:
|
||||
/// 1. Day header row (always first)
|
||||
/// 2. All items sorted by sortOrder
|
||||
/// - sortOrder < 0: before games (travel, custom)
|
||||
/// - sortOrder 100-1540: games (fixed by schedule)
|
||||
/// - sortOrder > game sortOrder: after games (travel, custom)
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - days: Day metadata (number, date)
|
||||
/// - items: All ItineraryItem models with (day, sortOrder)
|
||||
/// - games: RichGame data indexed by day
|
||||
/// - Returns: Ordered array of display rows
|
||||
static func flatten(
|
||||
days: [(dayNumber: Int, date: Date)],
|
||||
items: [ItineraryItem],
|
||||
gamesByDay: [Int: [RichGame]]
|
||||
) -> [ItineraryRowItem] {
|
||||
var result: [ItineraryRowItem] = []
|
||||
|
||||
for day in days {
|
||||
// Day header always first
|
||||
result.append(.dayHeader(dayNumber: day.dayNumber, date: day.date))
|
||||
|
||||
// Collect all items for this day
|
||||
var dayItems: [(sortOrder: Double, row: ItineraryRowItem)] = []
|
||||
|
||||
// Add games (get sortOrder from game time)
|
||||
if let games = gamesByDay[day.dayNumber], !games.isEmpty {
|
||||
let gameSortOrder = SortOrderProvider.initialSortOrder(
|
||||
forGameTime: games.first!.game.dateTime
|
||||
)
|
||||
dayItems.append((gameSortOrder, .games(games, dayNumber: day.dayNumber)))
|
||||
}
|
||||
|
||||
// Add travel and custom items
|
||||
for item in items where item.day == day.dayNumber {
|
||||
switch item.kind {
|
||||
case .travel(let info):
|
||||
if let segment = /* find matching TravelSegment */ {
|
||||
dayItems.append((item.sortOrder, .travel(segment, dayNumber: day.dayNumber)))
|
||||
}
|
||||
case .custom:
|
||||
dayItems.append((item.sortOrder, .customItem(item)))
|
||||
case .game:
|
||||
// Games handled separately above
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by sortOrder and append
|
||||
dayItems.sort { $0.sortOrder < $1.sortOrder }
|
||||
result.append(contentsOf: dayItems.map(\.row))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 2: Games as Positioned Items
|
||||
|
||||
**What:** Games have sortOrder derived from game time (100 + minutes since midnight)
|
||||
**When to use:** All flattening - games are anchors, not special cases
|
||||
**Example:**
|
||||
```swift
|
||||
// Source: SortOrderProvider.swift (existing)
|
||||
static func initialSortOrder(forGameTime gameTime: Date) -> Double {
|
||||
let calendar = Calendar.current
|
||||
let components = calendar.dateComponents([.hour, .minute], from: gameTime)
|
||||
let minutesSinceMidnight = (components.hour ?? 0) * 60 + (components.minute ?? 0)
|
||||
return 100.0 + Double(minutesSinceMidnight) // Range: 100-1540
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 3: Travel as Positioned Item (Not Day Property)
|
||||
|
||||
**What:** Travel segments stored as ItineraryItem with (day, sortOrder), not `travelBefore` property
|
||||
**When to use:** All travel handling - unified positioning model
|
||||
**Example:**
|
||||
```swift
|
||||
// Instead of: ItineraryDayData.travelBefore
|
||||
// Use: ItineraryItem with kind: .travel(TravelInfo) and its own sortOrder
|
||||
|
||||
// Travel before games: sortOrder < 100 (e.g., 50.0)
|
||||
// Travel between games: sortOrder between game sortOrders
|
||||
// Travel after games: sortOrder > last game sortOrder
|
||||
```
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
|
||||
- **Bucket-then-append:** Don't split items into "before games" and "after games" buckets. Sort by sortOrder directly.
|
||||
- **Special-case travel:** Travel is just an item with sortOrder. Don't use `travelBefore` day property.
|
||||
- **Hard-coded order:** Don't assume "header, travel, games, custom" structure. Let sortOrder determine order.
|
||||
- **Row-index based testing:** Test with sortOrder values, not "item at index 2."
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't Build | Use Instead | Why |
|
||||
|---------|-------------|-------------|-----|
|
||||
| Sort stability | Custom stable sort | Swift `sorted(by:)` | Already stable since Swift 5 |
|
||||
| Day grouping | Manual loop with counters | `Dictionary(grouping:by:)` | Built-in, cleaner |
|
||||
| Determinism verification | Manual comparison | Snapshot test with Codable | Existing test patterns |
|
||||
| TravelSegment lookup | Iteration every time | Dictionary keyed by cities | O(1) lookup |
|
||||
|
||||
**Key insight:** The flatten algorithm itself is simple (sort by sortOrder within each day). The complexity is in data transformation (ItineraryDayData -> unified items) which the wrapper already handles.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: Games Getting Wrong sortOrder
|
||||
|
||||
**What goes wrong:** Games don't sort correctly relative to travel/custom items
|
||||
**Why it happens:** Games treated as special case instead of having their own sortOrder
|
||||
**How to avoid:** Always derive game sortOrder from game time using `SortOrderProvider.initialSortOrder(forGameTime:)`
|
||||
**Warning signs:** Games appear at wrong position; travel appears after games when it should be before
|
||||
|
||||
### Pitfall 2: Sort Instability on Equal sortOrder
|
||||
|
||||
**What goes wrong:** Items with same sortOrder appear in different order across reloads
|
||||
**Why it happens:** Swift sort is stable but order depends on input order for equal keys
|
||||
**How to avoid:**
|
||||
1. Per CONTEXT.md: "Ties shouldn't occur - system always assigns unique sortOrder when positioning"
|
||||
2. Tiebreaker per CONTEXT.md: "by item type priority: games > travel > custom" if needed
|
||||
**Warning signs:** Same semantic state produces different visual order
|
||||
|
||||
### Pitfall 3: Day Header Position Assumption
|
||||
|
||||
**What goes wrong:** Items placed "before" day header
|
||||
**Why it happens:** sortOrder < 0 for "before games" doesn't mean "before day header"
|
||||
**How to avoid:** Day header is always first row of each day, not a positioned item
|
||||
**Warning signs:** Travel or custom items appearing above day header
|
||||
|
||||
### Pitfall 4: Reload Loses Position
|
||||
|
||||
**What goes wrong:** Item positions revert after data reload
|
||||
**Why it happens:** Flattening not using persisted sortOrder, instead using default/computed values
|
||||
**How to avoid:** Always use ItineraryItem.sortOrder from storage, never recompute for existing items
|
||||
**Warning signs:** User moves item, reload happens, item snaps back to original position
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Current Data Flow
|
||||
|
||||
```swift
|
||||
// Source: ItineraryTableViewWrapper.buildItineraryData()
|
||||
// 1. Calculate trip days
|
||||
let tripDays = calculateTripDays()
|
||||
|
||||
// 2. Build travel items with (day, sortOrder)
|
||||
for segment in trip.travelSegments {
|
||||
// ... calculate valid range, placement
|
||||
let travelItem = ItineraryItem(
|
||||
tripId: trip.id,
|
||||
day: placement.day,
|
||||
sortOrder: placement.sortOrder,
|
||||
kind: .travel(TravelInfo(...))
|
||||
)
|
||||
travelItems.append(travelItem)
|
||||
}
|
||||
|
||||
// 3. Build day data with custom items and travel rows
|
||||
for dayDate in tripDays {
|
||||
let dayNum = index + 1
|
||||
var rows: [ItineraryRowItem] = []
|
||||
|
||||
// Custom items
|
||||
let customItemsForDay = itineraryItems
|
||||
.filter { $0.day == dayNum && $0.isCustom }
|
||||
.sorted { $0.sortOrder < $1.sortOrder }
|
||||
for item in customItemsForDay {
|
||||
rows.append(.customItem(item))
|
||||
}
|
||||
|
||||
// Travel items
|
||||
for travel in travelItems.filter({ $0.day == dayNum }) {
|
||||
// ... convert to row
|
||||
}
|
||||
|
||||
// Sort by sortOrder
|
||||
rows.sort { /* by sortOrder */ }
|
||||
|
||||
days.append(ItineraryDayData(
|
||||
dayNumber: dayNum,
|
||||
date: dayDate,
|
||||
games: gamesOnDay,
|
||||
items: rows,
|
||||
travelBefore: nil // Currently unused
|
||||
))
|
||||
}
|
||||
```
|
||||
|
||||
### Recommended Flatten Logic
|
||||
|
||||
```swift
|
||||
// Source: Recommended refactoring
|
||||
extension ItineraryTableViewController {
|
||||
/// Flatten day data into display rows
|
||||
/// Pure function: same input always produces same output
|
||||
private func flattenToRows(days: [ItineraryDayData]) -> [ItineraryRowItem] {
|
||||
var result: [ItineraryRowItem] = []
|
||||
|
||||
for day in days {
|
||||
// 1. Day header (always first in day)
|
||||
result.append(.dayHeader(dayNumber: day.dayNumber, date: day.date))
|
||||
|
||||
// 2. Collect all positionable items with sortOrder
|
||||
var positioned: [(sortOrder: Double, row: ItineraryRowItem)] = []
|
||||
|
||||
// Games get sortOrder from first game time
|
||||
if !day.games.isEmpty {
|
||||
let gameSortOrder = SortOrderProvider.initialSortOrder(
|
||||
forGameTime: day.games.first!.game.dateTime
|
||||
)
|
||||
positioned.append((gameSortOrder, .games(day.games, dayNumber: day.dayNumber)))
|
||||
}
|
||||
|
||||
// Other items already have sortOrder
|
||||
for row in day.items {
|
||||
let sortOrder: Double
|
||||
switch row {
|
||||
case .customItem(let item):
|
||||
sortOrder = item.sortOrder
|
||||
case .travel(let segment, _):
|
||||
sortOrder = findItineraryItem(for: segment)?.sortOrder ?? 0.0
|
||||
default:
|
||||
continue // Skip non-positionable items
|
||||
}
|
||||
positioned.append((sortOrder, row))
|
||||
}
|
||||
|
||||
// 3. Sort by sortOrder (stable sort)
|
||||
positioned.sort { $0.sortOrder < $1.sortOrder }
|
||||
|
||||
// 4. Append in sorted order
|
||||
result.append(contentsOf: positioned.map(\.row))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Determinism Test Pattern
|
||||
|
||||
```swift
|
||||
// Source: Recommended test approach
|
||||
@Suite("ItineraryFlattener")
|
||||
struct ItineraryFlattenerTests {
|
||||
|
||||
@Test("same state produces identical row order")
|
||||
func flatten_sameState_producesIdenticalOrder() {
|
||||
let items = makeTestItems() // Fixed test data
|
||||
|
||||
// Flatten twice
|
||||
let result1 = ItineraryFlattener.flatten(days: days, items: items, gamesByDay: games)
|
||||
let result2 = ItineraryFlattener.flatten(days: days, items: items, gamesByDay: games)
|
||||
|
||||
// Compare IDs (stable identifiers)
|
||||
let ids1 = result1.map(\.id)
|
||||
let ids2 = result2.map(\.id)
|
||||
|
||||
#expect(ids1 == ids2)
|
||||
}
|
||||
|
||||
@Test("item with sortOrder -1.0 appears before games")
|
||||
func flatten_negativeSortOrder_appearsBeforeGames() {
|
||||
let tripId = UUID()
|
||||
let customItem = ItineraryItem(
|
||||
tripId: tripId,
|
||||
day: 1,
|
||||
sortOrder: -1.0,
|
||||
kind: .custom(CustomInfo(title: "Pre-game snack", icon: "fork.knife"))
|
||||
)
|
||||
|
||||
let result = ItineraryFlattener.flatten(
|
||||
days: [(dayNumber: 1, date: Date())],
|
||||
items: [customItem],
|
||||
gamesByDay: [1: testGames] // Games have sortOrder ~820 (noon)
|
||||
)
|
||||
|
||||
// Find positions
|
||||
let customIndex = result.firstIndex { $0.id.contains("item:") }!
|
||||
let gamesIndex = result.firstIndex { $0.id.starts(with: "games:") }!
|
||||
|
||||
#expect(customIndex < gamesIndex)
|
||||
}
|
||||
|
||||
@Test("reorder then reflatten preserves new order")
|
||||
func flatten_afterReorder_preservesNewOrder() {
|
||||
var items = makeTestItems()
|
||||
|
||||
// Simulate reorder: move item from sortOrder 1.0 to between 2.0 and 3.0
|
||||
if let idx = items.firstIndex(where: { $0.sortOrder == 1.0 }) {
|
||||
items[idx].sortOrder = 2.5
|
||||
}
|
||||
|
||||
// Flatten
|
||||
let result = ItineraryFlattener.flatten(days: days, items: items, gamesByDay: games)
|
||||
|
||||
// Verify order reflects new sortOrder
|
||||
let customIndices = result.enumerated()
|
||||
.filter { $0.element.id.contains("item:") }
|
||||
.map { $0.offset }
|
||||
|
||||
// Items should now be in order: 2.0, 2.5, 3.0
|
||||
// (not reverted to original 1.0, 2.0, 3.0)
|
||||
let sortOrders = customIndices.compactMap { idx -> Double? in
|
||||
if case .customItem(let item) = result[idx] {
|
||||
return item.sortOrder
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
#expect(sortOrders == [2.0, 2.5, 3.0])
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old Approach | Current Approach | When Changed | Impact |
|
||||
|--------------|------------------|--------------|--------|
|
||||
| Hard-coded order | sortOrder-based sorting | Phase 3 | Deterministic flattening |
|
||||
| Travel as day property | Travel as ItineraryItem | Wrapper already does this | Unified positioning |
|
||||
| Games as special case | Games with derived sortOrder | Phase 1 established convention | Consistent ordering |
|
||||
|
||||
**Deprecated/outdated:**
|
||||
- `ItineraryDayData.travelBefore` property (should be removed)
|
||||
- Bucket-based flatten (beforeGames / afterGames arrays)
|
||||
|
||||
## Open Questions
|
||||
|
||||
### Resolved by Research
|
||||
|
||||
1. **Where does flattening live?**
|
||||
- Answer: `ItineraryTableViewController.reloadData()` (current), should extract to `ItineraryFlattener` utility
|
||||
- Confidence: HIGH
|
||||
|
||||
2. **How do games get sortOrder?**
|
||||
- Answer: `SortOrderProvider.initialSortOrder(forGameTime:)` - already established in Phase 1
|
||||
- Range: 100-1540 (minutes since midnight + 100)
|
||||
- Confidence: HIGH
|
||||
|
||||
3. **What's the day header position?**
|
||||
- Answer: Always first in each day's section, not a positioned item
|
||||
- From CONTEXT.md: "Day headers display day number + date... Headers scroll with content - not sticky"
|
||||
- Confidence: HIGH
|
||||
|
||||
### Claude's Discretion per CONTEXT.md
|
||||
|
||||
1. **UITableView sections vs inline headers?**
|
||||
- Current: Single section with inline day headers as rows
|
||||
- Recommendation: Keep single section (simpler drag-drop)
|
||||
|
||||
2. **Travel/custom in 100-1540 range?**
|
||||
- Convention: sortOrder < 100 for "before all games", sortOrder > max game for "after all games"
|
||||
- Between games: use midpoint between adjacent game sortOrders
|
||||
|
||||
3. **Tiebreaker for identical sortOrder?**
|
||||
- Per CONTEXT.md: "by item type priority: games > travel > custom"
|
||||
- Implementation: Add secondary sort key if needed
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- `SportsTime/Features/Trip/Views/ItineraryTableViewController.swift` - Current flatten implementation (lines 484-545)
|
||||
- `SportsTime/Features/Trip/Views/ItineraryTableViewWrapper.swift` - Data preparation layer
|
||||
- `.planning/phases/03-visual-flattening/03-CONTEXT.md` - User decisions
|
||||
- `.planning/phases/01-semantic-position-model/01-RESEARCH.md` - sortOrder conventions
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- Phase 1 implementation: `SortOrderProvider.swift`, `ItineraryItem.swift`
|
||||
- Phase 2 implementation: `ItineraryConstraints.swift`
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- Current implementation: HIGH - Full code review completed
|
||||
- Refactoring approach: HIGH - Clear path from current to target
|
||||
- Test strategy: HIGH - Determinism tests are straightforward
|
||||
|
||||
**Research date:** 2026-01-18
|
||||
**Valid until:** Indefinite (architectural pattern, not framework-dependent)
|
||||
|
||||
## Recommendations for Planning
|
||||
|
||||
### Phase 3 Scope
|
||||
|
||||
1. **Extract flatten logic to pure function**
|
||||
- Move from `reloadData()` inline to `ItineraryFlattener.flatten()`
|
||||
- Enable unit testing without UITableView
|
||||
|
||||
2. **Remove bucket-based flattening**
|
||||
- Replace beforeGames/afterGames arrays with single sorted collection
|
||||
- Sort all items by sortOrder directly
|
||||
|
||||
3. **Add determinism tests**
|
||||
- Test: same input -> same output
|
||||
- Test: sortOrder -1.0 appears before games
|
||||
- Test: reorder + reflatten preserves new order
|
||||
|
||||
4. **Clean up travelBefore property**
|
||||
- Verify wrapper already handles travel as positioned items
|
||||
- Remove unused `travelBefore` from `ItineraryDayData` if confirmed
|
||||
|
||||
### What NOT to Build
|
||||
|
||||
- New sortOrder calculation logic (Phase 1 complete)
|
||||
- New constraint validation (Phase 2 complete)
|
||||
- Drag-drop interaction (Phase 4)
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Phase 1: sortOrder conventions (complete)
|
||||
- Phase 2: constraint validation (complete)
|
||||
- Phase 4: will consume flatten output for drag-drop
|
||||
@@ -1,85 +0,0 @@
|
||||
---
|
||||
phase: 03-visual-flattening
|
||||
verified: 2026-01-18T22:30:00Z
|
||||
status: passed
|
||||
score: 7/7 must-haves verified
|
||||
re_verification: false
|
||||
---
|
||||
|
||||
# Phase 3: Visual Flattening Verification Report
|
||||
|
||||
**Phase Goal:** Semantic items flatten to display rows deterministically, sorted by sortOrder.
|
||||
**Verified:** 2026-01-18T22:30:00Z
|
||||
**Status:** passed
|
||||
**Re-verification:** No - initial verification
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### Observable Truths
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|----------|
|
||||
| 1 | Items with lower sortOrder appear before items with higher sortOrder | VERIFIED | ItineraryFlattener.swift:93 sorts by sortOrder ascending |
|
||||
| 2 | Items with sortOrder < 0 appear before games | VERIFIED | Test `success_negativeSortOrderAppearsBeforeGames` passes |
|
||||
| 3 | Items with sortOrder >= 0 appear after or between games | VERIFIED | Tests `flatten_gamesGetSortOrderFromTime*` pass; games have sortOrder 100+minutes |
|
||||
| 4 | Day headers always appear first in each day's section | VERIFIED | Test `flatten_dayHeaderAlwaysFirst` passes; ItineraryFlattener.swift:58 appends header first |
|
||||
| 5 | Same semantic state produces identical row order | VERIFIED | Test `success_sameStateProducesIdenticalOrder` passes |
|
||||
| 6 | Item with sortOrder -1.0 appears before all games | VERIFIED | Test `success_negativeSortOrderAppearsBeforeGames` explicitly tests this |
|
||||
| 7 | Reordering an item and re-flattening preserves new order | VERIFIED | Test `success_reorderPreservesNewOrder` passes |
|
||||
|
||||
**Score:** 7/7 truths verified
|
||||
|
||||
### Required Artifacts
|
||||
|
||||
| Artifact | Expected | Status | Details |
|
||||
|----------|----------|--------|---------|
|
||||
| `SportsTime/Core/Models/Domain/ItineraryFlattener.swift` | Pure flatten function | VERIFIED | 124 lines, exports `flatten(days:itineraryItems:gamesByDay:)`, no stubs |
|
||||
| `SportsTime/Features/Trip/Views/ItineraryTableViewController.swift` | Refactored reloadData() using ItineraryFlattener | VERIFIED | Line 444 calls `ItineraryFlattener.flatten()`, no beforeGames/afterGames buckets |
|
||||
| `SportsTimeTests/Domain/ItineraryFlattenerTests.swift` | Determinism and ordering tests | VERIFIED | 765 lines, 13 tests, all pass |
|
||||
|
||||
### Key Link Verification
|
||||
|
||||
| From | To | Via | Status | Details |
|
||||
|------|----|-----|--------|---------|
|
||||
| `ItineraryTableViewController.reloadData()` | `ItineraryFlattener.flatten()` | function call | WIRED | Line 444 calls flattener with days, itineraryItems, gamesByDay |
|
||||
| `ItineraryFlattenerTests` | `ItineraryFlattener.flatten()` | test assertions | WIRED | 13 tests call flatten and verify output |
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
| Requirement | Status | Evidence |
|
||||
|-------------|--------|----------|
|
||||
| FLAT-01: Visual flattening sorts by sortOrder within each day | SATISFIED | `positioned.sort { $0.sortOrder < $1.sortOrder }` at line 93 |
|
||||
| FLAT-02: Flattening is deterministic and stateless | SATISFIED | Pure function enum with no instance state; test confirms identical output |
|
||||
| FLAT-03: sortOrder < 0 for "before games", >= 0 for "after/between games" | SATISFIED | Games get sortOrder 100+minutes; negative sortOrder items sort before |
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
| File | Line | Pattern | Severity | Impact |
|
||||
|------|------|---------|----------|--------|
|
||||
| None | - | - | - | No anti-patterns detected |
|
||||
|
||||
Scanned files:
|
||||
- `ItineraryFlattener.swift`: No TODO/FIXME/placeholder/stub patterns
|
||||
- `ItineraryTableViewController.swift`: No beforeGames/afterGames bucket logic remains
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
None - all phase goals are verifiable programmatically through:
|
||||
1. Code inspection (sortOrder-based sorting logic)
|
||||
2. Automated tests (13 passing tests covering all success criteria)
|
||||
|
||||
### Verification Summary
|
||||
|
||||
Phase 3 goal fully achieved:
|
||||
|
||||
1. **ItineraryFlattener utility created** - Pure function that transforms hierarchical day data into flat display rows sorted by sortOrder
|
||||
2. **reloadData() refactored** - Now uses `ItineraryFlattener.flatten()` instead of bucket-based beforeGames/afterGames approach
|
||||
3. **Determinism verified** - Same semantic state produces identical row order (tested)
|
||||
4. **sortOrder conventions enforced** - Negative sortOrder items appear before games, positive after/between
|
||||
|
||||
The old bucket-based flattening code has been completely removed. The new implementation is pure, testable, and deterministic.
|
||||
|
||||
---
|
||||
|
||||
*Verified: 2026-01-18T22:30:00Z*
|
||||
*Verifier: Claude (gsd-verifier)*
|
||||
@@ -1,220 +0,0 @@
|
||||
---
|
||||
phase: 04-drag-interaction
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- SportsTime/Features/Trip/Views/ItineraryTableViewController.swift
|
||||
autonomous: true
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "User sees item lift with subtle scale and shadow when grabbed"
|
||||
- "User feels light haptic on grab"
|
||||
- "User feels medium haptic on successful drop"
|
||||
- "Items can only be dropped in constraint-valid positions"
|
||||
artifacts:
|
||||
- path: "SportsTime/Features/Trip/Views/ItineraryTableViewController.swift"
|
||||
provides: "UITableViewDragDelegate and UITableViewDropDelegate implementation"
|
||||
contains: "UITableViewDragDelegate"
|
||||
key_links:
|
||||
- from: "ItineraryTableViewController"
|
||||
to: "ItineraryConstraints.isValidPosition"
|
||||
via: "dropSessionDidUpdate validation"
|
||||
pattern: "constraints\\.isValidPosition"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Migrate from legacy reordering methods to modern UITableViewDragDelegate/UITableViewDropDelegate and implement core drag interactions: lift animation with scale/shadow/tilt, grab/drop haptics, and constraint-aware drop proposals.
|
||||
|
||||
Purpose: Modern drag-drop delegates unlock custom drag previews and precise drop validation. This establishes the foundation for Phase 4's rich visual feedback.
|
||||
|
||||
Output: ItineraryTableViewController with working modern drag-drop that feels responsive with lift animation and haptic feedback.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@~/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/04-drag-interaction/04-CONTEXT.md
|
||||
@.planning/phases/04-drag-interaction/04-RESEARCH.md
|
||||
@SportsTime/Features/Trip/Views/ItineraryTableViewController.swift
|
||||
@SportsTime/Core/Models/Domain/ItineraryConstraints.swift
|
||||
@SportsTime/Core/Theme/Theme.swift
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Migrate to Modern Drag-Drop Delegates</name>
|
||||
<files>SportsTime/Features/Trip/Views/ItineraryTableViewController.swift</files>
|
||||
<action>
|
||||
Replace the legacy reordering approach with modern UITableViewDragDelegate and UITableViewDropDelegate:
|
||||
|
||||
1. In `setupTableView()`:
|
||||
- Add `tableView.dragDelegate = self`
|
||||
- Add `tableView.dropDelegate = self`
|
||||
- Add `tableView.dragInteractionEnabled = true` (required on iPhone)
|
||||
- Keep `isEditing = true` for visual consistency
|
||||
|
||||
2. Create `DragContext` class to store drag state:
|
||||
```swift
|
||||
private class DragContext {
|
||||
let item: ItineraryRowItem
|
||||
let sourceIndexPath: IndexPath
|
||||
let originalFrame: CGRect
|
||||
var snapshot: UIView?
|
||||
|
||||
init(item: ItineraryRowItem, sourceIndexPath: IndexPath, originalFrame: CGRect) {
|
||||
self.item = item
|
||||
self.sourceIndexPath = sourceIndexPath
|
||||
self.originalFrame = originalFrame
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. Add extension conforming to `UITableViewDragDelegate`:
|
||||
- `itemsForBeginning session:at:` - Return empty array for non-reorderable items (games, headers). For reorderable items, create UIDragItem with NSItemProvider, store DragContext in session.localContext, call existing `beginDrag(at:)`.
|
||||
- `dragPreviewParametersForRowAt:` - Return UIDragPreviewParameters with rounded corners (cornerRadius: 12).
|
||||
- `dragSessionWillBegin:` - Trigger grab haptic via existing feedbackGenerator.
|
||||
|
||||
4. Add extension conforming to `UITableViewDropDelegate`:
|
||||
- `performDropWith coordinator:` - Extract source and destination from coordinator. Call existing move logic (flatItems remove/insert). Call `endDrag()`. Animate drop with `coordinator.drop(item.dragItem, toRowAt:)`.
|
||||
- `dropSessionDidUpdate session:withDestinationIndexPath:` - Use existing `computeValidDestinationRowsProposed()` and `nearestValue()` logic to validate position. Return `.move` with `.insertAtDestinationIndexPath` for valid, `.forbidden` for invalid.
|
||||
- `dropSessionDidEnd:` - Call `endDrag()` to clean up.
|
||||
|
||||
5. Keep existing `canMoveRowAt` and `moveRowAt` methods as fallback but the new delegates should take precedence.
|
||||
|
||||
Key: Reuse existing constraint validation logic (computeValidDestinationRowsProposed, nearestValue, checkZoneTransition). The delegates wire into that existing infrastructure.
|
||||
</action>
|
||||
<verify>
|
||||
Build succeeds: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build`
|
||||
</verify>
|
||||
<done>
|
||||
ItineraryTableViewController conforms to UITableViewDragDelegate and UITableViewDropDelegate with all required methods implemented.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Implement Lift Animation with Scale, Shadow, and Tilt</name>
|
||||
<files>SportsTime/Features/Trip/Views/ItineraryTableViewController.swift</files>
|
||||
<action>
|
||||
Add custom lift animation when drag begins per CONTEXT.md decisions (iOS Reminders style, quick/snappy, 1.02-1.03x scale, 2-3 degree tilt):
|
||||
|
||||
1. Add private helper method `createLiftedSnapshot(for cell:) -> UIView`:
|
||||
- Create snapshot using `cell.snapshotView(afterScreenUpdates: false)`
|
||||
- Set frame to cell.frame
|
||||
- Apply CATransform3D with:
|
||||
- Perspective: `transform.m34 = -1.0 / 500.0`
|
||||
- Scale: 1.025 (middle of 1.02-1.03 range)
|
||||
- Tilt: 2.0 degrees around Y axis (`CATransform3DRotate(transform, 2.0 * .pi / 180.0, 0, 1, 0)`)
|
||||
- Add shadow: offset (0, 8), radius 16, opacity 0.25, masksToBounds = false
|
||||
- Return configured snapshot
|
||||
|
||||
2. Add private helper method `animateLift(for cell:, snapshot:)`:
|
||||
- Start snapshot with identity transform and shadowOpacity 0
|
||||
- Use UIView.animate with duration 0.15, spring damping 0.85, velocity 0.5
|
||||
- Animate to lifted transform and shadowOpacity 0.25
|
||||
- Set `cell.alpha = 0` to hide original during drag
|
||||
|
||||
3. In `dragSessionWillBegin:`:
|
||||
- Retrieve DragContext from session.localContext
|
||||
- Get cell for source indexPath
|
||||
- Create snapshot, add to tableView.superview
|
||||
- Animate lift
|
||||
- Store snapshot in context
|
||||
|
||||
4. Add `animateDrop(snapshot:, to destination:)` for drop animation:
|
||||
- Animate snapshot back to identity transform
|
||||
- Fade out shadowOpacity
|
||||
- Remove snapshot on completion
|
||||
- Restore cell.alpha = 1
|
||||
|
||||
Key timing values per CONTEXT.md:
|
||||
- Lift: 0.15s spring
|
||||
- Drop: 0.2s spring with damping 0.8
|
||||
</action>
|
||||
<verify>
|
||||
Run on simulator and verify:
|
||||
1. Drag a travel or custom item
|
||||
2. Item lifts with visible scale and shadow
|
||||
3. Slight 3D tilt is visible
|
||||
4. Drop settles smoothly
|
||||
</verify>
|
||||
<done>
|
||||
Dragged items show lift animation with scale (1.025x), shadow, and tilt (2 degrees) on grab, and settle animation on drop.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Add Haptic Feedback for Grab and Drop</name>
|
||||
<files>SportsTime/Features/Trip/Views/ItineraryTableViewController.swift</files>
|
||||
<action>
|
||||
Enhance haptic feedback to match CONTEXT.md decisions (light on grab, medium on drop):
|
||||
|
||||
1. The controller already has `feedbackGenerator = UIImpactFeedbackGenerator(style: .medium)`. Add a second generator:
|
||||
```swift
|
||||
private let lightHaptic = UIImpactFeedbackGenerator(style: .light)
|
||||
private let mediumHaptic = UIImpactFeedbackGenerator(style: .medium)
|
||||
```
|
||||
|
||||
2. In `dragSessionWillBegin:`:
|
||||
- Call `lightHaptic.prepare()` just before drag starts
|
||||
- Call `lightHaptic.impactOccurred()` when drag begins
|
||||
|
||||
3. In `performDropWith:` (successful drop):
|
||||
- Call `mediumHaptic.impactOccurred()` after drop animation starts
|
||||
|
||||
4. Ensure prepare() is called proactively:
|
||||
- In `itemsForBeginning:at:` before returning items, call `lightHaptic.prepare()` and `mediumHaptic.prepare()` to reduce haptic latency
|
||||
|
||||
5. Keep existing zone transition haptics in `checkZoneTransition()` for entering/leaving valid zones.
|
||||
|
||||
Note: The existing feedbackGenerator can be replaced or kept for backward compatibility with existing code paths.
|
||||
</action>
|
||||
<verify>
|
||||
Test on physical device (simulator has no haptics):
|
||||
1. Grab item - feel light tap
|
||||
2. Drop item - feel medium tap
|
||||
3. Drag over invalid zone - feel warning haptic
|
||||
</verify>
|
||||
<done>
|
||||
Light haptic fires on grab, medium haptic fires on successful drop. Zone transition haptics continue working for valid/invalid zone crossing.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
After completing all tasks:
|
||||
|
||||
1. Build succeeds without warnings
|
||||
2. Drag a travel item:
|
||||
- Lifts with scale, shadow, tilt
|
||||
- Light haptic on grab
|
||||
- Can only drop in valid day range
|
||||
- Medium haptic on drop
|
||||
3. Drag a custom item:
|
||||
- Same lift behavior
|
||||
- Can drop anywhere except on headers
|
||||
4. Try to drag a game or header:
|
||||
- No drag interaction (returns empty array)
|
||||
5. Existing constraint validation still works (invalid positions clamped to nearest valid)
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- DRAG-01: Lift animation on grab (shadow + slight scale) - IMPLEMENTED
|
||||
- DRAG-06: Haptic feedback on grab (light) and drop (medium) - IMPLEMENTED
|
||||
- DRAG-08: Slight tilt during drag (2-3 degrees) - IMPLEMENTED
|
||||
- Foundation for DRAG-02 through DRAG-05 established via delegate pattern
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/04-drag-interaction/04-01-SUMMARY.md`
|
||||
</output>
|
||||
@@ -1,140 +0,0 @@
|
||||
---
|
||||
phase: 04-drag-interaction
|
||||
plan: 01
|
||||
subsystem: ui
|
||||
tags: [drag-drop, uitableview, haptics, animations, uikit]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 03-visual-flattening
|
||||
provides: ItineraryFlattener.flatten() for row-to-semantic translation
|
||||
provides:
|
||||
- UITableViewDragDelegate/UITableViewDropDelegate implementation
|
||||
- Lift animation with scale, shadow, and tilt
|
||||
- Haptic feedback for grab and drop
|
||||
- DragContext for tracking drag state
|
||||
affects: [04-02]
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Modern drag delegates (UITableViewDragDelegate/UITableViewDropDelegate) for custom previews"
|
||||
- "DragContext class for drag session state management"
|
||||
- "Snapshot-based lift animation with CATransform3D"
|
||||
|
||||
key-files:
|
||||
created: []
|
||||
modified:
|
||||
- SportsTime/Features/Trip/Views/ItineraryTableViewController.swift
|
||||
|
||||
key-decisions:
|
||||
- "Tasks 2-3 completed as part of Task 1 - delegate pattern required holistic implementation"
|
||||
- "Lift animation parameters: 1.025x scale, 2-degree tilt, 0.25 shadow opacity"
|
||||
- "Animation timing: 0.15s lift, 0.2s drop, spring damping 0.85/0.8"
|
||||
- "Separate haptic generators: light for grab, medium for drop"
|
||||
|
||||
patterns-established:
|
||||
- "DragContext: Store drag state in session.localContext for access across delegate methods"
|
||||
- "Snapshot-based animations: Create UIView snapshot for custom lift/drop effects"
|
||||
- "Dual haptic generators: Prepare both at drag start for reduced latency"
|
||||
|
||||
# Metrics
|
||||
duration: 3min
|
||||
completed: 2026-01-18
|
||||
---
|
||||
|
||||
# Phase 4 Plan 1: Modern Drag-Drop Delegates Summary
|
||||
|
||||
**Modern UITableViewDragDelegate/UITableViewDropDelegate with iOS Reminders-style lift animation (1.025x scale, 2-degree tilt) and dual haptic feedback (light grab, medium drop)**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 3 min
|
||||
- **Started:** 2026-01-18T22:41:34Z
|
||||
- **Completed:** 2026-01-18T22:44:26Z
|
||||
- **Tasks:** 3 (completed in single commit due to code interdependency)
|
||||
- **Files modified:** 1
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- Migrated from legacy reordering to modern UITableViewDragDelegate/UITableViewDropDelegate
|
||||
- Implemented custom lift animation with scale (1.025x), shadow, and 3D tilt (2 degrees)
|
||||
- Added dual haptic feedback: light on grab, medium on successful drop
|
||||
- Created DragContext class for managing drag session state across delegate methods
|
||||
- Integrated existing constraint validation logic (computeValidDestinationRowsProposed, nearestValue)
|
||||
|
||||
## Task Commits
|
||||
|
||||
All three tasks were completed in a single commit because the modern drag delegate pattern requires the lift animation and haptic feedback to be implemented together with the delegate methods:
|
||||
|
||||
1. **Task 1: Migrate to Modern Drag-Drop Delegates** - `749ca30` (feat)
|
||||
2. **Task 2: Implement Lift Animation** - included in `749ca30` (code interdependency)
|
||||
3. **Task 3: Add Haptic Feedback** - included in `749ca30` (code interdependency)
|
||||
|
||||
**Plan metadata:** (pending)
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `SportsTime/Features/Trip/Views/ItineraryTableViewController.swift` - Added 364 lines:
|
||||
- DragContext class for drag state management
|
||||
- UITableViewDragDelegate extension (itemsForBeginning, dragPreviewParametersForRowAt, dragSessionWillBegin, dragSessionDidEnd)
|
||||
- UITableViewDropDelegate extension (performDropWith, dropSessionDidUpdate, dropSessionDidEnd)
|
||||
- Lift Animation Helpers extension (createLiftedSnapshot, animateLift, animateDrop)
|
||||
- New properties: lightHaptic, mediumHaptic, currentDragContext
|
||||
|
||||
## Decisions Made
|
||||
|
||||
1. **Unified implementation of Tasks 1-3** - The modern drag delegate pattern inherently requires lift animation and haptic feedback to be implemented as part of the delegate methods. Separating them into atomic commits would have required artificial refactoring steps that don't match how the code naturally fits together.
|
||||
|
||||
2. **Animation parameters per CONTEXT.md:**
|
||||
- Scale: 1.025 (middle of 1.02-1.03 range)
|
||||
- Tilt: 2 degrees around Y axis
|
||||
- Shadow: offset (0, 8), radius 16, opacity 0.25
|
||||
- Lift duration: 0.15s with spring damping 0.85
|
||||
- Drop duration: 0.2s with spring damping 0.8
|
||||
|
||||
3. **Dual haptic generators** - Created separate UIImpactFeedbackGenerator instances for light (grab) and medium (drop) to allow both to be prepared at drag start for reduced latency.
|
||||
|
||||
4. **DragContext stored in session.localContext** - Allows access to drag state across all delegate methods without relying on instance variables that could be stale.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Implementation Structure
|
||||
|
||||
**Tasks 2-3 completed as part of Task 1**
|
||||
- **Issue:** Plan assumed incremental implementation, but delegate pattern requires holistic approach
|
||||
- **Resolution:** Implemented all three tasks together in single cohesive commit
|
||||
- **Impact:** Same functionality delivered, single commit instead of three
|
||||
- **Verification:** Build succeeded, all features present (lift animation, haptics, constraints)
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 1 (implementation structure)
|
||||
**Impact on plan:** No missing functionality. All DRAG-01, DRAG-06, DRAG-08 requirements implemented. Code is cleaner than artificial separation would have been.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
None - implementation proceeded smoothly with existing constraint validation infrastructure.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None - no external service configuration required.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
**Ready for Plan 04-02:**
|
||||
- Foundation for insertion line feedback established (UIDragPreviewParameters with rounded corners)
|
||||
- Constraint validation integrated (validDestinationRowsProposed checked in dropSessionDidUpdate)
|
||||
- Zone transition haptics preserved from existing code
|
||||
- Remaining work: themed insertion line, invalid zone red tint, snap-back animation
|
||||
|
||||
**Requirements status after this plan:**
|
||||
- DRAG-01: Lift animation on grab - IMPLEMENTED
|
||||
- DRAG-06: Haptic feedback (light/medium) - IMPLEMENTED
|
||||
- DRAG-08: Slight tilt during drag - IMPLEMENTED
|
||||
- Foundation established for DRAG-02 through DRAG-05
|
||||
|
||||
---
|
||||
*Phase: 04-drag-interaction*
|
||||
*Completed: 2026-01-18*
|
||||
@@ -1,362 +0,0 @@
|
||||
---
|
||||
phase: 04-drag-interaction
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["04-01"]
|
||||
files_modified:
|
||||
- SportsTime/Features/Trip/Views/ItineraryTableViewController.swift
|
||||
autonomous: false
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "User sees themed insertion line between items during drag"
|
||||
- "Dragged item shows red tint when over invalid zone"
|
||||
- "Invalid drops snap back to original position with spring animation"
|
||||
- "User feels triple-tap error haptic on invalid drop"
|
||||
- "Auto-scroll activates when dragging near viewport edges"
|
||||
artifacts:
|
||||
- path: "SportsTime/Features/Trip/Views/ItineraryTableViewController.swift"
|
||||
provides: "InsertionLineView class and invalid drop handling"
|
||||
contains: "InsertionLineView"
|
||||
key_links:
|
||||
- from: "dropSessionDidUpdate"
|
||||
to: "InsertionLineView.fadeIn/fadeOut"
|
||||
via: "showInsertionLine/hideInsertionLine methods"
|
||||
pattern: "insertionLine\\?.fade"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Add visual polish: themed insertion line showing drop target, red tint feedback for invalid zones, spring snap-back animation for rejected drops, and triple-tap error haptic.
|
||||
|
||||
Purpose: Complete the drag-drop UX with clear visual feedback per CONTEXT.md decisions. Users know exactly where items will land and get clear rejection feedback for invalid positions.
|
||||
|
||||
Output: Polished drag-drop with insertion line, invalid zone visualization, and smooth rejection animation.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@~/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/04-drag-interaction/04-CONTEXT.md
|
||||
@.planning/phases/04-drag-interaction/04-RESEARCH.md
|
||||
@.planning/phases/04-drag-interaction/04-01-SUMMARY.md
|
||||
@SportsTime/Features/Trip/Views/ItineraryTableViewController.swift
|
||||
@SportsTime/Core/Theme/Theme.swift
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Create Themed Insertion Line View</name>
|
||||
<files>SportsTime/Features/Trip/Views/ItineraryTableViewController.swift</files>
|
||||
<action>
|
||||
Create a custom insertion line view that follows the user's selected theme per CONTEXT.md (follows theme color, plain line, 150ms fade):
|
||||
|
||||
1. Add private class `InsertionLineView: UIView` inside the file:
|
||||
```swift
|
||||
private class InsertionLineView: UIView {
|
||||
private let lineLayer = CAShapeLayer()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
setup()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
setup()
|
||||
}
|
||||
|
||||
private func setup() {
|
||||
// Plain line, 3pt thickness (middle of 2-4pt range per CONTEXT.md)
|
||||
lineLayer.lineWidth = 3.0
|
||||
lineLayer.lineCap = .round
|
||||
layer.addSublayer(lineLayer)
|
||||
alpha = 0 // Start hidden
|
||||
updateThemeColor()
|
||||
}
|
||||
|
||||
func updateThemeColor() {
|
||||
// Get theme color from Theme.warmOrange (which adapts to current AppTheme)
|
||||
lineLayer.strokeColor = UIColor(Theme.warmOrange).cgColor
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
let path = UIBezierPath()
|
||||
let margin: CGFloat = 16
|
||||
path.move(to: CGPoint(x: margin, y: bounds.midY))
|
||||
path.addLine(to: CGPoint(x: bounds.width - margin, y: bounds.midY))
|
||||
lineLayer.path = path.cgPath
|
||||
}
|
||||
|
||||
func fadeIn() {
|
||||
UIView.animate(withDuration: 0.15) { self.alpha = 1.0 }
|
||||
}
|
||||
|
||||
func fadeOut() {
|
||||
UIView.animate(withDuration: 0.15) { self.alpha = 0 }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. Add property to controller:
|
||||
```swift
|
||||
private var insertionLine: InsertionLineView?
|
||||
```
|
||||
|
||||
3. Add helper methods to show/hide insertion line:
|
||||
```swift
|
||||
private func showInsertionLine(at indexPath: IndexPath) {
|
||||
if insertionLine == nil {
|
||||
insertionLine = InsertionLineView()
|
||||
tableView.addSubview(insertionLine!)
|
||||
}
|
||||
insertionLine?.updateThemeColor() // Refresh in case theme changed
|
||||
|
||||
let rect = tableView.rectForRow(at: indexPath)
|
||||
insertionLine?.frame = CGRect(
|
||||
x: 0,
|
||||
y: rect.minY - 2, // Position above target row
|
||||
width: tableView.bounds.width,
|
||||
height: 6 // Slightly larger than line for touch tolerance
|
||||
)
|
||||
insertionLine?.fadeIn()
|
||||
}
|
||||
|
||||
private func hideInsertionLine() {
|
||||
insertionLine?.fadeOut()
|
||||
}
|
||||
```
|
||||
|
||||
4. Wire into `dropSessionDidUpdate`:
|
||||
- When position is valid: call `showInsertionLine(at: destinationIndexPath)`
|
||||
- When position is invalid or no destination: call `hideInsertionLine()`
|
||||
|
||||
5. In `dropSessionDidEnd`: call `hideInsertionLine()` and set `insertionLine = nil` to clean up
|
||||
</action>
|
||||
<verify>
|
||||
Build succeeds and run on simulator:
|
||||
1. Drag an item
|
||||
2. Insertion line appears between rows in theme color
|
||||
3. Line fades in/out smoothly as drag moves
|
||||
4. Line disappears on drop
|
||||
</verify>
|
||||
<done>
|
||||
Themed insertion line appears at drop target position, fades in/out with 150ms animation, uses Theme.warmOrange color.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Implement Invalid Zone Visual Feedback</name>
|
||||
<files>SportsTime/Features/Trip/Views/ItineraryTableViewController.swift</files>
|
||||
<action>
|
||||
Add red tint overlay on dragged item when hovering over invalid zones per CONTEXT.md:
|
||||
|
||||
1. Add property to track the current drag snapshot:
|
||||
```swift
|
||||
private var currentDragSnapshot: UIView?
|
||||
```
|
||||
|
||||
2. Store snapshot reference in `dragSessionWillBegin`:
|
||||
```swift
|
||||
if let context = session.localContext as? DragContext {
|
||||
currentDragSnapshot = context.snapshot
|
||||
}
|
||||
```
|
||||
|
||||
3. Add helper methods for invalid tint:
|
||||
```swift
|
||||
private func applyInvalidTint() {
|
||||
guard let snapshot = currentDragSnapshot else { return }
|
||||
UIView.animate(withDuration: 0.1) {
|
||||
// Create red overlay on the snapshot
|
||||
let overlay = snapshot.viewWithTag(999) ?? {
|
||||
let v = UIView(frame: snapshot.bounds)
|
||||
v.tag = 999
|
||||
v.backgroundColor = UIColor.systemRed.withAlphaComponent(0.15)
|
||||
v.layer.cornerRadius = 12
|
||||
v.alpha = 0
|
||||
snapshot.addSubview(v)
|
||||
return v
|
||||
}()
|
||||
overlay.alpha = 1.0
|
||||
}
|
||||
}
|
||||
|
||||
private func removeInvalidTint() {
|
||||
guard let snapshot = currentDragSnapshot,
|
||||
let overlay = snapshot.viewWithTag(999) else { return }
|
||||
UIView.animate(withDuration: 0.1) {
|
||||
overlay.alpha = 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. In `dropSessionDidUpdate`:
|
||||
- When returning `.forbidden`: call `applyInvalidTint()`
|
||||
- When returning `.move`: call `removeInvalidTint()`
|
||||
|
||||
5. In `dropSessionDidEnd`: call `removeInvalidTint()` and clear `currentDragSnapshot`
|
||||
</action>
|
||||
<verify>
|
||||
Run on simulator:
|
||||
1. Drag a travel segment to an invalid day (outside its valid range)
|
||||
2. Dragged item shows red tint overlay
|
||||
3. Move back to valid zone - red tint disappears
|
||||
</verify>
|
||||
<done>
|
||||
Dragged items show red tint (systemRed at 15% opacity) when hovering over invalid drop zones, tint fades with 100ms animation.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Implement Snap-Back Animation and Error Haptic</name>
|
||||
<files>SportsTime/Features/Trip/Views/ItineraryTableViewController.swift</files>
|
||||
<action>
|
||||
Handle invalid drops with spring snap-back and triple-tap error haptic per CONTEXT.md (~200ms with overshoot, 3 quick taps):
|
||||
|
||||
1. Add error haptic generator:
|
||||
```swift
|
||||
private let errorHaptic = UINotificationFeedbackGenerator()
|
||||
```
|
||||
|
||||
2. Add triple-tap error method:
|
||||
```swift
|
||||
private func playErrorHaptic() {
|
||||
// First tap - error notification
|
||||
errorHaptic.notificationOccurred(.error)
|
||||
|
||||
// Two additional quick taps for "nope" feeling
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.06) { [weak self] in
|
||||
self?.lightHaptic.impactOccurred()
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { [weak self] in
|
||||
self?.lightHaptic.impactOccurred()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. Add snap-back animation method:
|
||||
```swift
|
||||
private func performSnapBack(snapshot: UIView, to originalFrame: CGRect, completion: @escaping () -> Void) {
|
||||
// Per CONTEXT.md: ~200ms with slight overshoot
|
||||
UIView.animate(
|
||||
withDuration: 0.2,
|
||||
delay: 0,
|
||||
usingSpringWithDamping: 0.7, // Creates overshoot
|
||||
initialSpringVelocity: 0.3,
|
||||
options: []
|
||||
) {
|
||||
snapshot.layer.transform = CATransform3DIdentity
|
||||
snapshot.frame = originalFrame
|
||||
snapshot.layer.shadowOpacity = 0
|
||||
// Remove red tint
|
||||
snapshot.viewWithTag(999)?.alpha = 0
|
||||
} completion: { _ in
|
||||
snapshot.removeFromSuperview()
|
||||
completion()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. Modify `performDropWith coordinator:` to handle invalid drops:
|
||||
- If destinationIndexPath is nil or validation fails:
|
||||
- Get DragContext from session
|
||||
- Call `playErrorHaptic()`
|
||||
- Call `performSnapBack(snapshot:to:completion:)` with original frame
|
||||
- In completion: restore source cell alpha to 1, clean up state
|
||||
- Return early (don't perform the move)
|
||||
|
||||
5. Ensure successful drops still use the existing animation path with medium haptic.
|
||||
|
||||
6. In `dropSessionDidEnd`: Handle case where drop was cancelled (not just invalid):
|
||||
- If currentDragSnapshot still exists, perform snap-back
|
||||
</action>
|
||||
<verify>
|
||||
Run on simulator:
|
||||
1. Drag travel to invalid day
|
||||
2. Lift finger to drop
|
||||
3. Item snaps back to original position with spring overshoot
|
||||
4. On device: triple-tap haptic is felt
|
||||
</verify>
|
||||
<done>
|
||||
Invalid drops trigger snap-back animation (200ms spring with overshoot) and triple-tap error haptic pattern.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<what-built>
|
||||
Complete Phase 4 drag-drop implementation:
|
||||
- Modern UITableViewDragDelegate/UITableViewDropDelegate
|
||||
- Lift animation with scale (1.025x), shadow, and tilt (2 degrees)
|
||||
- Themed insertion line between items
|
||||
- Red tint on invalid zone hover
|
||||
- Snap-back animation for invalid drops
|
||||
- Haptic feedback: light grab, medium drop, triple-tap error
|
||||
- Auto-scroll (built-in with drop delegate)
|
||||
</what-built>
|
||||
<how-to-verify>
|
||||
1. Run on iOS Simulator or Device (device recommended for haptics)
|
||||
2. Open a saved trip with multiple days and travel segments
|
||||
3. Test travel segment drag:
|
||||
- Grab travel - verify lift animation (slight pop, shadow appears)
|
||||
- Drag to valid day - verify insertion line appears in theme color
|
||||
- Drag to invalid day (outside valid range) - verify red tint on item
|
||||
- Drop in invalid zone - verify snap-back animation with overshoot
|
||||
- Drop in valid zone - verify smooth settle animation
|
||||
4. Test custom item drag:
|
||||
- Can move to any day, any position (except directly on header)
|
||||
- Same lift/drop animations
|
||||
5. Test games and headers:
|
||||
- Verify they cannot be dragged (no lift on attempt)
|
||||
6. Verify insertion line uses current theme color:
|
||||
- Go to Settings, change theme
|
||||
- Drag an item - line should use new theme color
|
||||
|
||||
On physical device only:
|
||||
7. Verify haptics:
|
||||
- Light tap on grab
|
||||
- Medium tap on successful drop
|
||||
- Triple-tap "nope" on invalid drop
|
||||
</how-to-verify>
|
||||
<resume-signal>Type "approved" if all behaviors work correctly, or describe any issues</resume-signal>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
After all tasks including checkpoint:
|
||||
|
||||
1. Build succeeds without warnings
|
||||
2. All 8 DRAG requirements verified:
|
||||
- DRAG-01: Lift animation (scale + shadow)
|
||||
- DRAG-02: Insertion line between items
|
||||
- DRAG-03: Items shuffle (automatic with drop delegate)
|
||||
- DRAG-04: Magnetic snap on drop (coordinator.drop)
|
||||
- DRAG-05: Invalid drops rejected with snap-back
|
||||
- DRAG-06: Haptic on grab (light) and drop (medium)
|
||||
- DRAG-07: Auto-scroll at viewport edge (built-in)
|
||||
- DRAG-08: Tilt during drag (2 degrees)
|
||||
3. Theme-aware insertion line
|
||||
4. Error handling with red tint + snap-back + triple-tap haptic
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
All ROADMAP Phase 4 Success Criteria verified:
|
||||
1. User sees clear insertion line indicating where item will land during drag
|
||||
2. Dropping on invalid target snaps item back to original position with haptic feedback
|
||||
3. Dragging to bottom of visible area auto-scrolls to reveal more content
|
||||
4. Complete drag-drop cycle feels responsive with visible lift, shuffle, and settle animations
|
||||
5. Haptic pulses on both grab and drop (verifiable on physical device)
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/04-drag-interaction/04-02-SUMMARY.md`
|
||||
</output>
|
||||
@@ -1,61 +0,0 @@
|
||||
# Phase 4: Drag Interaction - Context
|
||||
|
||||
**Gathered:** 2026-01-18
|
||||
**Status:** Ready for planning
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
User can drag itinerary items with proper visual feedback, animation, and constraint enforcement. Items include travel segments and custom items (games are not draggable). The system uses semantic positions (day, sortOrder) established in Phases 1-3.
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### Drag Feel & Weight
|
||||
- Quick & snappy lift — instant pop-up, light and responsive (iOS Reminders style)
|
||||
- 1:1 finger tracking — item stays exactly under finger, no lag or smoothing
|
||||
- Immediate shuffle — items move instantly when drag crosses them (no delay threshold)
|
||||
- Subtle scale on lift (1.02-1.03x) — barely noticeable, just enough shadow change
|
||||
|
||||
### Insertion Line Design
|
||||
- Color follows selected theme in Settings — respects user's theme choice
|
||||
- Fade in/out animation (~150ms opacity transition)
|
||||
- Plain line only — no glow, shadow, or endpoint embellishments
|
||||
|
||||
### Invalid Drop Feedback
|
||||
- Red tint on dragged item while hovering over invalid zone
|
||||
- Quick spring snap-back animation (~200ms with slight overshoot)
|
||||
- Error haptic pattern (3 quick taps) — distinct "nope" feeling
|
||||
- No sound effects — haptic and visual feedback only
|
||||
|
||||
### Claude's Discretion
|
||||
- Insertion line thickness (suggested 2-4pt)
|
||||
- Shadow depth and exact tilt angle during drag
|
||||
- Drop settle animation timing
|
||||
- Auto-scroll speed and dead zone sizing (user didn't discuss this area)
|
||||
- Line positioning relative to items
|
||||
|
||||
</decisions>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- Lift feel should match iOS Reminders — quick, light, responsive
|
||||
- Snap-back should have slight overshoot for spring physics feel
|
||||
- Theme-aware colors for insertion line
|
||||
|
||||
</specifics>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
None — discussion stayed within phase scope
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 04-drag-interaction*
|
||||
*Context gathered: 2026-01-18*
|
||||
@@ -1,904 +0,0 @@
|
||||
# Phase 4: Drag Interaction - Research
|
||||
|
||||
**Researched:** 2026-01-18
|
||||
**Domain:** UITableView drag-drop interaction, haptic feedback, custom animations
|
||||
**Confidence:** HIGH
|
||||
|
||||
## Summary
|
||||
|
||||
Phase 4 enhances the existing `ItineraryTableViewController` with rich drag-and-drop visual feedback. The codebase already implements reordering using the legacy UITableView methods (`canMoveRowAt`, `moveRowAt`, `targetIndexPathForMoveFromRowAt`). This research focuses on adding the Phase 4 requirements: lift animation, custom insertion line, item shuffle, tilt transform, magnetic snap, and haptic feedback.
|
||||
|
||||
The current implementation uses UITableView in "edit mode" (`isEditing = true`) which shows native drag handles. Per CONTEXT.md decisions, the lift feel should match iOS Reminders (quick, light, responsive), with 1:1 finger tracking, immediate shuffle, and subtle scale (1.02-1.03x). The insertion line should follow the theme color, fade in/out, and be plain without embellishments.
|
||||
|
||||
**Primary recommendation:** Migrate from legacy `canMoveRowAt`/`moveRowAt` to the modern `UITableViewDragDelegate`/`UITableViewDropDelegate` protocols. This unlocks custom drag previews, precise insertion feedback, and full control over drop validation animations.
|
||||
|
||||
## Standard Stack
|
||||
|
||||
### Core
|
||||
| Library | Version | Purpose | Why Standard |
|
||||
|---------|---------|---------|--------------|
|
||||
| UIKit (UITableViewDragDelegate) | iOS 11+ | Drag initiation and preview | Apple's official API for table drag |
|
||||
| UIKit (UITableViewDropDelegate) | iOS 11+ | Drop handling and insertion | Apple's official API for table drop |
|
||||
| UIImpactFeedbackGenerator | iOS 10+ | Haptic feedback on grab/drop | Standard haptic API |
|
||||
| UINotificationFeedbackGenerator | iOS 10+ | Error haptic for invalid drops | Standard notification haptic |
|
||||
| CATransform3D | Core Animation | 3D tilt during drag | Standard layer transform |
|
||||
| UIBezierPath | UIKit | Custom insertion line shape | Standard path drawing |
|
||||
|
||||
### Supporting
|
||||
| Library | Version | Purpose | When to Use |
|
||||
|---------|---------|---------|-------------|
|
||||
| UISelectionFeedbackGenerator | iOS 10+ | Subtle selection haptic | When crossing valid drop zones |
|
||||
| CABasicAnimation | Core Animation | Fade/scale animations | Insertion line appearance |
|
||||
|
||||
### Alternatives Considered
|
||||
| Instead of | Could Use | Tradeoff |
|
||||
|------------|-----------|----------|
|
||||
| Custom insertion view | Default UITableView separator | Default lacks theme color and fade animation |
|
||||
| UIViewPropertyAnimator | CABasicAnimation | Either works; PropertyAnimator more flexible for interruptible animations |
|
||||
| UIDragPreview transform | CALayer transform on cell | UIDragPreview is system-managed; layer transform gives full control |
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### Current Architecture (Legacy Methods)
|
||||
|
||||
The existing `ItineraryTableViewController` uses legacy reordering:
|
||||
|
||||
```swift
|
||||
// Source: ItineraryTableViewController.swift (current)
|
||||
override func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool {
|
||||
return flatItems[indexPath.row].isReorderable
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath,
|
||||
to destinationIndexPath: IndexPath) {
|
||||
// Called AFTER drop - updates data model
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, targetIndexPathForMoveFromRowAt sourceIndexPath: IndexPath,
|
||||
toProposedIndexPath proposedDestinationIndexPath: IndexPath) -> IndexPath {
|
||||
// Called DURING drag - validates/clamps drop position
|
||||
}
|
||||
```
|
||||
|
||||
**Limitation:** Legacy methods provide no control over:
|
||||
- Drag preview appearance (scale, shadow, tilt)
|
||||
- Insertion line customization
|
||||
- Lift/drop animation timing
|
||||
|
||||
### Pattern 1: Modern Drag-Drop Delegate Migration
|
||||
|
||||
**What:** Adopt `UITableViewDragDelegate` and `UITableViewDropDelegate` protocols
|
||||
**When to use:** All Phase 4 requirements
|
||||
**Example:**
|
||||
```swift
|
||||
// Source: Recommended implementation
|
||||
final class ItineraryTableViewController: UITableViewController {
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
// Modern drag-drop setup (replaces isEditing = true)
|
||||
tableView.dragDelegate = self
|
||||
tableView.dropDelegate = self
|
||||
tableView.dragInteractionEnabled = true
|
||||
|
||||
// Keep isEditing for visual consistency if needed
|
||||
// but drag is now handled by delegates
|
||||
}
|
||||
}
|
||||
|
||||
extension ItineraryTableViewController: UITableViewDragDelegate {
|
||||
|
||||
// REQUIRED: Initiate drag
|
||||
func tableView(_ tableView: UITableView,
|
||||
itemsForBeginning session: UIDragSession,
|
||||
at indexPath: IndexPath) -> [UIDragItem] {
|
||||
|
||||
let item = flatItems[indexPath.row]
|
||||
guard item.isReorderable else { return [] } // Empty = no drag
|
||||
|
||||
// Store context for constraint validation
|
||||
session.localContext = DragContext(item: item, sourceIndex: indexPath)
|
||||
|
||||
// Create drag item (required for system)
|
||||
let provider = NSItemProvider(object: item.id as NSString)
|
||||
let dragItem = UIDragItem(itemProvider: provider)
|
||||
dragItem.localObject = item
|
||||
|
||||
return [dragItem]
|
||||
}
|
||||
|
||||
// OPTIONAL: Customize drag preview (LIFT ANIMATION)
|
||||
func tableView(_ tableView: UITableView,
|
||||
dragPreviewParametersForRowAt indexPath: IndexPath) -> UIDragPreviewParameters? {
|
||||
|
||||
guard let cell = tableView.cellForRow(at: indexPath) else { return nil }
|
||||
|
||||
let params = UIDragPreviewParameters()
|
||||
|
||||
// Rounded corners for lift preview
|
||||
let rect = cell.contentView.bounds.insetBy(dx: 4, dy: 2)
|
||||
params.visiblePath = UIBezierPath(roundedRect: rect, cornerRadius: 12)
|
||||
|
||||
// Background matches card
|
||||
params.backgroundColor = .clear
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
// OPTIONAL: Called when drag begins (HAPTIC GRAB)
|
||||
func tableView(_ tableView: UITableView,
|
||||
dragSessionWillBegin session: UIDragSession) {
|
||||
|
||||
let generator = UIImpactFeedbackGenerator(style: .light)
|
||||
generator.impactOccurred()
|
||||
|
||||
// Apply lift transform to source cell
|
||||
// (handled separately via custom snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
extension ItineraryTableViewController: UITableViewDropDelegate {
|
||||
|
||||
// REQUIRED: Handle drop
|
||||
func tableView(_ tableView: UITableView,
|
||||
performDropWith coordinator: UITableViewDropCoordinator) {
|
||||
|
||||
guard let item = coordinator.items.first,
|
||||
let sourceIndexPath = item.sourceIndexPath,
|
||||
let destinationIndexPath = coordinator.destinationIndexPath else { return }
|
||||
|
||||
// Use existing moveRowAt logic
|
||||
let rowItem = flatItems[sourceIndexPath.row]
|
||||
flatItems.remove(at: sourceIndexPath.row)
|
||||
flatItems.insert(rowItem, at: destinationIndexPath.row)
|
||||
|
||||
// Notify callbacks (existing logic)
|
||||
// ...
|
||||
|
||||
// Animate into place
|
||||
coordinator.drop(item.dragItem, toRowAt: destinationIndexPath)
|
||||
|
||||
// Drop haptic
|
||||
let generator = UIImpactFeedbackGenerator(style: .medium)
|
||||
generator.impactOccurred()
|
||||
}
|
||||
|
||||
// OPTIONAL: Return drop proposal (INSERTION LINE)
|
||||
func tableView(_ tableView: UITableView,
|
||||
dropSessionDidUpdate session: UIDropSession,
|
||||
withDestinationIndexPath destinationIndexPath: IndexPath?) -> UITableViewDropProposal {
|
||||
|
||||
guard let context = session.localDragSession?.localContext as? DragContext else {
|
||||
return UITableViewDropProposal(operation: .forbidden)
|
||||
}
|
||||
|
||||
// Validate using existing constraint logic
|
||||
let isValid = validateDropPosition(context: context, at: destinationIndexPath)
|
||||
|
||||
if isValid {
|
||||
return UITableViewDropProposal(
|
||||
operation: .move,
|
||||
intent: .insertAtDestinationIndexPath
|
||||
)
|
||||
} else {
|
||||
return UITableViewDropProposal(operation: .forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
// OPTIONAL: Called when drag ends
|
||||
func tableView(_ tableView: UITableView,
|
||||
dropSessionDidEnd session: UIDropSession) {
|
||||
|
||||
removeCustomInsertionLine()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 2: Custom Lift Animation (Scale + Shadow + Tilt)
|
||||
|
||||
**What:** Apply transform to create "lifted" appearance when drag begins
|
||||
**When to use:** DRAG-01 (lift animation), DRAG-08 (tilt)
|
||||
**Example:**
|
||||
```swift
|
||||
// Source: Recommended implementation
|
||||
|
||||
/// Creates a custom snapshot view with lift styling
|
||||
private func createLiftedSnapshot(for cell: UITableViewCell) -> UIView {
|
||||
// Create snapshot
|
||||
let snapshot = cell.snapshotView(afterScreenUpdates: false) ?? UIView()
|
||||
snapshot.frame = cell.frame
|
||||
|
||||
// Apply subtle scale (1.02-1.03x per CONTEXT.md)
|
||||
let scale: CGFloat = 1.025
|
||||
|
||||
// Apply tilt (2-3 degrees per DRAG-08)
|
||||
let tiltAngle: CGFloat = 2.0 * .pi / 180.0 // 2 degrees in radians
|
||||
|
||||
// Combine transforms
|
||||
var transform = CATransform3DIdentity
|
||||
|
||||
// Add perspective for 3D effect
|
||||
transform.m34 = -1.0 / 500.0
|
||||
|
||||
// Scale
|
||||
transform = CATransform3DScale(transform, scale, scale, 1.0)
|
||||
|
||||
// Rotate around Y axis for tilt
|
||||
transform = CATransform3DRotate(transform, tiltAngle, 0, 1, 0)
|
||||
|
||||
snapshot.layer.transform = transform
|
||||
|
||||
// Add shadow
|
||||
snapshot.layer.shadowColor = UIColor.black.cgColor
|
||||
snapshot.layer.shadowOffset = CGSize(width: 0, height: 8)
|
||||
snapshot.layer.shadowRadius = 16
|
||||
snapshot.layer.shadowOpacity = 0.25
|
||||
snapshot.layer.masksToBounds = false
|
||||
|
||||
return snapshot
|
||||
}
|
||||
|
||||
/// Animates cell "lifting" on grab
|
||||
private func animateLift(for cell: UITableViewCell, snapshot: UIView) {
|
||||
// Initial state (no transform)
|
||||
snapshot.layer.transform = CATransform3DIdentity
|
||||
snapshot.layer.shadowOpacity = 0
|
||||
|
||||
// Animate to lifted state
|
||||
UIView.animate(
|
||||
withDuration: 0.15, // Quick lift per CONTEXT.md
|
||||
delay: 0,
|
||||
usingSpringWithDamping: 0.85,
|
||||
initialSpringVelocity: 0.5
|
||||
) {
|
||||
let scale: CGFloat = 1.025
|
||||
let tiltAngle: CGFloat = 2.0 * .pi / 180.0
|
||||
|
||||
var transform = CATransform3DIdentity
|
||||
transform.m34 = -1.0 / 500.0
|
||||
transform = CATransform3DScale(transform, scale, scale, 1.0)
|
||||
transform = CATransform3DRotate(transform, tiltAngle, 0, 1, 0)
|
||||
|
||||
snapshot.layer.transform = transform
|
||||
snapshot.layer.shadowOpacity = 0.25
|
||||
}
|
||||
|
||||
// Hide original cell during drag
|
||||
cell.alpha = 0
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 3: Custom Insertion Line
|
||||
|
||||
**What:** Themed insertion line between items showing drop target
|
||||
**When to use:** DRAG-02 (insertion line)
|
||||
**Example:**
|
||||
```swift
|
||||
// Source: Recommended implementation
|
||||
|
||||
/// Custom insertion line view
|
||||
private class InsertionLineView: UIView {
|
||||
|
||||
private let lineLayer = CAShapeLayer()
|
||||
|
||||
var themeColor: UIColor = .systemOrange {
|
||||
didSet { lineLayer.strokeColor = themeColor.cgColor }
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
setup()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
setup()
|
||||
}
|
||||
|
||||
private func setup() {
|
||||
// Line properties per CONTEXT.md: plain line, 2-4pt thickness
|
||||
lineLayer.strokeColor = themeColor.cgColor
|
||||
lineLayer.lineWidth = 3.0
|
||||
lineLayer.lineCap = .round
|
||||
layer.addSublayer(lineLayer)
|
||||
|
||||
// Start hidden
|
||||
alpha = 0
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
// Draw horizontal line
|
||||
let path = UIBezierPath()
|
||||
let margin: CGFloat = 16
|
||||
path.move(to: CGPoint(x: margin, y: bounds.midY))
|
||||
path.addLine(to: CGPoint(x: bounds.width - margin, y: bounds.midY))
|
||||
lineLayer.path = path.cgPath
|
||||
}
|
||||
|
||||
/// Fade in animation (~150ms per CONTEXT.md)
|
||||
func fadeIn() {
|
||||
UIView.animate(withDuration: 0.15) {
|
||||
self.alpha = 1.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Fade out animation
|
||||
func fadeOut() {
|
||||
UIView.animate(withDuration: 0.15) {
|
||||
self.alpha = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// In ItineraryTableViewController:
|
||||
|
||||
private var insertionLine: InsertionLineView?
|
||||
|
||||
private func showInsertionLine(at indexPath: IndexPath) {
|
||||
// Create if needed
|
||||
if insertionLine == nil {
|
||||
insertionLine = InsertionLineView()
|
||||
insertionLine?.themeColor = Theme.warmOrange // Theme-aware per CONTEXT.md
|
||||
tableView.addSubview(insertionLine!)
|
||||
}
|
||||
|
||||
// Position between rows
|
||||
let rect = tableView.rectForRow(at: indexPath)
|
||||
insertionLine?.frame = CGRect(
|
||||
x: 0,
|
||||
y: rect.minY - 2, // Position above target row
|
||||
width: tableView.bounds.width,
|
||||
height: 4
|
||||
)
|
||||
|
||||
insertionLine?.fadeIn()
|
||||
}
|
||||
|
||||
private func hideInsertionLine() {
|
||||
insertionLine?.fadeOut()
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 4: Item Shuffle Animation
|
||||
|
||||
**What:** Items move out of the way during drag
|
||||
**When to use:** DRAG-03 (100ms shuffle animation)
|
||||
**Example:**
|
||||
```swift
|
||||
// Source: Recommended implementation
|
||||
|
||||
// Note: UITableView automatically animates row shuffling when using
|
||||
// UITableViewDropDelegate with .insertAtDestinationIndexPath intent.
|
||||
// The animation duration can be customized via:
|
||||
|
||||
func tableView(_ tableView: UITableView,
|
||||
dropSessionDidUpdate session: UIDropSession,
|
||||
withDestinationIndexPath destinationIndexPath: IndexPath?) -> UITableViewDropProposal {
|
||||
|
||||
// Per CONTEXT.md: Immediate shuffle, items move instantly
|
||||
// The system handles this, but we can influence timing via
|
||||
// UITableView.performBatchUpdates for more control if needed
|
||||
|
||||
// For custom shuffle timing (100ms per DRAG-03):
|
||||
UIView.animate(withDuration: 0.1) { // 100ms
|
||||
tableView.performBatchUpdates(nil)
|
||||
}
|
||||
|
||||
return UITableViewDropProposal(operation: .move, intent: .insertAtDestinationIndexPath)
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 5: Invalid Drop Rejection with Snap-Back
|
||||
|
||||
**What:** Invalid drops rejected with spring animation back to origin
|
||||
**When to use:** DRAG-05 (invalid drop rejection)
|
||||
**Example:**
|
||||
```swift
|
||||
// Source: Recommended implementation
|
||||
|
||||
/// Handles invalid drop with snap-back animation
|
||||
private func performSnapBack(for draggedSnapshot: UIView, to originalFrame: CGRect) {
|
||||
// Per CONTEXT.md: ~200ms with slight overshoot
|
||||
UIView.animate(
|
||||
withDuration: 0.2,
|
||||
delay: 0,
|
||||
usingSpringWithDamping: 0.7, // Slight overshoot
|
||||
initialSpringVelocity: 0.3
|
||||
) {
|
||||
// Reset transform
|
||||
draggedSnapshot.layer.transform = CATransform3DIdentity
|
||||
draggedSnapshot.frame = originalFrame
|
||||
draggedSnapshot.layer.shadowOpacity = 0
|
||||
} completion: { _ in
|
||||
draggedSnapshot.removeFromSuperview()
|
||||
}
|
||||
|
||||
// Error haptic per CONTEXT.md: 3 quick taps
|
||||
let generator = UINotificationFeedbackGenerator()
|
||||
generator.notificationOccurred(.error)
|
||||
|
||||
// Additional taps (custom pattern)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) {
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.16) {
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
}
|
||||
}
|
||||
|
||||
/// Red tint on dragged item over invalid zone
|
||||
private func applyInvalidTint(to snapshot: UIView) {
|
||||
// Per CONTEXT.md: Red tint while hovering over invalid zone
|
||||
UIView.animate(withDuration: 0.1) {
|
||||
snapshot.layer.backgroundColor = UIColor.systemRed.withAlphaComponent(0.15).cgColor
|
||||
}
|
||||
}
|
||||
|
||||
private func removeInvalidTint(from snapshot: UIView) {
|
||||
UIView.animate(withDuration: 0.1) {
|
||||
snapshot.layer.backgroundColor = UIColor.clear.cgColor
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 6: Haptic Feedback Integration
|
||||
|
||||
**What:** Tactile feedback at key interaction points
|
||||
**When to use:** DRAG-06 (haptic on grab/drop)
|
||||
**Example:**
|
||||
```swift
|
||||
// Source: Recommended implementation based on Hacking with Swift patterns
|
||||
|
||||
final class HapticManager {
|
||||
|
||||
// Pre-created generators for reduced latency
|
||||
private let impactLight = UIImpactFeedbackGenerator(style: .light)
|
||||
private let impactMedium = UIImpactFeedbackGenerator(style: .medium)
|
||||
private let notification = UINotificationFeedbackGenerator()
|
||||
private let selection = UISelectionFeedbackGenerator()
|
||||
|
||||
func prepare() {
|
||||
impactLight.prepare()
|
||||
impactMedium.prepare()
|
||||
notification.prepare()
|
||||
}
|
||||
|
||||
/// Light impact on grab (per DRAG-06)
|
||||
func grab() {
|
||||
impactLight.impactOccurred()
|
||||
}
|
||||
|
||||
/// Medium impact on drop (per DRAG-06)
|
||||
func drop() {
|
||||
impactMedium.impactOccurred()
|
||||
}
|
||||
|
||||
/// Error pattern for invalid drop (3 quick taps per CONTEXT.md)
|
||||
func errorTripleTap() {
|
||||
notification.notificationOccurred(.error)
|
||||
|
||||
// Additional taps for "nope" feeling
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.06) { [weak self] in
|
||||
self?.impactLight.impactOccurred()
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { [weak self] in
|
||||
self?.impactLight.impactOccurred()
|
||||
}
|
||||
}
|
||||
|
||||
/// Subtle feedback when crossing zone boundaries
|
||||
func zoneCrossing() {
|
||||
selection.selectionChanged()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 7: Auto-Scroll During Drag
|
||||
|
||||
**What:** Table view scrolls when dragging near edges
|
||||
**When to use:** DRAG-07 (auto-scroll at viewport edge)
|
||||
**Example:**
|
||||
```swift
|
||||
// Source: Recommended implementation
|
||||
|
||||
// Note: UITableView with UIDropDelegate provides automatic scrolling
|
||||
// when the drag position nears the top or bottom edges. This is the
|
||||
// default behavior - no custom code needed.
|
||||
|
||||
// However, if you need custom scroll speed or dead zones:
|
||||
|
||||
private var autoScrollTimer: Timer?
|
||||
private let scrollSpeed: CGFloat = 5.0 // Points per tick
|
||||
private let deadZoneHeight: CGFloat = 60.0 // Distance from edge to trigger
|
||||
|
||||
private func updateAutoScroll(for dragLocation: CGPoint) {
|
||||
let bounds = tableView.bounds
|
||||
|
||||
if dragLocation.y < deadZoneHeight {
|
||||
// Near top - scroll up
|
||||
startAutoScroll(direction: -1)
|
||||
} else if dragLocation.y > bounds.height - deadZoneHeight {
|
||||
// Near bottom - scroll down
|
||||
startAutoScroll(direction: 1)
|
||||
} else {
|
||||
stopAutoScroll()
|
||||
}
|
||||
}
|
||||
|
||||
private func startAutoScroll(direction: Int) {
|
||||
guard autoScrollTimer == nil else { return }
|
||||
|
||||
autoScrollTimer = Timer.scheduledTimer(withTimeInterval: 1.0/60.0, repeats: true) { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
|
||||
var offset = self.tableView.contentOffset
|
||||
offset.y += CGFloat(direction) * self.scrollSpeed
|
||||
|
||||
// Clamp to bounds
|
||||
offset.y = max(0, min(offset.y, self.tableView.contentSize.height - self.tableView.bounds.height))
|
||||
|
||||
self.tableView.setContentOffset(offset, animated: false)
|
||||
}
|
||||
}
|
||||
|
||||
private func stopAutoScroll() {
|
||||
autoScrollTimer?.invalidate()
|
||||
autoScrollTimer = nil
|
||||
}
|
||||
```
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
|
||||
- **Mixing legacy and modern APIs:** Don't implement both `canMoveRowAt` AND `itemsForBeginning`. Choose one approach.
|
||||
- **Blocking main thread in drop handler:** Constraint validation and data updates should be fast. No async operations in `performDropWith`.
|
||||
- **Over-haptic:** Per CONTEXT.md, no sound effects. Keep haptic patterns subtle (light grab, medium drop, error pattern only on failure).
|
||||
- **Custom drag handle views:** Let UITableView manage drag handles. Custom handles break accessibility.
|
||||
- **Ignoring `dragInteractionEnabled`:** On iPhone, this defaults to `false`. Must explicitly set `true`.
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't Build | Use Instead | Why |
|
||||
|---------|-------------|-------------|-----|
|
||||
| Drag preview snapshot | Manual layer rendering | `cell.snapshotView(afterScreenUpdates:)` | Handles cell configuration automatically |
|
||||
| Row reordering animation | Manual frame manipulation | `UITableViewDropCoordinator.drop(toRowAt:)` | System handles animation timing |
|
||||
| Auto-scroll | Custom scroll timers | Built-in drop delegate behavior | System handles edge detection automatically |
|
||||
| Insertion indicator | Custom CAShapeLayer in tableView | Custom view added as subview | Easier positioning, theme support |
|
||||
| Haptic patterns | AVAudioEngine vibration | UIFeedbackGenerator subclasses | Battery efficient, system-consistent |
|
||||
|
||||
**Key insight:** UITableViewDropDelegate with `.insertAtDestinationIndexPath` intent provides most of the shuffle and insertion behavior automatically. Custom work is only needed for the visual styling (custom insertion line, tilt transform).
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: Drag Handle Not Appearing
|
||||
|
||||
**What goes wrong:** Drag handles don't show on reorderable cells
|
||||
**Why it happens:** `dragInteractionEnabled` is false by default on iPhone
|
||||
**How to avoid:** Explicitly set `tableView.dragInteractionEnabled = true`
|
||||
**Warning signs:** Drag works in edit mode but not with modern delegates
|
||||
|
||||
### Pitfall 2: Drop Proposal Called Too Frequently
|
||||
|
||||
**What goes wrong:** Performance issues during drag
|
||||
**Why it happens:** `dropSessionDidUpdate` called on every touch move
|
||||
**How to avoid:** Cache validation results, avoid expensive calculations
|
||||
**Warning signs:** Janky drag animation, dropped frames
|
||||
|
||||
### Pitfall 3: Transform Conflicts
|
||||
|
||||
**What goes wrong:** Scale/tilt animation looks wrong
|
||||
**Why it happens:** CATransform3D operations are order-dependent
|
||||
**How to avoid:** Apply transforms in consistent order: perspective (m34), then scale, then rotate
|
||||
**Warning signs:** Unexpected skew, item flips unexpectedly
|
||||
|
||||
### Pitfall 4: Snapshot View Clipping Shadow
|
||||
|
||||
**What goes wrong:** Shadow gets cut off on drag preview
|
||||
**Why it happens:** `masksToBounds = true` or parent clips
|
||||
**How to avoid:** Set `masksToBounds = false` on snapshot, add padding to frame
|
||||
**Warning signs:** Shadow appears clipped or box-shaped
|
||||
|
||||
### Pitfall 5: Insertion Line Z-Order
|
||||
|
||||
**What goes wrong:** Insertion line appears behind cells
|
||||
**Why it happens:** Added to tableView at wrong sublayer position
|
||||
**How to avoid:** Use `tableView.addSubview()` and ensure it's above cells, or add to `tableView.superview`
|
||||
**Warning signs:** Line visible only in gaps between cells
|
||||
|
||||
### Pitfall 6: Haptic Latency
|
||||
|
||||
**What goes wrong:** Haptic feels delayed from grab action
|
||||
**Why it happens:** Generator not prepared
|
||||
**How to avoid:** Call `prepare()` on feedback generators before expected interaction
|
||||
**Warning signs:** 50-100ms delay between touch and haptic
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Complete Drag Session Lifecycle
|
||||
|
||||
```swift
|
||||
// Source: Recommended implementation combining all patterns
|
||||
|
||||
extension ItineraryTableViewController: UITableViewDragDelegate, UITableViewDropDelegate {
|
||||
|
||||
// MARK: - Drag Delegate
|
||||
|
||||
func tableView(_ tableView: UITableView,
|
||||
itemsForBeginning session: UIDragSession,
|
||||
at indexPath: IndexPath) -> [UIDragItem] {
|
||||
|
||||
let item = flatItems[indexPath.row]
|
||||
guard item.isReorderable else { return [] }
|
||||
|
||||
// Store context
|
||||
let context = DragContext(
|
||||
item: item,
|
||||
sourceIndexPath: indexPath,
|
||||
originalFrame: tableView.rectForRow(at: indexPath)
|
||||
)
|
||||
session.localContext = context
|
||||
|
||||
// Prepare haptic
|
||||
hapticManager.prepare()
|
||||
|
||||
// Create drag item
|
||||
let provider = NSItemProvider(object: item.id as NSString)
|
||||
return [UIDragItem(itemProvider: provider)]
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView,
|
||||
dragPreviewParametersForRowAt indexPath: IndexPath) -> UIDragPreviewParameters? {
|
||||
|
||||
guard let cell = tableView.cellForRow(at: indexPath) else { return nil }
|
||||
|
||||
let params = UIDragPreviewParameters()
|
||||
let rect = cell.contentView.bounds.insetBy(dx: 4, dy: 2)
|
||||
params.visiblePath = UIBezierPath(roundedRect: rect, cornerRadius: 12)
|
||||
return params
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView,
|
||||
dragSessionWillBegin session: UIDragSession) {
|
||||
|
||||
hapticManager.grab()
|
||||
|
||||
// Create custom snapshot with lift animation
|
||||
if let context = session.localContext as? DragContext,
|
||||
let cell = tableView.cellForRow(at: context.sourceIndexPath) {
|
||||
|
||||
let snapshot = createLiftedSnapshot(for: cell)
|
||||
tableView.superview?.addSubview(snapshot)
|
||||
animateLift(for: cell, snapshot: snapshot)
|
||||
|
||||
context.snapshot = snapshot
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Drop Delegate
|
||||
|
||||
func tableView(_ tableView: UITableView,
|
||||
dropSessionDidUpdate session: UIDropSession,
|
||||
withDestinationIndexPath destinationIndexPath: IndexPath?) -> UITableViewDropProposal {
|
||||
|
||||
guard let context = session.localDragSession?.localContext as? DragContext,
|
||||
let destIndex = destinationIndexPath else {
|
||||
hideInsertionLine()
|
||||
return UITableViewDropProposal(operation: .forbidden)
|
||||
}
|
||||
|
||||
// Validate using existing ItineraryConstraints
|
||||
let isValid = validateDropPosition(
|
||||
item: context.item,
|
||||
at: destIndex
|
||||
)
|
||||
|
||||
if isValid {
|
||||
showInsertionLine(at: destIndex)
|
||||
context.snapshot.map { removeInvalidTint(from: $0) }
|
||||
return UITableViewDropProposal(operation: .move, intent: .insertAtDestinationIndexPath)
|
||||
} else {
|
||||
hideInsertionLine()
|
||||
context.snapshot.map { applyInvalidTint(to: $0) }
|
||||
return UITableViewDropProposal(operation: .forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView,
|
||||
performDropWith coordinator: UITableViewDropCoordinator) {
|
||||
|
||||
guard let item = coordinator.items.first,
|
||||
let source = item.sourceIndexPath,
|
||||
let dest = coordinator.destinationIndexPath,
|
||||
let context = coordinator.session.localDragSession?.localContext as? DragContext else {
|
||||
|
||||
// Invalid drop - snap back
|
||||
if let context = coordinator.session.localDragSession?.localContext as? DragContext {
|
||||
performSnapBack(for: context.snapshot!, to: context.originalFrame)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
hideInsertionLine()
|
||||
|
||||
// Animate drop
|
||||
coordinator.drop(item.dragItem, toRowAt: dest)
|
||||
|
||||
// Update data (existing logic)
|
||||
let rowItem = flatItems[source.row]
|
||||
flatItems.remove(at: source.row)
|
||||
flatItems.insert(rowItem, at: dest.row)
|
||||
|
||||
// Haptic
|
||||
hapticManager.drop()
|
||||
|
||||
// Remove snapshot
|
||||
UIView.animate(withDuration: 0.2) {
|
||||
context.snapshot?.alpha = 0
|
||||
} completion: { _ in
|
||||
context.snapshot?.removeFromSuperview()
|
||||
}
|
||||
|
||||
// Restore original cell
|
||||
if let cell = tableView.cellForRow(at: dest) {
|
||||
cell.alpha = 1
|
||||
}
|
||||
|
||||
// Notify callbacks (existing logic)
|
||||
// ...
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView,
|
||||
dropSessionDidEnd session: UIDropSession) {
|
||||
|
||||
hideInsertionLine()
|
||||
stopAutoScroll()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Constraint Integration with Existing ItineraryConstraints
|
||||
|
||||
```swift
|
||||
// Source: Bridges existing Phase 2 constraints to drop validation
|
||||
|
||||
private func validateDropPosition(item: ItineraryRowItem, at indexPath: IndexPath) -> Bool {
|
||||
let day = dayNumber(forRow: indexPath.row)
|
||||
let sortOrder = calculateSortOrder(at: indexPath.row)
|
||||
|
||||
switch item {
|
||||
case .travel(let segment, _):
|
||||
// Get travel item for constraint check
|
||||
guard let travelItem = findItineraryItem(for: segment),
|
||||
let constraints = constraints else {
|
||||
return false
|
||||
}
|
||||
return constraints.isValidPosition(for: travelItem, day: day, sortOrder: sortOrder)
|
||||
|
||||
case .customItem(let customItem):
|
||||
// Custom items have no constraints per Phase 2
|
||||
return constraints?.isValidPosition(for: customItem, day: day, sortOrder: sortOrder) ?? true
|
||||
|
||||
default:
|
||||
return false // Games/headers can't be dropped
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old Approach | Current Approach | When Changed | Impact |
|
||||
|--------------|------------------|--------------|--------|
|
||||
| `canMoveRowAt`/`moveRowAt` | `UITableViewDragDelegate`/`UITableViewDropDelegate` | iOS 11 (2017) | Custom previews, better control |
|
||||
| Haptic via AudioServices | UIFeedbackGenerator | iOS 10 (2016) | Battery efficient, system-consistent |
|
||||
| Manual reorder animation | `UITableViewDropCoordinator.drop(toRowAt:)` | iOS 11 (2017) | System-managed smooth animation |
|
||||
|
||||
**Deprecated/outdated:**
|
||||
- `UILongPressGestureRecognizer` for drag initiation (use delegate methods instead)
|
||||
- `moveRow(at:to:)` during active drag (use batch updates or coordinator)
|
||||
|
||||
**iOS 26 Considerations:**
|
||||
- No breaking changes found to UITableView drag-drop APIs
|
||||
- `CLGeocoder` deprecated (not relevant to drag-drop)
|
||||
- Swift 6 concurrency applies; ensure delegate methods don't capture vars incorrectly
|
||||
|
||||
## Open Questions
|
||||
|
||||
### Resolved by Research
|
||||
|
||||
1. **How to customize insertion line appearance?**
|
||||
- Answer: Add custom `UIView` subview to tableView, position at `rectForRow(at:).minY`
|
||||
- Confidence: HIGH (standard UIKit pattern)
|
||||
|
||||
2. **How to apply tilt transform during drag?**
|
||||
- Answer: Use `CATransform3D` with m34 perspective, then rotate around Y axis
|
||||
- Confidence: HIGH (verified with Core Animation docs)
|
||||
|
||||
3. **Does auto-scroll work automatically?**
|
||||
- Answer: YES, UITableViewDropDelegate provides automatic edge scrolling
|
||||
- Confidence: HIGH (verified in WWDC session)
|
||||
|
||||
4. **How to integrate with existing constraint validation?**
|
||||
- Answer: Use `dropSessionDidUpdate` to call `ItineraryConstraints.isValidPosition()`
|
||||
- Confidence: HIGH (existing code already has this structure)
|
||||
|
||||
### Claude's Discretion per CONTEXT.md
|
||||
|
||||
1. **Insertion line thickness?**
|
||||
- Recommendation: 3pt (middle of 2-4pt range, visible but not chunky)
|
||||
|
||||
2. **Shadow depth during drag?**
|
||||
- Recommendation: offset (0, 8), radius 16, opacity 0.25 (matches iOS style)
|
||||
|
||||
3. **Drop settle animation timing?**
|
||||
- Recommendation: 0.2s with spring damping 0.8 (matches iOS system feel)
|
||||
|
||||
4. **Auto-scroll speed and dead zone?**
|
||||
- Recommendation: Use system default. If custom needed: 60pt dead zone, 5pt/tick scroll speed
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- [Apple WWDC 2017 Session 223 - Drag and Drop with Collection and Table View](https://asciiwwdc.com/2017/sessions/223) - Authoritative delegate documentation
|
||||
- [Hacking with Swift - How to generate haptic feedback](https://www.hackingwithswift.com/example-code/uikit/how-to-generate-haptic-feedback-with-uifeedbackgenerator) - UIFeedbackGenerator patterns
|
||||
- [Hacking with Swift - How to add drag and drop to your app](https://www.hackingwithswift.com/example-code/uikit/how-to-add-drag-and-drop-to-your-app) - UITableView delegate examples
|
||||
- Codebase: `ItineraryTableViewController.swift` - Current implementation to enhance
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- [Swiftjective-C - Using UIDragPreview to Customize Drag Items](https://swiftjectivec.com/Use-Preview-Parameters-To-Customize-Drag-Items/) - Preview customization
|
||||
- [RDerik - Using Drag and Drop on UITableView for reorder](https://rderik.com/blog/using-drag-and-drop-on-uitableview-for-reorder/) - Complete implementation example
|
||||
- [Josh Spadd - UIViewRepresentable with Delegates](https://www.joshspadd.com/2024/01/swiftui-view-representable-delegates/) - Coordinator pattern
|
||||
|
||||
### Tertiary (LOW confidence)
|
||||
- General CATransform3D knowledge from training data (verify m34 value experimentally)
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- Delegate API structure: HIGH - Official Apple sources, verified in WWDC
|
||||
- Transform/animation patterns: HIGH - Standard Core Animation
|
||||
- Haptic integration: HIGH - Well-documented UIKit API
|
||||
- Custom insertion line: MEDIUM - Pattern is standard but exact positioning may need tuning
|
||||
- iOS 26 compatibility: MEDIUM - No breaking changes found but not explicitly verified
|
||||
|
||||
**Research date:** 2026-01-18
|
||||
**Valid until:** 2026-04-18 (3 months - APIs are stable)
|
||||
|
||||
## Recommendations for Planning
|
||||
|
||||
### Phase 4 Scope
|
||||
|
||||
1. **Migrate to modern drag-drop delegates**
|
||||
- Replace `canMoveRowAt`/`moveRowAt` with `UITableViewDragDelegate`/`UITableViewDropDelegate`
|
||||
- Maintain backward compatibility with existing constraint validation
|
||||
|
||||
2. **Implement custom drag preview**
|
||||
- Scale (1.02-1.03x), shadow, tilt (2-3 degrees)
|
||||
- Quick lift animation (~150ms spring)
|
||||
|
||||
3. **Add themed insertion line**
|
||||
- Custom UIView positioned between rows
|
||||
- Follow theme color, fade in/out (150ms)
|
||||
|
||||
4. **Integrate haptic feedback**
|
||||
- Light impact on grab
|
||||
- Medium impact on successful drop
|
||||
- Triple-tap error pattern on invalid drop
|
||||
|
||||
5. **Handle invalid drops**
|
||||
- Red tint overlay on dragged item
|
||||
- Spring snap-back animation (200ms with overshoot)
|
||||
|
||||
### What NOT to Build
|
||||
|
||||
- Custom auto-scroll (use system default)
|
||||
- Sound effects (per CONTEXT.md decision)
|
||||
- New constraint validation logic (Phase 2 complete)
|
||||
- New flattening logic (Phase 3 complete)
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Phase 2: `ItineraryConstraints` for `isValidPosition()` (complete)
|
||||
- Phase 3: `ItineraryFlattener` for `calculateSortOrder()` (complete)
|
||||
- Settings: Theme color for insertion line (existing)
|
||||
@@ -1,423 +0,0 @@
|
||||
# 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)
|
||||
@@ -1,243 +0,0 @@
|
||||
# Features Research: Drag-Drop Editor UX
|
||||
|
||||
**Domain:** Drag-and-drop itinerary editor for iOS sports travel app
|
||||
**Researched:** 2026-01-18
|
||||
**Confidence:** HIGH (multiple authoritative sources cross-verified)
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Polished drag-drop requires deliberate visual feedback at every state transition. The difference between "feels broken" and "feels delightful" comes down to: lift animation, predictable insertion indicators, smooth reshuffling, and magnetic snap-to-place. Your constraints (fixed day headers, fixed games, movable travel/custom items) add complexity but are achievable with proper drop zone logic.
|
||||
|
||||
---
|
||||
|
||||
## Table Stakes
|
||||
|
||||
Features users expect. Missing any of these makes the editor feel broken.
|
||||
|
||||
| Feature | Why Expected | Complexity | Implementation Notes |
|
||||
|---------|--------------|------------|---------------------|
|
||||
| **Lift animation on grab** | Users expect physical metaphor - picking up an object | Low | Elevation (shadow), scale 1.02-1.05x, slight z-offset |
|
||||
| **Ghost/placeholder at origin** | Shows where item came from, reduces anxiety | Low | Semi-transparent copy or outlined placeholder in original position |
|
||||
| **Insertion indicator line** | Must show exactly where item will drop | Medium | Horizontal line with small terminal bleeds, appears between items |
|
||||
| **Items move out of the way** | Preview of final state while dragging | Medium | ~100ms animation, triggered when dragged item center overlaps edge |
|
||||
| **Magnetic snap on drop** | Satisfying completion, confirms action worked | Low | 100ms ease-out animation to final position |
|
||||
| **Clear invalid drop feedback** | Don't leave user guessing why drop failed | Low | Item animates back to origin if dropped in invalid zone |
|
||||
| **Touch hold delay (300-500ms)** | Distinguish tap from drag intent | Low | iOS standard; prevents accidental drags |
|
||||
| **Haptic on grab** | Tactile confirmation drag started | Low | UIImpactFeedbackGenerator.light on pickup |
|
||||
| **Haptic on drop** | Tactile confirmation action completed | Low | UIImpactFeedbackGenerator.medium on successful drop |
|
||||
| **Scroll when dragging to edge** | Lists longer than viewport need auto-scroll | Medium | Scroll speed increases closer to edge, ~40px threshold |
|
||||
|
||||
### Insertion Indicator Details
|
||||
|
||||
The insertion line is critical. Best practices:
|
||||
- Appears **between** items (in the gap), not on top
|
||||
- Has small terminal bleeds (~4px) extending past item edges
|
||||
- Triggered when center of dragged item crosses edge of potential neighbor
|
||||
- Color should contrast clearly (system accent or distinct color)
|
||||
|
||||
### Animation Timing
|
||||
|
||||
| Event | Duration | Easing |
|
||||
|-------|----------|--------|
|
||||
| Lift (pickup) | 150ms | ease-out |
|
||||
| Items shuffling | 100ms | ease-out |
|
||||
| Snap to place (drop) | 100ms | ease-out |
|
||||
| Return to origin (cancel) | 200ms | ease-in-out |
|
||||
|
||||
---
|
||||
|
||||
## Nice-to-Have
|
||||
|
||||
Polish features that delight but aren't expected.
|
||||
|
||||
| Feature | Value | Complexity | Notes |
|
||||
|---------|-------|------------|-------|
|
||||
| **Slight tilt on drag (2-3 degrees)** | Trello's signature polish; makes interaction feel playful | Low | Rotate3D effect, matches brand personality |
|
||||
| **Progressive drop zone highlighting** | Visual intensifies as item approaches valid zone | Medium | Background color change, border enhancement |
|
||||
| **Multi-item drag with count badge** | Power users moving multiple items at once | High | Not needed for v1; itinerary items are usually moved one at a time |
|
||||
| **Keyboard reordering (a11y)** | Up/Down arrows via rotor actions | Medium | Important for accessibility; add accessibilityActions |
|
||||
| **Undo after drop** | Recover from mistakes | Medium | Toast with "Undo" button, ~5 second timeout |
|
||||
| **Drag handle icon** | Visual affordance for draggability | Low | 6-dot grip icon (Notion-style) or horizontal lines |
|
||||
| **Cancel drag with escape/shake** | Quick abort | Low | Shake-to-cancel on iOS; return to origin |
|
||||
| **Drop zone "ready" state** | Zone visually activates before item enters | Low | Subtle background shift when drag starts |
|
||||
|
||||
### Tilt Animation (Trello-style)
|
||||
|
||||
The 2-3 degree tilt on dragged items is considered "gold standard" polish:
|
||||
- Adds personality without being distracting
|
||||
- Reinforces physical metaphor (picking up a card)
|
||||
- Should match your app's design language (may be too playful for some apps)
|
||||
|
||||
---
|
||||
|
||||
## Overkill
|
||||
|
||||
Skip these - high complexity, low value for an itinerary editor.
|
||||
|
||||
| Feature | Why Skip | What to Do Instead |
|
||||
|---------|----------|-------------------|
|
||||
| **Drag between sections/screens** | Your items live within days; cross-day moves are rare | Allow within same list only, or use "Move to..." action menu |
|
||||
| **Nested drag-drop** | Games within days is hierarchy enough | Keep flat list per day section |
|
||||
| **Free-form canvas positioning** | Not applicable to linear itinerary | Stick to list reordering |
|
||||
| **Real-time collaborative drag** | Massive sync complexity | Single-user editing |
|
||||
| **Drag-to-resize** | Items don't have variable size | Fixed item heights |
|
||||
| **Custom drag preview images** | Native preview is sufficient | Use default lifted appearance |
|
||||
| **Physics-based spring animations** | Overkill for list reordering | Simple ease-out is fine |
|
||||
|
||||
---
|
||||
|
||||
## Interactions to Support
|
||||
|
||||
Specific drag scenarios for your itinerary context.
|
||||
|
||||
### Scenario 1: Move Custom Item Within Same Day
|
||||
|
||||
**User intent:** Reorder "Dinner at Lou Malnati's" from after to before the Cubs game
|
||||
|
||||
**Expected behavior:**
|
||||
1. Long-press on custom item (300ms) - haptic feedback
|
||||
2. Item lifts (shadow + scale), ghost remains at origin
|
||||
3. Drag within day section - insertion line appears between valid positions
|
||||
4. Games and travel segments shuffle with 100ms animation
|
||||
5. Drop - item snaps into place, haptic confirms
|
||||
|
||||
**Constraints:**
|
||||
- Custom item can move anywhere within the day
|
||||
- Cannot move before/after day header
|
||||
- Cannot replace or overlay a game (games are fixed)
|
||||
|
||||
### Scenario 2: Move Custom Item to Different Day
|
||||
|
||||
**User intent:** Move hotel check-in from Day 2 to Day 1
|
||||
|
||||
**Expected behavior:**
|
||||
1. Long-press and lift
|
||||
2. Drag toward Day 1 section
|
||||
3. Auto-scroll if Day 1 is off-screen
|
||||
4. Insertion line appears at valid positions in Day 1
|
||||
5. Day 2 collapses to show item removed; Day 1 expands
|
||||
6. Drop - item now in Day 1
|
||||
|
||||
**Constraints:**
|
||||
- Can cross day boundaries
|
||||
- Still cannot land on games
|
||||
|
||||
### Scenario 3: Move Travel Segment (Constrained)
|
||||
|
||||
**User intent:** Move "Drive: Chicago to Milwaukee" earlier in the day
|
||||
|
||||
**Expected behavior:**
|
||||
1. Long-press on travel segment
|
||||
2. Item lifts (possibly with different visual treatment since it's constrained)
|
||||
3. Insertion line only appears at **valid** positions (before/after games it connects)
|
||||
4. Invalid positions show no insertion line (or dimmed indicator)
|
||||
5. If dropped at invalid position, item animates back to origin
|
||||
|
||||
**Constraints:**
|
||||
- Travel segments connect stadiums/locations
|
||||
- Can only move within logical route order
|
||||
- Must validate position before showing insertion indicator
|
||||
|
||||
### Scenario 4: Attempt to Move Fixed Item (Game)
|
||||
|
||||
**User intent:** User tries to drag a game (not allowed)
|
||||
|
||||
**Expected behavior:**
|
||||
1. Long-press on game item
|
||||
2. **No lift animation** - item doesn't respond as draggable
|
||||
3. Optionally: subtle shake or tooltip "Games cannot be reordered"
|
||||
4. User understands this item is fixed
|
||||
|
||||
**Visual differentiation:**
|
||||
- Fixed items should NOT have drag handles
|
||||
- Could have different visual treatment (no grip icon, different background)
|
||||
|
||||
### Scenario 5: Drag to Invalid Zone
|
||||
|
||||
**User intent:** User drags custom item but releases over a game
|
||||
|
||||
**Expected behavior:**
|
||||
1. Item is being dragged
|
||||
2. Hovers over game - no insertion line appears (invalid)
|
||||
3. User releases
|
||||
4. Item animates back to origin (~200ms)
|
||||
5. Optional: brief error state or haptic warning
|
||||
|
||||
---
|
||||
|
||||
## Visual States Summary
|
||||
|
||||
| Element State | Visual Treatment |
|
||||
|--------------|------------------|
|
||||
| **Resting (draggable)** | Normal appearance, optional drag handle icon on hover/focus |
|
||||
| **Resting (fixed)** | Normal, but NO drag handle; visually distinct |
|
||||
| **Lifted/grabbed** | Elevated (shadow), slight scale up (1.02-1.05), optional tilt |
|
||||
| **Ghost at origin** | Semi-transparent (30-50% opacity) or outlined placeholder |
|
||||
| **Insertion line** | Accent-colored horizontal line, ~2px height, bleeds past edges |
|
||||
| **Invalid drop zone** | No insertion line; item over zone dims or shows warning |
|
||||
| **Drop zone ready** | Subtle background color shift when any drag starts |
|
||||
| **Dropped/success** | Snaps to place, haptic feedback, ghost disappears |
|
||||
| **Cancelled/error** | Returns to origin with animation, optional warning haptic |
|
||||
|
||||
---
|
||||
|
||||
## Accessibility Requirements
|
||||
|
||||
| Requirement | Implementation | Priority |
|
||||
|-------------|----------------|----------|
|
||||
| **VoiceOver reordering** | accessibilityActions with "Move Up" / "Move Down" | High |
|
||||
| **Rotor integration** | Actions appear in VoiceOver rotor | High |
|
||||
| **Focus management** | Focus follows moved item after reorder | Medium |
|
||||
| **Live region announcements** | Announce position change ("Item moved to position 3") | Medium |
|
||||
| **Fallback buttons** | Optional up/down arrows as visual alternative | Low (nice to have) |
|
||||
|
||||
SwiftUI example for accessibility:
|
||||
```swift
|
||||
.accessibilityAction(named: "Move Up") { moveItemUp(item) }
|
||||
.accessibilityAction(named: "Move Down") { moveItemDown(item) }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mobile-Specific Considerations
|
||||
|
||||
| Concern | Solution |
|
||||
|---------|----------|
|
||||
| **Fat finger problem** | Minimum 44x44pt touch targets; drag handles at least 44pt wide |
|
||||
| **Scroll vs. drag conflict** | Long-press delay (300-500ms) distinguishes intent |
|
||||
| **Viewport limitations** | Auto-scroll at edges (40px threshold), speed increases near edge |
|
||||
| **One-handed use** | Consider "Move to..." button as alternative to long-distance drags |
|
||||
| **Accidental drops** | Generous drop zones; magnetic snap; undo option |
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
| Anti-Pattern | Why Bad | Do Instead |
|
||||
|--------------|---------|------------|
|
||||
| **Edge-to-edge shuffle trigger** | Feels "twitchy", items move unexpectedly | Use center-overlap-edge trigger |
|
||||
| **Instant reshuffle (no animation)** | Disorienting, hard to track what moved | 100ms animated transitions |
|
||||
| **No ghost/placeholder** | User loses context of original position | Always show origin indicator |
|
||||
| **Drag handle too small** | Frustrating on touch | Minimum 44pt, ideally larger |
|
||||
| **Remove item during drag** | Anxiety - "where did it go?" | Keep ghost visible at origin |
|
||||
| **Scroll too fast at edges** | Overshoots, loses control | Gradual speed increase |
|
||||
| **No invalid feedback** | User thinks interaction is broken | Clear visual/haptic for invalid drops |
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
**High Confidence (verified with multiple authoritative sources):**
|
||||
- [Smart Interface Design Patterns - Drag and Drop UX](https://smart-interface-design-patterns.com/articles/drag-and-drop-ux/)
|
||||
- [Atlassian Pragmatic Drag and Drop Design Guidelines](https://atlassian.design/components/pragmatic-drag-and-drop/design-guidelines/)
|
||||
- [Pencil & Paper - Drag & Drop UX Design Best Practices](https://www.pencilandpaper.io/articles/ux-pattern-drag-and-drop)
|
||||
- [Nielsen Norman Group - Drag and Drop: How to Design for Ease of Use](https://www.nngroup.com/articles/drag-drop/)
|
||||
|
||||
**Medium Confidence (single authoritative source):**
|
||||
- [LogRocket - Designing Drag and Drop UIs](https://blog.logrocket.com/ux-design/drag-and-drop-ui-examples/)
|
||||
- [Darin Senneff - Designing a Reorderable List Component](https://www.darins.page/articles/designing-a-reorderable-list-component)
|
||||
- [Apple Human Interface Guidelines - Drag and Drop](https://developer.apple.com/design/human-interface-guidelines/drag-and-drop)
|
||||
|
||||
**Low Confidence (community patterns):**
|
||||
- Various SwiftUI implementation guides (verify APIs against current documentation)
|
||||
- Trello UX patterns referenced in multiple articles (de facto standard)
|
||||
@@ -1,380 +0,0 @@
|
||||
# Pitfalls Research: UITableView Drag-Drop with Semantic Positioning
|
||||
|
||||
**Domain:** iOS drag-drop reordering with constrained semantic positions (day + sortOrder)
|
||||
**Researched:** 2026-01-18
|
||||
**Context:** SportsTime itinerary editor - trip items constrained by game schedules
|
||||
|
||||
## Critical Pitfalls
|
||||
|
||||
### 1. Row Index vs Semantic Position Confusion
|
||||
|
||||
**What goes wrong:** Code treats UITableView row indices as the source of truth instead of semantic positions (day, sortOrder). When the table flattens hierarchical data, row indices become disconnected from business logic.
|
||||
|
||||
**Why it happens:** UITableView's `moveRowAt:to:` gives you row indices. It's tempting to translate row → position directly. But flattening destroys the semantic relationship.
|
||||
|
||||
**Consequences:**
|
||||
- Items appear in wrong positions after reload
|
||||
- Constraints calculated against stale row indices
|
||||
- Save/load round-trip loses item positions
|
||||
- Drag logic and reload logic fight each other (observed in previous attempts)
|
||||
|
||||
**Prevention:**
|
||||
1. Define canonical semantic model: `(day: Int, sortOrder: Double)` per item
|
||||
2. Row indices are DISPLAY concerns only - never persist them
|
||||
3. All constraint validation operates on semantic positions, not rows
|
||||
4. After drop, immediately calculate semantic position, discard row index
|
||||
|
||||
**Detection (Warning Signs):**
|
||||
- Code stores row indices anywhere except during drag
|
||||
- Constraint checks reference `indexPath.row` instead of `item.day`/`item.sortOrder`
|
||||
- Test passes with static data but fails after reload
|
||||
|
||||
**Phase to Address:** Phase 1 (data model design) - get this wrong and everything else breaks
|
||||
|
||||
---
|
||||
|
||||
### 2. Treating Travel as Structural Instead of Positional
|
||||
|
||||
**What goes wrong:** Travel segments treated as "travelBefore" (attached to a day) instead of independent positioned items.
|
||||
|
||||
**Why it happens:** It's intuitive to think "travel happens before Day 3" rather than "travel is an item with day=3, sortOrder=-1.5". The former creates tight coupling.
|
||||
|
||||
**Consequences:**
|
||||
- Can't position travel AFTER games on the same day (morning arrival vs evening arrival)
|
||||
- Reordering travel requires updating the day's structural property, not just the item
|
||||
- Travel placement logic diverges from custom item logic (code duplication)
|
||||
- Hard to represent "travel morning of game day" vs "travel after last game"
|
||||
|
||||
**Prevention:**
|
||||
1. Travel is an item with `kind: .travel`, not a day property
|
||||
2. Use `sortOrder < 0` convention for "before games", `sortOrder >= 0` for "after games"
|
||||
3. Travel follows same drag/drop code path as custom items (with additional constraints)
|
||||
4. Store travel position the same way as other items: `(day, sortOrder)`
|
||||
|
||||
**Detection (Warning Signs):**
|
||||
- Data model has `travelBefore` or `travelDay` as a day property
|
||||
- Different code paths for moving travel vs moving custom items
|
||||
- Can't drop travel between two games on the same day
|
||||
|
||||
**Phase to Address:** Phase 1 (data model) - defines how travel is represented
|
||||
|
||||
---
|
||||
|
||||
### 3. Hard-Coded Flatten Order That Ignores sortOrder
|
||||
|
||||
**What goes wrong:** Flattening algorithm builds rows in a fixed order (header, travel, games, custom items) and ignores actual sortOrder values.
|
||||
|
||||
**Why it happens:** Initial implementation works without sortOrder, so it gets hard-coded. Then sortOrder is added for persistence but flatten logic isn't updated.
|
||||
|
||||
**Consequences:**
|
||||
- Items render in wrong order even though sortOrder is correct in data
|
||||
- Drag works during session but positions reset after view reload
|
||||
- Tests pass for initial render, fail for reload scenarios
|
||||
|
||||
**Prevention:**
|
||||
1. Flatten algorithm MUST sort by `sortOrder` within each day
|
||||
2. Use `sortOrder < 0` convention to place items before games, `sortOrder >= 0` after
|
||||
3. Write test: "items render in sortOrder order after reload"
|
||||
4. Single source of truth: `flatItems = items.sorted(by: { $0.sortOrder < $1.sortOrder })`
|
||||
|
||||
**Detection (Warning Signs):**
|
||||
- Flatten code has `if .travel { append }` followed by `if .games { append }` without sorting
|
||||
- Items snap to different positions after view reload
|
||||
- Manual reordering works but persistence loses order
|
||||
|
||||
**Phase to Address:** Phase 2 (view implementation) - flattening is the bridge from model to display
|
||||
|
||||
---
|
||||
|
||||
### 4. Data Model Out of Sync During Drag
|
||||
|
||||
**What goes wrong:** UITableView's visual state diverges from data model during drag, causing `NSInternalInconsistencyException` crashes.
|
||||
|
||||
**Why it happens:** UITableView manages its own internal row state during drag. If you call `reloadData()` or `performBatchUpdates()` while dragging, the table's internal state conflicts with yours.
|
||||
|
||||
**Consequences:**
|
||||
- Crash: "attempt to delete row X from section Y which only contains Z rows"
|
||||
- Crash: "Invalid update: invalid number of rows in section"
|
||||
- Visual glitches where rows jump or disappear
|
||||
|
||||
**Prevention:**
|
||||
1. Never call `reloadData()` during active drag
|
||||
2. Update data model in `moveRowAt:to:` completion (after UITableView has settled)
|
||||
3. Guard SwiftUI updates that would trigger re-render during drag
|
||||
4. Use `draggingItem != nil` flag to skip external data updates
|
||||
|
||||
**Detection (Warning Signs):**
|
||||
- Crashes during drag (not on drop)
|
||||
- SwiftUI parent triggers updates that propagate to UIKit during drag
|
||||
- `performBatchUpdates` called from background thread
|
||||
|
||||
**Phase to Address:** Phase 2 (view implementation) - UIKit/SwiftUI bridging requires explicit guards
|
||||
|
||||
---
|
||||
|
||||
### 5. reloadData vs performBatchUpdates During Reorder
|
||||
|
||||
**What goes wrong:** Using `reloadData()` after drop causes flickering, scroll position reset, and lost drag handle state.
|
||||
|
||||
**Why it happens:** `reloadData()` is the simple approach, but it destroys all cell state. During reordering, this fights with UIKit's internal animations.
|
||||
|
||||
**Consequences:**
|
||||
- Flickering after drop (entire table redraws)
|
||||
- Scroll position jumps to top
|
||||
- Cell selection state lost
|
||||
- No smooth animation for settled items
|
||||
|
||||
**Prevention:**
|
||||
1. After drop, update `flatItems` in place (remove/insert)
|
||||
2. Let UITableView's internal move animation complete naturally
|
||||
3. Only call `reloadData()` for external data changes (not user reorder)
|
||||
4. For external changes during editing, batch updates or defer until drag ends
|
||||
|
||||
**Detection (Warning Signs):**
|
||||
- Visible flicker after dropping an item
|
||||
- Scroll position resets after reorder
|
||||
- Debug logs show `reloadData` called in `moveRowAt:to:`
|
||||
|
||||
**Phase to Address:** Phase 2 (view implementation)
|
||||
|
||||
---
|
||||
|
||||
### 6. Coordinate Space Confusion in targetIndexPath
|
||||
|
||||
**What goes wrong:** `targetIndexPathForMoveFromRowAt:toProposedIndexPath:` operates in "proposed" coordinate space where the source row is conceptually removed. Code assumes original coordinates.
|
||||
|
||||
**Why it happens:** UITableView's coordinate system during drag is subtle. "Proposed destination row 5" means "row 5 after removing the source". If source was row 2, the original array's row 5 is now row 4.
|
||||
|
||||
**Consequences:**
|
||||
- Constraints validated against wrong row
|
||||
- Items snap to unexpected positions
|
||||
- Off-by-one errors in constraint checking
|
||||
|
||||
**Prevention:**
|
||||
1. Understand UIKit's drag semantics: destination is in "post-removal" space
|
||||
2. When validating constraints, simulate the move first
|
||||
3. Pre-compute valid destination rows in proposed coordinate space at drag start
|
||||
4. Use helper: `simulateMove(original:, sourceRow:, destinationProposedRow:)`
|
||||
|
||||
**Detection (Warning Signs):**
|
||||
- Constraint validation works for some drags, fails for others
|
||||
- Off-by-one errors when source is above/below destination
|
||||
- Tests pass when source is first row, fail otherwise
|
||||
|
||||
**Phase to Address:** Phase 2 (constraint validation during drag)
|
||||
|
||||
---
|
||||
|
||||
## Subtle Pitfalls
|
||||
|
||||
### 7. iPhone vs iPad Drag Interaction Defaults
|
||||
|
||||
**What goes wrong:** Drag works on iPad but not iPhone because `dragInteractionEnabled` defaults to `false` on iPhone.
|
||||
|
||||
**Why it happens:** iPad has split-screen multitasking where drag-drop is common. iPhone doesn't, so Apple disabled it by default.
|
||||
|
||||
**Prevention:**
|
||||
```swift
|
||||
tableView.dragInteractionEnabled = true // Required for iPhone
|
||||
```
|
||||
|
||||
**Detection:** Drag handle visible but nothing happens when dragging on iPhone
|
||||
|
||||
**Phase to Address:** Phase 2 (initial setup)
|
||||
|
||||
---
|
||||
|
||||
### 8. NSItemProvider Without Object Breaks Mac Catalyst
|
||||
|
||||
**What goes wrong:** Drag works on iOS but drop handlers never fire on Mac Catalyst.
|
||||
|
||||
**Why it happens:** Mac Catalyst has stricter requirements. An `NSItemProvider` constructed without an object causes silent failures even if `localObject` is set.
|
||||
|
||||
**Prevention:**
|
||||
```swift
|
||||
// Wrong: NSItemProvider with localObject only
|
||||
let provider = NSItemProvider()
|
||||
provider.suggestLocalObject(item) // Breaks Catalyst
|
||||
|
||||
// Right: NSItemProvider with actual object
|
||||
let provider = NSItemProvider(object: item as NSItemProviderWriting)
|
||||
```
|
||||
|
||||
**Detection:** Works on iOS simulator, fails on Mac Catalyst
|
||||
|
||||
**Phase to Address:** Phase 2 (if supporting Mac Catalyst)
|
||||
|
||||
---
|
||||
|
||||
### 9. Nil Destination Index Path on Whitespace Drop
|
||||
|
||||
**What goes wrong:** User drops item on empty area of table, `destinationIndexPath` is nil, app crashes or behaves unexpectedly.
|
||||
|
||||
**Why it happens:** `dropSessionDidUpdate` and `performDropWith` receive nil destination when dropping over areas without cells.
|
||||
|
||||
**Prevention:**
|
||||
```swift
|
||||
let destinationIndexPath: IndexPath
|
||||
if let indexPath = coordinator.destinationIndexPath {
|
||||
destinationIndexPath = indexPath
|
||||
} else {
|
||||
// Drop on whitespace: append to last section
|
||||
let section = tableView.numberOfSections - 1
|
||||
let row = tableView.numberOfRows(inSection: section)
|
||||
destinationIndexPath = IndexPath(row: row, section: section)
|
||||
}
|
||||
```
|
||||
|
||||
**Detection:** Crash when dropping below last visible row
|
||||
|
||||
**Phase to Address:** Phase 2 (drop handling)
|
||||
|
||||
---
|
||||
|
||||
### 10. sortOrder Precision Exhaustion
|
||||
|
||||
**What goes wrong:** After many insertions between items, midpoint algorithm produces values too close together to distinguish.
|
||||
|
||||
**Why it happens:** Repeatedly inserting between two values (1.0 and 2.0 -> 1.5 -> 1.25 -> 1.125...) eventually exhausts Double precision.
|
||||
|
||||
**Consequences:**
|
||||
- Items with "equal" sortOrder render in undefined order
|
||||
- Reorder appears to work but fails on reload
|
||||
|
||||
**Prevention:**
|
||||
1. Double has ~15 significant digits - sufficient for ~50 midpoint insertions
|
||||
2. For extreme cases, implement "normalize" function that resets to 1.0, 2.0, 3.0...
|
||||
3. Monitor: if `abs(a.sortOrder - b.sortOrder) < 1e-10`, trigger normalize
|
||||
|
||||
**Detection:** Items render in wrong order despite correct sortOrder values
|
||||
|
||||
**Phase to Address:** Phase 3 (long-term maintenance) - unlikely in normal use
|
||||
|
||||
---
|
||||
|
||||
### 11. Missing Section Header Handling
|
||||
|
||||
**What goes wrong:** Day headers (section markers) treated as drop targets, items get "stuck" at day boundaries.
|
||||
|
||||
**Why it happens:** If day headers are regular rows, nothing stops items from being dropped ON them instead of after them.
|
||||
|
||||
**Prevention:**
|
||||
1. Day headers are non-reorderable (`canMoveRowAt` returns false)
|
||||
2. `targetIndexPathForMoveFromRowAt` redirects drops ON headers to AFTER headers
|
||||
3. Or use actual UITableView sections with headers (more complex)
|
||||
|
||||
**Detection:** Items can be dragged onto day header rows
|
||||
|
||||
**Phase to Address:** Phase 2 (drop target validation)
|
||||
|
||||
---
|
||||
|
||||
### 12. SwiftUI Update Loop with UIHostingConfiguration
|
||||
|
||||
**What goes wrong:** UIHostingConfiguration cell causes infinite layout/update loops.
|
||||
|
||||
**Why it happens:** SwiftUI state change -> cell update -> triggers UITableView layout -> triggers another SwiftUI update.
|
||||
|
||||
**Prevention:**
|
||||
1. Track header height changes with threshold (`abs(new - old) > 1.0`)
|
||||
2. Use `isAdjustingHeader` guard to prevent re-entrant updates
|
||||
3. Don't pass changing state through UIHostingConfiguration during drag
|
||||
|
||||
**Detection:** App freezes or CPU spins during table interaction
|
||||
|
||||
**Phase to Address:** Phase 2 (UIKit/SwiftUI bridging)
|
||||
|
||||
---
|
||||
|
||||
## Previous Failures (Addressed)
|
||||
|
||||
Based on the stated previous failures, here's how to address each:
|
||||
|
||||
### "Row-based snapping instead of semantic (day, sortOrder)"
|
||||
|
||||
**Root Cause:** Using row indices as positions
|
||||
**Fix:** Define `ItineraryItem` with `day: Int` and `sortOrder: Double`. All position logic operates on these fields, never row indices. Row indices are ephemeral display concerns.
|
||||
|
||||
### "Treating travel as structural ('travelBefore') instead of positional"
|
||||
|
||||
**Root Cause:** Travel was a day property, not an item
|
||||
**Fix:** Travel is an `ItineraryItem` with `kind: .travel(TravelInfo)`. It has its own `day` and `sortOrder` like any other item. Use `sortOrder < 0` for "before games" convention.
|
||||
|
||||
### "Losing sortOrder during flattening"
|
||||
|
||||
**Root Cause:** Flatten algorithm ignored sortOrder, used hard-coded order
|
||||
**Fix:** Flatten sorts items by `sortOrder` within each day. Write test: "after drop and reload, items appear in same order".
|
||||
|
||||
### "Hard-coded flatten order that ignored sortOrder"
|
||||
|
||||
**Root Cause:** Same as above - flatten was `header, travel, games, custom` without sorting
|
||||
**Fix:** Split items into `beforeGames` (sortOrder < 0) and `afterGames` (sortOrder >= 0), sort each group by sortOrder, then assemble: header -> beforeGames -> games -> afterGames.
|
||||
|
||||
### "Drag logic and reload logic fighting each other"
|
||||
|
||||
**Root Cause:** SwiftUI parent triggered reloads during UIKit drag
|
||||
**Fix:**
|
||||
1. `draggingItem != nil` flag guards against external updates
|
||||
2. Never call `reloadData()` in `moveRowAt:to:`
|
||||
3. Use completion handler or end-drag callback for state sync
|
||||
|
||||
---
|
||||
|
||||
## Warning Signs Checklist
|
||||
|
||||
Use this during implementation to catch problems early:
|
||||
|
||||
### Data Model Red Flags
|
||||
- [ ] Row indices stored anywhere except during active drag
|
||||
- [ ] `travelDay` or `travelBefore` as a day property
|
||||
- [ ] No `sortOrder` field on reorderable items
|
||||
- [ ] Different data structures for travel vs custom items
|
||||
|
||||
### Flatten/Display Red Flags
|
||||
- [ ] Hard-coded render order that doesn't reference sortOrder
|
||||
- [ ] Items render correctly initially but wrong after reload
|
||||
- [ ] Constraint checks use row indices instead of semantic positions
|
||||
|
||||
### Drag Interaction Red Flags
|
||||
- [ ] Crashes during drag (before drop completes)
|
||||
- [ ] Flickering or scroll jump after drop
|
||||
- [ ] Works on iPad but not iPhone
|
||||
- [ ] Works in simulator but not Mac Catalyst
|
||||
|
||||
### Persistence Red Flags
|
||||
- [ ] Items change position after save/load cycle
|
||||
- [ ] Debug logs show mismatched positions before/after reload
|
||||
- [ ] Tests pass for single operation but fail for sequences
|
||||
|
||||
---
|
||||
|
||||
## Phase Mapping
|
||||
|
||||
| Pitfall | Phase to Address | Risk Level |
|
||||
|---------|------------------|------------|
|
||||
| Row Index vs Semantic | Phase 1 (Data Model) | CRITICAL |
|
||||
| Travel as Structural | Phase 1 (Data Model) | CRITICAL |
|
||||
| Hard-coded Flatten | Phase 2 (View) | CRITICAL |
|
||||
| Data Out of Sync | Phase 2 (View) | HIGH |
|
||||
| reloadData vs Batch | Phase 2 (View) | HIGH |
|
||||
| Coordinate Space | Phase 2 (Constraints) | HIGH |
|
||||
| iPhone Drag Disabled | Phase 2 (Setup) | MEDIUM |
|
||||
| NSItemProvider Catalyst | Phase 2 (if Mac) | MEDIUM |
|
||||
| Nil Destination | Phase 2 (Drop) | MEDIUM |
|
||||
| sortOrder Precision | Phase 3 (Maintenance) | LOW |
|
||||
| Section Headers | Phase 2 (Validation) | MEDIUM |
|
||||
| SwiftUI Update Loop | Phase 2 (Bridging) | MEDIUM |
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
- [Apple: Adopting drag and drop in a table view](https://developer.apple.com/documentation/uikit/drag_and_drop/adopting_drag_and_drop_in_a_table_view)
|
||||
- [Apple: Supporting drag and drop in table views](https://developer.apple.com/documentation/uikit/views_and_controls/table_views/supporting_drag_and_drop_in_table_views)
|
||||
- [WWDC 2017 Session 223: Drag and Drop with Collection and Table View](https://asciiwwdc.com/2017/sessions/223)
|
||||
- [Apple Developer Forums: UITableView Drag Drop between sections](https://developer.apple.com/forums/thread/96034)
|
||||
- [Apple Developer Forums: Drag and drop reorder not working on iPhone](https://developer.apple.com/forums/thread/80873)
|
||||
- [Swiftjective-C: Drag to Reorder with Diffable Datasource](https://swiftjectivec.com/Tableview-Diffable-Datasource-Drag-to-Reorder/)
|
||||
- [Bumble Tech: Batch updates for UITableView and UICollectionView](https://medium.com/bumble-tech/batch-updates-for-uitableview-and-uicollectionview-baaa1e6a66b5)
|
||||
- [Hacking with Swift: How to add drag and drop to your app](https://www.hackingwithswift.com/example-code/uikit/how-to-add-drag-and-drop-to-your-app)
|
||||
- SportsTime codebase analysis: `ItineraryTableViewController.swift`, `ItineraryConstraints.swift`, `CONCERNS.md`
|
||||
@@ -1,291 +0,0 @@
|
||||
# Stack Research: UITableView Drag-Drop for Itinerary Editor
|
||||
|
||||
**Project:** SportsTime Itinerary Editor
|
||||
**Researched:** 2026-01-18
|
||||
**Overall Confidence:** HIGH (existing implementation in codebase + stable APIs)
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The SportsTime codebase already contains a production-quality UITableView drag-drop implementation in `ItineraryTableViewController.swift` and `ItineraryTableViewWrapper.swift`. This research validates that approach and documents the recommended stack for extending it to support external drops.
|
||||
|
||||
**Key Finding:** The existing implementation uses the traditional `canMoveRowAt`/`moveRowAt` approach with `tableView.isEditing = true`. For external drops (from outside the table), the codebase will need to add `UITableViewDropDelegate` protocol conformance.
|
||||
|
||||
---
|
||||
|
||||
## Recommended APIs
|
||||
|
||||
### Core APIs (Already in Use)
|
||||
|
||||
| API | Purpose | Confidence |
|
||||
|-----|---------|------------|
|
||||
| `UITableViewController` | Native table with built-in drag handling | HIGH |
|
||||
| `tableView.isEditing = true` | Enables drag handles on rows | HIGH |
|
||||
| `canMoveRowAt:` | Controls which rows show drag handles | HIGH |
|
||||
| `moveRowAt:to:` | Called when reorder completes | HIGH |
|
||||
| `targetIndexPathForMoveFromRowAt:toProposedIndexPath:` | Real-time validation during drag | HIGH |
|
||||
| `UIHostingConfiguration` | Embeds SwiftUI views in cells | HIGH |
|
||||
|
||||
**Rationale:** These APIs provide the smooth, native iOS reordering experience with real-time insertion line feedback. The existing implementation demonstrates this working well.
|
||||
|
||||
### APIs Needed for External Drops
|
||||
|
||||
| API | Purpose | When to Use | Confidence |
|
||||
|-----|---------|-------------|------------|
|
||||
| `UITableViewDropDelegate` | Accept drops from outside the table | Required for external drops | HIGH |
|
||||
| `UITableViewDragDelegate` | Provide drag items (not strictly needed if only receiving) | Optional | HIGH |
|
||||
| `dropSessionDidUpdate(_:withDestinationIndexPath:)` | Validate drop during hover | Shows insertion feedback for external drags | HIGH |
|
||||
| `performDropWith(_:)` | Handle external drop completion | Called only for external drops (not internal moves) | HIGH |
|
||||
| `canHandle(_:)` | Validate drop session types | Filter what can be dropped | HIGH |
|
||||
| `NSItemProvider` | Data transfer wrapper | Encodes dragged item data | HIGH |
|
||||
| `UIDragItem.localObject` | In-app optimization | Avoids encoding when drag is same-app | HIGH |
|
||||
|
||||
**Rationale:** For external drops, `UITableViewDropDelegate` is required. The key insight from research: when both `moveRowAt:` and `performDropWith:` are implemented, UIKit automatically routes internal reorders through `moveRowAt:` and external drops through `performDropWith:`. This is documented behavior.
|
||||
|
||||
---
|
||||
|
||||
## SwiftUI Integration Pattern
|
||||
|
||||
### Current Pattern (Validated)
|
||||
|
||||
The codebase uses `UIViewControllerRepresentable` with a Coordinator pattern:
|
||||
|
||||
```swift
|
||||
struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresentable {
|
||||
// Callbacks for data mutations (lifted state)
|
||||
var onTravelMoved: ((String, Int, Double) -> Void)?
|
||||
var onCustomItemMoved: ((UUID, Int, Double) -> Void)?
|
||||
|
||||
class Coordinator {
|
||||
var headerHostingController: UIHostingController<HeaderContent>?
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator()
|
||||
}
|
||||
|
||||
func makeUIViewController(context: Context) -> ItineraryTableViewController {
|
||||
let controller = ItineraryTableViewController(style: .plain)
|
||||
// Configure callbacks
|
||||
return controller
|
||||
}
|
||||
|
||||
func updateUIViewController(_ controller: ItineraryTableViewController, context: Context) {
|
||||
// Push new data to controller
|
||||
controller.reloadData(days: days, ...)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Confidence:** HIGH (implemented and working)
|
||||
|
||||
### For External Drops: Callback Extension
|
||||
|
||||
Add a new callback for external drops:
|
||||
|
||||
```swift
|
||||
var onExternalItemDropped: ((ExternalDropItem, Int, Double) -> Void)?
|
||||
// Parameters: dropped item, target day, target sortOrder
|
||||
```
|
||||
|
||||
The ItineraryTableViewController would need to:
|
||||
1. Conform to `UITableViewDropDelegate`
|
||||
2. Set `tableView.dropDelegate = self`
|
||||
3. Implement required delegate methods
|
||||
4. Call the callback when external drop completes
|
||||
|
||||
**Confidence:** HIGH (standard pattern extension)
|
||||
|
||||
---
|
||||
|
||||
## What to Avoid
|
||||
|
||||
### Anti-Pattern 1: SwiftUI-Only Drag-Drop for Complex Reordering
|
||||
|
||||
**What:** Using `.draggable()` / `.dropDestination()` / `.onMove()` directly in SwiftUI List
|
||||
|
||||
**Why Avoid:**
|
||||
- No real-time insertion line feedback during drag (item only moves on drop)
|
||||
- `ForEach.onMove` only works within a single section
|
||||
- Limited control over valid drop positions during drag
|
||||
- iPhone has additional limitations for SwiftUI List drag-drop
|
||||
|
||||
**Evidence:** The codebase documentation explicitly states: "SwiftUI's drag-and-drop APIs have significant limitations for complex reordering"
|
||||
|
||||
**Confidence:** HIGH
|
||||
|
||||
### Anti-Pattern 2: Third-Party Reordering Libraries
|
||||
|
||||
**What:** Using libraries like SwiftReorder, LPRTableView, TableViewDragger
|
||||
|
||||
**Why Avoid:**
|
||||
- Compatibility issues with recent iOS versions reported
|
||||
- Built-in UITableView drag-drop (iOS 11+) is more reliable
|
||||
- Additional dependency for functionality that's native
|
||||
|
||||
**Evidence:** Multiple search results recommend "use the built-in UITableView drag and drop API" over third-party libraries
|
||||
|
||||
**Confidence:** MEDIUM (anecdotal reports)
|
||||
|
||||
### Anti-Pattern 3: Mixing Diffable Data Source with Manual Array Updates
|
||||
|
||||
**What:** Using `UITableViewDiffableDataSource` but manually manipulating the array in `moveRowAt:`
|
||||
|
||||
**Why Avoid:**
|
||||
- Risk of data source inconsistency
|
||||
- Diffable data sources have their own update patterns
|
||||
- The current implementation uses manual `flatItems` array management which works correctly
|
||||
|
||||
**If Using Diffable Data Source:** Must reconcile changes through snapshot mechanism, not direct array manipulation
|
||||
|
||||
**Confidence:** MEDIUM
|
||||
|
||||
### Anti-Pattern 4: Ignoring `localObject` for Same-App Drops
|
||||
|
||||
**What:** Always encoding/decoding NSItemProvider data even for internal drops
|
||||
|
||||
**Why Avoid:**
|
||||
- Unnecessary overhead for same-app transfers
|
||||
- `UIDragItem.localObject` provides direct object access without serialization
|
||||
- More complex code for no benefit
|
||||
|
||||
**Best Practice:** Check `localObject` first, fall back to NSItemProvider decoding only for cross-app drops
|
||||
|
||||
**Confidence:** HIGH
|
||||
|
||||
---
|
||||
|
||||
## iOS 26 Considerations
|
||||
|
||||
### New SwiftUI Drag-Drop Modifiers (iOS 26)
|
||||
|
||||
iOS 26 introduces improved SwiftUI drag-drop modifiers:
|
||||
- `.draggable(containerItemID:)` - Marks items as draggable
|
||||
- `.dragContainer(for:selection:)` - Defines container and selection
|
||||
- `.dragConfiguration()` - Controls behavior (allowMove, allowDelete)
|
||||
- `.onDragSessionUpdated()` - Handles drag phases
|
||||
- `.dragPreviewsFormation(.stack)` - Customizes preview
|
||||
|
||||
**Assessment:** These are promising for simpler use cases, particularly macOS file management UIs. However, for the existing UITableView-based itinerary editor:
|
||||
|
||||
**Recommendation:** Keep the UITableView approach. The new SwiftUI modifiers don't provide the same level of control needed for:
|
||||
- Constraint-aware drop validation (travel can only go on certain days)
|
||||
- Real-time insertion line between specific rows
|
||||
- Semantic positioning (day + sortOrder) vs row indices
|
||||
|
||||
**Confidence:** MEDIUM (iOS 26 APIs are new, full capabilities not fully documented)
|
||||
|
||||
### Swift 6 Concurrency Considerations
|
||||
|
||||
The existing `ItineraryTableViewController` is a `final class` (not actor). Key considerations:
|
||||
|
||||
1. **Coordinator should be `@MainActor`** - Delegate callbacks occur on main thread
|
||||
2. **Callbacks are closures** - Already work correctly with Swift 6
|
||||
3. **No async operations during drag** - Validation is synchronous, which is correct
|
||||
|
||||
**No changes required** for Swift 6 compliance in the existing implementation.
|
||||
|
||||
**Confidence:** HIGH
|
||||
|
||||
---
|
||||
|
||||
## Architecture Decision: Two Approaches for External Drops
|
||||
|
||||
### Option A: Extend Existing UITableViewController (Recommended)
|
||||
|
||||
Add `UITableViewDropDelegate` to `ItineraryTableViewController`:
|
||||
|
||||
```swift
|
||||
extension ItineraryTableViewController: UITableViewDropDelegate {
|
||||
func tableView(_ tableView: UITableView, canHandle session: UIDropSession) -> Bool {
|
||||
// Accept your custom item types
|
||||
return session.canLoadObjects(ofClass: ItineraryItemTransferable.self)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView,
|
||||
dropSessionDidUpdate session: UIDropSession,
|
||||
withDestinationIndexPath destinationIndexPath: IndexPath?) -> UITableViewDropProposal {
|
||||
// Return .insertAtDestinationIndexPath for insertion line feedback
|
||||
return UITableViewDropProposal(operation: .copy, intent: .insertAtDestinationIndexPath)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, performDropWith coordinator: UITableViewDropCoordinator) {
|
||||
// Extract item and calculate semantic position
|
||||
// Call onExternalItemDropped callback
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Builds on existing, working implementation
|
||||
- Minimal code changes
|
||||
- Maintains semantic positioning logic
|
||||
|
||||
**Cons:**
|
||||
- None significant
|
||||
|
||||
### Option B: SwiftUI Overlay for Drag Source
|
||||
|
||||
If the external drag SOURCE is a SwiftUI view (e.g., a "suggestions" panel):
|
||||
|
||||
```swift
|
||||
// In SwiftUI
|
||||
SuggestionCard(item: item)
|
||||
.draggable(item) {
|
||||
SuggestionPreview(item: item)
|
||||
}
|
||||
```
|
||||
|
||||
The UITableView receives this via `UITableViewDropDelegate` as above.
|
||||
|
||||
**Note:** This hybrid approach works well - SwiftUI provides the drag source, UIKit receives the drop.
|
||||
|
||||
**Confidence:** HIGH
|
||||
|
||||
---
|
||||
|
||||
## Summary: Recommended Stack
|
||||
|
||||
| Component | Recommendation | Rationale |
|
||||
|-----------|---------------|-----------|
|
||||
| **Table View** | `UITableViewController` | Native drag handles, real-time feedback |
|
||||
| **Internal Reorder** | `canMoveRowAt` / `moveRowAt` | Already working, proven |
|
||||
| **External Drops** | Add `UITableViewDropDelegate` | Required for external drops |
|
||||
| **SwiftUI Bridge** | `UIViewControllerRepresentable` | Already working |
|
||||
| **Cell Content** | `UIHostingConfiguration` | SwiftUI views in UIKit cells |
|
||||
| **State Management** | Lifted callbacks to parent | Unidirectional data flow |
|
||||
| **Drag Source (external)** | SwiftUI `.draggable()` | Simple for source views |
|
||||
| **Position Model** | (day, sortOrder) semantics | Already working, robust |
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
### Official Documentation
|
||||
- [UITableViewDragDelegate](https://developer.apple.com/documentation/uikit/uitableviewdragdelegate)
|
||||
- [UITableViewDropDelegate](https://developer.apple.com/documentation/uikit/uitableviewdropdelegate)
|
||||
- [Supporting drag and drop in table views](https://developer.apple.com/documentation/uikit/views_and_controls/table_views/supporting_drag_and_drop_in_table_views)
|
||||
- [Adopting drag and drop using SwiftUI](https://developer.apple.com/documentation/SwiftUI/Adopting-drag-and-drop-using-SwiftUI)
|
||||
|
||||
### Technical Articles
|
||||
- [Using Drag and Drop on UITableView for reorder](https://rderik.com/blog/using-drag-and-drop-on-uitableview-for-reorder/)
|
||||
- [Drag to Reorder in UITableView with Diffable Datasource](https://swiftjectivec.com/Tableview-Diffable-Datasource-Drag-to-Reorder/)
|
||||
- [Coding for iOS 11: How to drag & drop into collections & tables](https://hackernoon.com/drag-it-drop-it-in-collection-table-ios-11-6bd28795b313)
|
||||
- [SwiftUI in iOS 26 - What's new from WWDC 2025](https://differ.blog/p/swift-ui-in-ios-26-what-s-new-from-wwdc-2025-819b42)
|
||||
- [Drag and drop transferable data in SwiftUI](https://swiftwithmajid.com/2023/04/05/drag-and-drop-transferable-data-in-swiftui/)
|
||||
|
||||
### SwiftUI Limitations References
|
||||
- [Dragging list rows between sections - Apple Forums](https://developer.apple.com/forums/thread/674393)
|
||||
- [How to let users move rows in a list - Hacking with Swift](https://www.hackingwithswift.com/quick-start/swiftui/how-to-let-users-move-rows-in-a-list)
|
||||
|
||||
---
|
||||
|
||||
## Confidence Assessment
|
||||
|
||||
| Area | Confidence | Reason |
|
||||
|------|------------|--------|
|
||||
| Core UITableView Drag APIs | HIGH | Stable since iOS 11, extensive documentation |
|
||||
| External Drop via UITableViewDropDelegate | HIGH | Standard documented pattern |
|
||||
| SwiftUI Bridge Pattern | HIGH | Already implemented and working in codebase |
|
||||
| iOS 26 SwiftUI Improvements | MEDIUM | New APIs, limited production experience |
|
||||
| Swift 6 Compatibility | HIGH | Existing code is already compliant |
|
||||
| Third-party library avoidance | MEDIUM | Based on community reports, not direct testing |
|
||||
@@ -1,178 +0,0 @@
|
||||
# Project Research Summary
|
||||
|
||||
**Project:** SportsTime Itinerary Editor
|
||||
**Domain:** iOS drag-drop reordering with semantic positioning
|
||||
**Researched:** 2026-01-18
|
||||
**Confidence:** HIGH
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Building a drag-drop itinerary editor for iOS requires bridging two coordinate systems: UITableView's row indices (visual) and the semantic model of (day, sortOrder) (business logic). The SportsTime codebase already contains a working UITableView-based implementation with UIHostingConfiguration for SwiftUI cells. This research validates that approach and identifies the key architectural decision that makes or breaks the feature: **row indices are ephemeral display concerns; semantic positions (day, sortOrder) are the source of truth**.
|
||||
|
||||
The recommended approach extends the existing implementation rather than replacing it. UITableView's native drag-drop APIs (iOS 11+) provide superior UX compared to SwiftUI-only solutions: real-time insertion line feedback, proper scroll-while-dragging, and constraint validation during drag. The existing `canMoveRowAt`/`moveRowAt` pattern handles internal reordering well. For external drops (e.g., from a suggestions panel), add `UITableViewDropDelegate` conformance.
|
||||
|
||||
The critical risks are all related to confusing row indices with semantic positions. Previous attempts failed because travel was treated as a structural day property rather than a positioned item, flattening ignored sortOrder values, and drag logic fought reload logic. The architecture must enforce strict separation: row indices exist only during display, semantic positions exist in the data model, and the bridge between them is recalculated on every flatten operation.
|
||||
|
||||
## Key Findings
|
||||
|
||||
### Recommended Stack
|
||||
|
||||
The existing UIKit + SwiftUI hybrid pattern is correct. UITableView provides the drag-drop infrastructure; SwiftUI provides the cell content through `UIHostingConfiguration`.
|
||||
|
||||
**Core technologies:**
|
||||
- **UITableViewController**: Native drag handles, real-time insertion feedback, proven since iOS 11
|
||||
- **UIHostingConfiguration**: Embeds SwiftUI views in UIKit cells without wrapper hacks
|
||||
- **UITableViewDropDelegate**: Required for accepting external drops (not internal reorders)
|
||||
- **UIViewControllerRepresentable + Coordinator**: Bridge pattern already working in codebase
|
||||
|
||||
**What to avoid:**
|
||||
- SwiftUI-only drag-drop (`.draggable()`, `.dropDestination()`) - lacks insertion line feedback
|
||||
- Third-party reordering libraries - compatibility issues, unnecessary dependency
|
||||
- iOS 26 SwiftUI drag modifiers - promising but not mature enough for complex constraints
|
||||
|
||||
### Expected Features
|
||||
|
||||
**Must have (table stakes):**
|
||||
- Lift animation on grab (shadow + scale)
|
||||
- Ghost/placeholder at original position
|
||||
- Insertion indicator line between items
|
||||
- Items shuffle out of the way (100ms animation)
|
||||
- Magnetic snap on drop
|
||||
- Invalid drop feedback (animate back to origin)
|
||||
- Haptic feedback on grab and drop
|
||||
- Auto-scroll when dragging to viewport edge
|
||||
|
||||
**Should have (polish):**
|
||||
- Slight tilt on drag (Trello-style, 2-3 degrees)
|
||||
- Keyboard reordering for accessibility (VoiceOver actions)
|
||||
- Undo after drop (toast with 5-second timeout)
|
||||
- Drag handle icon (visual affordance)
|
||||
|
||||
**Defer (overkill for itinerary):**
|
||||
- Drag between screens
|
||||
- Multi-item drag with count badge
|
||||
- Physics-based spring animations
|
||||
- Custom drag preview images
|
||||
|
||||
### Architecture Approach
|
||||
|
||||
The architecture uses five layers that cleanly separate concerns. Each layer has a single responsibility, making the system resilient to the frequent reloads from SwiftUI state changes.
|
||||
|
||||
**Major components:**
|
||||
1. **Semantic Position Model** (`ItineraryItem`) - Source of truth with day and sortOrder
|
||||
2. **Constraint Validation** (`ItineraryConstraints`) - Determines valid positions per item type
|
||||
3. **Visual Flattening** - Transforms semantic items into flat row array
|
||||
4. **Drop Slot Calculation** - Translates row indices back to semantic positions
|
||||
5. **Drag Interaction** - UITableView delegate methods with constraint snapping
|
||||
|
||||
**Key pattern:** Midpoint insertion for sortOrder (1.0, 2.0 -> 1.5 -> 1.25 etc.) enables unlimited insertions without renumbering existing items.
|
||||
|
||||
### Critical Pitfalls
|
||||
|
||||
1. **Row Index vs Semantic Position Confusion** - Never store row indices as positions. Row indices are ephemeral; semantic (day, sortOrder) is persistent. Address in Phase 1 data model.
|
||||
|
||||
2. **Travel as Structural Instead of Positional** - Travel must be an item with its own (day, sortOrder), not a day property like `travelBefore`. Use sortOrder < 0 for "before games" convention.
|
||||
|
||||
3. **Hard-Coded Flatten Order** - Flattening MUST sort by sortOrder within each day. Hard-coding "header, travel, games, custom" ignores sortOrder and breaks reload.
|
||||
|
||||
4. **Data Out of Sync During Drag** - Never call `reloadData()` while drag is active. Guard SwiftUI updates with `draggingItem != nil` flag.
|
||||
|
||||
5. **Coordinate Space Confusion** - UITableView's `targetIndexPath` uses "proposed" coordinates (source row removed). Pre-compute valid destinations in proposed space at drag start.
|
||||
|
||||
## Implications for Roadmap
|
||||
|
||||
Based on research, suggested phase structure:
|
||||
|
||||
### Phase 1: Semantic Position Model
|
||||
**Rationale:** Everything depends on getting the data model right. Previous failures stemmed from row-based thinking.
|
||||
**Delivers:** `ItineraryItem` with `day: Int` and `sortOrder: Double`, travel as positioned item
|
||||
**Addresses:** Table stakes data representation
|
||||
**Avoids:** Row Index vs Semantic Position Confusion, Travel as Structural pitfalls
|
||||
|
||||
### Phase 2: Constraint Validation Engine
|
||||
**Rationale:** Constraints must be validated semantically, not by row index. Build this before drag interaction.
|
||||
**Delivers:** `ItineraryConstraints` that determines valid positions for games (fixed), travel (bounded), custom (any)
|
||||
**Uses:** Semantic position model from Phase 1
|
||||
**Implements:** Constraint validation layer
|
||||
|
||||
### Phase 3: Visual Flattening
|
||||
**Rationale:** Needs semantic model and constraint awareness. Bridge between model and display.
|
||||
**Delivers:** Deterministic flatten algorithm that sorts by sortOrder, produces flat row array
|
||||
**Addresses:** Hard-coded flatten order pitfall
|
||||
**Implements:** Flattening layer with sortOrder < 0 / >= 0 split
|
||||
|
||||
### Phase 4: Drag Interaction
|
||||
**Rationale:** Depends on all previous layers. This is where UIKit integration happens.
|
||||
**Delivers:** Working drag-drop with constraint snapping, haptics, insertion line
|
||||
**Uses:** UITableViewDragDelegate/DropDelegate, flattening, constraints
|
||||
**Avoids:** Data sync during drag, coordinate space confusion pitfalls
|
||||
|
||||
### Phase 5: Polish and Edge Cases
|
||||
**Rationale:** Core functionality first, polish second.
|
||||
**Delivers:** Lift animation, ghost placeholder, auto-scroll, accessibility actions
|
||||
**Addresses:** All remaining table stakes features
|
||||
|
||||
### Phase 6: External Drops (Optional)
|
||||
**Rationale:** Only if accepting drops from outside the table (e.g., suggestions panel)
|
||||
**Delivers:** `UITableViewDropDelegate` conformance for external items
|
||||
**Uses:** Same constraint validation and drop slot calculation
|
||||
|
||||
### Phase Ordering Rationale
|
||||
|
||||
- **Data model first (Phase 1-2):** The architecture analysis identified semantic positioning as the foundation. Constraints depend on semantics, not rows.
|
||||
- **Flatten before drag (Phase 3):** Drag operations call flatten after every move. Getting flatten right prevents the "drag logic vs reload logic" battle.
|
||||
- **Interaction last (Phase 4-6):** UITableView delegate methods are the integration point. They consume all other layers.
|
||||
|
||||
### Research Flags
|
||||
|
||||
Phases likely needing deeper research during planning:
|
||||
- **Phase 4:** Coordinate space translation is subtle. May need prototype to validate proposed vs current index handling.
|
||||
- **Phase 6:** External drops require NSItemProvider/Transferable patterns. Research if implementing.
|
||||
|
||||
Phases with standard patterns (skip research-phase):
|
||||
- **Phase 1-2:** Data modeling is straightforward once semantics are understood.
|
||||
- **Phase 3:** Flattening is deterministic algorithm, well-documented in existing code.
|
||||
- **Phase 5:** Polish features are standard iOS patterns.
|
||||
|
||||
## Confidence Assessment
|
||||
|
||||
| Area | Confidence | Notes |
|
||||
|------|------------|-------|
|
||||
| Stack | HIGH | Existing implementation validates approach, APIs stable since iOS 11 |
|
||||
| Features | HIGH | Multiple authoritative UX sources (NN Group, Atlassian, Apple HIG) agree |
|
||||
| Architecture | HIGH | Based on existing working codebase analysis |
|
||||
| Pitfalls | HIGH | Documented previous failures + Apple documentation |
|
||||
|
||||
**Overall confidence:** HIGH
|
||||
|
||||
### Gaps to Address
|
||||
|
||||
- **iOS 26 SwiftUI drag modifiers:** New in WWDC 2025, limited production experience. Assess if they mature enough to replace UIKit approach in future versions.
|
||||
- **Mac Catalyst support:** NSItemProvider quirks noted. Validate if targeting Catalyst.
|
||||
- **sortOrder precision exhaustion:** Theoretical concern after thousands of insertions. Implement normalize function if needed (unlikely in practice).
|
||||
|
||||
## Critical Insight
|
||||
|
||||
**The ONE most important thing:** Row indices are lies. They change constantly as items are added, removed, reordered, and the table flattens. The semantic model (day, sortOrder) is truth. Every previous failure traced back to treating row indices as positions. Every function that touches positions must speak semantic coordinates, converting to/from row indices only at the UITableView boundary.
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- Apple: [Supporting drag and drop in table views](https://developer.apple.com/documentation/uikit/views_and_controls/table_views/supporting_drag_and_drop_in_table_views)
|
||||
- Apple: [UITableViewDragDelegate](https://developer.apple.com/documentation/uikit/uitableviewdragdelegate)
|
||||
- Apple: [UITableViewDropDelegate](https://developer.apple.com/documentation/uikit/uitableviewdropdelegate)
|
||||
- Existing codebase: `ItineraryTableViewController.swift`, `ItineraryTableViewWrapper.swift`, `ItineraryConstraints.swift`
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- [Smart Interface Design Patterns - Drag and Drop UX](https://smart-interface-design-patterns.com/articles/drag-and-drop-ux/)
|
||||
- [Atlassian Pragmatic Drag and Drop Design Guidelines](https://atlassian.design/components/pragmatic-drag-and-drop/design-guidelines/)
|
||||
- [Nielsen Norman Group - Drag and Drop](https://www.nngroup.com/articles/drag-drop/)
|
||||
- [Apple Human Interface Guidelines - Drag and Drop](https://developer.apple.com/design/human-interface-guidelines/drag-and-drop)
|
||||
|
||||
### Tertiary (LOW confidence)
|
||||
- iOS 26 SwiftUI drag modifiers documentation (new APIs, limited production validation)
|
||||
- Third-party library compatibility reports (community anecdotes)
|
||||
|
||||
---
|
||||
*Research completed: 2026-01-18*
|
||||
*Ready for roadmap: yes*
|
||||
Reference in New Issue
Block a user