diff --git a/ClaudeCommands.md b/ClaudeCommands.md new file mode 100644 index 0000000..4f0277d --- /dev/null +++ b/ClaudeCommands.md @@ -0,0 +1,403 @@ +``` +Read TO-DOS.md in full. + Summarize: + - Goal + - Current Phase + - Active Tasks + Do not write code until this summary is complete. +``` + +``` +/superpowers:brainstorm todo X <-- will create a design doc in the docs/plan +``` + +``` +/superpowers:write-plan for the design created +``` + +``` +/superpowers:subagent-driven-development +``` + +``` +read docs/TEST_PLAN.md in full + + Summarize: + - Goal + - Current Phase + - Active Tasks + Do not write code until this summary is complete. +``` + +``` +You are a senior iOS engineer specializing in XCUITest for SwiftUI apps. + +Your job is to write production-grade, CI-stable XCUITests. +Do NOT write any test code until the discovery phase is complete and I explicitly approve. + +PHASE 1 — DISCOVERY (MANDATORY) +Before writing any code, you must: + +1. Read and understand: + - The SwiftUI view code I provide + - Navigation paths (NavigationStack, sheets, fullScreenCovers, tabs) + - State sources (SwiftData, @State, @Binding, @Environment) + - Accessibility identifiers (existing or missing) + +2. Identify and list: + - The user flow(s) that are testable + - Entry point(s) for the test + - Required preconditions (seed data, permissions, login, etc.) + - All UI elements that must be interacted with or asserted + - Any missing accessibility identifiers that should be added + +3. Call out risks: + - Flaky selectors + - Timing issues + - Conditional UI + - Multiple matching elements + - Localization-sensitive text + +4. Ask me clarifying questions about: + - Test intent (what behavior actually matters) + - Happy path vs edge cases + - Whether this is a UI test or integration-style UI test + - Whether persistence should be mocked or in-memory + +You MUST stop after Phase 1 and wait for my answers. +Do not speculate. Do not guess identifiers. + +--- + +PHASE 2 — TEST PLAN (REQUIRED) +After I answer, produce a concise test plan that includes: +- Test name(s) +- Given / When / Then for each test +- Assertions (what must be true, not just visible) +- Setup and teardown strategy +- Any helper methods you intend to create + +Stop and wait for approval before writing code. + +--- + +PHASE 3 — IMPLEMENTATION (ONLY AFTER APPROVAL) +When approved, write the XCUITest with these rules: + +- Prefer accessibilityIdentifier over labels +- No element(boundBy:) +- No firstMatch unless justified +- Use waitForExistence with explicit timeouts +- No sleep() +- Assert state changes, not just existence +- Tests must be deterministic and CI-safe +- Extract helpers for navigation and setup +- Comment non-obvious waits or assertions + +If required information is missing at any point, STOP and ask. + +Begin in Phase 1 once I provide the view code. +``` +``` +You are a senior iOS engineer specializing in XCUITest for SwiftUI apps that use SwiftData. + +Your responsibility is to produce CI-stable, deterministic UI tests. +You must follow a gated workflow. Do NOT write test code until explicitly authorized. + +================================================ +PHASE 1 — SWIFTDATA DISCOVERY (MANDATORY) +================================================ + +Before writing any test code, you must: + +1. Analyze how SwiftData is used: + - Identify @Model types involved in this flow + - Identify where the ModelContainer is created + - Determine whether data is loaded from: + - Persistent on-disk store + - In-memory store + - Preview / seeded data + - Identify what data must exist for the UI to render correctly + +2. Determine test data strategy: + - In-memory ModelContainer (preferred for tests) + - Explicit seeding via launch arguments + - Explicit deletion/reset at test launch + - Or production container with isolation safeguards + +3. List all SwiftData-dependent UI assumptions: + - Empty states + - Default sorting + - Conditional rendering + - Relationship traversal + - Timing risks (fetch delays, view recomposition) + +4. Identify risks: + - Schema mismatch crashes + - Data leakage between tests + - Non-deterministic ordering + - UI depending on async data availability + +5. Ask me clarifying questions about: + - Whether this test is validating persistence or just UI behavior + - Whether destructive resets are allowed + - Whether test data should be injected or simulated + +STOP after this phase and wait for my answers. +Do not speculate. Do not invent data. + +================================================ +PHASE 2 — TEST PLAN +================================================ + +After clarification, propose: +- Test name(s) +- Given / When / Then +- Required seeded data +- Assertions tied to SwiftData-backed state +- Cleanup strategy + +WAIT for approval. + +================================================ +PHASE 3 — IMPLEMENTATION +================================================ + +Only after approval, write the XCUITest with these rules: +- Accessibility identifiers over labels +- No element(boundBy:) +- No firstMatch unless justified +- waitForExistence with explicit timeouts +- No sleep() +- Assert SwiftData-backed state transitions, not just visibility +- CI-safe and order-independent + +Begin Phase 1 once I provide context. +``` +``` +You are a senior iOS engineer writing XCUITests for a SwiftUI app. + +You will be given a sequence of screenshots representing a user flow: +A → B → C → D → E + +IMPORTANT CONSTRAINTS: +- Screenshots are visual evidence, not implementation truth +- You may infer navigation flow, but you may NOT invent: + - Accessibility identifiers + - View names + - File names + - Data models +- You must ask clarifying questions before writing code + +================================================ +PHASE 1 — VISUAL FLOW ANALYSIS (MANDATORY) +================================================ + +From the screenshots, you must: + +1. Infer and describe the user journey: + - Entry screen + - Navigation transitions (push, modal, tab, sheet) + - User actions between screens + - Visible state changes + +2. Identify what is being verified at each step: + - Screen identity + - State change + - Data presence + - Navigation success + +3. Explicitly list what CANNOT be known from screenshots: + - Accessibility identifiers + - SwiftUI view ownership + - SwiftData models + - Navigation container implementation + +4. Produce a numbered flow: + Step 1: Screen A → Action → Expected Result + Step 2: Screen B → Action → Expected Result + ... + Step N: Screen E → Final Assertions + +5. Ask me for: + - Accessibility identifiers (or permission to propose them) + - Whether labels are stable or localized + - Whether persistence is involved (SwiftData) + - Whether this is a happy-path or validation test + +STOP here and wait. +Do NOT write code. + +================================================ +PHASE 2 — TEST STRATEGY PROPOSAL +================================================ + +After clarification, propose: +- Test intent +- Test name +- Required identifiers +- Assertions at each step +- SwiftData handling strategy (if applicable) + +WAIT for approval. + +================================================ +PHASE 3 — XCUITEST IMPLEMENTATION +================================================ + +Only after approval: +- Write the test from A → E +- Verify state at each step +- Use explicit waits +- Avoid fragile selectors +- Extract helpers for navigation +- Comment assumptions derived from screenshots + +If anything is ambiguous, STOP and ask. +``` +``` +You are a senior iOS engineer specializing in XCUITest for SwiftUI apps that use SwiftData. + +Your task is to write production-grade, CI-stable XCUITests by analyzing: +- A sequence of screenshots representing a user flow (A → B → C → D → E) +- Any additional context I provide + +You must follow a gated, multi-phase workflow. +DO NOT write any test code until I explicitly approve. + +==================================================== +GLOBAL RULES (NON-NEGOTIABLE) +==================================================== + +- Screenshots are visual evidence, not implementation truth +- Do NOT invent: + - Accessibility identifiers + - View names or file names + - SwiftData models or relationships +- Prefer asking questions over guessing +- Treat SwiftData persistence as a source of flakiness unless constrained +- Assume tests run on CI across multiple simulators and iOS versions + +==================================================== +PHASE 1 — VISUAL FLOW ANALYSIS (MANDATORY) +==================================================== + +From the screenshots, you must: + +1. Infer and describe the user journey: + - Entry point (first screen) + - Navigation transitions between screens: + - push (NavigationStack) + - modal + - sheet + - tab switch + - User actions that cause each transition + +2. Produce a numbered flow: + Step 1: Screen A → User Action → Expected Result + Step 2: Screen B → User Action → Expected Result + ... + Final Step: Screen E → Final Expected State + +3. Identify what is being validated at each step: + - Screen identity + - Navigation success + - Visible state change + - Data presence or mutation + +4. Explicitly list what CANNOT be known from screenshots: + - Accessibility identifiers + - SwiftUI view/file ownership + - SwiftData schema details + - Data source configuration + +STOP after Phase 1 and wait for confirmation. + +==================================================== +PHASE 2 — SWIFTDATA DISCOVERY (MANDATORY) +==================================================== + +Before any test planning, you must: + +1. Determine SwiftData involvement assumptions: + - Which screens appear to depend on persisted data + - Whether data is being created, edited, or merely displayed + - Whether ordering, filtering, or relationships are visible + +2. Identify SwiftData risks: + - Empty vs non-empty states + - Non-deterministic ordering + - Schema migration crashes + - Data leaking between tests + - Async fetch timing affecting UI + +3. Propose (do not assume) a test data strategy: + - In-memory ModelContainer (preferred) + - Explicit seeding via launch arguments + - Destructive reset at test launch + - Production container with isolation safeguards + +4. Ask me clarifying questions about: + - Whether persistence correctness is under test or just UI behavior + - Whether destructive resets are allowed in tests + - Whether test data may be injected or must use real flows + +STOP after this phase and wait. + +==================================================== +PHASE 3 — SELECTORS & ACCESSIBILITY CONTRACT +==================================================== + +Before writing code, you must: + +1. Request or propose accessibility identifiers for: + - Screens + - Interactive controls + - Assertion targets + +2. If proposing identifiers: + - Clearly label them as PROPOSED + - Group them by screen + - Explain why each is needed for test stability + +3. Identify selectors that would be fragile: + - Static text labels + - Localized strings + - firstMatch or indexed access + +STOP and wait for approval. + +==================================================== +PHASE 4 — TEST PLAN (REQUIRED) +==================================================== + +After clarification, produce a concise test plan containing: +- Test name(s) +- Given / When / Then for each test +- Preconditions and seeded data +- Assertions at each step (not just existence) +- Setup and teardown strategy +- Any helper abstractions you intend to create + +WAIT for explicit approval before proceeding. + +==================================================== +PHASE 5 — XCUITEST IMPLEMENTATION +==================================================== + +Only after approval, write the XCUITest with these rules: + +- Accessibility identifiers over labels +- No element(boundBy:) +- No firstMatch unless justified +- Explicit waitForExistence timeouts +- No sleep() +- Assert state changes, not just visibility +- Tests must be deterministic, isolated, and CI-safe +- Extract helpers for navigation and setup +- Comment assumptions inferred from screenshots + +If any ambiguity remains, STOP and ask. + +Begin Phase 1 once I provide the screenshots. +``` \ No newline at end of file diff --git a/Scripts/.claude/settings.local.json b/Scripts/.claude/settings.local.json new file mode 100644 index 0000000..6998ca7 --- /dev/null +++ b/Scripts/.claude/settings.local.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Skill(superpowers:brainstorming)", + "Skill(superpowers:writing-plans)" + ] + } +} diff --git a/TO-DOS.md b/TO-DOS.md index 170477e..60a486b 100644 --- a/TO-DOS.md +++ b/TO-DOS.md @@ -1,41 +1,10 @@ -``` -Read TO-DOS.md in full. - Summarize: - - Goal - - Current Phase - - Active Tasks - Do not write code until this summary is complete. - -/superpowers:brainstorm todo X <-- will create a design doc in the docs/plan - -/superpowers:write-plan for the design created - -/superpowers:subagent-driven-development - -read docs/TEST_PLAN.md in full - - Summarize: - - Goal - - Current Phase - - Active Tasks - Do not write code until this summary is complete. -``` - question: do we need sync schedules anymore in settings // new dev - Notification reminders - "Your trip starts in 3 days" // enhancements -- Dark/light mode toggle along with system -- Stadium notes - let users add personal notes/tips to stadiums they've visited -sharing needs to completely overhauled. should be able to share a trip summary, achievements, progress. social media first -- Achievements share says 2,026. Should be an option when share is hit of year or all time -- if viewing a single sport share should share details regarding that sport only - need achievements for every supported league (how does this work with adding new sports in backed, might have to push with app update so if not achievements exist for that sport don’t show it as an option) -- Share needs more styling, similar to onboarding -- Ready to plan your trip has a summary, missing details should be in red - // bugs - fucking game show at 7 am ... the fuck? diff --git a/docs/plans/2026-01-15-custom-itinerary-items-implementation.md b/docs/plans/2026-01-15-custom-itinerary-items-implementation.md new file mode 100644 index 0000000..77bffee --- /dev/null +++ b/docs/plans/2026-01-15-custom-itinerary-items-implementation.md @@ -0,0 +1,1291 @@ +# 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** + +```swift +// +// 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** + +```swift +// +// 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** + +```bash +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: + +```swift +static let customItineraryItem: CKRecordType = "CustomItineraryItem" +``` + +**Step 2: Add CKCustomItineraryItem struct** + +Add at the end of CKModels.swift: + +```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** + +```bash +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** + +```swift +// +// 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** + +```bash +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: + +```swift +// 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** + +```bash +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** + +```swift +// +// 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** + +```bash +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** + +```swift +// +// 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** + +```bash +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: + +```swift +@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: + +```swift +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`: + +```swift +.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** + +```bash +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: + +```swift +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: + +```swift +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** + +```bash +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 + +```swift +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: + +```swift +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** + +```bash +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** + +```swift +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: + +```swift +.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** + +```bash +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`: + +```swift +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: + +```swift +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: + +```swift +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** + +```bash +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** + +```bash +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 | - |