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:
Trey t
2026-01-15 23:07:18 -06:00
parent 00a5e4ef0e
commit b534ca771b

View 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.