diff --git a/docs/plans/2026-01-17-itinerary-reorder-design.md b/docs/plans/2026-01-17-itinerary-reorder-design.md index 6119438..97f0dd3 100644 --- a/docs/plans/2026-01-17-itinerary-reorder-design.md +++ b/docs/plans/2026-01-17-itinerary-reorder-design.md @@ -1,632 +1,330 @@ # Itinerary Reorder Refactor Design +**Date:** 2026-01-17 +**Status:** Approved +**Scope:** Complete rewrite of TripDetailView itinerary with constrained drag-and-drop + ## Overview -Refactor the itinerary drag-and-drop system to use a unified data model, fix current bugs, and implement proper constraint validation with visual feedback. +Refactor TripDetailView to support drag-and-drop reordering where: +- **Games** are fixed anchors (immovable, ordered by time) +- **Travel** is movable with hard constraints (must be after departure games, before arrival games) +- **Custom items** are freely movable anywhere -## Current Problems +## Core Data Model -1. Can't move custom item below a game -2. Can't move custom item to empty/rest days -3. Travel positioning is inconsistent - always appears above custom items regardless of drop position -4. General weirdness with drag targeting and sort order calculation - -## Goals - -- **Custom items can go anywhere** - any position on any day, including empty rest days -- **Travel has hard constraints** - day range based on game cities, position constraints on edge days -- **Games are fixed** - no drag handle, sorted by game time -- **Red zone feedback** - invalid drop zones are visually highlighted -- **Unified data model** - single `ItineraryItem` type for all positionable items - ---- - -## Data Model - -### Unified ItineraryItem - -Replace `CustomItineraryItem` and `TravelDayOverride` with a single model: +The itinerary is a flat list of items grouped by day headers. Each item has: ```swift -struct ItineraryItem: Identifiable, Codable, Hashable { +struct ItineraryItem: Identifiable, Codable { let id: UUID let tripId: UUID - var category: ItemCategory - var day: Int // 1-indexed - var sortOrder: Double - var title: String - let createdAt: Date + var day: Int // Which day (1, 2, 3...) + var sortOrder: Double // Position within the day (fractional) + let kind: ItemKind // game, travel, or custom var modifiedAt: Date - - // Location (for mappable items) - var latitude: Double? - var longitude: Double? - var address: String? - - // Travel-specific (only when category == .travel) - var travelFromCity: String? - var travelToCity: String? - var travelDistanceMeters: Double? - var travelDurationSeconds: Double? - - var isMappable: Bool { - latitude != nil && longitude != nil - } - - var isTravel: Bool { - category == .travel - } } -enum ItemCategory: String, Codable, CaseIterable { - case restaurant - case hotel - case activity - case note - case travel - - var icon: String { - switch self { - case .restaurant: return "🍽️" - case .hotel: return "🏨" - case .activity: return "🎯" - case .note: return "📝" - case .travel: return "🚗" - } - } - - var label: String { - switch self { - case .restaurant: return "Restaurant" - case .hotel: return "Hotel" - case .activity: return "Activity" - case .note: return "Note" - case .travel: return "Travel" - } - } +enum ItemKind: Codable { + case game(gameId: String) // Fixed, ordered by game time + case travel(fromCity: String, toCity: String) // Constrained by games + case custom(title: String, icon: String, time: Date?, location: CLLocationCoordinate2D?) } ``` -### Games Remain Derived +### Key Points -Games are NOT stored as `ItineraryItem`. They're computed from trip data and rendered with assigned sortOrder values: +- Games and travel are stored as items (not derived) — full structure persisted +- Travel segments are system-generated when cities change, but stored with positions +- Custom items have optional time and location (location for map display only, no constraints) +- `sortOrder` uses fractional values for efficient insertion without reindexing -| SortOrder Range | Usage | -|-----------------|-------| -| 0 - 99 | Items BEFORE games | -| 100 - 199 | Games (100 + index by game time) | -| 200+ | Items AFTER games | +### Storage ---- +- Items synced to CloudKit with debounced writes (1-2 seconds after last change) +- Local-first: changes persist locally immediately, sync retries silently on failure +- Clean break from existing `CustomItineraryItem` and `TravelDayOverride` records -## Constraint Rules +## Constraint Logic -### Custom Items (non-travel) +A separate `ItineraryConstraints` type handles all validation, making it testable in isolation. -- **Can go anywhere** within trip date range -- Any day (1 to tripDayCount), including empty rest days -- Any sortOrder position +### Game Constraints -### Travel Items +- Games are **immovable** — fixed to their day, ordered by game time within the day +- Multiple games on same day sort by time (1pm above 7pm) +- Games act as **barriers** that travel cannot cross -**Day constraint:** -- Earliest: Day of last game in departure city (can leave after game) -- Latest: Day of first game in arrival city (must arrive before game) +### Travel Constraints -**Position constraint on edge days:** -- On departure day: Must be AFTER all games (sortOrder > max game sortOrder) -- On arrival day: Must be BEFORE all games (sortOrder < min game sortOrder) -- On rest days: Can be anywhere +- Travel has a start city (departure) and end city (arrival) +- Must be **below** ALL games in the departure city (on any day) +- Must be **above** ALL games in the arrival city (on any day) +- Can be placed on any day within this valid window **Example:** -``` -Day 1: Game in Detroit (sortOrder 100) -Day 2: Rest day -Day 3: Rest day -Day 4: Game in Milwaukee (sortOrder 100) +- Day 1: Chicago game +- Day 2: Rest day +- Day 3: Detroit game -Travel "Detroit → Milwaukee" valid positions: -- Day 1: sortOrder > 100 (after the game) -- Day 2: any sortOrder -- Day 3: any sortOrder -- Day 4: sortOrder < 100 (before the game) -``` +Chicago->Detroit travel is valid on: +- Day 1 (after the game) +- Day 2 (anywhere) +- Day 3 (before the game) -### Games +### Custom Item Constraints -- Fixed position, no drag handle -- Sorted by game time within a day -- Multiple games on same day: travel must be after ALL on departure, before ALL on arrival +- **No constraints** — can be placed on any day, in any position +- Location (if set) is for map display only, doesn't affect placement ---- - -## Constraint Validation Layer +### Constraint API ```swift struct ItineraryConstraints { - let trip: Trip - let games: [RichGame] + /// Returns all valid drop zones for an item being dragged + func validDropZones(for item: ItineraryItem, in itinerary: [ItineraryItem]) -> [DropZone] - private var tripDayCount: Int { - // Calculate from trip.startDate to trip.endDate - } + /// Validates if a specific position is valid for an item + func isValidPosition(item: ItineraryItem, day: Int, sortOrder: Double, in itinerary: [ItineraryItem]) -> Bool - /// Check if position is valid for an item - func isValidPosition(for item: ItineraryItem, day: Int, sortOrder: Double) -> Bool { - // Day must be within trip range - guard day >= 1 && day <= tripDayCount else { return false } - - switch item.category { - case .travel: - return isValidTravelPosition(item: item, day: day, sortOrder: sortOrder) - default: - return true // Custom items can go anywhere - } - } - - /// Get valid day range for travel - func validDayRange(for item: ItineraryItem) -> ClosedRange? { - guard item.category == .travel, - let fromCity = item.travelFromCity, - let toCity = item.travelToCity else { return nil } - - let departureDays = gameDays(in: fromCity) - let arrivalDays = gameDays(in: toCity) - - let minDay = departureDays.max() ?? 1 // Day of last game in departure city - let maxDay = arrivalDays.min() ?? tripDayCount // Day of first game in arrival city - - return minDay...maxDay - } - - /// Get valid sortOrder range for travel on a specific day - func validSortOrderRange(for item: ItineraryItem, on day: Int) -> ClosedRange { - guard item.category == .travel, - let fromCity = item.travelFromCity, - let toCity = item.travelToCity else { - return 0...Double.greatestFiniteMagnitude - } - - let lastDepartureGameDay = gameDays(in: fromCity).max() ?? 0 - let firstArrivalGameDay = gameDays(in: toCity).min() ?? tripDayCount + 1 - - if day == lastDepartureGameDay { - // Must be AFTER all games - let maxGameSortOrder = maxGameSortOrder(on: day) - return (maxGameSortOrder + 0.001)...Double.greatestFiniteMagnitude - } else if day == firstArrivalGameDay { - // Must be BEFORE all games - let minGameSortOrder = minGameSortOrder(on: day) - return 0...(minGameSortOrder - 0.001) - } else { - // Rest day - anywhere - return 0...Double.greatestFiniteMagnitude - } - } - - private func isValidTravelPosition(item: ItineraryItem, day: Int, sortOrder: Double) -> Bool { - guard let dayRange = validDayRange(for: item) else { return false } - guard dayRange.contains(day) else { return false } - - let sortOrderRange = validSortOrderRange(for: item, on: day) - return sortOrderRange.contains(sortOrder) - } - - private func gameDays(in city: String) -> [Int] { - // Return day numbers that have games in this city - } - - private func maxGameSortOrder(on day: Int) -> Double { - let dayGames = games.filter { /* game is on this day */ } - return 100.0 + Double(dayGames.count - 1) - } - - private func minGameSortOrder(on day: Int) -> Double { - return 100.0 - } + /// Returns the games that act as barriers for a travel item (for visual highlighting) + func barrierGames(for travelItem: ItineraryItem, in itinerary: [ItineraryItem]) -> [ItineraryItem] } ``` ---- +## Visual Design -## Flattening Logic +### Layout Structure -Simplified approach - group by day, sort by sortOrder: +- Full map header at top (current design, with save/heart button overlay) +- Flat list below — day headers are separators, not containers +- Day headers scroll with content (not sticky) -```swift -enum ItineraryRowItem { - case dayHeader(dayNumber: Int, date: Date) - case game(RichGame, sortOrder: Double) - case item(ItineraryItem) +### Day Header - var sortOrder: Double? { - switch self { - case .dayHeader: return nil - case .game(_, let order): return order - case .item(let item): return item.sortOrder - } - } +- Format: "Day 1 - Friday, January 17" +- Plus (+) button on the right to add custom items +- Empty state text when no items: "No items yet, tap + to add" +- Same styling for rest days (just no games) - var isReorderable: Bool { - switch self { - case .dayHeader, .game: return false - case .item: return true - } - } +### Game Rows (Prominent) - var id: String { - switch self { - case .dayHeader(let day, _): return "day:\(day)" - case .game(let game, _): return "game:\(game.game.id)" - case .item(let item): return "item:\(item.id)" - } - } -} +- Larger card style to show importance +- Sport badge, team matchup, stadium, time (current detailed design) +- **No drag handle** — absence signals they're immovable +- Ordered by game time within the day -func buildFlatList(trip: Trip, games: [RichGame], items: [ItineraryItem]) -> [ItineraryRowItem] { - var result: [ItineraryRowItem] = [] +### Travel Rows (Distinct) - for dayNumber in 1...tripDayCount { - let dayDate = dateFor(dayNumber) +- Gold/route-colored border and accent +- Shows "Chicago -> Detroit - 280 mi - 4h 30m" +- Drag handle on left (always visible) +- No EV charger info (removed for cleaner UI) - // 1. Day header (always first) - result.append(.dayHeader(dayNumber: dayNumber, date: dayDate)) +### Custom Item Rows (Minimal) - // 2. Collect all orderable content - var dayContent: [ItineraryRowItem] = [] - - // Games with assigned sortOrder (100, 101, 102...) - let dayGames = games - .filter { Calendar.current.isDate($0.game.dateTime, inSameDayAs: dayDate) } - .sorted { $0.game.dateTime < $1.game.dateTime } +- Compact: icon + title only +- Time shown on right if set (e.g., "7:00 PM") +- Drag handle on left (always visible) - for (index, game) in dayGames.enumerated() { - dayContent.append(.game(game, sortOrder: 100.0 + Double(index))) - } +### Removed from Current Design - // Items (travel + custom) - for item in items.filter({ $0.day == dayNumber }) { - dayContent.append(.item(item)) - } +- Overview section (stats row, score card) — focus on itinerary +- EV charger expandable section in travel - // 3. Sort by sortOrder - dayContent.sort { ($0.sortOrder ?? 0) < ($1.sortOrder ?? 0) } +## Drag & Drop Interaction - result.append(contentsOf: dayContent) - } +### Initiating Drag - return result -} -``` +- Drag handle (grip icon) on left side of travel and custom items +- Games have no handle — visually signals they're locked +- Standard iOS drag threshold (system default) ---- +### During Drag -## Drag Handling with Red Zone Feedback +- Standard iOS drag appearance (system handles lift/shadow) +- **Invalid zones dim** — valid stays normal, invalid fades out +- **Barrier games highlight** (gold border) when dragging travel — shows which games create the constraint +- **Gap opens** between items at valid drop positions +- Auto-scroll when dragging near top/bottom edge +- Haptic feedback: on pickup, when hovering valid zone, and on drop -### Pre-calculate Invalid Zones on Drag Start +### Drop Behavior -```swift -final class ItineraryTableViewController: UITableViewController { +- Drop to exact position between items (even across days) +- Dropping outside valid zones: item snaps back to original position +- After drop: smooth reflow animation as items settle - private var constraints: ItineraryConstraints! - private var draggingItem: ItineraryItem? - private var invalidRows: Set = [] +### Invalid Drop Prevention - override func tableView(_ tableView: UITableView, - dragSessionWillBegin session: UIDragSession) { - - guard let indexPath = tableView.indexPathForRow(at: session.location(in: tableView)), - case .item(let item) = flatItems[indexPath.row] else { return } - - draggingItem = item - invalidRows = calculateInvalidRows(for: item) - - // Refresh cells to show red zones - tableView.reloadData() - } - - private func calculateInvalidRows(for item: ItineraryItem) -> Set { - var invalid = Set() - - for (index, row) in flatItems.enumerated() { - // Day headers are always invalid drop targets - if case .dayHeader = row { - invalid.insert(index) - continue - } - - // For travel items, check constraints - if item.category == .travel { - let day = dayNumber(forRow: index) - let sortOrder = calculateSortOrder(at: index) - - if !constraints.isValidPosition(for: item, day: day, sortOrder: sortOrder) { - invalid.insert(index) - } - } - } - - return invalid - } - - override func tableView(_ tableView: UITableView, - dragSessionDidEnd session: UIDragSession) { - - draggingItem = nil - invalidRows.removeAll() - tableView.reloadData() - } -} -``` - -### Apply Red Zone Styling +- Travel dropped above a departure game -> prevented (no drop zone shown) +- Travel dropped below an arrival game -> prevented (no drop zone shown) +- Custom items have no restrictions — all zones valid -```swift -override func tableView(_ tableView: UITableView, - cellForRowAt indexPath: IndexPath) -> UITableViewCell { - - let cell = // ... configure normally - - // Apply red zone styling during drag - if draggingItem != nil && invalidRows.contains(indexPath.row) { - cell.contentView.alpha = 0.3 - cell.backgroundColor = UIColor.systemRed.withAlphaComponent(0.1) - } else { - cell.contentView.alpha = 1.0 - cell.backgroundColor = .clear - } - - return cell -} -``` - -### Block Invalid Drops - -```swift -override func tableView(_ tableView: UITableView, - targetIndexPathForMoveFromRowAt source: IndexPath, - toProposedIndexPath proposed: IndexPath) -> IndexPath { - - guard case .item(let item) = flatItems[source.row] else { return source } - - // Can't drop on position 0 - var targetRow = max(1, proposed.row) - targetRow = min(targetRow, flatItems.count - 1) - - // Can't drop ON a day header - go after it - if case .dayHeader = flatItems[targetRow] { - targetRow += 1 - } - - let targetDay = dayNumber(forRow: targetRow) - let targetSortOrder = calculateSortOrder(at: targetRow) - - // If valid, allow it - if constraints.isValidPosition(for: item, day: targetDay, sortOrder: targetSortOrder) { - return IndexPath(row: targetRow, section: 0) - } - - // Otherwise, find nearest valid position - return findNearestValidPosition(for: item, from: targetRow) -} - -private func findNearestValidPosition(for item: ItineraryItem, from row: Int) -> IndexPath { - // Search outward from proposed position to find nearest valid - var searchRadius = 1 - while searchRadius < flatItems.count { - // Check row + radius - let upRow = row + searchRadius - if upRow < flatItems.count { - let day = dayNumber(forRow: upRow) - let sortOrder = calculateSortOrder(at: upRow) - if constraints.isValidPosition(for: item, day: day, sortOrder: sortOrder) { - return IndexPath(row: upRow, section: 0) - } - } - - // Check row - radius - let downRow = row - searchRadius - if downRow >= 1 { - let day = dayNumber(forRow: downRow) - let sortOrder = calculateSortOrder(at: downRow) - if constraints.isValidPosition(for: item, day: day, sortOrder: sortOrder) { - return IndexPath(row: downRow, section: 0) - } - } - - searchRadius += 1 - } - - // Fallback to source (shouldn't happen) - return IndexPath(row: row, section: 0) -} -``` - ---- - -## Travel Auto-Generation - -When a trip is created, generate travel items for each segment: - -```swift -func generateTravelItems(for trip: Trip, games: [RichGame]) -> [ItineraryItem] { - let constraints = ItineraryConstraints(trip: trip, games: games) - var items: [ItineraryItem] = [] - - for segment in trip.travelSegments { - let fromCity = segment.fromLocation.name - let toCity = segment.toLocation.name - - // Calculate default day (prefer first valid day) - let validRange = constraints.validDayRange(for: tempTravelItem) ?? 1...trip.dayCount - let defaultDay = validRange.lowerBound - - // Calculate default sortOrder based on day - let defaultSortOrder: Double - if defaultDay == validRange.lowerBound && gamesExist(on: defaultDay, in: fromCity) { - defaultSortOrder = 200.0 // After games on departure day - } else if defaultDay == validRange.upperBound && gamesExist(on: defaultDay, in: toCity) { - defaultSortOrder = 50.0 // Before games on arrival day - } else { - defaultSortOrder = 50.0 // Rest day, default to morning - } - - let item = ItineraryItem( - id: UUID(), - tripId: trip.id, - category: .travel, - day: defaultDay, - sortOrder: defaultSortOrder, - title: "\(fromCity) → \(toCity)", - createdAt: Date(), - modifiedAt: Date(), - travelFromCity: fromCity, - travelToCity: toCity, - travelDistanceMeters: segment.distanceMeters, - travelDurationSeconds: segment.durationSeconds - ) - items.append(item) - } - - return items -} -``` - -### Smart Merge on Re-plan - -```swift -func mergeTravelItems( - existing: [ItineraryItem], - newSegments: [TravelSegment], - trip: Trip, - games: [RichGame] -) -> [ItineraryItem] { - - let constraints = ItineraryConstraints(trip: trip, games: games) - var result: [ItineraryItem] = [] - - // Index existing travel by route - let existingByRoute = Dictionary( - grouping: existing.filter { $0.category == .travel } - ) { "\($0.travelFromCity ?? "")->\($0.travelToCity ?? "")" } - - for segment in newSegments { - let routeKey = "\(segment.fromLocation.name)->\(segment.toLocation.name)" - - if var existingItem = existingByRoute[routeKey]?.first { - // Route still exists - preserve position if valid - if constraints.isValidPosition(for: existingItem, day: existingItem.day, sortOrder: existingItem.sortOrder) { - existingItem.modifiedAt = Date() - result.append(existingItem) - } else { - // Position no longer valid - reset to default - let newItems = generateTravelItems(for: trip, games: games) - if let newItem = newItems.first(where: { $0.travelFromCity == segment.fromLocation.name && $0.travelToCity == segment.toLocation.name }) { - result.append(newItem) - } - } - } else { - // New segment - generate fresh - let newItems = generateTravelItems(for: trip, games: games) - if let newItem = newItems.first(where: { $0.travelFromCity == segment.fromLocation.name && $0.travelToCity == segment.toLocation.name }) { - result.append(newItem) - } - } - } - - // Orphaned travel items (route removed) are NOT included - return result -} -``` - ---- - -## Persistence - -### CloudKit Record: `ItineraryItem` - -``` -Fields: -- itemId: String (UUID) -- tripId: String (UUID) -- category: String -- day: Int64 -- sortOrder: Double -- title: String -- createdAt: Date -- modifiedAt: Date -- latitude: Double? -- longitude: Double? -- address: String? -- travelFromCity: String? -- travelToCity: String? -- travelDistanceMeters: Double? -- travelDurationSeconds: Double? -``` - -### SwiftData Model - -```swift -@Model -final class ItineraryItemModel { - @Attribute(.unique) var id: UUID - var tripId: UUID - var category: String - var day: Int - var sortOrder: Double - var title: String - var createdAt: Date - var modifiedAt: Date - - var latitude: Double? - var longitude: Double? - var address: String? - var travelFromCity: String? - var travelToCity: String? - var travelDistanceMeters: Double? - var travelDurationSeconds: Double? - - var ckRecordName: String? - var ckModifiedAt: Date? -} -``` - -### Service Consolidation - -- Delete `TravelOverrideService` -- Rename `CustomItemService` → `ItineraryItemService` -- Single service handles all item types - ---- - -## Files to Change - -### Create -- `SportsTime/Core/Models/Domain/ItineraryItem.swift` -- `SportsTime/Core/Models/Domain/ItineraryConstraints.swift` - -### Modify -- `SportsTime/Features/Trip/Views/ItineraryTableViewController.swift` - Simplified flattening, red zone feedback -- `SportsTime/Features/Trip/Views/ItineraryTableViewWrapper.swift` - Use unified items -- `SportsTime/Features/Trip/Views/TripDetailView.swift` - Single items state, unified service -- `SportsTime/Core/Models/CloudKit/CKModels.swift` - Add `CKItineraryItem` -- `SportsTime/Core/Services/CustomItemService.swift` → rename to `ItineraryItemService.swift` - -### Delete -- `SportsTime/Core/Models/Domain/TravelDayOverride.swift` -- `SportsTime/Core/Models/Domain/CustomItineraryItem.swift` -- `SportsTime/Core/Services/TravelOverrideService.swift` - ---- - -## Summary - -| Aspect | Current | New | -|--------|---------|-----| -| Data models | `CustomItineraryItem` + `TravelDayOverride` | Single `ItineraryItem` | -| Travel storage | Separate override model | `.travel` category | -| Custom item placement | Only after games | Anywhere | -| Travel constraints | Day range only | Day range + position on edge days | -| Invalid drop feedback | Snap (buggy) | Red zone highlighting | -| Services | Two services | One `ItineraryItemService` | -| Flattening | Complex special-casing | Simple group + sort | +### Day-to-Day Movement + +- Drag item to different day by scrolling (auto-scroll at edges) +- Drop at exact position within target day + +## Persistence & Sync + +### Storage Model + +- Full itinerary structure stored (not derived) +- Each `ItineraryItem` is a CloudKit record linked to trip by `tripId` +- Clean break from old format — existing `CustomItineraryItem` and `TravelDayOverride` data not migrated + +### Sync Behavior + +- **Debounced writes**: Wait 1-2 seconds after last change, then sync +- **Local-first**: Changes persist locally immediately +- **Silent retry**: If sync fails, keep retrying in background +- **Last write wins**: No complex conflict resolution, most recent change overwrites + +### Offline + +- **Read-only offline**: Can view itinerary but editing requires connection +- Sync resumes automatically when back online + +### Trip Changes (Game Added/Removed Externally) + +- **Auto-update**: Detect changes to underlying trip, merge into stored itinerary +- New games inserted in time order on correct day +- Removed games simply disappear, other items stay in place +- If removing a game makes travel invalid (no games left in that city), auto-remove the travel segment + +### Error Handling + +- Corrupted data (e.g., invalid day reference): Show error, offer reset option + +## Item Operations + +### Adding Custom Items + +- Tap (+) in day header -> opens AddItemSheet (current behavior) +- New item appears at end of day (user can drag to reorder) +- Sheet allows setting: title, icon/category, optional time, optional location + +### Editing Custom Items + +- Tap item -> opens edit sheet (current behavior) +- Can modify title, icon, time, location +- Delete button available in edit sheet + +### Deleting Custom Items + +- Multiple options: swipe to delete, long-press context menu, or delete button in edit sheet +- **Confirmation dialog** before delete: "Are you sure?" +- Games and travel cannot be deleted (system-managed) + +### Reordering + +- Drag handle to reorder within day or move to different day +- Single item drag only (no multi-select) +- No undo feature — moves are simple enough to redo manually + +### Map Integration + +- Route lines follow itinerary order (includes custom items with locations) +- Quick fade/flash animation when route updates after reorder +- Tap map marker -> list scrolls to that item +- Tapping list items does NOT affect map (list is for editing, map is overview) + +## Implementation Approach + +### Architecture + +- `ItineraryConstraints` — separate testable type for all constraint logic +- `ItineraryItem` — unified model for games, travel, and custom items +- `ItineraryItemService` — handles persistence, sync, and CRUD operations +- View layer uses the constraint API to determine valid drop zones + +### Implementation Order + +1. Data model (`ItineraryItem`, `ItemKind`) +2. Constraint logic (`ItineraryConstraints` with unit tests) +3. Persistence layer (`ItineraryItemService` with CloudKit sync) +4. UI components (day header, item rows, drag handles) +5. Drag & drop integration with constraint validation +6. Map route updates + +### Code Strategy + +- Branch replacement — new code replaces old in a feature branch +- Clean break from existing `CustomItineraryItem`, `TravelDayOverride`, and related services +- Lazy rendering for performance (variable trip length, potentially hundreds of items) +- PDF export updated to respect user's itinerary order + +### Testing + +- Unit tests for `ItineraryConstraints` — all constraint rules +- Integration tests for drag/drop behavior +- Edge cases: multiple games same day, games in multiple cities same day, rest days + +### Not in Scope + +- Accessibility/VoiceOver reordering (touch first) +- Keyboard shortcuts +- Multi-select drag +- Undo feature + +## Files to Create/Modify + +### New Files + +- `Core/Models/Domain/ItineraryItem.swift` — unified item model +- `Core/Models/Domain/ItineraryConstraints.swift` — constraint logic +- `Core/Services/ItineraryItemService.swift` — persistence and sync + +### Modified Files + +- `Features/Trip/Views/TripDetailView.swift` — complete rewrite of itinerary section +- `Features/Trip/Views/AddItemSheet.swift` — adapt to new model +- `Export/PDFGenerator.swift` — respect user's itinerary order + +### Deleted Files + +- `Core/Models/Domain/CustomItineraryItem.swift` +- `Core/Models/Domain/TravelDayOverride.swift` +- `Core/Services/CustomItemService.swift` +- `Core/Services/CustomItemSubscriptionService.swift` +- `Core/Services/TravelOverrideService.swift` +- `Features/Trip/Views/CustomItemRow.swift` + +## Decision Log + +| Question | Decision | +|----------|----------| +| Core model | List of days, each containing ordered items | +| Item order within day | User-controlled via drag | +| Item types | Games (fixed), Travel (constrained), Custom (free) | +| Travel constraints | After ALL departure games, before ALL arrival games | +| Custom item location | Optional, for map only, no placement constraints | +| Invalid drop handling | Don't show invalid drop zones (dim them) | +| Visual feedback for barriers | Highlight barrier games during travel drag | +| Persistence | Full structure stored, not derived | +| Sync strategy | Debounced (1-2s), local-first, silent retry | +| Conflict resolution | Last write wins | +| Offline | Read-only | +| Trip changes | Auto-merge, auto-remove invalid travel | +| Delete confirmation | Yes, dialog | +| Undo | No | +| Keyboard/accessibility | Not in scope (touch first) | +| Day header format | "Day 1 - Friday, January 17" | +| Add button location | In day header (+) | +| New item position | End of day | +| Game row style | Prominent card (current detailed design) | +| Travel row style | Distinct gold accent | +| Custom item row style | Minimal (icon + title + optional time) | +| EV charger info | Removed | +| Overview section | Removed (focus on itinerary) | +| Map at top | Yes, full map | +| Drag initiation | Drag handle (grip icon) | +| Haptic feedback | Yes (pickup, hover, drop) | +| Drop indicator | Gap opens between items | +| Scroll during drag | Auto-scroll at edges | +| Rest day styling | Same as game days | +| Empty day text | "No items yet, tap + to add" | +| PDF export | Respects user's itinerary order | +| Max trip length | Variable (optimize for long trips) | +| Max custom items | Unlimited (hundreds possible) |