Files
Sportstime/docs/plans/2026-01-15-custom-itinerary-items-implementation.md
2026-01-16 00:32:14 -06:00

37 KiB

Custom Itinerary Items Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Allow users to add, edit, reorder, and delete custom items (restaurants, hotels, activities, notes) within saved trip itineraries, with CloudKit sync.

Architecture: Domain model with anchor-based positioning, CloudKit wrapper following CKPollVote pattern, CustomItemService actor for CRUD, SwiftData for local cache, UI integration in TripDetailView with drag/drop.

Tech Stack: Swift, SwiftUI, SwiftData, CloudKit, draggable/dropDestination APIs


Task 1: Domain Model

Files:

  • Create: SportsTime/Core/Models/Domain/CustomItineraryItem.swift
  • Test: SportsTimeTests/Domain/CustomItineraryItemTests.swift

Step 1: Create the domain model file

//
//  CustomItineraryItem.swift
//  SportsTime
//

import Foundation

struct CustomItineraryItem: Identifiable, Codable, Hashable {
    let id: UUID
    let tripId: UUID
    var category: ItemCategory
    var title: String
    var anchorType: AnchorType
    var anchorId: String?
    var anchorDay: Int
    let createdAt: Date
    var modifiedAt: Date

    init(
        id: UUID = UUID(),
        tripId: UUID,
        category: ItemCategory,
        title: String,
        anchorType: AnchorType = .startOfDay,
        anchorId: String? = nil,
        anchorDay: Int,
        createdAt: Date = Date(),
        modifiedAt: Date = Date()
    ) {
        self.id = id
        self.tripId = tripId
        self.category = category
        self.title = title
        self.anchorType = anchorType
        self.anchorId = anchorId
        self.anchorDay = anchorDay
        self.createdAt = createdAt
        self.modifiedAt = modifiedAt
    }

    enum ItemCategory: String, Codable, CaseIterable {
        case restaurant
        case hotel
        case activity
        case 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"
            }
        }

        var systemImage: String {
            switch self {
            case .restaurant: return "fork.knife"
            case .hotel: return "bed.double.fill"
            case .activity: return "figure.run"
            case .note: return "note.text"
            }
        }
    }

    enum AnchorType: String, Codable {
        case startOfDay
        case afterGame
        case afterTravel
    }
}

Step 2: Create basic unit tests

//
//  CustomItineraryItemTests.swift
//  SportsTimeTests
//

import Testing
@testable import SportsTime
import Foundation

struct CustomItineraryItemTests {

    @Test("Item initializes with default values")
    func item_InitializesWithDefaults() {
        let tripId = UUID()
        let item = CustomItineraryItem(
            tripId: tripId,
            category: .restaurant,
            title: "Joe's BBQ",
            anchorDay: 1
        )

        #expect(item.tripId == tripId)
        #expect(item.category == .restaurant)
        #expect(item.title == "Joe's BBQ")
        #expect(item.anchorType == .startOfDay)
        #expect(item.anchorId == nil)
        #expect(item.anchorDay == 1)
    }

    @Test("Item category has correct icons")
    func category_HasCorrectIcons() {
        #expect(CustomItineraryItem.ItemCategory.restaurant.icon == "🍽️")
        #expect(CustomItineraryItem.ItemCategory.hotel.icon == "🏨")
        #expect(CustomItineraryItem.ItemCategory.activity.icon == "🎯")
        #expect(CustomItineraryItem.ItemCategory.note.icon == "📝")
    }

    @Test("Item is Codable")
    func item_IsCodable() throws {
        let item = CustomItineraryItem(
            tripId: UUID(),
            category: .hotel,
            title: "Hilton Downtown",
            anchorType: .afterGame,
            anchorId: "game_123",
            anchorDay: 2
        )

        let encoded = try JSONEncoder().encode(item)
        let decoded = try JSONDecoder().decode(CustomItineraryItem.self, from: encoded)

        #expect(decoded.id == item.id)
        #expect(decoded.title == item.title)
        #expect(decoded.anchorType == .afterGame)
        #expect(decoded.anchorId == "game_123")
    }
}

Step 3: Run tests to verify

Run: xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/CustomItineraryItemTests test Expected: PASS

Step 4: Commit

