diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index 7fcf942..2bfb44e 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -71,8 +71,8 @@ Plans:
**Plans:** 2 plans
Plans:
-- [ ] 03-01-PLAN.md - Create ItineraryFlattener utility and refactor reloadData()
-- [ ] 03-02-PLAN.md - Add determinism tests for flattening behavior
+- [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
@@ -92,6 +92,12 @@ Plans:
**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
@@ -117,10 +123,10 @@ Plans:
|-------|--------|--------------|-----------|
| 1 - Semantic Position Model | Complete | 8 | 8 |
| 2 - Constraint Validation | Complete | 4 | 4 |
-| 3 - Visual Flattening | In Progress | 3 | 0 |
-| 4 - Drag Interaction | Not Started | 8 | 0 |
+| 3 - Visual Flattening | Complete | 3 | 3 |
+| 4 - Drag Interaction | Planned | 8 | 0 |
-**Total:** 12/23 requirements completed
+**Total:** 15/23 requirements completed
---
*Roadmap created: 2026-01-18*
diff --git a/.planning/phases/04-drag-interaction/04-01-PLAN.md b/.planning/phases/04-drag-interaction/04-01-PLAN.md
new file mode 100644
index 0000000..6f77b6d
--- /dev/null
+++ b/.planning/phases/04-drag-interaction/04-01-PLAN.md
@@ -0,0 +1,220 @@
+---
+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"
+---
+
+
+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.
+
+
+
+@~/.claude/get-shit-done/workflows/execute-plan.md
+@~/.claude/get-shit-done/templates/summary.md
+
+
+
+@.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
+
+
+
+
+
+ Task 1: Migrate to Modern Drag-Drop Delegates
+ SportsTime/Features/Trip/Views/ItineraryTableViewController.swift
+
+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.
+
+
+Build succeeds: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build`
+
+
+ItineraryTableViewController conforms to UITableViewDragDelegate and UITableViewDropDelegate with all required methods implemented.
+
+
+
+
+ Task 2: Implement Lift Animation with Scale, Shadow, and Tilt
+ SportsTime/Features/Trip/Views/ItineraryTableViewController.swift
+
+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
+
+
+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
+
+
+Dragged items show lift animation with scale (1.025x), shadow, and tilt (2 degrees) on grab, and settle animation on drop.
+
+
+
+
+ Task 3: Add Haptic Feedback for Grab and Drop
+ SportsTime/Features/Trip/Views/ItineraryTableViewController.swift
+
+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.
+
+
+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
+
+
+Light haptic fires on grab, medium haptic fires on successful drop. Zone transition haptics continue working for valid/invalid zone crossing.
+
+
+
+
+
+
+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)
+
+
+
+- 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
+
+
+
diff --git a/.planning/phases/04-drag-interaction/04-02-PLAN.md b/.planning/phases/04-drag-interaction/04-02-PLAN.md
new file mode 100644
index 0000000..cbcbe8d
--- /dev/null
+++ b/.planning/phases/04-drag-interaction/04-02-PLAN.md
@@ -0,0 +1,362 @@
+---
+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"
+---
+
+
+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.
+
+
+
+@~/.claude/get-shit-done/workflows/execute-plan.md
+@~/.claude/get-shit-done/templates/summary.md
+
+
+
+@.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
+
+
+
+
+
+ Task 1: Create Themed Insertion Line View
+ SportsTime/Features/Trip/Views/ItineraryTableViewController.swift
+
+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
+
+
+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
+
+
+Themed insertion line appears at drop target position, fades in/out with 150ms animation, uses Theme.warmOrange color.
+
+
+
+
+ Task 2: Implement Invalid Zone Visual Feedback
+ SportsTime/Features/Trip/Views/ItineraryTableViewController.swift
+
+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`
+
+
+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
+
+
+Dragged items show red tint (systemRed at 15% opacity) when hovering over invalid drop zones, tint fades with 100ms animation.
+
+
+
+
+ Task 3: Implement Snap-Back Animation and Error Haptic
+ SportsTime/Features/Trip/Views/ItineraryTableViewController.swift
+
+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
+
+
+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
+
+
+Invalid drops trigger snap-back animation (200ms spring with overshoot) and triple-tap error haptic pattern.
+
+
+
+
+
+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)
+
+
+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
+
+ Type "approved" if all behaviors work correctly, or describe any issues
+
+
+
+
+
+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
+
+
+
+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)
+
+
+