Stabilize beta release with warning cleanup and edge-case fixes

This commit is contained in:
Trey t
2026-02-22 13:18:14 -06:00
parent fddea81e36
commit ec2bbb4764
55 changed files with 712 additions and 315 deletions

View File

@@ -27,8 +27,13 @@ final class TripWizardViewModel {
// MARK: - Dates
var startDate: Date = Date()
var endDate: Date = Date().addingTimeInterval(86400 * 7)
var startDate: Date = Calendar.current.startOfDay(for: Date())
var endDate: Date = {
let calendar = Calendar.current
let start = calendar.startOfDay(for: Date())
let endDay = calendar.date(byAdding: .day, value: 7, to: start) ?? start
return calendar.date(bySettingHour: 23, minute: 59, second: 59, of: endDay) ?? endDay
}()
var hasSetDates: Bool = false
// MARK: - Regions
@@ -212,13 +217,17 @@ final class TripWizardViewModel {
defer { isLoadingSportAvailability = false }
var availability: [Sport: Bool] = [:]
let calendar = Calendar.current
let queryStart = calendar.startOfDay(for: startDate)
let endDay = calendar.startOfDay(for: endDate)
let queryEnd = calendar.date(bySettingHour: 23, minute: 59, second: 59, of: endDay) ?? endDay
for sport in Sport.supported {
do {
let games = try await AppDataProvider.shared.filterGames(
sports: [sport],
startDate: startDate,
endDate: endDate
startDate: queryStart,
endDate: queryEnd
)
availability[sport] = !games.isEmpty
} catch {

View File

@@ -449,20 +449,13 @@ private struct PlaceRow: View {
}
private var formattedAddress: String? {
let placemark = place.placemark
var components: [String] = []
if let thoroughfare = placemark.thoroughfare {
components.append(thoroughfare)
if let shortAddress = place.address?.shortAddress, !shortAddress.isEmpty {
return shortAddress
}
if let locality = placemark.locality {
components.append(locality)
if let fullAddress = place.address?.fullAddress, !fullAddress.isEmpty {
return fullAddress
}
if let administrativeArea = placemark.administrativeArea {
components.append(administrativeArea)
}
return components.isEmpty ? nil : components.joined(separator: ", ")
return place.addressRepresentations?.cityWithContext
}
}

View File

@@ -429,10 +429,14 @@ struct QuickAddItemSheet: View {
// Restore location if present
if let lat = info.latitude,
let lon = info.longitude {
let placemark = MKPlacemark(
coordinate: CLLocationCoordinate2D(latitude: lat, longitude: lon)
)
let mapItem = MKMapItem(placemark: placemark)
let location = CLLocation(latitude: lat, longitude: lon)
let mapItem: MKMapItem
if #available(iOS 26.0, *) {
mapItem = MKMapItem(location: location, address: nil)
} else {
let placemark = MKPlacemark(coordinate: location.coordinate)
mapItem = MKMapItem(placemark: placemark)
}
mapItem.name = info.title
selectedPlace = mapItem
}
@@ -447,7 +451,7 @@ struct QuickAddItemSheet: View {
if let place = selectedPlace {
// Item with location
let coordinate = place.placemark.coordinate
let coordinate = mapItemCoordinate(for: place)
customInfo = CustomInfo(
title: trimmedTitle,
icon: "\u{1F4CC}",
@@ -489,21 +493,18 @@ struct QuickAddItemSheet: View {
dismiss()
}
private func mapItemCoordinate(for place: MKMapItem) -> CLLocationCoordinate2D {
place.location.coordinate
}
private func formatAddress(for place: MKMapItem) -> String? {
let placemark = place.placemark
var components: [String] = []
if let thoroughfare = placemark.thoroughfare {
components.append(thoroughfare)
if let shortAddress = place.address?.shortAddress, !shortAddress.isEmpty {
return shortAddress
}
if let locality = placemark.locality {
components.append(locality)
if let fullAddress = place.address?.fullAddress, !fullAddress.isEmpty {
return fullAddress
}
if let administrativeArea = placemark.administrativeArea {
components.append(administrativeArea)
}
return components.isEmpty ? nil : components.joined(separator: ", ")
return place.addressRepresentations?.cityWithContext
}
// MARK: - POI Loading

View File

@@ -340,24 +340,9 @@ private struct PlaceResultRow: View {
}
private var formattedAddress: String? {
let placemark = item.placemark
var components: [String] = []
if let locality = placemark.locality {
components.append(locality)
if let cityContext = item.addressRepresentations?.cityWithContext, !cityContext.isEmpty {
return cityContext
}
if let administrativeArea = placemark.administrativeArea {
components.append(administrativeArea)
}
return components.isEmpty ? nil : components.joined(separator: ", ")
return item.address?.shortAddress ?? item.address?.fullAddress
}
}
#Preview {
AddItemSheet(
tripId: UUID(),
day: 1,
existingItem: nil
) { _ in }
}

View File

@@ -715,10 +715,7 @@ enum ItineraryReorderingLogic {
travelValidRanges: [:], // Custom items don't use travel ranges
constraints: constraints,
findTravelItem: { _ in nil },
makeTravelItem: { _ in
// This won't be called for custom items
fatalError("makeTravelItem called for custom item")
},
makeTravelItem: { _ in item },
findTravelSortOrder: findTravelSortOrder
)

View File

@@ -320,7 +320,7 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresent
.sorted { $0.game.dateTime < $1.game.dateTime }
}
private func stableTravelAnchorId(_ segment: TravelSegment, at index: Int) -> String {
nonisolated private func stableTravelAnchorId(_ segment: TravelSegment, at index: Int) -> String {
let from = TravelInfo.normalizeCityName(segment.fromLocation.name)
let to = TravelInfo.normalizeCityName(segment.toLocation.name)
return "travel:\(index):\(from)->\(to)"

View File

@@ -21,6 +21,7 @@ struct TripDetailView: View {
@Query private var savedTrips: [SavedTrip]
@State private var showProPaywall = false
@State private var persistenceErrorMessage: String?
@State private var selectedDay: ItineraryDay?
@State private var showExportSheet = false
@State private var exportURL: URL?
@@ -119,6 +120,17 @@ struct TripDetailView: View {
} message: {
Text("This trip has \(multiRouteChunks.flatMap { $0 }.count) stops, which exceeds Apple Maps' limit of 16. Open the route in parts?")
}
.alert(
"Unable to Save Changes",
isPresented: Binding(
get: { persistenceErrorMessage != nil },
set: { if !$0 { persistenceErrorMessage = nil } }
)
) {
Button("OK", role: .cancel) { persistenceErrorMessage = nil }
} message: {
Text(persistenceErrorMessage ?? "Please try again.")
}
.onAppear {
AnalyticsManager.shared.track(.tripViewed(tripId: trip.id.uuidString, source: allowCustomItems ? "saved" : "new"))
checkIfSaved()
@@ -1354,7 +1366,7 @@ struct TripDetailView: View {
gameCount: trip.totalGames
))
} catch {
// Save failed silently
persistenceErrorMessage = "Failed to save this trip. Please try again."
}
}
@@ -1375,7 +1387,7 @@ struct TripDetailView: View {
}
AnalyticsManager.shared.track(.tripDeleted(tripId: tripId.uuidString))
} catch {
// Unsave failed silently
persistenceErrorMessage = "Failed to remove this saved trip. Please try again."
}
}
@@ -1404,7 +1416,13 @@ struct TripDetailView: View {
let descriptor = FetchDescriptor<LocalItineraryItem>(
predicate: #Predicate { $0.tripId == tripId }
)
let localItems = (try? modelContext.fetch(descriptor))?.compactMap(\.toItem) ?? []
let localModels = (try? modelContext.fetch(descriptor)) ?? []
let localItems = localModels.compactMap(\.toItem)
let pendingLocalItems = localModels.compactMap { local -> ItineraryItem? in
guard local.pendingSync else { return nil }
return local.toItem
}
if !localItems.isEmpty {
#if DEBUG
print("✅ [ItineraryItems] Loaded \(localItems.count) items from local cache")
@@ -1420,16 +1438,42 @@ struct TripDetailView: View {
print("✅ [ItineraryItems] Loaded \(cloudItems.count) items from CloudKit")
#endif
// Merge: use CloudKit as source of truth when available
if !cloudItems.isEmpty || localItems.isEmpty {
itineraryItems = cloudItems
extractTravelOverrides(from: cloudItems)
syncLocalCache(with: cloudItems)
// Merge: cloud as baseline, but always preserve local pending edits.
let cloudById = Dictionary(uniqueKeysWithValues: cloudItems.map { ($0.id, $0) })
let unresolvedPendingItems = pendingLocalItems.filter { localPending in
guard let cloudItem = cloudById[localPending.id] else { return true }
return cloudItem.modifiedAt < localPending.modifiedAt
}
var mergedById = cloudById
for localPending in unresolvedPendingItems {
mergedById[localPending.id] = localPending
}
let mergedItems = mergedById.values.sorted { lhs, rhs in
if lhs.day != rhs.day { return lhs.day < rhs.day }
if lhs.sortOrder != rhs.sortOrder { return lhs.sortOrder < rhs.sortOrder }
return lhs.modifiedAt < rhs.modifiedAt
}
if !mergedItems.isEmpty || localItems.isEmpty {
itineraryItems = mergedItems
extractTravelOverrides(from: mergedItems)
syncLocalCache(with: mergedItems, pendingSyncItemIDs: Set(unresolvedPendingItems.map(\.id)))
}
// Ensure unsynced local edits continue retrying in the background.
for pendingItem in unresolvedPendingItems {
await ItineraryItemService.shared.updateItem(pendingItem)
}
} catch {
#if DEBUG
print("⚠️ [ItineraryItems] CloudKit fetch failed (using local cache): \(error)")
#endif
for pendingItem in pendingLocalItems {
await ItineraryItemService.shared.updateItem(pendingItem)
}
}
}
@@ -1475,7 +1519,7 @@ struct TripDetailView: View {
#endif
}
private func syncLocalCache(with items: [ItineraryItem]) {
private func syncLocalCache(with items: [ItineraryItem], pendingSyncItemIDs: Set<UUID> = []) {
let tripId = trip.id
let descriptor = FetchDescriptor<LocalItineraryItem>(
predicate: #Predicate { $0.tripId == tripId }
@@ -1483,7 +1527,7 @@ struct TripDetailView: View {
let existing = (try? modelContext.fetch(descriptor)) ?? []
for old in existing { modelContext.delete(old) }
for item in items {
if let local = LocalItineraryItem.from(item) {
if let local = LocalItineraryItem.from(item, pendingSync: pendingSyncItemIDs.contains(item.id)) {
modelContext.insert(local)
}
}
@@ -1524,6 +1568,7 @@ struct TripDetailView: View {
markLocalItemSynced(item.id)
}
} catch {
await ItineraryItemService.shared.updateItem(item)
#if DEBUG
print("⚠️ [ItineraryItems] CloudKit save failed (saved locally): \(error)")
#endif
@@ -1542,6 +1587,8 @@ struct TripDetailView: View {
existing.kindData = (try? JSONEncoder().encode(item.kind)) ?? existing.kindData
existing.modifiedAt = item.modifiedAt
existing.pendingSync = true
} else if let local = LocalItineraryItem.from(item, pendingSync: true) {
modelContext.insert(local)
}
} else {
if let local = LocalItineraryItem.from(item, pendingSync: true) {
@@ -1598,8 +1645,6 @@ struct TripDetailView: View {
// 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

View File

@@ -338,8 +338,7 @@ struct GamePickerStep: View {
endDate = calendar.startOfDay(for: newEnd)
} else {
// Multiple games: span from first to last with 1-day buffer
let firstGameDate = gameDates.first!
let lastGameDate = gameDates.last!
guard let firstGameDate = gameDates.first, let lastGameDate = gameDates.last else { return }
let newStart = calendar.date(byAdding: .day, value: -1, to: firstGameDate) ?? firstGameDate
let newEnd = calendar.date(byAdding: .day, value: 1, to: lastGameDate) ?? lastGameDate
startDate = calendar.startOfDay(for: newStart)

View File

@@ -190,7 +190,7 @@ struct TripWizardView: View {
defer { viewModel.isPlanning = false }
do {
var preferences = buildPreferences()
let preferences = buildPreferences()
// Build dictionaries from arrays
let teamsById = Dictionary(uniqueKeysWithValues: AppDataProvider.shared.teams.map { ($0.id, $0) })
@@ -231,10 +231,15 @@ struct TripWizardView: View {
}
} else {
// Standard mode: fetch games for date range
let calendar = Calendar.current
let queryStart = calendar.startOfDay(for: preferences.startDate)
let endDay = calendar.startOfDay(for: preferences.endDate)
let queryEnd = calendar.date(bySettingHour: 23, minute: 59, second: 59, of: endDay) ?? endDay
games = try await AppDataProvider.shared.filterGames(
sports: preferences.sports,
startDate: preferences.startDate,
endDate: preferences.endDate
startDate: queryStart,
endDate: queryEnd
)
}
@@ -292,6 +297,10 @@ struct TripWizardView: View {
}
private func buildPreferences() -> TripPreferences {
let calendar = Calendar.current
let normalizedStartDate = calendar.startOfDay(for: viewModel.startDate)
let normalizedEndDate = calendar.startOfDay(for: viewModel.endDate)
// Determine which sports to use based on mode
let sports: Set<Sport>
if viewModel.planningMode == .gameFirst {
@@ -310,8 +319,8 @@ struct TripWizardView: View {
endLocation: viewModel.endLocation,
sports: sports,
mustSeeGameIds: viewModel.selectedGameIds,
startDate: viewModel.startDate,
endDate: viewModel.endDate,
startDate: normalizedStartDate,
endDate: normalizedEndDate,
mustStopLocations: viewModel.mustStopLocations,
routePreference: viewModel.routePreference,
allowRepeatCities: viewModel.allowRepeatCities,