git add SportsTime/Core/Models/Domain/CustomItineraryItem.swift SportsTimeTests/Domain/CustomItineraryItemTests.swift
git commit -m "feat(models): add CustomItineraryItem domain model"

Task 2: CloudKit Wrapper

Files:

  • Modify: SportsTime/Core/Models/CloudKit/CKModels.swift (add at end)

Step 1: Add CKRecordType constant

Find the CKRecordType extension and add:

static let customItineraryItem: CKRecordType = "CustomItineraryItem"

Step 2: Add CKCustomItineraryItem struct

Add at the end of CKModels.swift:

// MARK: - CKCustomItineraryItem

struct CKCustomItineraryItem {
    static let itemIdKey = "itemId"
    static let tripIdKey = "tripId"
    static let categoryKey = "category"
    static let titleKey = "title"
    static let anchorTypeKey = "anchorType"
    static let anchorIdKey = "anchorId"
    static let anchorDayKey = "anchorDay"
    static let createdAtKey = "createdAt"
    static let modifiedAtKey = "modifiedAt"

    let record: CKRecord

    init(record: CKRecord) {
        self.record = record
    }

    init(item: CustomItineraryItem) {
        let record = CKRecord(
            recordType: CKRecordType.customItineraryItem,
            recordID: CKRecord.ID(recordName: item.id.uuidString)
        )
        record[CKCustomItineraryItem.itemIdKey] = item.id.uuidString
        record[CKCustomItineraryItem.tripIdKey] = item.tripId.uuidString
        record[CKCustomItineraryItem.categoryKey] = item.category.rawValue
        record[CKCustomItineraryItem.titleKey] = item.title
        record[CKCustomItineraryItem.anchorTypeKey] = item.anchorType.rawValue
        record[CKCustomItineraryItem.anchorIdKey] = item.anchorId
        record[CKCustomItineraryItem.anchorDayKey] = item.anchorDay
        record[CKCustomItineraryItem.createdAtKey] = item.createdAt
        record[CKCustomItineraryItem.modifiedAtKey] = item.modifiedAt
        self.record = record
    }

    func toItem() -> CustomItineraryItem? {
        guard let itemIdString = record[CKCustomItineraryItem.itemIdKey] as? String,
              let itemId = UUID(uuidString: itemIdString),
              let tripIdString = record[CKCustomItineraryItem.tripIdKey] as? String,
              let tripId = UUID(uuidString: tripIdString),
              let categoryString = record[CKCustomItineraryItem.categoryKey] as? String,
              let category = CustomItineraryItem.ItemCategory(rawValue: categoryString),
              let title = record[CKCustomItineraryItem.titleKey] as? String,
              let anchorTypeString = record[CKCustomItineraryItem.anchorTypeKey] as? String,
              let anchorType = CustomItineraryItem.AnchorType(rawValue: anchorTypeString),
              let anchorDay = record[CKCustomItineraryItem.anchorDayKey] as? Int,
              let createdAt = record[CKCustomItineraryItem.createdAtKey] as? Date,
              let modifiedAt = record[CKCustomItineraryItem.modifiedAtKey] as? Date
        else { return nil }

        let anchorId = record[CKCustomItineraryItem.anchorIdKey] as? String

        return CustomItineraryItem(
            id: itemId,
            tripId: tripId,
            category: category,
            title: title,
            anchorType: anchorType,
            anchorId: anchorId,
            anchorDay: anchorDay,
            createdAt: createdAt,
            modifiedAt: modifiedAt
        )
    }
}

Step 3: Build to verify

Run: xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build Expected: BUILD SUCCEEDED

Step 4: Commit

git add SportsTime/Core/Models/CloudKit/CKModels.swift
git commit -m "feat(cloudkit): add CKCustomItineraryItem wrapper"

Task 3: CustomItemService

Files:

  • Create: SportsTime/Core/Services/CustomItemService.swift

Step 1: Create the service

//
//  CustomItemService.swift
//  SportsTime
//
//  CloudKit service for custom itinerary items
//

import Foundation
import CloudKit

