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 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-16 19:00:52 -06:00
parent 29942eee57
commit bf9619a207
4 changed files with 670 additions and 156 deletions

View File

@@ -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
)
}
}

View File

@@ -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
}
}

View File

@@ -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)"
}
}
}

View File

@@ -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<Int>? {
// 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()
}
}