Files
Sportstime/docs/plans/2026-01-15-custom-itinerary-items-design.md
Trey t b534ca771b 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>
2026-01-15 23:07:18 -06:00

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

  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

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