actor CustomItemService {
    static let shared = CustomItemService()

    private let container: CKContainer
    private let publicDatabase: CKDatabase

    private init() {
        self.container = CKContainer(identifier: "iCloud.com.sportstime.app")
        self.publicDatabase = container.publicCloudDatabase
    }

    // MARK: - CRUD Operations

    func createItem(_ item: CustomItineraryItem) async throws -> CustomItineraryItem {
        let ckItem = CKCustomItineraryItem(item: item)

        do {
            try await publicDatabase.save(ckItem.record)
            return item
        } catch let error as CKError {
            throw mapCloudKitError(error)
        } catch {
            throw CustomItemError.unknown(error)
        }
    }

    func updateItem(_ item: CustomItineraryItem) async throws -> CustomItineraryItem {
        // Fetch existing record to get changeTag
        let recordID = CKRecord.ID(recordName: item.id.uuidString)
        let existingRecord: CKRecord

        do {
            existingRecord = try await publicDatabase.record(for: recordID)
        } catch let error as CKError {
            throw mapCloudKitError(error)
        } catch {
            throw CustomItemError.unknown(error)
        }

        // Update fields
        let now = Date()
        existingRecord[CKCustomItineraryItem.categoryKey] = item.category.rawValue
        existingRecord[CKCustomItineraryItem.titleKey] = item.title
        existingRecord[CKCustomItineraryItem.anchorTypeKey] = item.anchorType.rawValue
        existingRecord[CKCustomItineraryItem.anchorIdKey] = item.anchorId
        existingRecord[CKCustomItineraryItem.anchorDayKey] = item.anchorDay
        existingRecord[CKCustomItineraryItem.modifiedAtKey] = now

        do {
            try await publicDatabase.save(existingRecord)
            var updatedItem = item
            updatedItem.modifiedAt = now
            return updatedItem
        } catch let error as CKError {
            throw mapCloudKitError(error)
        } catch {
            throw CustomItemError.unknown(error)
        }
    }

    func deleteItem(_ itemId: UUID) async throws {
        let recordID = CKRecord.ID(recordName: itemId.uuidString)

        do {
            try await publicDatabase.deleteRecord(withID: recordID)
        } catch let error as CKError {
            if error.code != .unknownItem {
                throw mapCloudKitError(error)
            }
            // Item already deleted - ignore
        } catch {
            throw CustomItemError.unknown(error)
        }
    }

    func fetchItems(forTripId tripId: UUID) async throws -> [CustomItineraryItem] {
        let predicate = NSPredicate(
            format: "%K == %@",
            CKCustomItineraryItem.tripIdKey,
            tripId.uuidString
        )
        let query = CKQuery(recordType: CKRecordType.customItineraryItem, predicate: predicate)

        do {
            let (results, _) = try await publicDatabase.records(matching: query)

            return results.compactMap { result in
                guard case .success(let record) = result.1 else { return nil }
                return CKCustomItineraryItem(record: record).toItem()
            }.sorted { $0.anchorDay < $1.anchorDay }
        } catch let error as CKError {
            throw mapCloudKitError(error)
        } catch {
            throw CustomItemError.unknown(error)
        }
    }

    // MARK: - Error Mapping

    private func mapCloudKitError(_ error: CKError) -> CustomItemError {
        switch error.code {
        case .notAuthenticated:
            return .notSignedIn
        case .networkUnavailable, .networkFailure:
            return .networkUnavailable
        case .unknownItem:
            return .itemNotFound
        default:
            return .unknown(error)
        }
    }
}

// MARK: - Errors

enum CustomItemError: Error, LocalizedError {
    case notSignedIn
    case itemNotFound
    case networkUnavailable
    case unknown(Error)

    var errorDescription: String? {
        switch self {
        case .notSignedIn:
            return "Please sign in to iCloud to use custom items."
        case .itemNotFound:
            return "Item not found. It may have been deleted."
        case .networkUnavailable:
            return "Unable to connect. Please check your internet connection."
        case .unknown(let error):
            return "An error occurred: \(error.localizedDescription)"
        }
    }
}

Step 2: Build to verify

Run: xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build Expected: BUILD SUCCEEDED

Step 3: Commit

git add SportsTime/Core/Services/CustomItemService.swift
git commit -m "feat(services): add CustomItemService for CloudKit CRUD"

Task 4: SwiftData Local Model

Files:

  • Modify: SportsTime/Core/Models/Local/SavedTrip.swift

