docs: add custom itinerary items design
Design for allowing users to add personal items (restaurants, hotels, activities, notes) to saved trip itineraries with drag-to-reorder and CloudKit sync. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
195
docs/plans/2026-01-15-custom-itinerary-items-design.md
Normal file
195
docs/plans/2026-01-15-custom-itinerary-items-design.md
Normal file
@@ -0,0 +1,195 @@
|
||||
# Custom Itinerary Items Design
|
||||
|
||||
**Date:** 2026-01-15
|
||||
**Status:** Approved
|
||||
|
||||
## Overview
|
||||
|
||||
Allow users to add custom items (restaurants, hotels, activities, notes) to saved itineraries. Items can be repositioned anywhere among the system-generated content (games, travel segments) but system items remain fixed.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Categorized items with visual icons (Restaurant, Hotel, Activity, Note)
|
||||
- Inline "Add" buttons between each itinerary section
|
||||
- Always-visible drag handles on custom items (system items have no handles)
|
||||
- CloudKit sync across devices
|
||||
- Swipe to delete, tap to edit
|
||||
|
||||
## Data Model
|
||||
|
||||
```swift
|
||||
struct CustomItineraryItem: Identifiable, Codable, Hashable {
|
||||
let id: UUID
|
||||
let tripId: UUID
|
||||
var category: ItemCategory
|
||||
var title: String
|
||||
var anchorType: AnchorType // What this item is positioned after
|
||||
var anchorId: String? // ID of game or travel segment (nil = start of day)
|
||||
var anchorDay: Int // Day number (1-based)
|
||||
let createdAt: Date
|
||||
var modifiedAt: Date
|
||||
|
||||
enum ItemCategory: String, Codable, CaseIterable {
|
||||
case restaurant, hotel, activity, note
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .restaurant: return "🍽️"
|
||||
case .hotel: return "🏨"
|
||||
case .activity: return "🎯"
|
||||
case .note: return "📝"
|
||||
}
|
||||
}
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .restaurant: return "Restaurant"
|
||||
case .hotel: return "Hotel"
|
||||
case .activity: return "Activity"
|
||||
case .note: return "Note"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AnchorType: String, Codable {
|
||||
case startOfDay // Before any games/travel on this day
|
||||
case afterGame // After a specific game
|
||||
case afterTravel // After a specific travel segment
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Why anchor-based positioning?** If a game gets rescheduled or removed, index-based positions would break. Anchoring to a specific game/travel ID is more stable. If the anchor is deleted, the item falls back to `startOfDay`.
|
||||
|
||||
## UI Components
|
||||
|
||||
### Inline "Add Item" Buttons
|
||||
|
||||
Small, subtle `+ Add` buttons appear between each itinerary section:
|
||||
|
||||
```
|
||||
Day 1 - Arlington
|
||||
├─ 🎾 SFG @ TEX (12:05 AM)
|
||||
├─ [+ Add]
|
||||
├─ 🚗 Drive to Milwaukee
|
||||
├─ [+ Add]
|
||||
└─ 🎾 PIT @ MIL (7:10 PM)
|
||||
[+ Add]
|
||||
|
||||
Day 2 - Chicago
|
||||
├─ [+ Add] ← Start of day
|
||||
├─ 🎾 LAD @ CHC (1:20 PM)
|
||||
[+ Add]
|
||||
```
|
||||
|
||||
### Add Item Sheet
|
||||
|
||||
A simple modal with:
|
||||
- Category picker (4 icons in a horizontal row, tap to select)
|
||||
- Text field for title
|
||||
- "Add" button
|
||||
|
||||
### Custom Item Row
|
||||
|
||||
Displays in the itinerary with:
|
||||
- Category icon on the left
|
||||
- Title text
|
||||
- Drag handle (≡) on the right - always visible
|
||||
- Swipe left reveals red "Delete" action
|
||||
- Tap opens edit sheet (same as add, pre-filled)
|
||||
- Subtle warm background tint for visual distinction
|
||||
|
||||
## Reordering Logic
|
||||
|
||||
Games and travel segments are fixed. Only custom items can be dragged.
|
||||
|
||||
### How it works
|
||||
|
||||
1. The itinerary renders as a flat list of mixed item types
|
||||
2. When a custom item is dragged, it can only be dropped into valid "slots" between system items
|
||||
3. On drop, the item's anchor is updated to reference what it's now positioned after
|
||||
|
||||
### Implementation approach
|
||||
|
||||
<!--
|
||||
REORDERING OPTIONS (for future reference):
|
||||
1. Edit mode toggle - User taps "Edit" to show drag handles, then "Done" (like Reminders)
|
||||
2. Long-press to drag - No edit mode, just long-press and drag
|
||||
3. Always show handles - Custom items always have visible drag handle (CURRENT CHOICE)
|
||||
-->
|
||||
|
||||
Use custom drag-and-drop with `draggable()` and `dropDestination()` modifiers rather than `.onMove`. This gives precise control over what can be dragged and where it can land.
|
||||
|
||||
```swift
|
||||
ForEach(itinerarySections) { section in
|
||||
switch section {
|
||||
case .game, .travel:
|
||||
// Render normally, no drag handle
|
||||
// But IS a drop destination
|
||||
case .customItem:
|
||||
// Render with drag handle
|
||||
// draggable() + dropDestination()
|
||||
case .addButton:
|
||||
// Inline add button (also a drop destination)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## CloudKit Sync
|
||||
|
||||
### New record type: `CustomItineraryItem`
|
||||
|
||||
Fields:
|
||||
- `itemId` (String) - UUID
|
||||
- `tripId` (String) - References the SavedTrip
|
||||
- `category` (String) - restaurant/hotel/activity/note
|
||||
- `title` (String)
|
||||
- `anchorType` (String)
|
||||
- `anchorId` (String, optional)
|
||||
- `anchorDay` (Int)
|
||||
- `createdAt` (Date)
|
||||
- `modifiedAt` (Date)
|
||||
|
||||
### Sync approach
|
||||
|
||||
Follows the same pattern as `PollVote`:
|
||||
- `CKCustomItineraryItem` wrapper struct in `CKModels.swift`
|
||||
- CRUD methods in a new `CustomItemService` actor
|
||||
- Items are fetched when loading a saved trip
|
||||
- Changes sync immediately on add/edit/delete/reorder
|
||||
|
||||
### Conflict resolution
|
||||
|
||||
Last-write-wins based on `modifiedAt` for both content edits and position changes.
|
||||
|
||||
### Offline handling
|
||||
|
||||
Custom items are also stored locally in SwiftData alongside `SavedTrip`. When offline, changes queue locally and sync when connection restores.
|
||||
|
||||
## Files to Create
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `Core/Models/Domain/CustomItineraryItem.swift` | Domain model |
|
||||
| `Core/Models/CloudKit/CKCustomItineraryItem.swift` | CloudKit wrapper |
|
||||
| `Core/Services/CustomItemService.swift` | CloudKit CRUD operations |
|
||||
| `Features/Trip/Views/AddItemSheet.swift` | Modal for adding/editing items |
|
||||
| `Features/Trip/Views/CustomItemRow.swift` | Row component with drag handle |
|
||||
|
||||
## Files to Modify
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `TripDetailView.swift` | Add inline buttons, render custom items, handle drag/drop |
|
||||
| `SavedTrip.swift` | Add relationship to custom items (local cache) |
|
||||
| `CanonicalSyncService.swift` | Sync custom items with CloudKit |
|
||||
|
||||
## Out of Scope (YAGNI)
|
||||
|
||||
- Time/duration fields on custom items
|
||||
- Location/address fields
|
||||
- Photos/attachments
|
||||
- Sharing custom items in polls
|
||||
- Notifications/reminders for custom items
|
||||
|
||||
These can be added in future iterations if needed.
|
||||
Reference in New Issue
Block a user