Merge branch 'custom_ish'

This commit is contained in:
Trey t
2026-01-16 15:38:28 -06:00
18 changed files with 3393 additions and 65 deletions

403
ClaudeCommands.md Normal file
View File

@@ -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.
```

View File

@@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Skill(superpowers:brainstorming)",
"Skill(superpowers:writing-plans)"
]
}
}

View File

@@ -20,6 +20,7 @@ enum CKRecordType {
static let stadiumAlias = "StadiumAlias"
static let tripPoll = "TripPoll"
static let pollVote = "PollVote"
static let customItineraryItem = "CustomItineraryItem"
}
// MARK: - CKTeam
@@ -622,3 +623,74 @@ struct CKPollVote {
)
}
}
// 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 sortOrderKey = "sortOrder"
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.sortOrderKey] = item.sortOrder
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
let sortOrder = record[CKCustomItineraryItem.sortOrderKey] as? Int ?? 0
return CustomItineraryItem(
id: itemId,
tripId: tripId,
category: category,
title: title,
anchorType: anchorType,
anchorId: anchorId,
anchorDay: anchorDay,
sortOrder: sortOrder,
createdAt: createdAt,
modifiedAt: modifiedAt
)
}
}

View File

@@ -0,0 +1,83 @@
//
// 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
var sortOrder: Int // For ordering within same anchor position
let createdAt: Date
var modifiedAt: Date
init(
id: UUID = UUID(),
tripId: UUID,
category: ItemCategory,
title: String,
anchorType: AnchorType = .startOfDay,
anchorId: String? = nil,
anchorDay: Int,
sortOrder: Int = 0,
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.sortOrder = sortOrder
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
}
}

View File

@@ -101,6 +101,79 @@ final class TripVote {
}
}
// 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
)
}
}
// MARK: - User Preferences
@Model

View File

@@ -0,0 +1,48 @@
//
// AppDelegate.swift
// SportsTime
//
// Handles push notification registration and CloudKit subscription notifications
//
import UIKit
import CloudKit
import UserNotifications
class AppDelegate: NSObject, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
// Register for remote notifications (required for CloudKit subscriptions)
application.registerForRemoteNotifications()
return true
}
func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
print("📡 [Push] Registered for remote notifications")
}
func application(
_ application: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error
) {
print("📡 [Push] Failed to register: \(error.localizedDescription)")
}
func application(
_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
) {
// Handle CloudKit subscription notification
Task {
await CustomItemSubscriptionService.shared.handleRemoteNotification(userInfo: userInfo)
completionHandler(.newData)
}
}
}

View File

@@ -0,0 +1,204 @@
//
// 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 {
print("☁️ [CloudKit] Creating record for item: \(item.id)")
let ckItem = CKCustomItineraryItem(item: item)
do {
let savedRecord = try await publicDatabase.save(ckItem.record)
print("☁️ [CloudKit] Record saved: \(savedRecord.recordID.recordName)")
return item
} catch let error as CKError {
print("☁️ [CloudKit] CKError on create: \(error.code.rawValue) - \(error.localizedDescription)")
throw mapCloudKitError(error)
} catch {
print("☁️ [CloudKit] Unknown error on create: \(error)")
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.sortOrderKey] = item.sortOrder
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)
}
}
/// Batch update sortOrder for multiple items (for reordering)
func updateSortOrders(_ items: [CustomItineraryItem]) async throws {
guard !items.isEmpty else { return }
print("☁️ [CloudKit] Batch updating sortOrder for \(items.count) items")
// Fetch all records
let recordIDs = items.map { CKRecord.ID(recordName: $0.id.uuidString) }
let fetchResults = try await publicDatabase.records(for: recordIDs)
// Update each record's sortOrder
var recordsToSave: [CKRecord] = []
let now = Date()
for item in items {
let recordID = CKRecord.ID(recordName: item.id.uuidString)
guard case .success(let record) = fetchResults[recordID] else { continue }
record[CKCustomItineraryItem.sortOrderKey] = item.sortOrder
record[CKCustomItineraryItem.modifiedAtKey] = now
recordsToSave.append(record)
}
// Save all in one batch operation
let modifyOp = CKModifyRecordsOperation(recordsToSave: recordsToSave, recordIDsToDelete: nil)
modifyOp.savePolicy = .changedKeys
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
modifyOp.modifyRecordsResultBlock = { result in
switch result {
case .success:
print("☁️ [CloudKit] Batch sortOrder update complete")
continuation.resume()
case .failure(let error):
print("☁️ [CloudKit] Batch update failed: \(error)")
continuation.resume(throwing: error)
}
}
publicDatabase.add(modifyOp)
}
}
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] {
print("☁️ [CloudKit] Fetching items for tripId: \(tripId.uuidString)")
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)
print("☁️ [CloudKit] Query returned \(results.count) records")
let items = results.compactMap { result -> CustomItineraryItem? in
guard case .success(let record) = result.1 else {
print("☁️ [CloudKit] Record fetch failed for: \(result.0.recordName)")
return nil
}
let item = CKCustomItineraryItem(record: record).toItem()
if item == nil {
print("☁️ [CloudKit] Failed to parse record: \(record.recordID.recordName)")
}
return item
}.sorted { ($0.anchorDay, $0.sortOrder) < ($1.anchorDay, $1.sortOrder) }
print("☁️ [CloudKit] Parsed \(items.count) valid items")
return items
} catch let error as CKError {
print("☁️ [CloudKit] CKError on fetch: \(error.code.rawValue) - \(error.localizedDescription)")
throw mapCloudKitError(error)
} catch {
print("☁️ [CloudKit] Unknown error on fetch: \(error)")
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)"
}
}
}

View File

@@ -0,0 +1,147 @@
//
// CustomItemSubscriptionService.swift
// SportsTime
//
// CloudKit subscription service for real-time custom item updates
//
import Foundation
import CloudKit
import Combine
/// Manages CloudKit subscriptions for custom itinerary items on shared trips
actor CustomItemSubscriptionService {
static let shared = CustomItemSubscriptionService()
private let container: CKContainer
private let publicDatabase: CKDatabase
/// Publisher that emits trip IDs when their custom items change
private let changeSubject = PassthroughSubject<UUID, Never>()
var changePublisher: AnyPublisher<UUID, Never> {
changeSubject.eraseToAnyPublisher()
}
/// Track which trip IDs have active subscriptions
private var subscribedTripIds: Set<UUID> = []
private init() {
self.container = CKContainer(identifier: "iCloud.com.sportstime.app")
self.publicDatabase = container.publicCloudDatabase
}
// MARK: - Subscription Management
/// Subscribe to changes for a specific trip's custom items
func subscribeToTrip(_ tripId: UUID) async throws {
guard !subscribedTripIds.contains(tripId) else {
print("📡 [Subscription] Already subscribed to trip: \(tripId)")
return
}
let subscriptionID = "custom-items-\(tripId.uuidString)"
// Check if subscription already exists
do {
_ = try await publicDatabase.subscription(for: subscriptionID)
subscribedTripIds.insert(tripId)
print("📡 [Subscription] Found existing subscription for trip: \(tripId)")
return
} catch let error as CKError where error.code == .unknownItem {
// Subscription doesn't exist, create it
} catch {
print("📡 [Subscription] Error checking subscription: \(error)")
}
// Create subscription for this trip's custom items
let predicate = NSPredicate(
format: "%K == %@",
CKCustomItineraryItem.tripIdKey,
tripId.uuidString
)
let subscription = CKQuerySubscription(
recordType: CKRecordType.customItineraryItem,
predicate: predicate,
subscriptionID: subscriptionID,
options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion]
)
// Configure notification
let notificationInfo = CKSubscription.NotificationInfo()
notificationInfo.shouldSendContentAvailable = true // Silent notification
subscription.notificationInfo = notificationInfo
do {
try await publicDatabase.save(subscription)
subscribedTripIds.insert(tripId)
print("📡 [Subscription] Created subscription for trip: \(tripId)")
} catch let error as CKError {
print("📡 [Subscription] Failed to create subscription: \(error.code.rawValue) - \(error.localizedDescription)")
throw error
}
}
/// Unsubscribe from a specific trip's custom items
func unsubscribeFromTrip(_ tripId: UUID) async throws {
guard subscribedTripIds.contains(tripId) else { return }
let subscriptionID = "custom-items-\(tripId.uuidString)"
do {
try await publicDatabase.deleteSubscription(withID: subscriptionID)
subscribedTripIds.remove(tripId)
print("📡 [Subscription] Removed subscription for trip: \(tripId)")
} catch let error as CKError where error.code == .unknownItem {
// Already deleted
subscribedTripIds.remove(tripId)
}
}
/// Subscribe to all trips that the user has access to (via polls)
func subscribeToSharedTrips(tripIds: [UUID]) async {
for tripId in tripIds {
do {
try await subscribeToTrip(tripId)
} catch {
print("📡 [Subscription] Failed to subscribe to trip \(tripId): \(error)")
}
}
}
// MARK: - Notification Handling
/// Handle CloudKit notification - call this from AppDelegate/SceneDelegate
func handleNotification(_ notification: CKNotification) {
guard let queryNotification = notification as? CKQueryNotification,
let subscriptionID = queryNotification.subscriptionID,
subscriptionID.hasPrefix("custom-items-") else {
return
}
// Extract trip ID from subscription ID
let tripIdString = String(subscriptionID.dropFirst("custom-items-".count))
guard let tripId = UUID(uuidString: tripIdString) else { return }
print("📡 [Subscription] Received change notification for trip: \(tripId)")
changeSubject.send(tripId)
}
/// Handle notification from userInfo dictionary (from push notification)
nonisolated func handleRemoteNotification(userInfo: [AnyHashable: Any]) async {
guard let notification = CKNotification(fromRemoteNotificationDictionary: userInfo) else {
return
}
await handleNotification(notification)
}
// MARK: - Cleanup
/// Remove all subscriptions (call on sign out or cleanup)
func removeAllSubscriptions() async {
for tripId in subscribedTripIds {
try? await unsubscribeFromTrip(tripId)
}
subscribedTripIds.removeAll()
}
}

View File

@@ -315,7 +315,7 @@ struct SavedTripCard: View {
var body: some View {
NavigationLink {
TripDetailView(trip: trip)
TripDetailView(trip: trip, games: savedTrip.games, allowCustomItems: true)
} label: {
HStack(spacing: Theme.Spacing.md) {
// Route preview icon
@@ -555,7 +555,7 @@ struct SavedTripsListView: View {
ForEach(Array(sortedTrips.enumerated()), id: \.element.id) { index, savedTrip in
if let trip = savedTrip.trip {
NavigationLink {
TripDetailView(trip: trip)
TripDetailView(trip: trip, games: savedTrip.games, allowCustomItems: true)
} label: {
SavedTripListRow(trip: trip)
}

View File

@@ -126,7 +126,7 @@ struct PollDetailView: View {
}
.sheet(item: $selectedTrip) { trip in
NavigationStack {
TripDetailView(trip: trip)
TripDetailView(trip: trip, allowCustomItems: true)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {

View File

@@ -0,0 +1,149 @@
//
// 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 }
}

View File

@@ -0,0 +1,98 @@
//
// 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
// Drag handle visible - users can drag to reorder using .draggable/.dropDestination
var body: some View {
Button(action: onTap) {
HStack(spacing: 12) {
// Drag handle
Image(systemName: "line.3.horizontal")
.foregroundStyle(.tertiary)
.font(.caption)
// Category icon
Text(item.category.icon)
.font(.title3)
// Title
Text(item.title)
.font(.subheadline)
.foregroundStyle(Theme.textPrimary(colorScheme))
.lineLimit(2)
Spacer()
// Chevron to indicate tappable (tap to edit)
Image(systemName: "chevron.right")
.foregroundStyle(.tertiary)
.font(.caption)
}
.padding(.horizontal, Theme.Spacing.md)
.padding(.vertical, Theme.Spacing.sm)
.background {
// Opaque base + semi-transparent accent
RoundedRectangle(cornerRadius: 8)
.fill(Theme.cardBackground(colorScheme))
RoundedRectangle(cornerRadius: 8)
.fill(Theme.warmOrange.opacity(0.1))
}
.overlay {
RoundedRectangle(cornerRadius: 8)
.strokeBorder(Theme.warmOrange.opacity(0.3), lineWidth: 1)
}
}
.buttonStyle(.plain)
.contextMenu {
Button {
onTap()
} label: {
Label("Edit", systemImage: "pencil")
}
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()
}

View File

@@ -6,6 +6,8 @@
import SwiftUI
import SwiftData
import MapKit
import Combine
import UniformTypeIdentifiers
struct TripDetailView: View {
@Environment(\.modelContext) private var modelContext
@@ -14,6 +16,9 @@ struct TripDetailView: View {
let trip: Trip
private let providedGames: [String: RichGame]?
/// When true, shows Add buttons and custom items (only for saved trips)
private let allowCustomItems: Bool
@Query private var savedTrips: [SavedTrip]
@State private var showProPaywall = false
@State private var selectedDay: ItineraryDay?
@@ -28,6 +33,14 @@ struct TripDetailView: View {
@State private var loadedGames: [String: RichGame] = [:]
@State private var isLoadingGames = false
// Custom items state
@State private var customItems: [CustomItineraryItem] = []
@State private var addItemAnchor: AddItemAnchor?
@State private var editingItem: CustomItineraryItem?
@State private var subscriptionCancellable: AnyCancellable?
@State private var draggedItem: CustomItineraryItem?
@State private var dropTargetId: String? // Track which drop zone is being hovered
private let exportService = ExportService()
private let dataProvider = AppDataProvider.shared
@@ -36,16 +49,32 @@ struct TripDetailView: View {
providedGames ?? loadedGames
}
/// Initialize with trip and games dictionary (existing callers)
/// Initialize with trip and games dictionary (existing callers - no custom items)
init(trip: Trip, games: [String: RichGame]) {
self.trip = trip
self.providedGames = games
self.allowCustomItems = false
}
/// Initialize with just trip - games will be loaded from AppDataProvider
/// Initialize with just trip - games will be loaded from AppDataProvider (no custom items)
init(trip: Trip) {
self.trip = trip
self.providedGames = nil
self.allowCustomItems = false
}
/// Initialize for saved trip context - enables custom items
init(trip: Trip, games: [String: RichGame], allowCustomItems: Bool) {
self.trip = trip
self.providedGames = games
self.allowCustomItems = allowCustomItems
}
/// Initialize for saved trip context without provided games - loads from AppDataProvider
init(trip: Trip, allowCustomItems: Bool) {
self.trip = trip
self.providedGames = nil
self.allowCustomItems = allowCustomItems
}
var body: some View {
@@ -110,11 +139,45 @@ struct TripDetailView: View {
.sheet(isPresented: $showProPaywall) {
PaywallView()
}
.sheet(item: $addItemAnchor) { anchor in
AddItemSheet(
tripId: trip.id,
anchorDay: anchor.day,
anchorType: anchor.type,
anchorId: anchor.anchorId,
existingItem: nil
) { item in
Task { await saveCustomItem(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 saveCustomItem(updatedItem) }
}
}
.onAppear {
checkIfSaved()
}
.task {
await loadGamesIfNeeded()
if allowCustomItems {
await loadCustomItems()
await setupSubscription()
}
}
.onDisappear {
subscriptionCancellable?.cancel()
}
.onChange(of: customItems) { _, _ in
// Clear drag state after items update (move completed)
draggedItem = nil
dropTargetId = nil
}
.overlay {
if isExporting {
@@ -330,25 +393,311 @@ struct TripDetailView: View {
Spacer()
}
} else {
ForEach(Array(itinerarySections.enumerated()), id: \.offset) { index, section in
switch section {
case .day(let dayNumber, let date, let gamesOnDay):
DaySection(
dayNumber: dayNumber,
date: date,
games: gamesOnDay
)
.staggeredAnimation(index: index)
case .travel(let segment):
TravelSection(segment: segment)
.staggeredAnimation(index: index)
}
// ZStack with continuous vertical line behind all content
ZStack(alignment: .top) {
// Continuous vertical line down the center
Rectangle()
.fill(Theme.routeGold.opacity(0.4))
.frame(width: 2)
.frame(maxHeight: .infinity)
// Itinerary content
VStack(spacing: Theme.Spacing.md) {
ForEach(Array(itinerarySections.enumerated()), id: \.offset) { index, section in
itineraryRow(for: section, at: index)
}
}
}
}
}
}
/// Build itinerary sections: group by day AND city, with travel between different cities
@ViewBuilder
private func itineraryRow(for section: ItinerarySection, at index: Int) -> some View {
let sectionId = sectionIdentifier(for: section, at: index)
let isDropTarget = dropTargetId == sectionId && draggedItem != nil
switch section {
case .day(let dayNumber, let date, let gamesOnDay):
DaySection(
dayNumber: dayNumber,
date: date,
games: gamesOnDay
)
.staggeredAnimation(index: index)
.overlay(alignment: .bottom) {
if isDropTarget {
DropTargetIndicator()
}
}
.dropDestination(for: String.self) { items, _ in
guard let itemIdString = items.first,
let itemId = UUID(uuidString: itemIdString),
let item = customItems.first(where: { $0.id == itemId }),
let lastGame = gamesOnDay.last else { return false }
Task {
// Insert at beginning (sortOrder 0) when dropping on day card
await moveItemToBeginning(item, toDay: dayNumber, anchorType: .afterGame, anchorId: lastGame.game.id)
}
return true
} isTargeted: { targeted in
withAnimation(.easeInOut(duration: 0.2)) {
dropTargetId = targeted ? sectionId : (dropTargetId == sectionId ? nil : dropTargetId)
}
}
case .travel(let segment):
TravelSection(segment: segment)
.staggeredAnimation(index: index)
.overlay(alignment: .bottom) {
if isDropTarget {
DropTargetIndicator()
}
}
.dropDestination(for: String.self) { items, _ in
guard let itemIdString = items.first,
let itemId = UUID(uuidString: itemIdString),
let item = customItems.first(where: { $0.id == itemId }) else { return false }
// Find the day for this travel segment
let day = findDayForTravelSegment(segment)
// Use stable identifier instead of UUID (UUIDs change on reload)
let stableAnchorId = stableTravelAnchorId(segment)
Task {
// Insert at beginning when dropping on travel section
await moveItemToBeginning(item, toDay: day, anchorType: .afterTravel, anchorId: stableAnchorId)
}
return true
} isTargeted: { targeted in
withAnimation(.easeInOut(duration: 0.2)) {
dropTargetId = targeted ? sectionId : (dropTargetId == sectionId ? nil : dropTargetId)
}
}
case .customItem(let item):
let isDragging = draggedItem?.id == item.id
CustomItemRow(
item: item,
onTap: { editingItem = item },
onDelete: { Task { await deleteCustomItem(item) } }
)
.opacity(isDragging ? 0.4 : 1.0)
.staggeredAnimation(index: index)
.overlay(alignment: .top) {
if isDropTarget && !isDragging {
DropTargetIndicator()
}
}
.draggable(item.id.uuidString) {
// Drag preview
CustomItemRow(
item: item,
onTap: {},
onDelete: {}
)
.frame(width: 300)
.opacity(0.9)
.onAppear { draggedItem = item }
}
.dropDestination(for: String.self) { items, _ in
guard let itemIdString = items.first,
let itemId = UUID(uuidString: itemIdString),
let droppedItem = customItems.first(where: { $0.id == itemId }),
droppedItem.id != item.id else { return false }
Task {
await moveItem(droppedItem, toDay: item.anchorDay, anchorType: item.anchorType, anchorId: item.anchorId, beforeItem: item)
}
draggedItem = nil
dropTargetId = nil
return true
} isTargeted: { targeted in
withAnimation(.easeInOut(duration: 0.2)) {
dropTargetId = targeted ? sectionId : (dropTargetId == sectionId ? nil : dropTargetId)
}
}
case .addButton(let day, let anchorType, let anchorId):
VStack(spacing: 0) {
if isDropTarget {
DropTargetIndicator()
}
InlineAddButton {
addItemAnchor = AddItemAnchor(day: day, type: anchorType, anchorId: anchorId)
}
}
.dropDestination(for: String.self) { items, _ in
guard let itemIdString = items.first,
let itemId = UUID(uuidString: itemIdString),
let item = customItems.first(where: { $0.id == itemId }) else { return false }
Task {
// Insert at beginning when dropping on add button area
await moveItemToBeginning(item, toDay: day, anchorType: anchorType, anchorId: anchorId)
}
draggedItem = nil
dropTargetId = nil
return true
} isTargeted: { targeted in
withAnimation(.easeInOut(duration: 0.2)) {
dropTargetId = targeted ? sectionId : (dropTargetId == sectionId ? nil : dropTargetId)
}
}
}
}
/// Create a stable identifier for an itinerary section (for drop target tracking)
private func sectionIdentifier(for section: ItinerarySection, at index: Int) -> String {
switch section {
case .day(let dayNumber, _, _):
return "day-\(dayNumber)"
case .travel(let segment):
return "travel-\(segment.fromLocation.name)-\(segment.toLocation.name)"
case .customItem(let item):
return "item-\(item.id.uuidString)"
case .addButton(let day, let anchorType, _):
return "add-\(day)-\(anchorType.rawValue)"
}
}
private func findDayForTravelSegment(_ segment: TravelSegment) -> Int {
// Find which day this travel segment belongs to by looking at sections
// Travel appears BEFORE the arrival day, so look FORWARD to find arrival day
for (index, section) in itinerarySections.enumerated() {
if case .travel(let s) = section, s.id == segment.id {
// Look forward to find the arrival day
for i in (index + 1)..<itinerarySections.count {
if case .day(let dayNumber, _, _) = itinerarySections[i] {
return dayNumber
}
}
}
}
return 1
}
/// Create a stable anchor ID for a travel segment (UUIDs regenerate on reload)
private func stableTravelAnchorId(_ segment: TravelSegment) -> String {
let from = segment.fromLocation.name.lowercased().trimmingCharacters(in: .whitespaces)
let to = segment.toLocation.name.lowercased().trimmingCharacters(in: .whitespaces)
return "travel:\(from)->\(to)"
}
private func moveItem(_ item: CustomItineraryItem, toDay day: Int, anchorType: CustomItineraryItem.AnchorType, anchorId: String?, beforeItem: CustomItineraryItem? = nil) async {
var updated = item
updated.anchorDay = day
updated.anchorType = anchorType
updated.anchorId = anchorId
updated.modifiedAt = Date()
// Calculate sortOrder
let itemsAtSameAnchor = customItems.filter {
$0.anchorDay == day &&
$0.anchorType == anchorType &&
$0.anchorId == anchorId &&
$0.id != item.id
}.sorted { $0.sortOrder < $1.sortOrder }
var itemsToSync: [CustomItineraryItem] = []
print("📍 [Move] itemsAtSameAnchor: \(itemsAtSameAnchor.map { "\($0.title) (id: \($0.id.uuidString.prefix(8)))" })")
if let beforeItem = beforeItem {
print("📍 [Move] beforeItem: \(beforeItem.title) (id: \(beforeItem.id.uuidString.prefix(8)))")
}
if let beforeItem = beforeItem,
let beforeIndex = itemsAtSameAnchor.firstIndex(where: { $0.id == beforeItem.id }) {
updated.sortOrder = beforeIndex
print("📍 [Move] Setting \(item.title) sortOrder to \(beforeIndex) (before \(beforeItem.title))")
// Shift other items and track them for syncing
for i in beforeIndex..<itemsAtSameAnchor.count {
if var shiftItem = customItems.first(where: { $0.id == itemsAtSameAnchor[i].id }) {
shiftItem.sortOrder = i + 1
shiftItem.modifiedAt = Date()
print("📍 [Move] Shifting \(shiftItem.title) sortOrder to \(i + 1)")
if let idx = customItems.firstIndex(where: { $0.id == shiftItem.id }) {
customItems[idx] = shiftItem
}
itemsToSync.append(shiftItem)
}
}
} else {
updated.sortOrder = itemsAtSameAnchor.count
print("📍 [Move] Setting \(item.title) sortOrder to \(itemsAtSameAnchor.count) (end of list)")
}
// Update local state
if let idx = customItems.firstIndex(where: { $0.id == item.id }) {
customItems[idx] = updated
}
// Sync moved item and all shifted items to CloudKit
do {
_ = try await CustomItemService.shared.updateItem(updated)
print("✅ [Move] Synced \(updated.title) to day \(day), anchor: \(anchorType.rawValue), sortOrder: \(updated.sortOrder)")
// Also sync shifted items
for shiftItem in itemsToSync {
_ = try await CustomItemService.shared.updateItem(shiftItem)
print("✅ [Move] Synced shifted item \(shiftItem.title) with sortOrder: \(shiftItem.sortOrder)")
}
} catch {
print("❌ [Move] Failed to sync: \(error)")
}
}
/// Move item to the beginning of an anchor position (sortOrder 0), shifting existing items down
private func moveItemToBeginning(_ item: CustomItineraryItem, toDay day: Int, anchorType: CustomItineraryItem.AnchorType, anchorId: String?) async {
var updated = item
updated.anchorDay = day
updated.anchorType = anchorType
updated.anchorId = anchorId
updated.sortOrder = 0 // Insert at beginning
updated.modifiedAt = Date()
// Get existing items at this anchor position (excluding the moved item)
let existingItems = customItems.filter {
$0.anchorDay == day &&
$0.anchorType == anchorType &&
$0.anchorId == anchorId &&
$0.id != item.id
}.sorted { $0.sortOrder < $1.sortOrder }
print("📍 [MoveToBeginning] Moving \(item.title) to beginning of day \(day), anchor: \(anchorType.rawValue)")
print("📍 [MoveToBeginning] Existing items to shift: \(existingItems.map { "\($0.title) (order: \($0.sortOrder))" })")
// Shift all existing items down by 1
var itemsToSync: [CustomItineraryItem] = []
for (index, existingItem) in existingItems.enumerated() {
if var shiftItem = customItems.first(where: { $0.id == existingItem.id }) {
shiftItem.sortOrder = index + 1 // Shift down
shiftItem.modifiedAt = Date()
print("📍 [MoveToBeginning] Shifting \(shiftItem.title) sortOrder to \(index + 1)")
if let idx = customItems.firstIndex(where: { $0.id == shiftItem.id }) {
customItems[idx] = shiftItem
}
itemsToSync.append(shiftItem)
}
}
// Update local state for the moved item
if let idx = customItems.firstIndex(where: { $0.id == item.id }) {
customItems[idx] = updated
}
// Sync to CloudKit
do {
_ = try await CustomItemService.shared.updateItem(updated)
print("✅ [MoveToBeginning] Synced \(updated.title) with sortOrder: 0")
for shiftItem in itemsToSync {
_ = try await CustomItemService.shared.updateItem(shiftItem)
print("✅ [MoveToBeginning] Synced shifted item \(shiftItem.title) with sortOrder: \(shiftItem.sortOrder)")
}
} catch {
print("❌ [MoveToBeginning] Failed to sync: \(error)")
}
}
/// Build itinerary sections: group by day AND city, with travel, custom items, and add buttons
private var itinerarySections: [ItinerarySection] {
var sections: [ItinerarySection] = []
@@ -381,7 +730,7 @@ struct TripDetailView: View {
}
}
// Build sections: insert travel BEFORE each section when coming from different city
// Build sections: insert travel, custom items (only if allowed), and add buttons
for (index, section) in dayCitySections.enumerated() {
// Check if we need travel BEFORE this section (coming from different city)
@@ -394,12 +743,57 @@ struct TripDetailView: View {
if !prevCity.isEmpty && !currentCity.isEmpty && prevCity != currentCity {
if let travelSegment = findTravelSegment(from: prevCity, to: currentCity) {
sections.append(.travel(travelSegment))
if allowCustomItems {
// Use stable anchor ID for travel segments
let stableId = stableTravelAnchorId(travelSegment)
// Add button after travel
sections.append(.addButton(day: section.dayNumber, anchorType: .afterTravel, anchorId: stableId))
// Custom items after this travel (sorted by sortOrder)
let itemsAfterTravel = customItems.filter {
$0.anchorDay == section.dayNumber &&
$0.anchorType == .afterTravel &&
$0.anchorId == stableId
}.sorted { $0.sortOrder < $1.sortOrder }
for item in itemsAfterTravel {
sections.append(.customItem(item))
}
}
}
}
}
if allowCustomItems {
// Custom items at start of day (sorted by sortOrder)
let itemsAtStart = customItems.filter {
$0.anchorDay == section.dayNumber && $0.anchorType == .startOfDay
}.sorted { $0.sortOrder < $1.sortOrder }
for item in itemsAtStart {
sections.append(.customItem(item))
}
}
// Add the day section
sections.append(.day(dayNumber: section.dayNumber, date: section.date, games: section.games))
if allowCustomItems {
// 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 (sorted by sortOrder)
let itemsAfterGame = customItems.filter {
$0.anchorDay == section.dayNumber &&
$0.anchorType == .afterGame &&
$0.anchorId == lastGame.game.id
}.sorted { $0.sortOrder < $1.sortOrder }
for item in itemsAfterGame {
sections.append(.customItem(item))
}
}
}
}
return sections
@@ -643,6 +1037,87 @@ struct TripDetailView: View {
isSaved = true
}
}
// MARK: - Custom Items (CloudKit persistence)
private func setupSubscription() async {
// Subscribe to real-time updates for this trip
do {
try await CustomItemSubscriptionService.shared.subscribeToTrip(trip.id)
// Listen for changes and reload
subscriptionCancellable = await CustomItemSubscriptionService.shared.changePublisher
.filter { $0 == self.trip.id }
.receive(on: DispatchQueue.main)
.sink { [self] _ in
print("📡 [Subscription] Received update, reloading custom items...")
Task {
await loadCustomItems()
}
}
} catch {
print("📡 [Subscription] Failed to subscribe: \(error)")
}
}
private func loadCustomItems() async {
print("🔍 [CustomItems] Loading items for trip: \(trip.id)")
do {
let items = try await CustomItemService.shared.fetchItems(forTripId: trip.id)
print("✅ [CustomItems] Loaded \(items.count) items from CloudKit")
for item in items {
print(" - \(item.title) (day \(item.anchorDay), anchor: \(item.anchorType.rawValue), sortOrder: \(item.sortOrder))")
}
customItems = items
} catch {
print("❌ [CustomItems] Failed to load: \(error)")
}
}
private func saveCustomItem(_ item: CustomItineraryItem) async {
// Check if this is an update or create
let isUpdate = customItems.contains(where: { $0.id == item.id })
print("💾 [CustomItems] Saving item: '\(item.title)' (isUpdate: \(isUpdate))")
print(" - tripId: \(item.tripId)")
print(" - anchorDay: \(item.anchorDay), anchorType: \(item.anchorType.rawValue)")
// Update local state immediately for responsive UI
if isUpdate {
if let index = customItems.firstIndex(where: { $0.id == item.id }) {
customItems[index] = item
}
} else {
customItems.append(item)
}
// Persist to CloudKit
do {
if isUpdate {
let updated = try await CustomItemService.shared.updateItem(item)
print("✅ [CustomItems] Updated in CloudKit: \(updated.title)")
} else {
let created = try await CustomItemService.shared.createItem(item)
print("✅ [CustomItems] Created in CloudKit: \(created.title)")
}
} catch {
print("❌ [CustomItems] CloudKit save failed: \(error)")
}
}
private func deleteCustomItem(_ item: CustomItineraryItem) async {
print("🗑️ [CustomItems] Deleting item: '\(item.title)'")
// Remove from local state immediately
customItems.removeAll { $0.id == item.id }
// Delete from CloudKit
do {
try await CustomItemService.shared.deleteItem(item.id)
print("✅ [CustomItems] Deleted from CloudKit")
} catch {
print("❌ [CustomItems] CloudKit delete failed: \(error)")
}
}
}
// MARK: - Itinerary Section
@@ -650,6 +1125,65 @@ struct TripDetailView: View {
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?)
var isCustomItem: Bool {
if case .customItem = self { return true }
return false
}
}
// MARK: - Add Item Anchor (for sheet)
struct AddItemAnchor: Identifiable {
let id = UUID()
let day: Int
let type: CustomItineraryItem.AnchorType
let anchorId: String?
}
// MARK: - Inline Add Button
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)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
// MARK: - Drop Target Indicator
private struct DropTargetIndicator: View {
var body: some View {
HStack(spacing: 6) {
Circle()
.fill(Theme.warmOrange)
.frame(width: 8, height: 8)
Rectangle()
.fill(Theme.warmOrange)
.frame(height: 2)
Circle()
.fill(Theme.warmOrange)
.frame(width: 8, height: 8)
}
.padding(.horizontal, Theme.Spacing.md)
.padding(.vertical, 4)
.transition(.opacity.combined(with: .scale(scale: 0.8)))
}
}
// MARK: - Day Section
@@ -783,14 +1317,8 @@ struct TravelSection: View {
}
var body: some View {
// Travel card
VStack(spacing: 0) {
// Top connector
Rectangle()
.fill(Theme.routeGold.opacity(0.4))
.frame(width: 2, height: 16)
// Travel card
VStack(spacing: 0) {
// Main travel info
HStack(spacing: Theme.Spacing.md) {
// Icon
@@ -869,18 +1397,18 @@ struct TravelSection: View {
}
}
}
.background(Theme.cardBackground(colorScheme).opacity(0.7))
.background {
// Opaque base + semi-transparent accent (prevents line showing through)
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
.fill(Theme.cardBackground(colorScheme))
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
.fill(Theme.routeGold.opacity(0.05))
}
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
.overlay {
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
.strokeBorder(Theme.routeGold.opacity(0.3), lineWidth: 1)
}
// Bottom connector
Rectangle()
.fill(Theme.routeGold.opacity(0.4))
.frame(width: 2, height: 16)
}
}
}

View File

@@ -11,6 +11,9 @@ import BackgroundTasks
@main
struct SportsTimeApp: App {
/// App delegate for handling push notifications
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
/// Task that listens for StoreKit transaction updates
private var transactionListener: Task<Void, Never>?

View File

@@ -0,0 +1,57 @@
//
// 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")
}
}

View File

@@ -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 dont 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?

View File

@@ -0,0 +1,195 @@
# Custom Itinerary Items Design
**Date:** 2026-01-15
**Status:** Approved
## Overview
Allow users to add custom items (restaurants, hotels, activities, notes) to saved itineraries. Items can be repositioned anywhere among the system-generated content (games, travel segments) but system items remain fixed.
## Requirements
- Categorized items with visual icons (Restaurant, Hotel, Activity, Note)
- Inline "Add" buttons between each itinerary section
- Always-visible drag handles on custom items (system items have no handles)
- CloudKit sync across devices
- Swipe to delete, tap to edit
## Data Model
```swift
struct CustomItineraryItem: Identifiable, Codable, Hashable {
let id: UUID
let tripId: UUID
var category: ItemCategory
var title: String
var anchorType: AnchorType // What this item is positioned after
var anchorId: String? // ID of game or travel segment (nil = start of day)
var anchorDay: Int // Day number (1-based)
let createdAt: Date
var modifiedAt: Date
enum ItemCategory: String, Codable, CaseIterable {
case restaurant, hotel, activity, note
var icon: String {
switch self {
case .restaurant: return "🍽️"
case .hotel: return "🏨"
case .activity: return "🎯"
case .note: return "📝"
}
}
var label: String {
switch self {
case .restaurant: return "Restaurant"
case .hotel: return "Hotel"
case .activity: return "Activity"
case .note: return "Note"
}
}
}
enum AnchorType: String, Codable {
case startOfDay // Before any games/travel on this day
case afterGame // After a specific game
case afterTravel // After a specific travel segment
}
}
```
**Why anchor-based positioning?** If a game gets rescheduled or removed, index-based positions would break. Anchoring to a specific game/travel ID is more stable. If the anchor is deleted, the item falls back to `startOfDay`.
## UI Components
### Inline "Add Item" Buttons
Small, subtle `+ Add` buttons appear between each itinerary section:
```
Day 1 - Arlington
├─ 🎾 SFG @ TEX (12:05 AM)
├─ [+ Add]
├─ 🚗 Drive to Milwaukee
├─ [+ Add]
└─ 🎾 PIT @ MIL (7:10 PM)
[+ Add]
Day 2 - Chicago
├─ [+ Add] ← Start of day
├─ 🎾 LAD @ CHC (1:20 PM)
[+ Add]
```
### Add Item Sheet
A simple modal with:
- Category picker (4 icons in a horizontal row, tap to select)
- Text field for title
- "Add" button
### Custom Item Row
Displays in the itinerary with:
- Category icon on the left
- Title text
- Drag handle (≡) on the right - always visible
- Swipe left reveals red "Delete" action
- Tap opens edit sheet (same as add, pre-filled)
- Subtle warm background tint for visual distinction
## Reordering Logic
Games and travel segments are fixed. Only custom items can be dragged.
### How it works
1. The itinerary renders as a flat list of mixed item types
2. When a custom item is dragged, it can only be dropped into valid "slots" between system items
3. On drop, the item's anchor is updated to reference what it's now positioned after
### Implementation approach
<!--
REORDERING OPTIONS (for future reference):
1. Edit mode toggle - User taps "Edit" to show drag handles, then "Done" (like Reminders)
2. Long-press to drag - No edit mode, just long-press and drag
3. Always show handles - Custom items always have visible drag handle (CURRENT CHOICE)
-->
Use custom drag-and-drop with `draggable()` and `dropDestination()` modifiers rather than `.onMove`. This gives precise control over what can be dragged and where it can land.
```swift
ForEach(itinerarySections) { section in
switch section {
case .game, .travel:
// Render normally, no drag handle
// But IS a drop destination
case .customItem:
// Render with drag handle
// draggable() + dropDestination()
case .addButton:
// Inline add button (also a drop destination)
}
}
```
## CloudKit Sync
### New record type: `CustomItineraryItem`
Fields:
- `itemId` (String) - UUID
- `tripId` (String) - References the SavedTrip
- `category` (String) - restaurant/hotel/activity/note
- `title` (String)
- `anchorType` (String)
- `anchorId` (String, optional)
- `anchorDay` (Int)
- `createdAt` (Date)
- `modifiedAt` (Date)
### Sync approach
Follows the same pattern as `PollVote`:
- `CKCustomItineraryItem` wrapper struct in `CKModels.swift`
- CRUD methods in a new `CustomItemService` actor
- Items are fetched when loading a saved trip
- Changes sync immediately on add/edit/delete/reorder
### Conflict resolution
Last-write-wins based on `modifiedAt` for both content edits and position changes.
### Offline handling
Custom items are also stored locally in SwiftData alongside `SavedTrip`. When offline, changes queue locally and sync when connection restores.
## Files to Create
| File | Purpose |
|------|---------|
| `Core/Models/Domain/CustomItineraryItem.swift` | Domain model |
| `Core/Models/CloudKit/CKCustomItineraryItem.swift` | CloudKit wrapper |
| `Core/Services/CustomItemService.swift` | CloudKit CRUD operations |
| `Features/Trip/Views/AddItemSheet.swift` | Modal for adding/editing items |
| `Features/Trip/Views/CustomItemRow.swift` | Row component with drag handle |
## Files to Modify
| File | Changes |
|------|---------|
| `TripDetailView.swift` | Add inline buttons, render custom items, handle drag/drop |
| `SavedTrip.swift` | Add relationship to custom items (local cache) |
| `CanonicalSyncService.swift` | Sync custom items with CloudKit |
## Out of Scope (YAGNI)
- Time/duration fields on custom items
- Location/address fields
- Photos/attachments
- Sharing custom items in polls
- Notifications/reminders for custom items
These can be added in future iterations if needed.

File diff suppressed because it is too large Load Diff