Step 1: Add LocalCustomItem model

Add after the TripVote model:

// MARK: - Local Custom Item (Cache)

@Model
final class LocalCustomItem {
    @Attribute(.unique) var id: UUID
    var tripId: UUID
    var category: String
    var title: String
    var anchorType: String
    var anchorId: String?
    var anchorDay: Int
    var createdAt: Date
    var modifiedAt: Date
    var pendingSync: Bool  // True if needs to sync to CloudKit

    init(
        id: UUID = UUID(),
        tripId: UUID,
        category: CustomItineraryItem.ItemCategory,
        title: String,
        anchorType: CustomItineraryItem.AnchorType = .startOfDay,
        anchorId: String? = nil,
        anchorDay: Int,
        createdAt: Date = Date(),
        modifiedAt: Date = Date(),
        pendingSync: Bool = false
    ) {
        self.id = id
        self.tripId = tripId
        self.category = category.rawValue
        self.title = title
        self.anchorType = anchorType.rawValue
        self.anchorId = anchorId
        self.anchorDay = anchorDay
        self.createdAt = createdAt
        self.modifiedAt = modifiedAt
        self.pendingSync = pendingSync
    }

    var toItem: CustomItineraryItem? {
        guard let category = CustomItineraryItem.ItemCategory(rawValue: category),
              let anchorType = CustomItineraryItem.AnchorType(rawValue: anchorType)
        else { return nil }

        return CustomItineraryItem(
            id: id,
            tripId: tripId,
            category: category,
            title: title,
            anchorType: anchorType,
            anchorId: anchorId,
            anchorDay: anchorDay,
            createdAt: createdAt,
            modifiedAt: modifiedAt
        )
    }

    static func from(_ item: CustomItineraryItem, pendingSync: Bool = false) -> LocalCustomItem {
        LocalCustomItem(
            id: item.id,
            tripId: item.tripId,
            category: item.category,
            title: item.title,
            anchorType: item.anchorType,
            anchorId: item.anchorId,
            anchorDay: item.anchorDay,
            createdAt: item.createdAt,
            modifiedAt: item.modifiedAt,
            pendingSync: pendingSync
        )
    }
}

Step 2: Build to verify

Run: xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build Expected: BUILD SUCCEEDED

Step 3: Commit

git add SportsTime/Core/Models/Local/SavedTrip.swift
git commit -m "feat(models): add LocalCustomItem SwiftData model"

Task 5: AddItemSheet UI

Files:

  • Create: SportsTime/Features/Trip/Views/AddItemSheet.swift

Step 1: Create the sheet

//
//  AddItemSheet.swift
//  SportsTime
//
//  Sheet for adding/editing custom itinerary items
//

import SwiftUI

struct AddItemSheet: View {
    @Environment(\.dismiss) private var dismiss
    @Environment(\.colorScheme) private var colorScheme

    let tripId: UUID
    let anchorDay: Int
    let anchorType: CustomItineraryItem.AnchorType
    let anchorId: String?
    let existingItem: CustomItineraryItem?
    var onSave: (CustomItineraryItem) -> Void

    @State private var selectedCategory: CustomItineraryItem.ItemCategory = .restaurant
    @State private var title: String = ""
    @State private var isSaving = false

    private var isEditing: Bool { existingItem != nil }

