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>
5.8 KiB
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
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
- The itinerary renders as a flat list of mixed item types
- When a custom item is dragged, it can only be dropped into valid "slots" between system items
- On drop, the item's anchor is updated to reference what it's now positioned after
Implementation approach
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.
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) - UUIDtripId(String) - References the SavedTripcategory(String) - restaurant/hotel/activity/notetitle(String)anchorType(String)anchorId(String, optional)anchorDay(Int)createdAt(Date)modifiedAt(Date)
Sync approach
Follows the same pattern as PollVote:
CKCustomItineraryItemwrapper struct inCKModels.swift- CRUD methods in a new
CustomItemServiceactor - 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.