From bf9619a207967a3f7d5524ae86b74b0f38839f76 Mon Sep 17 00:00:00 2001 From: Trey t Date: Fri, 16 Jan 2026 19:00:52 -0600 Subject: [PATCH] feat(itinerary): add draggable travel day positioning with CloudKit persistence - Add TravelDayOverride model for storing user-customized travel day positions - Add TravelOverrideService for CloudKit CRUD operations on travel overrides - Add CKTravelDayOverride CloudKit model wrapper - Refactor itinerarySections to validate travel day bounds (must be after last game in departure city) - Travel segments can now be dragged to different days within valid range - Persist travel day overrides to CloudKit for cross-device sync Co-Authored-By: Claude Opus 4.5 --- .../Core/Models/CloudKit/CKModels.swift | 53 ++ .../Models/Domain/TravelDayOverride.swift | 38 ++ .../Core/Services/TravelOverrideService.swift | 180 ++++++ .../Features/Trip/Views/TripDetailView.swift | 555 +++++++++++++----- 4 files changed, 670 insertions(+), 156 deletions(-) create mode 100644 SportsTime/Core/Models/Domain/TravelDayOverride.swift create mode 100644 SportsTime/Core/Services/TravelOverrideService.swift diff --git a/SportsTime/Core/Models/CloudKit/CKModels.swift b/SportsTime/Core/Models/CloudKit/CKModels.swift index 1360300..03cc7d3 100644 --- a/SportsTime/Core/Models/CloudKit/CKModels.swift +++ b/SportsTime/Core/Models/CloudKit/CKModels.swift @@ -21,6 +21,7 @@ enum CKRecordType { static let tripPoll = "TripPoll" static let pollVote = "PollVote" static let customItineraryItem = "CustomItineraryItem" + static let travelDayOverride = "TravelDayOverride" } // MARK: - CKTeam @@ -694,3 +695,55 @@ struct CKCustomItineraryItem { ) } } + +// MARK: - CKTravelDayOverride + +struct CKTravelDayOverride { + static let overrideIdKey = "overrideId" + static let tripIdKey = "tripId" + static let travelAnchorIdKey = "travelAnchorId" + static let displayDayKey = "displayDay" + static let createdAtKey = "createdAt" + static let modifiedAtKey = "modifiedAt" + + let record: CKRecord + + init(record: CKRecord) { + self.record = record + } + + init(override: TravelDayOverride) { + let record = CKRecord( + recordType: CKRecordType.travelDayOverride, + recordID: CKRecord.ID(recordName: override.id.uuidString) + ) + record[CKTravelDayOverride.overrideIdKey] = override.id.uuidString + record[CKTravelDayOverride.tripIdKey] = override.tripId.uuidString + record[CKTravelDayOverride.travelAnchorIdKey] = override.travelAnchorId + record[CKTravelDayOverride.displayDayKey] = override.displayDay + record[CKTravelDayOverride.createdAtKey] = override.createdAt + record[CKTravelDayOverride.modifiedAtKey] = override.modifiedAt + self.record = record + } + + func toOverride() -> TravelDayOverride? { + guard let overrideIdString = record[CKTravelDayOverride.overrideIdKey] as? String, + let overrideId = UUID(uuidString: overrideIdString), + let tripIdString = record[CKTravelDayOverride.tripIdKey] as? String, + let tripId = UUID(uuidString: tripIdString), + let travelAnchorId = record[CKTravelDayOverride.travelAnchorIdKey] as? String, + let displayDay = record[CKTravelDayOverride.displayDayKey] as? Int, + let createdAt = record[CKTravelDayOverride.createdAtKey] as? Date, + let modifiedAt = record[CKTravelDayOverride.modifiedAtKey] as? Date + else { return nil } + + return TravelDayOverride( + id: overrideId, + tripId: tripId, + travelAnchorId: travelAnchorId, + displayDay: displayDay, + createdAt: createdAt, + modifiedAt: modifiedAt + ) + } +} diff --git a/SportsTime/Core/Models/Domain/TravelDayOverride.swift b/SportsTime/Core/Models/Domain/TravelDayOverride.swift new file mode 100644 index 0000000..0610215 --- /dev/null +++ b/SportsTime/Core/Models/Domain/TravelDayOverride.swift @@ -0,0 +1,38 @@ +// +// TravelDayOverride.swift +// SportsTime +// +// Stores user-customized travel day positions +// + +import Foundation + +/// Represents a user override of the default travel day placement. +/// Travel segments normally appear on the day after the last game in the departure city. +/// This model allows users to drag travel to a different day within valid bounds. +struct TravelDayOverride: Identifiable, Codable, Hashable { + let id: UUID + let tripId: UUID + /// Stable identifier for the travel segment (e.g., "travel:houston->arlington") + let travelAnchorId: String + /// The day number (1-indexed) where the travel should display + var displayDay: Int + let createdAt: Date + var modifiedAt: Date + + init( + id: UUID = UUID(), + tripId: UUID, + travelAnchorId: String, + displayDay: Int, + createdAt: Date = Date(), + modifiedAt: Date = Date() + ) { + self.id = id + self.tripId = tripId + self.travelAnchorId = travelAnchorId + self.displayDay = displayDay + self.createdAt = createdAt + self.modifiedAt = modifiedAt + } +} diff --git a/SportsTime/Core/Services/TravelOverrideService.swift b/SportsTime/Core/Services/TravelOverrideService.swift new file mode 100644 index 0000000..c6d003f --- /dev/null +++ b/SportsTime/Core/Services/TravelOverrideService.swift @@ -0,0 +1,180 @@ +// +// TravelOverrideService.swift +// SportsTime +// +// CloudKit service for travel day position overrides +// + +import Foundation +import CloudKit + +actor TravelOverrideService { + static let shared = TravelOverrideService() + + 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 + + /// Create or update a travel day override (upserts by tripId + travelAnchorId) + func saveOverride(_ override: TravelDayOverride) async throws -> TravelDayOverride { + print("☁️ [CloudKit] Saving travel override for: \(override.travelAnchorId)") + + // Look up existing override by travelAnchorId (not by UUID) + let existingOverrides = try await fetchOverrides(forTripId: override.tripId) + let existingOverride = existingOverrides.first { $0.travelAnchorId == override.travelAnchorId } + + let record: CKRecord + let now = Date() + + if let existing = existingOverride { + // Fetch the actual CKRecord to update it + let recordID = CKRecord.ID(recordName: existing.id.uuidString) + do { + let existingRecord = try await publicDatabase.record(for: recordID) + existingRecord[CKTravelDayOverride.displayDayKey] = override.displayDay + existingRecord[CKTravelDayOverride.modifiedAtKey] = now + record = existingRecord + print("☁️ [CloudKit] Updating existing override record: \(existing.id)") + } catch { + // Record doesn't exist anymore, create new + let ckOverride = CKTravelDayOverride(override: override) + record = ckOverride.record + } + } else { + // Create new record + let ckOverride = CKTravelDayOverride(override: override) + record = ckOverride.record + print("☁️ [CloudKit] Creating new override record") + } + + do { + try await publicDatabase.save(record) + print("☁️ [CloudKit] Travel override saved: \(record.recordID.recordName)") + var savedOverride = override + savedOverride.modifiedAt = now + return savedOverride + } catch let error as CKError { + print("☁️ [CloudKit] CKError on save: \(error.code.rawValue) - \(error.localizedDescription)") + throw mapCloudKitError(error) + } catch { + print("☁️ [CloudKit] Unknown error on save: \(error)") + throw TravelOverrideError.unknown(error) + } + } + + /// Delete a travel day override + func deleteOverride(_ overrideId: UUID) async throws { + let recordID = CKRecord.ID(recordName: overrideId.uuidString) + + do { + try await publicDatabase.deleteRecord(withID: recordID) + print("☁️ [CloudKit] Travel override deleted: \(overrideId)") + } catch let error as CKError { + if error.code != .unknownItem { + throw mapCloudKitError(error) + } + // Already deleted - ignore + } catch { + throw TravelOverrideError.unknown(error) + } + } + + /// Delete override by travel anchor ID (useful when removing by travel segment) + func deleteOverride(forTripId tripId: UUID, travelAnchorId: String) async throws { + // First fetch to find the record + let overrides = try await fetchOverrides(forTripId: tripId) + if let existing = overrides.first(where: { $0.travelAnchorId == travelAnchorId }) { + try await deleteOverride(existing.id) + } + } + + /// Fetch all travel day overrides for a trip + func fetchOverrides(forTripId tripId: UUID) async throws -> [TravelDayOverride] { + print("☁️ [CloudKit] Fetching travel overrides for tripId: \(tripId.uuidString)") + let predicate = NSPredicate( + format: "%K == %@", + CKTravelDayOverride.tripIdKey, + tripId.uuidString + ) + let query = CKQuery(recordType: CKRecordType.travelDayOverride, predicate: predicate) + + do { + let (results, _) = try await publicDatabase.records(matching: query) + print("☁️ [CloudKit] Query returned \(results.count) travel override records") + + let overrides = results.compactMap { result -> TravelDayOverride? in + guard case .success(let record) = result.1 else { + print("☁️ [CloudKit] Record fetch failed for: \(result.0.recordName)") + return nil + } + let override = CKTravelDayOverride(record: record).toOverride() + if override == nil { + print("☁️ [CloudKit] Failed to parse travel override record: \(record.recordID.recordName)") + } + return override + } + + print("☁️ [CloudKit] Parsed \(overrides.count) valid travel overrides") + return overrides + } 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 TravelOverrideError.unknown(error) + } + } + + /// Convert array of overrides to dictionary keyed by travelAnchorId + /// If duplicate travelAnchorIds exist (legacy data), takes the most recently modified one + func fetchOverridesAsDictionary(forTripId tripId: UUID) async throws -> [String: Int] { + let overrides = try await fetchOverrides(forTripId: tripId) + // Sort by modifiedAt descending so most recent comes first + let sorted = overrides.sorted { $0.modifiedAt > $1.modifiedAt } + // Use uniquingKeysWith to keep the first (most recent) value for duplicates + return Dictionary(sorted.map { ($0.travelAnchorId, $0.displayDay) }) { first, _ in first } + } + + // MARK: - Error Mapping + + private func mapCloudKitError(_ error: CKError) -> TravelOverrideError { + switch error.code { + case .notAuthenticated: + return .notSignedIn + case .networkUnavailable, .networkFailure: + return .networkUnavailable + case .unknownItem: + return .overrideNotFound + default: + return .unknown(error) + } + } +} + +// MARK: - Errors + +enum TravelOverrideError: Error, LocalizedError { + case notSignedIn + case overrideNotFound + case networkUnavailable + case unknown(Error) + + var errorDescription: String? { + switch self { + case .notSignedIn: + return "Please sign in to iCloud to customize travel days." + case .overrideNotFound: + return "Travel day setting not found." + case .networkUnavailable: + return "Unable to connect. Please check your internet connection." + case .unknown(let error): + return "An error occurred: \(error.localizedDescription)" + } + } +} diff --git a/SportsTime/Features/Trip/Views/TripDetailView.swift b/SportsTime/Features/Trip/Views/TripDetailView.swift index d266233..81f1617 100644 --- a/SportsTime/Features/Trip/Views/TripDetailView.swift +++ b/SportsTime/Features/Trip/Views/TripDetailView.swift @@ -39,7 +39,9 @@ struct TripDetailView: View { @State private var editingItem: CustomItineraryItem? @State private var subscriptionCancellable: AnyCancellable? @State private var draggedItem: CustomItineraryItem? + @State private var draggedTravelId: String? // Track which travel segment is being dragged @State private var dropTargetId: String? // Track which drop zone is being hovered + @State private var travelDayOverrides: [String: Int] = [:] // Key: travel ID, Value: day number private let exportService = ExportService() private let dataProvider = AppDataProvider.shared @@ -104,6 +106,13 @@ struct TripDetailView: View { .padding(.horizontal, Theme.Spacing.lg) .padding(.bottom, Theme.Spacing.xxl) } + // Catch-all drop handler - clears drag state and accepts the drop to end drag + .onDrop(of: [.text, .plainText, .utf8PlainText], isTargeted: .constant(false)) { _ in + draggedTravelId = nil + draggedItem = nil + dropTargetId = nil + return true // Accept the drop to end the drag operation cleanly + } } .background(Theme.backgroundGradient(colorScheme)) .toolbarBackground(Theme.cardBackground(colorScheme), for: .navigationBar) @@ -179,6 +188,11 @@ struct TripDetailView: View { draggedItem = nil dropTargetId = nil } + .onChange(of: travelDayOverrides) { _, _ in + // Clear drag state after travel move completed + draggedTravelId = nil + dropTargetId = nil + } .overlay { if isExporting { exportProgressOverlay @@ -415,104 +429,114 @@ struct TripDetailView: View { @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 + let isDragging = draggedItem != nil || draggedTravelId != nil + let isDropTarget = dropTargetId == sectionId && isDragging switch section { case .day(let dayNumber, let date, let gamesOnDay): + // Show indicator at TOP for travel (travel appears above day), BOTTOM for custom items + let indicatorAlignment: Alignment = draggedTravelId != nil ? .top : .bottom + + // Pre-compute if this day is a valid travel target + let isValidTravelTarget: Bool = { + guard let travelId = draggedTravelId, + let validRange = validDayRange(for: travelId) else { return true } + return validRange.contains(dayNumber) + }() + DaySection( dayNumber: dayNumber, date: date, games: gamesOnDay ) .staggeredAnimation(index: index) - .overlay(alignment: .bottom) { - if isDropTarget { + .overlay(alignment: indicatorAlignment) { + // Only show indicator if valid target (or dragging custom item) + if isDropTarget && (draggedTravelId == nil || isValidTravelTarget) { 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) + .onDrop(of: [.text, .plainText, .utf8PlainText], isTargeted: Binding( + get: { dropTargetId == sectionId }, + set: { targeted in + // Only show as target if it's a valid drop location + let shouldShowTarget = targeted && (draggedTravelId == nil || isValidTravelTarget) + withAnimation(.easeInOut(duration: 0.2)) { + if shouldShowTarget { + dropTargetId = sectionId + } else if dropTargetId == sectionId { + dropTargetId = nil + } + } } + )) { providers in + handleDayDrop(providers: providers, dayNumber: dayNumber, gamesOnDay: gamesOnDay) } case .travel(let segment): + let travelId = stableTravelAnchorId(segment) TravelSection(segment: segment) .staggeredAnimation(index: index) .overlay(alignment: .bottom) { - if isDropTarget { + // Show drop indicator for custom items, but not when dragging this travel + if isDropTarget && draggedTravelId != travelId { 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) + .onDrag { + draggedTravelId = travelId + return NSItemProvider(object: travelId as NSString) + } + .onDrop(of: [.text, .plainText, .utf8PlainText], isTargeted: Binding( + get: { dropTargetId == sectionId }, + set: { targeted in + // Only accept custom items on travel, not other travel + let shouldShow = targeted && draggedItem != nil + withAnimation(.easeInOut(duration: 0.2)) { + if shouldShow { + dropTargetId = sectionId + } else if dropTargetId == sectionId { + dropTargetId = nil + } + } } + )) { providers in + handleTravelDrop(providers: providers, segment: segment) } case .customItem(let item): - let isDragging = draggedItem?.id == item.id + let isDraggingThis = draggedItem?.id == item.id CustomItemRow( item: item, onTap: { editingItem = item }, onDelete: { Task { await deleteCustomItem(item) } } ) - .opacity(isDragging ? 0.4 : 1.0) + .opacity(isDraggingThis ? 0.4 : 1.0) .staggeredAnimation(index: index) .overlay(alignment: .top) { - if isDropTarget && !isDragging { + if isDropTarget && !isDraggingThis { DropTargetIndicator() } } - .draggable(item.id.uuidString) { - // Drag preview - CustomItemRow( - item: item, - onTap: {}, - onDelete: {} - ) - .frame(width: 300) - .opacity(0.9) - .onAppear { draggedItem = item } + .onDrag { + draggedItem = item + return NSItemProvider(object: item.id.uuidString as NSString) } - .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) + .onDrop(of: [.text, .plainText, .utf8PlainText], isTargeted: Binding( + get: { dropTargetId == sectionId }, + set: { targeted in + // Only accept custom items, not travel + let shouldShow = targeted && draggedItem != nil && draggedItem?.id != item.id + withAnimation(.easeInOut(duration: 0.2)) { + if shouldShow { + dropTargetId = sectionId + } else if dropTargetId == sectionId { + dropTargetId = nil + } + } } + )) { providers in + handleCustomItemDrop(providers: providers, targetItem: item) } case .addButton(let day, let anchorType, let anchorId): @@ -524,25 +548,102 @@ struct TripDetailView: View { 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) + .onDrop(of: [.text, .plainText, .utf8PlainText], isTargeted: Binding( + get: { dropTargetId == sectionId }, + set: { targeted in + // Only accept custom items, not travel + let shouldShow = targeted && draggedItem != nil + withAnimation(.easeInOut(duration: 0.2)) { + if shouldShow { + dropTargetId = sectionId + } else if dropTargetId == sectionId { + dropTargetId = nil + } + } } + )) { providers in + handleAddButtonDrop(providers: providers, day: day, anchorType: anchorType, anchorId: anchorId) } } } + // MARK: - Drop Handlers + + private func handleTravelDrop(providers: [NSItemProvider], segment: TravelSegment) -> Bool { + guard let provider = providers.first, provider.canLoadObject(ofClass: NSString.self) else { + return false + } + + // Clear drag state immediately (synchronously) before async work + draggedTravelId = nil + draggedItem = nil + dropTargetId = nil + + provider.loadObject(ofClass: NSString.self) { item, _ in + guard let droppedId = item as? String, + let itemId = UUID(uuidString: droppedId), + let droppedItem = self.customItems.first(where: { $0.id == itemId }) else { return } + + Task { @MainActor in + let day = self.findDayForTravelSegment(segment) + let stableAnchorId = self.stableTravelAnchorId(segment) + await self.moveItemToBeginning(droppedItem, toDay: day, anchorType: .afterTravel, anchorId: stableAnchorId) + } + } + return true + } + + private func handleCustomItemDrop(providers: [NSItemProvider], targetItem: CustomItineraryItem) -> Bool { + guard let provider = providers.first, provider.canLoadObject(ofClass: NSString.self) else { + return false + } + + // Clear drag state immediately (synchronously) before async work + draggedTravelId = nil + draggedItem = nil + dropTargetId = nil + + provider.loadObject(ofClass: NSString.self) { item, _ in + guard let droppedId = item as? String, + let itemId = UUID(uuidString: droppedId), + let droppedItem = self.customItems.first(where: { $0.id == itemId }), + droppedItem.id != targetItem.id else { return } + + Task { @MainActor in + await self.moveItem(droppedItem, toDay: targetItem.anchorDay, anchorType: targetItem.anchorType, anchorId: targetItem.anchorId, beforeItem: targetItem) + } + } + return true + } + + private func handleAddButtonDrop(providers: [NSItemProvider], day: Int, anchorType: CustomItineraryItem.AnchorType, anchorId: String?) -> Bool { + guard let provider = providers.first, provider.canLoadObject(ofClass: NSString.self) else { + return false + } + + // Clear drag state immediately (synchronously) before async work + draggedTravelId = nil + draggedItem = nil + dropTargetId = nil + + provider.loadObject(ofClass: NSString.self) { item, _ in + guard let droppedId = item as? String, + let itemId = UUID(uuidString: droppedId), + let droppedItem = self.customItems.first(where: { $0.id == itemId }) else { return } + + Task { @MainActor in + await self.moveItemToBeginning(droppedItem, toDay: day, anchorType: anchorType, anchorId: anchorId) + } + } + return true + } + + private func clearDragState() { + draggedItem = nil + draggedTravelId = nil + dropTargetId = nil + } + /// Create a stable identifier for an itinerary section (for drop target tracking) private func sectionIdentifier(for section: ItinerarySection, at index: Int) -> String { switch section { @@ -697,95 +798,98 @@ struct TripDetailView: View { } } - /// Build itinerary sections: group by day AND city, with travel, custom items, and add buttons + /// Build itinerary sections: shows ALL days with travel, custom items, and add buttons private var itinerarySections: [ItinerarySection] { var sections: [ItinerarySection] = [] - - // Build day+city sections for days with games - 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) + // Pre-calculate which day each travel segment belongs to + // Default: day after last game in departure city, or use validated override + var travelByDay: [Int: TravelSegment] = [:] + for segment in trip.travelSegments { + let travelId = stableTravelAnchorId(segment) + let fromCity = segment.fromLocation.name + let toCity = segment.toLocation.name - guard !gamesOnDay.isEmpty else { continue } + // Calculate valid range for this travel + // Travel can only happen AFTER the last game in departure city + let lastGameInFromCity = findLastGameDay(in: fromCity) + let firstGameInToCity = findFirstGameDay(in: toCity) + let minDay = max(lastGameInFromCity + 1, 1) + let maxDay = min(firstGameInToCity, tripDays.count) + let validRange = minDay <= maxDay ? minDay...maxDay : minDay...minDay - // Group games by city, maintaining chronological order - 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 { - // Same city as previous game - add to existing group - gamesByCity[lastIndex].games.append(game) - } else { - // Different city - start new group - gamesByCity.append((city, [game])) - } + // Calculate default day (day after last game in departure city) + let defaultDay: Int + if lastGameInFromCity > 0 && lastGameInFromCity + 1 <= tripDays.count { + defaultDay = lastGameInFromCity + 1 + } else if lastGameInFromCity > 0 { + defaultDay = lastGameInFromCity + } else { + defaultDay = 1 } - // Add each city group as a separate section - for cityGroup in gamesByCity { - dayCitySections.append((dayNum, dayDate, cityGroup.city, cityGroup.games)) + // Check for user override - only use if within valid range + if let overrideDay = travelDayOverrides[travelId], validRange.contains(overrideDay) { + travelByDay[overrideDay] = segment + } else { + // Use default (clamped to valid range) + let clampedDefault = max(validRange.lowerBound, min(defaultDay, validRange.upperBound)) + travelByDay[clampedDefault] = segment } } - // Build sections: insert travel, custom items (only if allowed), and add buttons - for (index, section) in dayCitySections.enumerated() { + // Process ALL days + for (index, dayDate) in days.enumerated() { + let dayNum = index + 1 + let gamesOnDay = gamesOn(date: dayDate) + let isRestDay = gamesOnDay.isEmpty - // Check if we need travel BEFORE this section (coming from different city) - if index > 0 { - let prevSection = dayCitySections[index - 1] - let prevCity = prevSection.city - let currentCity = section.city + // Travel for this day (if any) + if let travelSegment = travelByDay[dayNum] { + sections.append(.travel(travelSegment)) - // If cities differ, find travel segment from prev -> current - if !prevCity.isEmpty && !currentCity.isEmpty && prevCity != currentCity { - if let travelSegment = findTravelSegment(from: prevCity, to: currentCity) { - sections.append(.travel(travelSegment)) + if allowCustomItems { + let stableId = stableTravelAnchorId(travelSegment) - if allowCustomItems { - // Use stable anchor ID for travel segments - let stableId = stableTravelAnchorId(travelSegment) + // Add button after travel + sections.append(.addButton(day: dayNum, anchorType: .afterTravel, anchorId: stableId)) - // 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)) - } - } + // Custom items after this travel (sorted by sortOrder) + let itemsAfterTravel = customItems.filter { + $0.anchorType == .afterTravel && $0.anchorId == stableId + }.sorted { $0.sortOrder < $1.sortOrder } + for item in itemsAfterTravel { + sections.append(.customItem(item)) } } } + // Custom items at start of day (before games or as main content for rest days) if allowCustomItems { - // Custom items at start of day (sorted by sortOrder) let itemsAtStart = customItems.filter { - $0.anchorDay == section.dayNumber && $0.anchorType == .startOfDay + $0.anchorDay == dayNum && $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)) + // Day section - shows games or minimal rest day display + sections.append(.day(dayNumber: dayNum, date: dayDate, games: gamesOnDay)) + // Add button after day (different anchor for game days vs rest days) 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)) + if isRestDay { + // Rest day: add button anchored to start of day (no games to anchor to) + sections.append(.addButton(day: dayNum, anchorType: .startOfDay, anchorId: nil)) + } else if let lastGame = gamesOnDay.last { + // Game day: add button anchored after last game + sections.append(.addButton(day: dayNum, anchorType: .afterGame, anchorId: lastGame.game.id)) // Custom items after this game (sorted by sortOrder) let itemsAfterGame = customItems.filter { - $0.anchorDay == section.dayNumber && + $0.anchorDay == dayNum && $0.anchorType == .afterGame && $0.anchorId == lastGame.game.id }.sorted { $0.sortOrder < $1.sortOrder } @@ -839,16 +943,63 @@ struct TripDetailView: View { }?.city } - /// Find travel segment that goes from one city to another - private func findTravelSegment(from fromCity: String, to toCity: String) -> TravelSegment? { - let fromLower = fromCity.lowercased().trimmingCharacters(in: .whitespaces) - let toLower = toCity.lowercased().trimmingCharacters(in: .whitespaces) + /// Find the last day number that has a game in the given city + private func findLastGameDay(in city: String) -> Int { + let cityLower = city.lowercased() + let days = tripDays + var lastDay = 0 - return trip.travelSegments.first { segment in - let segmentFrom = segment.fromLocation.name.lowercased().trimmingCharacters(in: .whitespaces) - let segmentTo = segment.toLocation.name.lowercased().trimmingCharacters(in: .whitespaces) - return segmentFrom == fromLower && segmentTo == toLower + for (index, dayDate) in days.enumerated() { + let dayNum = index + 1 + let gamesOnDay = gamesOn(date: dayDate) + if gamesOnDay.contains(where: { $0.stadium.city.lowercased() == cityLower }) { + lastDay = dayNum + } } + return lastDay + } + + /// Find the first day number that has a game in the given city + private func findFirstGameDay(in city: String) -> Int { + let cityLower = city.lowercased() + let days = tripDays + + for (index, dayDate) in days.enumerated() { + let dayNum = index + 1 + let gamesOnDay = gamesOn(date: dayDate) + if gamesOnDay.contains(where: { $0.stadium.city.lowercased() == cityLower }) { + return dayNum + } + } + return tripDays.count // Default to last day if no games found + } + + /// Get valid day range for a travel segment + /// Travel can be displayed from the day of last departure game to the day of first arrival game + private func validDayRange(for travelId: String) -> ClosedRange? { + // Find the segment matching this travel ID + guard let segment = trip.travelSegments.first(where: { stableTravelAnchorId($0) == travelId }) else { + return nil + } + + let fromCity = segment.fromLocation.name + let toCity = segment.toLocation.name + + // Travel can only happen AFTER the last game in departure city + // So the earliest travel day is the day AFTER the last game + let lastGameInFromCity = findLastGameDay(in: fromCity) + let minDay = max(lastGameInFromCity + 1, 1) + + // Travel must happen BEFORE or ON the first game day in arrival city + let firstGameInToCity = findFirstGameDay(in: toCity) + let maxDay = min(firstGameInToCity, tripDays.count) + + // Handle edge case where minDay > maxDay shouldn't happen, but safeguard + if minDay > maxDay { + return minDay...minDay + } + + return minDay...maxDay } // MARK: - Map Helpers @@ -1069,6 +1220,11 @@ struct TripDetailView: View { print(" - \(item.title) (day \(item.anchorDay), anchor: \(item.anchorType.rawValue), sortOrder: \(item.sortOrder))") } customItems = items + + // Also load travel day overrides + let overrides = try await TravelOverrideService.shared.fetchOverridesAsDictionary(forTripId: trip.id) + print("✅ [TravelOverrides] Loaded \(overrides.count) travel day overrides") + travelDayOverrides = overrides } catch { print("❌ [CustomItems] Failed to load: \(error)") } @@ -1118,6 +1274,78 @@ struct TripDetailView: View { print("❌ [CustomItems] CloudKit delete failed: \(error)") } } + + private func handleDayDrop(providers: [NSItemProvider], dayNumber: Int, gamesOnDay: [RichGame]) -> Bool { + guard let provider = providers.first else { return false } + + // Capture and clear drag state immediately (synchronously) before async work + // This ensures the UI resets even if validation fails + let capturedTravelId = draggedTravelId + let capturedItem = draggedItem + draggedTravelId = nil + draggedItem = nil + dropTargetId = nil + + // Load the string from the provider + if provider.canLoadObject(ofClass: NSString.self) { + provider.loadObject(ofClass: NSString.self) { item, _ in + guard let droppedId = item as? String else { return } + + Task { @MainActor in + // Check if this is a travel segment being dropped + if droppedId.hasPrefix("travel:") { + // Validate travel is within valid bounds + if let validRange = self.validDayRange(for: droppedId) { + guard validRange.contains(dayNumber) else { + // Day is outside valid range - reject drop (state already cleared) + return + } + } + + // Move travel to this day + withAnimation { + self.travelDayOverrides[droppedId] = dayNumber + } + + // Persist the override to CloudKit + await self.saveTravelDayOverride(travelAnchorId: droppedId, displayDay: dayNumber) + return + } + + // Otherwise, it's a custom item drop + guard let itemId = UUID(uuidString: droppedId), + let item = self.customItems.first(where: { $0.id == itemId }) else { return } + + // For game days, anchor to last game; for rest days, anchor to start of day + if let lastGame = gamesOnDay.last { + await self.moveItemToBeginning(item, toDay: dayNumber, anchorType: .afterGame, anchorId: lastGame.game.id) + } else { + // Rest day - anchor to start of day + await self.moveItemToBeginning(item, toDay: dayNumber, anchorType: .startOfDay, anchorId: nil) + } + } + } + return true + } + return false + } + + private func saveTravelDayOverride(travelAnchorId: String, displayDay: Int) async { + print("💾 [TravelOverrides] Saving override: \(travelAnchorId) -> day \(displayDay)") + + let override = TravelDayOverride( + tripId: trip.id, + travelAnchorId: travelAnchorId, + displayDay: displayDay + ) + + do { + _ = try await TravelOverrideService.shared.saveOverride(override) + print("✅ [TravelOverrides] Saved to CloudKit") + } catch { + print("❌ [TravelOverrides] CloudKit save failed: \(error)") + } + } } // MARK: - Itinerary Section @@ -1207,8 +1435,8 @@ struct DaySection: View { } var body: some View { - VStack(alignment: .leading, spacing: Theme.Spacing.sm) { - // Day header + if isRestDay { + // Minimal rest day display - just header with date HStack { VStack(alignment: .leading, spacing: 2) { Text("Day \(dayNumber)") @@ -1221,29 +1449,44 @@ struct DaySection: View { } Spacer() + } + .frame(maxWidth: .infinity, alignment: .leading) + .cardStyle() + } else { + // Full game day display + VStack(alignment: .leading, spacing: Theme.Spacing.sm) { + // Day header + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Day \(dayNumber)") + .font(.title2) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + Text(formattedDate) + .font(.subheadline) + .foregroundStyle(Theme.textSecondary(colorScheme)) + } + + Spacer() - if isRestDay { - Text("Rest Day") - .badgeStyle(color: Theme.mlsGreen, filled: false) - } else if !games.isEmpty { Text("\(games.count) game\(games.count > 1 ? "s" : "")") .badgeStyle(color: Theme.warmOrange, filled: false) } - } - // City label - if let city = gameCity { - Label(city, systemImage: "mappin") - .font(.subheadline) - .foregroundStyle(Theme.textSecondary(colorScheme)) - } + // City label + if let city = gameCity { + Label(city, systemImage: "mappin") + .font(.subheadline) + .foregroundStyle(Theme.textSecondary(colorScheme)) + } - // Games - ForEach(games, id: \.game.id) { richGame in - GameRow(game: richGame) + // Games + ForEach(games, id: \.game.id) { richGame in + GameRow(game: richGame) + } } + .cardStyle() } - .cardStyle() } }