    var body: some View {
        NavigationStack {
            VStack(spacing: Theme.Spacing.lg) {
                // Category picker
                categoryPicker

                // Title input
                TextField("What's the plan?", text: $title)
                    .textFieldStyle(.roundedBorder)
                    .font(.body)

                Spacer()
            }
            .padding()
            .background(Theme.backgroundGradient(colorScheme))
            .navigationTitle(isEditing ? "Edit Item" : "Add Item")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("Cancel") { dismiss() }
                }
                ToolbarItem(placement: .confirmationAction) {
                    Button(isEditing ? "Save" : "Add") {
                        saveItem()
                    }
                    .disabled(title.trimmingCharacters(in: .whitespaces).isEmpty || isSaving)
                }
            }
            .onAppear {
                if let existing = existingItem {
                    selectedCategory = existing.category
                    title = existing.title
                }
            }
        }
    }

    @ViewBuilder
    private var categoryPicker: some View {
        HStack(spacing: Theme.Spacing.md) {
            ForEach(CustomItineraryItem.ItemCategory.allCases, id: \.self) { category in
                CategoryButton(
                    category: category,
                    isSelected: selectedCategory == category
                ) {
                    selectedCategory = category
                }
            }
        }
    }

    private func saveItem() {
        let trimmedTitle = title.trimmingCharacters(in: .whitespaces)
        guard !trimmedTitle.isEmpty else { return }

        isSaving = true

        let item: CustomItineraryItem
        if let existing = existingItem {
            item = CustomItineraryItem(
                id: existing.id,
                tripId: existing.tripId,
                category: selectedCategory,
                title: trimmedTitle,
                anchorType: existing.anchorType,
                anchorId: existing.anchorId,
                anchorDay: existing.anchorDay,
                createdAt: existing.createdAt,
                modifiedAt: Date()
            )
        } else {
            item = CustomItineraryItem(
                tripId: tripId,
                category: selectedCategory,
                title: trimmedTitle,
                anchorType: anchorType,
                anchorId: anchorId,
                anchorDay: anchorDay
            )
        }

        onSave(item)
        dismiss()
    }
}

// MARK: - Category Button

private struct CategoryButton: View {
    let category: CustomItineraryItem.ItemCategory
    let isSelected: Bool
    let action: () -> Void

    var body: some View {
        Button(action: action) {
            VStack(spacing: 4) {
                Text(category.icon)
                    .font(.title2)
                Text(category.label)
                    .font(.caption2)
            }
            .frame(maxWidth: .infinity)
            .padding(.vertical, 12)
            .background(isSelected ? Theme.warmOrange.opacity(0.2) : Color.clear)
            .cornerRadius(8)
            .overlay(
                RoundedRectangle(cornerRadius: 8)
                    .stroke(isSelected ? Theme.warmOrange : Color.secondary.opacity(0.3), lineWidth: isSelected ? 2 : 1)
            )
        }
        .buttonStyle(.plain)
    }
}

#Preview {
    AddItemSheet(
        tripId: UUID(),
        anchorDay: 1,
        anchorType: .startOfDay,
        anchorId: nil,
        existingItem: nil
    ) { _ in }
}

Step 2: Build to verify

Run: xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build Expected: BUILD SUCCEEDED

Step 3: Commit

git add SportsTime/Features/Trip/Views/AddItemSheet.swift
git commit -m "feat(ui): add AddItemSheet for custom itinerary items"

Task 6: CustomItemRow UI

Files:

  • Create: SportsTime/Features/Trip/Views/CustomItemRow.swift

Step 1: Create the row component

//
//  CustomItemRow.swift
//  SportsTime
//
//  Row component for custom itinerary items with drag handle
//

import SwiftUI

struct CustomItemRow: View {
    @Environment(\.colorScheme) private var colorScheme

    let item: CustomItineraryItem
    var onTap: () -> Void
    var onDelete: () -> Void

    /*
    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)
    */

    var body: some View {
        HStack(spacing: 12) {
            // Category icon
            Text(item.category.icon)
                .font(.title3)

            // Title
            Text(item.title)
                .font(.subheadline)
                .lineLimit(2)

            Spacer()

            // Drag handle - always visible
            Image(systemName: "line.3.horizontal")
                .foregroundStyle(.secondary)
                .font(.subheadline)
        }
        .padding(.horizontal, Theme.Spacing.md)
        .padding(.vertical, Theme.Spacing.sm)
        .background(Theme.warmOrange.opacity(0.08))
        .cornerRadius(8)
        .contentShape(Rectangle())
        .onTapGesture {
            onTap()
        }
        .swipeActions(edge: .trailing, allowsFullSwipe: true) {
            Button(role: .destructive) {
                onDelete()
            } label: {
                Label("Delete", systemImage: "trash")
            }
        }
    }
}

#Preview {
    VStack {
        CustomItemRow(
            item: CustomItineraryItem(
                tripId: UUID(),
                category: .restaurant,
                title: "Joe's BBQ - Best brisket in Texas!",
                anchorDay: 1
            ),
            onTap: {},
            onDelete: {}
        )
        CustomItemRow(
            item: CustomItineraryItem(
                tripId: UUID(),
                category: .hotel,
                title: "Hilton Downtown",
                anchorDay: 1
            ),
            onTap: {},
            onDelete: {}
        )
    }
    .padding()
}

Step 2: Build to verify

Run: xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build Expected: BUILD SUCCEEDED

Step 3: Commit

git add SportsTime/Features/Trip/Views/CustomItemRow.swift
git commit -m "feat(ui): add CustomItemRow with drag handle and swipe-to-delete"

Task 7: TripDetailView Integration - Part 1 (State & Data Loading)

Files:

  • Modify: SportsTime/Features/Trip/Views/TripDetailView.swift

Step 1: Add state properties

Add these state properties near the existing @State declarations:

@State private var customItems: [CustomItineraryItem] = []
@State private var showAddItemSheet = false
@State private var editingItem: CustomItineraryItem?
@State private var addItemAnchor: (day: Int, type: CustomItineraryItem.AnchorType, id: String?)? = nil

Step 2: Add loadCustomItems function

Add this function in the view:

private func loadCustomItems() async {
    guard let tripId = trip.id as UUID? else { return }
    do {
        customItems = try await CustomItemService.shared.fetchItems(forTripId: tripId)
    } catch {
        print("Failed to load custom items: \(error)")
    }
}

Step 3: Add task modifier to load items

In the body, add a .task modifier after the existing .onAppear:

.task {
    await loadCustomItems()
}

Step 4: Build to verify

Run: xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build Expected: BUILD SUCCEEDED

Step 5: Commit

git add SportsTime/Features/Trip/Views/TripDetailView.swift
git commit -m "feat(ui): add custom items state and loading to TripDetailView"

Task 8: TripDetailView Integration - Part 2 (Inline Add Buttons)

Files:

  • Modify: SportsTime/Features/Trip/Views/TripDetailView.swift

Step 1: Create InlineAddButton component

Add this private struct inside or near TripDetailView:

private struct InlineAddButton: View {
    let action: () -> Void

    var body: some View {
        Button(action: action) {
            HStack {
                Image(systemName: "plus.circle.fill")
                    .foregroundStyle(Theme.warmOrange.opacity(0.6))
                Text("Add")
                    .font(.caption)
                    .foregroundStyle(.secondary)
            }
            .padding(.vertical, 4)
            .padding(.horizontal, 8)
        }
        .buttonStyle(.plain)
    }
}

Step 2: Update ItinerarySection enum

Find the ItinerarySection enum and add a new case:

enum ItinerarySection {
    case day(dayNumber: Int, date: Date, games: [RichGame])
    case travel(TravelSegment)
    case customItem(CustomItineraryItem)
    case addButton(day: Int, anchorType: CustomItineraryItem.AnchorType, anchorId: String?)
}

Step 3: Build to verify

Run: xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build Expected: BUILD SUCCEEDED (may have warnings about unhandled cases - fix in next task)

Step 4: Commit

git add SportsTime/Features/Trip/Views/TripDetailView.swift
git commit -m "feat(ui): add InlineAddButton and update ItinerarySection enum"

Task 9: TripDetailView Integration - Part 3 (Rendering Custom Items)

Files:

  • Modify: SportsTime/Features/Trip/Views/TripDetailView.swift

Step 1: Update itinerarySections to include custom items and add buttons

Replace the itinerarySections computed property to interleave custom items and add buttons. This is complex - the key is to:

  1. For each day section, insert custom items anchored to that day
  2. After each game/travel, insert any custom items anchored to it
  3. Insert add buttons in the gaps
private var itinerarySections: [ItinerarySection] {
    var sections: [ItinerarySection] = []
    var dayCitySections: [(dayNumber: Int, date: Date, city: String, games: [RichGame])] = []
    let days = tripDays

    for (index, dayDate) in days.enumerated() {
        let dayNum = index + 1
        let gamesOnDay = gamesOn(date: dayDate)

        guard !gamesOnDay.isEmpty else { continue }

        var gamesByCity: [(city: String, games: [RichGame])] = []
        for game in gamesOnDay {
            let city = game.stadium.city
            if let lastIndex = gamesByCity.indices.last, gamesByCity[lastIndex].city == city {
                gamesByCity[lastIndex].games.append(game)
            } else {
                gamesByCity.append((city, [game]))
            }
        }

        for cityGroup in gamesByCity {
            dayCitySections.append((dayNum, dayDate, cityGroup.city, cityGroup.games))
        }
    }

    for (index, section) in dayCitySections.enumerated() {
        // Travel before this section
        if index > 0 {
            let prevSection = dayCitySections[index - 1]
            if prevSection.city != section.city {
                if let travelSegment = findTravelSegment(from: prevSection.city, to: section.city) {
                    sections.append(.travel(travelSegment))
                    // Add button after travel
                    sections.append(.addButton(day: section.dayNumber, anchorType: .afterTravel, anchorId: travelSegment.id.uuidString))
                    // Custom items after this travel
                    let itemsAfterTravel = customItems.filter {
                        $0.anchorDay == section.dayNumber &&
                        $0.anchorType == .afterTravel &&
                        $0.anchorId == travelSegment.id.uuidString
                    }
                    for item in itemsAfterTravel {
                        sections.append(.customItem(item))
                    }
                }
            }
        }

        // Add button at start of day (before games)
        sections.append(.addButton(day: section.dayNumber, anchorType: .startOfDay, anchorId: nil))

        // Custom items at start of day
        let itemsAtStart = customItems.filter {
            $0.anchorDay == section.dayNumber && $0.anchorType == .startOfDay
        }
        for item in itemsAtStart {
            sections.append(.customItem(item))
        }

        // Day section
        sections.append(.day(dayNumber: section.dayNumber, date: section.date, games: section.games))

        // Add button after day's games
        if let lastGame = section.games.last {
            sections.append(.addButton(day: section.dayNumber, anchorType: .afterGame, anchorId: lastGame.game.id))
            // Custom items after this game
            let itemsAfterGame = customItems.filter {
                $0.anchorDay == section.dayNumber &&
                $0.anchorType == .afterGame &&
                $0.anchorId == lastGame.game.id
            }
            for item in itemsAfterGame {
                sections.append(.customItem(item))
            }
        }
    }

    return sections
}

Step 2: Update the ForEach in itinerarySection to handle new cases

Find where itinerarySections is rendered and update the switch:

ForEach(Array(itinerarySections.enumerated()), id: \.offset) { index, section in
    switch section {
    case .day(let dayNumber, let date, let games):
        DaySection(
            dayNumber: dayNumber,
            date: date,
            games: games,
            trip: trip,
            colorScheme: colorScheme
        )
        .staggeredAnimation(index: index)
    case .travel(let segment):
        TravelSection(segment: segment)
            .staggeredAnimation(index: index)
    case .customItem(let item):
        CustomItemRow(
            item: item,
            onTap: { editingItem = item },
            onDelete: { Task { await deleteItem(item) } }
        )
        .staggeredAnimation(index: index)
    case .addButton(let day, let anchorType, let anchorId):
        InlineAddButton {
            addItemAnchor = (day, anchorType, anchorId)
            showAddItemSheet = true
        }
    }
}

Step 3: Build to verify

Run: xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build Expected: BUILD SUCCEEDED (may need to add missing functions in next task)

Step 4: Commit

git add SportsTime/Features/Trip/Views/TripDetailView.swift
git commit -m "feat(ui): render custom items and add buttons in itinerary"

Task 10: TripDetailView Integration - Part 4 (CRUD Operations & Sheets)

Files:

  • Modify: SportsTime/Features/Trip/Views/TripDetailView.swift

Step 1: Add CRUD helper functions

private func saveItem(_ item: CustomItineraryItem) async {
    do {
        if customItems.contains(where: { $0.id == item.id }) {
            // Update existing
            let updated = try await CustomItemService.shared.updateItem(item)
            if let index = customItems.firstIndex(where: { $0.id == updated.id }) {
                customItems[index] = updated
            }
        } else {
            // Create new
            let created = try await CustomItemService.shared.createItem(item)
            customItems.append(created)
        }
    } catch {
        print("Failed to save item: \(error)")
    }
}

private func deleteItem(_ item: CustomItineraryItem) async {
    do {
        try await CustomItemService.shared.deleteItem(item.id)
        customItems.removeAll { $0.id == item.id }
    } catch {
        print("Failed to delete item: \(error)")
    }
}

Step 2: Add sheet modifiers

Add these modifiers to the view body:

.sheet(isPresented: $showAddItemSheet) {
    if let anchor = addItemAnchor {
        AddItemSheet(
            tripId: trip.id,
            anchorDay: anchor.day,
            anchorType: anchor.type,
            anchorId: anchor.id,
            existingItem: nil
        ) { item in
            Task { await saveItem(item) }
        }
    }
}
.sheet(item: $editingItem) { item in
    AddItemSheet(
        tripId: trip.id,
        anchorDay: item.anchorDay,
        anchorType: item.anchorType,
        anchorId: item.anchorId,
        existingItem: item
    ) { updatedItem in
        Task { await saveItem(updatedItem) }
    }
}

Step 3: Build to verify

Run: xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build Expected: BUILD SUCCEEDED

Step 4: Commit

git add SportsTime/Features/Trip/Views/TripDetailView.swift
git commit -m "feat(ui): add CRUD operations and sheets for custom items"

Task 11: Drag and Drop (Optional Enhancement)

Files:

  • Modify: SportsTime/Features/Trip/Views/TripDetailView.swift
  • Modify: SportsTime/Features/Trip/Views/CustomItemRow.swift

Note: This task adds drag-and-drop reordering. It can be deferred if basic add/edit/delete is sufficient for now.

Step 1: Make CustomItemRow draggable

Update CustomItemRow to add .draggable:

var body: some View {
    HStack(spacing: 12) {
        // ... existing content
    }
    // ... existing modifiers
    .draggable(item.id.uuidString) {
        // Drag preview
        HStack {
            Text(item.category.icon)
            Text(item.title)
        }
        .padding(8)
        .background(Theme.cardBackground(.dark))
        .cornerRadius(8)
    }
}

Step 2: Add drop destinations to add buttons

Update InlineAddButton:

private struct InlineAddButton: View {
    let day: Int
    let anchorType: CustomItineraryItem.AnchorType
    let anchorId: String?
    let onAdd: () -> Void
    let onDrop: (CustomItineraryItem.AnchorType, String?, Int) -> Void

    var body: some View {
        Button(action: onAdd) {
            // ... existing content
        }
        .dropDestination(for: String.self) { items, _ in
            guard let itemIdString = items.first else { return false }
            onDrop(anchorType, anchorId, day)
            return true
        }
    }
}

Step 3: Implement move logic

Add function to handle drops:

private func moveItem(itemId: String, toAnchorType: CustomItineraryItem.AnchorType, toAnchorId: String?, toDay: Int) async {
    guard let uuid = UUID(uuidString: itemId),
          var item = customItems.first(where: { $0.id == uuid }) else { return }

    item.anchorType = toAnchorType
    item.anchorId = toAnchorId
    item.anchorDay = toDay
    item.modifiedAt = Date()

    await saveItem(item)
}

Step 4: Build and test

Run: xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build Expected: BUILD SUCCEEDED

Step 5: Commit

git add SportsTime/Features/Trip/Views/TripDetailView.swift SportsTime/Features/Trip/Views/CustomItemRow.swift
git commit -m "feat(ui): add drag-and-drop reordering for custom items"

Task 12: Final Integration Test

Step 1: Run full test suite

Run: xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' test Expected: All tests PASS

Step 2: Manual testing checklist

  • Open a saved trip
  • Tap "+ Add" button between items
  • Select category and enter title
  • Verify item appears in correct position
  • Tap item to edit
  • Swipe left to delete
  • (If drag implemented) Drag item to new position
  • Close and reopen trip - items persist
  • Test on second device - items sync via CloudKit

Step 3: Final commit

git add -A
git commit -m "feat: complete custom itinerary items feature"

Summary

Task Description Files
1 Domain model CustomItineraryItem.swift, tests
2 CloudKit wrapper CKModels.swift
3 Service layer CustomItemService.swift
4 SwiftData model SavedTrip.swift
5 Add/Edit sheet AddItemSheet.swift
6 Item row component CustomItemRow.swift
7-10 TripDetailView integration TripDetailView.swift
11 Drag and drop (optional) Multiple
12 Integration testing -