From ec2bbb47642c9d1d5e010baa513821c3991415e9 Mon Sep 17 00:00:00 2001 From: Trey t Date: Sun, 22 Feb 2026 13:18:14 -0600 Subject: [PATCH] Stabilize beta release with warning cleanup and edge-case fixes --- SportsTime/Core/Models/Domain/Trip.swift | 5 +- SportsTime/Core/Models/Domain/TripPoll.swift | 15 ++- .../Core/Models/Local/CanonicalModels.swift | 9 -- .../Core/Services/AchievementEngine.swift | 4 +- .../Core/Services/AppleMapsLauncher.swift | 33 ++--- .../Core/Services/CanonicalSyncService.swift | 52 +++++++- .../Core/Services/CloudKitService.swift | 114 ++++++++++++++++-- .../Core/Services/DeepLinkHandler.swift | 2 +- .../Core/Services/EVChargingService.swift | 25 ++-- .../Core/Services/ItineraryItemService.swift | 73 ++++++----- .../Core/Services/LocationService.swift | 31 +++-- SportsTime/Core/Services/PollService.swift | 46 +++++-- .../Services/RouteDescriptionGenerator.swift | 34 +++--- .../ScoreAPIProviders/NBAStatsProvider.swift | 1 - SportsTime/Core/Services/SyncLogger.swift | 3 +- SportsTime/Core/Store/StoreManager.swift | 1 + SportsTime/Core/Theme/Theme.swift | 8 +- .../Export/Services/POISearchService.swift | 15 +-- .../ViewModels/PollDetailViewModel.swift | 23 +++- .../Features/Polls/Views/PollsListView.swift | 41 +++++-- .../ViewModels/ProgressViewModel.swift | 5 +- .../ViewModels/ScheduleViewModel.swift | 81 ++++++++++--- .../Schedule/Views/ScheduleListView.swift | 18 ++- .../Settings/SportsIconImageGenerator.swift | 6 +- .../Settings/Views/SettingsView.swift | 4 +- .../Trip/ViewModels/TripWizardViewModel.swift | 17 ++- .../Trip/Views/AddItem/PlaceSearchSheet.swift | 17 +-- .../Views/AddItem/QuickAddItemSheet.swift | 35 +++--- .../Features/Trip/Views/AddItemSheet.swift | 21 +--- .../Trip/Views/ItineraryReorderingLogic.swift | 5 +- .../Views/ItineraryTableViewWrapper.swift | 2 +- .../Features/Trip/Views/TripDetailView.swift | 69 +++++++++-- .../Views/Wizard/Steps/GamePickerStep.swift | 3 +- .../Trip/Views/Wizard/TripWizardView.swift | 19 ++- SportsTime/Planning/Engine/RouteFilters.swift | 32 ++++- .../Planning/Engine/ScenarioAPlanner.swift | 2 +- .../Planning/Engine/ScenarioBPlanner.swift | 11 +- SportsTime/SportsTimeApp.swift | 12 +- SportsTimeTests/Domain/TripPollTests.swift | 18 +++ .../Trip/ItineraryCustomItemTests.swift | 1 + .../Trip/ItineraryEdgeCaseTests.swift | 1 + .../Trip/ItineraryReorderingLogicTests.swift | 42 ++++--- .../Trip/ItineraryReorderingTests.swift | 1 + .../Trip/ItineraryRowFlatteningTests.swift | 1 + .../Trip/ItinerarySortOrderTests.swift | 1 + .../Trip/ItineraryTravelConstraintTests.swift | 5 +- .../Trip/RegionMapSelectorTests.swift | 1 + .../Features/Trip/TravelPlacementTests.swift | 5 +- .../Planning/GameDAGRouterTests.swift | 2 +- .../PlanningPipelineBugRegressionTests.swift | 1 - .../Planning/RouteFiltersTests.swift | 28 ++++- .../Services/PollServiceTests.swift | 10 ++ .../Framework/BaseUITestCase.swift | 1 + SportsTimeUITests/Framework/Screens.swift | 14 +++ SportsTimeUITests/SportsTimeUITests.swift | 1 + 55 files changed, 712 insertions(+), 315 deletions(-) diff --git a/SportsTime/Core/Models/Domain/Trip.swift b/SportsTime/Core/Models/Domain/Trip.swift index b6ba890..0009be7 100644 --- a/SportsTime/Core/Models/Domain/Trip.swift +++ b/SportsTime/Core/Models/Domain/Trip.swift @@ -7,12 +7,13 @@ // - Expected Behavior: // - itineraryDays() returns one ItineraryDay per calendar day from first arrival to last activity // - Last activity day includes departure day if there are games on that day -// - tripDuration is max(1, days between first arrival and last departure + 1) +// - tripDuration is 0 when there are no stops, otherwise max(1, days between first arrival and last departure + 1) // - cities returns deduplicated city list preserving visit order // - displayName uses " → " separator between cities // // - Invariants: -// - tripDuration >= 1 (minimum 1 day) +// - tripDuration >= 0 +// - tripDuration == 0 iff stops is empty // - cities has no duplicates // - itineraryDays() dayNumber starts at 1 and increments // diff --git a/SportsTime/Core/Models/Domain/TripPoll.swift b/SportsTime/Core/Models/Domain/TripPoll.swift index 20d483b..20a1f56 100644 --- a/SportsTime/Core/Models/Domain/TripPoll.swift +++ b/SportsTime/Core/Models/Domain/TripPoll.swift @@ -56,7 +56,8 @@ struct TripPoll: Identifiable, Codable, Hashable { private static let shareCodeCharacters = Array("ABCDEFGHJKMNPQRSTUVWXYZ23456789") static func generateShareCode() -> String { - String((0..<6).map { _ in shareCodeCharacters.randomElement()! }) + let fallback: Character = "A" + return String((0..<6).map { _ in shareCodeCharacters.randomElement() ?? fallback }) } // MARK: - Trip Hash @@ -73,7 +74,11 @@ struct TripPoll: Identifiable, Codable, Hashable { // MARK: - Deep Link URL var shareURL: URL { - URL(string: "sportstime://poll/\(shareCode)")! + var components = URLComponents() + components.scheme = "sportstime" + components.host = "poll" + components.path = "/\(shareCode)" + return components.url ?? URL(string: "sportstime://poll")! } } @@ -107,9 +112,11 @@ struct PollVote: Identifiable, Codable, Hashable { /// Returns array where index = trip index, value = score static func calculateScores(rankings: [Int], tripCount: Int) -> [Int] { var scores = Array(repeating: 0, count: tripCount) + var seenTripIndices = Set() for (rank, tripIndex) in rankings.enumerated() { - guard tripIndex < tripCount else { continue } - let points = tripCount - rank + guard tripIndex >= 0, tripIndex < tripCount else { continue } + guard seenTripIndices.insert(tripIndex).inserted else { continue } + let points = max(tripCount - rank, 0) scores[tripIndex] = points } return scores diff --git a/SportsTime/Core/Models/Local/CanonicalModels.swift b/SportsTime/Core/Models/Local/CanonicalModels.swift index cf9dacc..d442959 100644 --- a/SportsTime/Core/Models/Local/CanonicalModels.swift +++ b/SportsTime/Core/Models/Local/CanonicalModels.swift @@ -556,15 +556,6 @@ final class CanonicalSport { } } -// MARK: - Sendable Conformance - -// These SwiftData models are passed across actor boundaries during sync operations. -// Access is still coordinated by SwiftData contexts and higher-level sync orchestration. -extension StadiumAlias: @unchecked Sendable {} -extension TeamAlias: @unchecked Sendable {} -extension LeagueStructureModel: @unchecked Sendable {} -extension CanonicalSport: @unchecked Sendable {} - // MARK: - Bundled Data Timestamps /// Timestamps for bundled data files. diff --git a/SportsTime/Core/Services/AchievementEngine.swift b/SportsTime/Core/Services/AchievementEngine.swift index 2482f6b..b60c98e 100644 --- a/SportsTime/Core/Services/AchievementEngine.swift +++ b/SportsTime/Core/Services/AchievementEngine.swift @@ -227,7 +227,7 @@ final class AchievementEngine { } private func checkDivisionComplete(_ divisionId: String, visitedStadiumIds: Set) -> Bool { - guard let division = LeagueStructure.division(byId: divisionId) else { return false } + guard LeagueStructure.division(byId: divisionId) != nil else { return false } // Get stadium IDs for teams in this division let stadiumIds = getStadiumIdsForDivision(divisionId) @@ -237,7 +237,7 @@ final class AchievementEngine { } private func checkConferenceComplete(_ conferenceId: String, visitedStadiumIds: Set) -> Bool { - guard let conference = LeagueStructure.conference(byId: conferenceId) else { return false } + guard LeagueStructure.conference(byId: conferenceId) != nil else { return false } // Get stadium IDs for all teams in this conference let stadiumIds = getStadiumIdsForConference(conferenceId) diff --git a/SportsTime/Core/Services/AppleMapsLauncher.swift b/SportsTime/Core/Services/AppleMapsLauncher.swift index e948450..8f7023b 100644 --- a/SportsTime/Core/Services/AppleMapsLauncher.swift +++ b/SportsTime/Core/Services/AppleMapsLauncher.swift @@ -51,11 +51,8 @@ struct AppleMapsLauncher { return .noWaypoints } - let mapItems = waypoints.map { waypoint -> MKMapItem in - let placemark = MKPlacemark(coordinate: waypoint.coordinate) - let item = MKMapItem(placemark: placemark) - item.name = waypoint.name - return item + let mapItems = waypoints.map { waypoint in + makeMapItem(name: waypoint.name, coordinate: waypoint.coordinate) } if mapItems.count <= maxWaypoints { @@ -90,9 +87,7 @@ struct AppleMapsLauncher { /// - coordinate: Location to display /// - name: Display name for the pin static func openLocation(coordinate: CLLocationCoordinate2D, name: String) { - let placemark = MKPlacemark(coordinate: coordinate) - let mapItem = MKMapItem(placemark: placemark) - mapItem.name = name + let mapItem = makeMapItem(name: name, coordinate: coordinate) mapItem.openInMaps(launchOptions: nil) } @@ -108,13 +103,8 @@ struct AppleMapsLauncher { to: CLLocationCoordinate2D, toName: String ) { - let fromPlacemark = MKPlacemark(coordinate: from) - let fromItem = MKMapItem(placemark: fromPlacemark) - fromItem.name = fromName - - let toPlacemark = MKPlacemark(coordinate: to) - let toItem = MKMapItem(placemark: toPlacemark) - toItem.name = toName + let fromItem = makeMapItem(name: fromName, coordinate: from) + let toItem = makeMapItem(name: toName, coordinate: to) MKMapItem.openMaps(with: [fromItem, toItem], launchOptions: [ MKLaunchOptionsDirectionsModeKey: MKLaunchOptionsDirectionsModeDriving @@ -169,4 +159,17 @@ struct AppleMapsLauncher { Array(items[start.. MKMapItem { + let location = CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude) + let item: MKMapItem + if #available(iOS 26.0, *) { + item = MKMapItem(location: location, address: nil) + } else { + let placemark = MKPlacemark(coordinate: coordinate) + item = MKMapItem(placemark: placemark) + } + item.name = name + return item + } } diff --git a/SportsTime/Core/Services/CanonicalSyncService.swift b/SportsTime/Core/Services/CanonicalSyncService.swift index 79f8b30..3794ae8 100644 --- a/SportsTime/Core/Services/CanonicalSyncService.swift +++ b/SportsTime/Core/Services/CanonicalSyncService.swift @@ -569,7 +569,19 @@ final class CanonicalSyncService { var skippedOlder = 0 for remoteStructure in remoteStructures { - let result = try mergeLeagueStructure(remoteStructure, context: context) + let structureType = LeagueStructureType(rawValue: remoteStructure.structureTypeRaw.lowercased()) ?? .division + let model = LeagueStructureModel( + id: remoteStructure.id, + sport: remoteStructure.sport, + structureType: structureType, + name: remoteStructure.name, + abbreviation: remoteStructure.abbreviation, + parentId: remoteStructure.parentId, + displayOrder: remoteStructure.displayOrder, + schemaVersion: remoteStructure.schemaVersion, + lastModified: remoteStructure.lastModified + ) + let result = try mergeLeagueStructure(model, context: context) switch result { case .applied: updated += 1 @@ -593,7 +605,18 @@ final class CanonicalSyncService { var skippedOlder = 0 for remoteAlias in remoteAliases { - let result = try mergeTeamAlias(remoteAlias, context: context) + let aliasType = TeamAliasType(rawValue: remoteAlias.aliasTypeRaw.lowercased()) ?? .name + let model = TeamAlias( + id: remoteAlias.id, + teamCanonicalId: remoteAlias.teamCanonicalId, + aliasType: aliasType, + aliasValue: remoteAlias.aliasValue, + validFrom: remoteAlias.validFrom, + validUntil: remoteAlias.validUntil, + schemaVersion: remoteAlias.schemaVersion, + lastModified: remoteAlias.lastModified + ) + let result = try mergeTeamAlias(model, context: context) switch result { case .applied: updated += 1 @@ -617,7 +640,15 @@ final class CanonicalSyncService { var skippedOlder = 0 for remoteAlias in remoteAliases { - let result = try mergeStadiumAlias(remoteAlias, context: context) + let model = StadiumAlias( + aliasName: remoteAlias.aliasName, + stadiumCanonicalId: remoteAlias.stadiumCanonicalId, + validFrom: remoteAlias.validFrom, + validUntil: remoteAlias.validUntil, + schemaVersion: remoteAlias.schemaVersion, + lastModified: remoteAlias.lastModified + ) + let result = try mergeStadiumAlias(model, context: context) switch result { case .applied: updated += 1 @@ -641,7 +672,20 @@ final class CanonicalSyncService { var skippedOlder = 0 for remoteSport in remoteSports { - let result = try mergeSport(remoteSport, context: context) + let model = CanonicalSport( + id: remoteSport.id, + abbreviation: remoteSport.abbreviation, + displayName: remoteSport.displayName, + iconName: remoteSport.iconName, + colorHex: remoteSport.colorHex, + seasonStartMonth: remoteSport.seasonStartMonth, + seasonEndMonth: remoteSport.seasonEndMonth, + isActive: remoteSport.isActive, + lastModified: remoteSport.lastModified, + schemaVersion: remoteSport.schemaVersion, + source: .cloudKit + ) + let result = try mergeSport(model, context: context) switch result { case .applied: updated += 1 diff --git a/SportsTime/Core/Services/CloudKitService.swift b/SportsTime/Core/Services/CloudKitService.swift index 6e832fa..08cc75d 100644 --- a/SportsTime/Core/Services/CloudKitService.swift +++ b/SportsTime/Core/Services/CloudKitService.swift @@ -224,6 +224,51 @@ actor CloudKitService { let stadiumCanonicalId: String? } + struct SyncLeagueStructure: Sendable { + let id: String + let sport: String + let structureTypeRaw: String + let name: String + let abbreviation: String? + let parentId: String? + let displayOrder: Int + let schemaVersion: Int + let lastModified: Date + } + + struct SyncTeamAlias: Sendable { + let id: String + let teamCanonicalId: String + let aliasTypeRaw: String + let aliasValue: String + let validFrom: Date? + let validUntil: Date? + let schemaVersion: Int + let lastModified: Date + } + + struct SyncStadiumAlias: Sendable { + let aliasName: String + let stadiumCanonicalId: String + let validFrom: Date? + let validUntil: Date? + let schemaVersion: Int + let lastModified: Date + } + + struct SyncSport: Sendable { + let id: String + let abbreviation: String + let displayName: String + let iconName: String + let colorHex: String + let seasonStartMonth: Int + let seasonEndMonth: Int + let isActive: Bool + let schemaVersion: Int + let lastModified: Date + } + // MARK: - Availability Check func isAvailable() async -> Bool { @@ -579,7 +624,7 @@ actor CloudKitService { /// - Parameters: /// - lastSync: If nil, fetches all records. If provided, fetches only records modified since that date. /// - cancellationToken: Optional token to check for cancellation between pages - func fetchLeagueStructureChanges(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [LeagueStructureModel] { + func fetchLeagueStructureChanges(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [SyncLeagueStructure] { let log = SyncLogger.shared let predicate: NSPredicate if let deltaStart = effectiveDeltaStartDate(lastSync) { @@ -594,11 +639,23 @@ actor CloudKitService { let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken) log.log("☁️ [CK] Received \(records.count) league structure records from CloudKit") - var parsed: [LeagueStructureModel] = [] + var parsed: [SyncLeagueStructure] = [] var skipped = 0 for record in records { if let model = CKLeagueStructure(record: record).toModel() { - parsed.append(model) + parsed.append( + SyncLeagueStructure( + id: model.id, + sport: model.sport, + structureTypeRaw: model.structureTypeRaw, + name: model.name, + abbreviation: model.abbreviation, + parentId: model.parentId, + displayOrder: model.displayOrder, + schemaVersion: model.schemaVersion, + lastModified: model.lastModified + ) + ) } else { skipped += 1 } @@ -618,7 +675,7 @@ actor CloudKitService { /// - Parameters: /// - lastSync: If nil, fetches all records. If provided, fetches only records modified since that date. /// - cancellationToken: Optional token to check for cancellation between pages - func fetchTeamAliasChanges(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [TeamAlias] { + func fetchTeamAliasChanges(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [SyncTeamAlias] { let log = SyncLogger.shared let predicate: NSPredicate if let deltaStart = effectiveDeltaStartDate(lastSync) { @@ -633,11 +690,22 @@ actor CloudKitService { let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken) log.log("☁️ [CK] Received \(records.count) team alias records from CloudKit") - var parsed: [TeamAlias] = [] + var parsed: [SyncTeamAlias] = [] var skipped = 0 for record in records { if let model = CKTeamAlias(record: record).toModel() { - parsed.append(model) + parsed.append( + SyncTeamAlias( + id: model.id, + teamCanonicalId: model.teamCanonicalId, + aliasTypeRaw: model.aliasTypeRaw, + aliasValue: model.aliasValue, + validFrom: model.validFrom, + validUntil: model.validUntil, + schemaVersion: model.schemaVersion, + lastModified: model.lastModified + ) + ) } else { skipped += 1 } @@ -657,7 +725,7 @@ actor CloudKitService { /// - Parameters: /// - lastSync: If nil, fetches all records. If provided, fetches only records modified since that date. /// - cancellationToken: Optional token to check for cancellation between pages - func fetchStadiumAliasChanges(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [StadiumAlias] { + func fetchStadiumAliasChanges(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [SyncStadiumAlias] { let log = SyncLogger.shared let predicate: NSPredicate if let deltaStart = effectiveDeltaStartDate(lastSync) { @@ -672,11 +740,20 @@ actor CloudKitService { let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken) log.log("☁️ [CK] Received \(records.count) stadium alias records from CloudKit") - var parsed: [StadiumAlias] = [] + var parsed: [SyncStadiumAlias] = [] var skipped = 0 for record in records { if let model = CKStadiumAlias(record: record).toModel() { - parsed.append(model) + parsed.append( + SyncStadiumAlias( + aliasName: model.aliasName, + stadiumCanonicalId: model.stadiumCanonicalId, + validFrom: model.validFrom, + validUntil: model.validUntil, + schemaVersion: model.schemaVersion, + lastModified: model.lastModified + ) + ) } else { skipped += 1 } @@ -698,7 +775,7 @@ actor CloudKitService { /// - Parameters: /// - lastSync: If nil, fetches all sports. If provided, fetches only sports modified since that date. /// - cancellationToken: Optional token to check for cancellation between pages - func fetchSportsForSync(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [CanonicalSport] { + func fetchSportsForSync(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [SyncSport] { let log = SyncLogger.shared let predicate: NSPredicate if let deltaStart = effectiveDeltaStartDate(lastSync) { @@ -713,11 +790,24 @@ actor CloudKitService { let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken) log.log("☁️ [CK] Received \(records.count) sport records from CloudKit") - var parsed: [CanonicalSport] = [] + var parsed: [SyncSport] = [] var skipped = 0 for record in records { if let sport = CKSport(record: record).toCanonical() { - parsed.append(sport) + parsed.append( + SyncSport( + id: sport.id, + abbreviation: sport.abbreviation, + displayName: sport.displayName, + iconName: sport.iconName, + colorHex: sport.colorHex, + seasonStartMonth: sport.seasonStartMonth, + seasonEndMonth: sport.seasonEndMonth, + isActive: sport.isActive, + schemaVersion: sport.schemaVersion, + lastModified: sport.lastModified + ) + ) } else { skipped += 1 } diff --git a/SportsTime/Core/Services/DeepLinkHandler.swift b/SportsTime/Core/Services/DeepLinkHandler.swift index b716d5e..c8f534b 100644 --- a/SportsTime/Core/Services/DeepLinkHandler.swift +++ b/SportsTime/Core/Services/DeepLinkHandler.swift @@ -10,7 +10,7 @@ import SwiftUI // MARK: - Identifiable Share Code -struct IdentifiableShareCode: Identifiable { +struct IdentifiableShareCode: Identifiable, Hashable { let id: String var value: String { id } } diff --git a/SportsTime/Core/Services/EVChargingService.swift b/SportsTime/Core/Services/EVChargingService.swift index 3fa68b7..f44dc3f 100644 --- a/SportsTime/Core/Services/EVChargingService.swift +++ b/SportsTime/Core/Services/EVChargingService.swift @@ -128,19 +128,14 @@ actor EVChargingService { let chargerType = detectChargerType(from: name) let estimatedChargeTime = estimateChargeTime(for: chargerType) - var address: String? = nil - if let placemark = item.placemark as MKPlacemark? { - var components: [String] = [] - if let city = placemark.locality { components.append(city) } - if let state = placemark.administrativeArea { components.append(state) } - address = components.isEmpty ? nil : components.joined(separator: ", ") - } + let address = formattedAddress(for: item) + let coordinate = coordinate(for: item) return EVChargingStop( name: name, location: LocationInput( name: name, - coordinate: item.placemark.coordinate, + coordinate: coordinate, address: address ), chargerType: chargerType, @@ -148,6 +143,20 @@ actor EVChargingService { ) } + private func formattedAddress(for item: MKMapItem) -> String? { + if let cityContext = item.addressRepresentations?.cityWithContext, !cityContext.isEmpty { + return cityContext + } + if let shortAddress = item.address?.shortAddress, !shortAddress.isEmpty { + return shortAddress + } + return item.address?.fullAddress + } + + private func coordinate(for item: MKMapItem) -> CLLocationCoordinate2D { + item.location.coordinate + } + /// Detect charger type from name using heuristics private func detectChargerType(from name: String) -> ChargerType { let lowercased = name.lowercased() diff --git a/SportsTime/Core/Services/ItineraryItemService.swift b/SportsTime/Core/Services/ItineraryItemService.swift index 2fe091b..9dd313c 100644 --- a/SportsTime/Core/Services/ItineraryItemService.swift +++ b/SportsTime/Core/Services/ItineraryItemService.swift @@ -13,7 +13,7 @@ actor ItineraryItemService { // Debounce tracking private var pendingUpdates: [UUID: ItineraryItem] = [:] private var retryCount: [UUID: Int] = [:] - private var debounceTask: Task? + private var flushTask: Task? private let maxRetries = 3 private let debounceInterval: Duration = .seconds(1.5) @@ -22,8 +22,8 @@ actor ItineraryItemService { /// Cancel any pending debounce task (for cleanup) func cancelPendingSync() { - debounceTask?.cancel() - debounceTask = nil + flushTask?.cancel() + flushTask = nil } // MARK: - CRUD Operations @@ -52,26 +52,7 @@ actor ItineraryItemService { func updateItem(_ item: ItineraryItem) async { pendingUpdates[item.id] = item - // Cancel existing debounce task - debounceTask?.cancel() - - // Start new debounce task with proper cancellation handling - debounceTask = Task { - // Check cancellation before sleeping - guard !Task.isCancelled else { return } - - do { - try await Task.sleep(for: debounceInterval) - } catch { - // Task was cancelled during sleep - return - } - - // Check cancellation after sleeping (belt and suspenders) - guard !Task.isCancelled else { return } - - await flushPendingUpdates() - } + scheduleFlush(after: debounceInterval) } /// Force immediate sync of pending updates @@ -92,15 +73,47 @@ actor ItineraryItemService { retryCount[item.id] = nil } } catch { - // Add back to pending for retry, respecting max retry limit + // Keep failed updates queued and retry with backoff; never drop user edits. + var highestRetry = 0 for item in updates.values { let currentRetries = retryCount[item.id] ?? 0 - if currentRetries < maxRetries { - pendingUpdates[item.id] = item - retryCount[item.id] = currentRetries + 1 - } - // If max retries exceeded, silently drop the update to prevent infinite loop + let nextRetry = min(currentRetries + 1, maxRetries) + retryCount[item.id] = nextRetry + highestRetry = max(highestRetry, nextRetry) + pendingUpdates[item.id] = item } + + let retryDelay = retryDelay(for: highestRetry) + scheduleFlush(after: retryDelay) + } + } + + // MARK: - Flush Scheduling + + private func scheduleFlush(after delay: Duration) { + flushTask?.cancel() + flushTask = Task { + guard !Task.isCancelled else { return } + + do { + try await Task.sleep(for: delay) + } catch { + return + } + + guard !Task.isCancelled else { return } + await flushPendingUpdates() + } + } + + private func retryDelay(for retryLevel: Int) -> Duration { + switch retryLevel { + case 0, 1: + return .seconds(5) + case 2: + return .seconds(15) + default: + return .seconds(30) } } @@ -116,7 +129,7 @@ actor ItineraryItemService { for item in items { let recordId = CKRecord.ID(recordName: item.id.uuidString) - try? await database.deleteRecord(withID: recordId) + _ = try? await database.deleteRecord(withID: recordId) } } } diff --git a/SportsTime/Core/Services/LocationService.swift b/SportsTime/Core/Services/LocationService.swift index 1776608..a41ae28 100644 --- a/SportsTime/Core/Services/LocationService.swift +++ b/SportsTime/Core/Services/LocationService.swift @@ -7,7 +7,7 @@ import Foundation import CoreLocation import MapKit -extension MKPolyline: @unchecked Sendable {} +extension MKPolyline: @retroactive @unchecked Sendable {} actor LocationService { static let shared = LocationService() @@ -80,22 +80,29 @@ actor LocationService { } } - @available(iOS, deprecated: 26.0, message: "Uses placemark for address formatting") private func formatMapItem(_ item: MKMapItem) -> String { var components: [String] = [] - if let locality = item.placemark.locality { - components.append(locality) + + if let cityContext = item.addressRepresentations?.cityWithContext, + !cityContext.isEmpty { + components.append(cityContext) } - if let state = item.placemark.administrativeArea { - components.append(state) + if let regionName = item.addressRepresentations?.regionName, + regionName != "United States" { + components.append(regionName) } - if let country = item.placemark.country, country != "United States" { - components.append(country) + + if !components.isEmpty { + return components.joined(separator: ", ") } - if components.isEmpty { - return item.name ?? "" + + if let shortAddress = item.address?.shortAddress, !shortAddress.isEmpty { + return shortAddress } - return components.joined(separator: ", ") + if let fullAddress = item.address?.fullAddress, !fullAddress.isEmpty { + return fullAddress + } + return item.name ?? "" } // MARK: - Distance Calculations @@ -163,7 +170,7 @@ actor LocationService { struct RouteInfo: Sendable { let distance: CLLocationDistance // meters let expectedTravelTime: TimeInterval // seconds - nonisolated(unsafe) let polyline: MKPolyline? + let polyline: MKPolyline? var distanceMiles: Double { distance * 0.000621371 } var travelTimeHours: Double { expectedTravelTime / 3600.0 } diff --git a/SportsTime/Core/Services/PollService.swift b/SportsTime/Core/Services/PollService.swift index 2684f15..4bc5b9e 100644 --- a/SportsTime/Core/Services/PollService.swift +++ b/SportsTime/Core/Services/PollService.swift @@ -16,6 +16,7 @@ enum PollError: Error, LocalizedError { case pollNotFound case alreadyVoted case notPollOwner + case notVoteOwner case networkUnavailable case encodingError case unknown(Error) @@ -30,6 +31,8 @@ enum PollError: Error, LocalizedError { return "You have already voted on this poll." case .notPollOwner: return "Only the poll owner can perform this action." + case .notVoteOwner: + return "Only the vote owner can update this vote." case .networkUnavailable: return "Unable to connect. Please check your internet connection." case .encodingError: @@ -49,7 +52,7 @@ actor PollService { private let publicDatabase: CKDatabase private var currentUserRecordID: String? - private var pollSubscriptionID: CKSubscription.ID? + private var pollSubscriptionIDs: Set = [] private init() { self.container = CloudKitContainerConfig.makeContainer() @@ -231,7 +234,7 @@ actor PollService { func updateVote(_ vote: PollVote) async throws -> PollVote { let userId = try await getCurrentUserRecordID() guard vote.odg == userId else { - throw PollError.notPollOwner // Using this error for now - voter can only update own vote + throw PollError.notVoteOwner } // Fetch the existing record to get the server's changeTag @@ -245,6 +248,13 @@ actor PollService { throw PollError.unknown(error) } + guard let existingVoterId = existingRecord[CKPollVote.voterIdKey] as? String else { + throw PollError.encodingError + } + guard existingVoterId == userId else { + throw PollError.notVoteOwner + } + // Update the fields on the fetched record let now = Date() existingRecord[CKPollVote.rankingsKey] = vote.rankings @@ -320,11 +330,12 @@ actor PollService { // MARK: - Subscriptions func subscribeToVoteUpdates(forPollId pollId: UUID) async throws { + let subscriptionId = "poll-votes-\(pollId.uuidString)" let predicate = NSPredicate(format: "%K == %@", CKPollVote.pollIdKey, pollId.uuidString) let subscription = CKQuerySubscription( recordType: CKRecordType.pollVote, predicate: predicate, - subscriptionID: "poll-votes-\(pollId.uuidString)", + subscriptionID: subscriptionId, options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion] ) @@ -334,10 +345,12 @@ actor PollService { do { try await publicDatabase.save(subscription) - pollSubscriptionID = subscription.subscriptionID + pollSubscriptionIDs.insert(subscription.subscriptionID) } catch let error as CKError { // Subscription already exists is OK - if error.code != .serverRejectedRequest { + if error.code == .serverRejectedRequest { + pollSubscriptionIDs.insert(subscriptionId) + } else { throw mapCloudKitError(error) } } catch { @@ -345,14 +358,23 @@ actor PollService { } } - func unsubscribeFromVoteUpdates() async throws { - guard let subscriptionID = pollSubscriptionID else { return } + func unsubscribeFromVoteUpdates(forPollId pollId: UUID? = nil) async throws { + let subscriptionIds: [CKSubscription.ID] + if let pollId { + subscriptionIds = ["poll-votes-\(pollId.uuidString)"] + } else { + subscriptionIds = Array(pollSubscriptionIDs) + } - do { - try await publicDatabase.deleteSubscription(withID: subscriptionID) - pollSubscriptionID = nil - } catch { - // Ignore errors - subscription may not exist + guard !subscriptionIds.isEmpty else { return } + + for subscriptionID in subscriptionIds { + do { + try await publicDatabase.deleteSubscription(withID: subscriptionID) + } catch { + // Ignore errors - subscription may not exist + } + pollSubscriptionIDs.remove(subscriptionID) } } diff --git a/SportsTime/Core/Services/RouteDescriptionGenerator.swift b/SportsTime/Core/Services/RouteDescriptionGenerator.swift index 18a1fa9..3f72248 100644 --- a/SportsTime/Core/Services/RouteDescriptionGenerator.swift +++ b/SportsTime/Core/Services/RouteDescriptionGenerator.swift @@ -25,24 +25,24 @@ final class RouteDescriptionGenerator { } private func checkAvailability() { - // TEMPORARILY DISABLED: Foundation Models crashes in iOS 26.2 Simulator - // due to NLLanguageRecognizer.processString: assertion failure in Collection.suffix(from:) - // TODO: Re-enable when Apple fixes the Foundation Models framework + #if targetEnvironment(simulator) + // Keep simulator behavior deterministic and avoid known FoundationModels simulator crashes. isAvailable = false - return - - // Original code (disabled): - // switch SystemLanguageModel.default.availability { - // case .available: - // isAvailable = true - // session = LanguageModelSession(instructions: """ - // You are a travel copywriter creating exciting, brief descriptions for sports road trips. - // Write in an enthusiastic but concise style. Focus on the adventure and variety of the trip. - // Keep descriptions to 1-2 short sentences maximum. - // """) - // case .unavailable: - // isAvailable = false - // } + session = nil + #else + switch SystemLanguageModel.default.availability { + case .available: + isAvailable = true + session = LanguageModelSession(instructions: """ + You are a travel copywriter creating exciting, brief descriptions for sports road trips. + Write in an enthusiastic but concise style. Focus on the adventure and variety of the trip. + Keep descriptions to 1-2 short sentences maximum. + """) + case .unavailable: + isAvailable = false + session = nil + } + #endif } /// Generate a brief, exciting description for a route option diff --git a/SportsTime/Core/Services/ScoreAPIProviders/NBAStatsProvider.swift b/SportsTime/Core/Services/ScoreAPIProviders/NBAStatsProvider.swift index 3413780..48d9533 100644 --- a/SportsTime/Core/Services/ScoreAPIProviders/NBAStatsProvider.swift +++ b/SportsTime/Core/Services/ScoreAPIProviders/NBAStatsProvider.swift @@ -131,7 +131,6 @@ struct NBAStatsProvider: ScoreAPIProvider { // Get column indices let homeTeamIdIdx = headers.firstIndex(of: "HOME_TEAM_ID") let visitorTeamIdIdx = headers.firstIndex(of: "VISITOR_TEAM_ID") - let gameStatusIdx = headers.firstIndex(of: "GAME_STATUS_TEXT") // Find the LineScore result set for scores let lineScoreSet = resultSets.first(where: { ($0["name"] as? String) == "LineScore" }) diff --git a/SportsTime/Core/Services/SyncLogger.swift b/SportsTime/Core/Services/SyncLogger.swift index cdd2dcb..cbd6946 100644 --- a/SportsTime/Core/Services/SyncLogger.swift +++ b/SportsTime/Core/Services/SyncLogger.swift @@ -16,7 +16,8 @@ final class SyncLogger: @unchecked Sendable { private let queue = DispatchQueue(label: "com.88oakapps.SportsTime.synclogger") private init() { - let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first + ?? FileManager.default.temporaryDirectory fileURL = docs.appendingPathComponent("sync_log.txt") } diff --git a/SportsTime/Core/Store/StoreManager.swift b/SportsTime/Core/Store/StoreManager.swift index 38433d5..de403e7 100644 --- a/SportsTime/Core/Store/StoreManager.swift +++ b/SportsTime/Core/Store/StoreManager.swift @@ -75,6 +75,7 @@ final class StoreManager { // MARK: - Transaction Listener + @ObservationIgnored nonisolated(unsafe) private var transactionListenerTask: Task? // MARK: - Initialization diff --git a/SportsTime/Core/Theme/Theme.swift b/SportsTime/Core/Theme/Theme.swift index 1edd332..8e2ae3a 100644 --- a/SportsTime/Core/Theme/Theme.swift +++ b/SportsTime/Core/Theme/Theme.swift @@ -75,8 +75,10 @@ final class ThemeManager: @unchecked Sendable { private func updateAppIcon(for theme: AppTheme) { let iconName = theme.alternateIconName - guard UIApplication.shared.alternateIconName != iconName else { return } - UIApplication.shared.setAlternateIconName(iconName) + Task { @MainActor in + guard UIApplication.shared.alternateIconName != iconName else { return } + UIApplication.shared.setAlternateIconName(iconName) + } } private init() { @@ -460,11 +462,13 @@ enum Theme { } /// Whether the system Reduce Motion preference is enabled. + @MainActor static var prefersReducedMotion: Bool { UIAccessibility.isReduceMotionEnabled } /// Performs a state change with animation, or instantly if Reduce Motion is enabled. + @MainActor static func withMotion(_ animation: SwiftUI.Animation = spring, _ body: () -> Void) { if prefersReducedMotion { body() diff --git a/SportsTime/Export/Services/POISearchService.swift b/SportsTime/Export/Services/POISearchService.swift index 4a7589b..9ccc917 100644 --- a/SportsTime/Export/Services/POISearchService.swift +++ b/SportsTime/Export/Services/POISearchService.swift @@ -225,18 +225,13 @@ actor POISearchService { return pois } - @available(iOS, deprecated: 26.0, message: "Uses placemark for address formatting") private func formatAddress(_ item: MKMapItem) -> String? { - var components: [String] = [] - - if let subThoroughfare = item.placemark.subThoroughfare { - components.append(subThoroughfare) + if let shortAddress = item.address?.shortAddress, !shortAddress.isEmpty { + return shortAddress } - if let thoroughfare = item.placemark.thoroughfare { - components.append(thoroughfare) + if let fullAddress = item.address?.fullAddress, !fullAddress.isEmpty { + return fullAddress } - - guard !components.isEmpty else { return nil } - return components.joined(separator: " ") + return nil } } diff --git a/SportsTime/Features/Polls/ViewModels/PollDetailViewModel.swift b/SportsTime/Features/Polls/ViewModels/PollDetailViewModel.swift index 31c749e..501c9eb 100644 --- a/SportsTime/Features/Polls/ViewModels/PollDetailViewModel.swift +++ b/SportsTime/Features/Polls/ViewModels/PollDetailViewModel.swift @@ -19,6 +19,7 @@ final class PollDetailViewModel { var error: PollError? private let pollService = PollService.shared + private var subscribedPollId: UUID? var results: PollResults? { guard let poll else { return nil } @@ -65,7 +66,7 @@ final class PollDetailViewModel { self.myVote = fetchedMyVote // Subscribe to vote updates - try? await pollService.subscribeToVoteUpdates(forPollId: pollId) + await subscribeToVoteUpdates(for: pollId) } catch let pollError as PollError { error = pollError } catch { @@ -92,7 +93,7 @@ final class PollDetailViewModel { self.myVote = fetchedMyVote // Subscribe to vote updates - try? await pollService.subscribeToVoteUpdates(forPollId: fetchedPoll.id) + await subscribeToVoteUpdates(for: fetchedPoll.id) } catch let pollError as PollError { error = pollError } catch { @@ -119,7 +120,7 @@ final class PollDetailViewModel { self.myVote = fetchedMyVote // Subscribe to vote updates - try? await pollService.subscribeToVoteUpdates(forPollId: existingPoll.id) + await subscribeToVoteUpdates(for: existingPoll.id) } catch { // Votes fetch failed, but we have the poll - non-critical error self.votes = [] @@ -155,7 +156,8 @@ final class PollDetailViewModel { do { try await pollService.deletePoll(poll.id) - try? await pollService.unsubscribeFromVoteUpdates() + try? await pollService.unsubscribeFromVoteUpdates(forPollId: poll.id) + subscribedPollId = nil self.poll = nil return true } catch let pollError as PollError { @@ -169,6 +171,17 @@ final class PollDetailViewModel { } func cleanup() async { - try? await pollService.unsubscribeFromVoteUpdates() + guard let subscribedPollId else { return } + try? await pollService.unsubscribeFromVoteUpdates(forPollId: subscribedPollId) + self.subscribedPollId = nil + } + + private func subscribeToVoteUpdates(for pollId: UUID) async { + if let existingPollId = subscribedPollId, existingPollId != pollId { + try? await pollService.unsubscribeFromVoteUpdates(forPollId: existingPollId) + } + + try? await pollService.subscribeToVoteUpdates(forPollId: pollId) + subscribedPollId = pollId } } diff --git a/SportsTime/Features/Polls/Views/PollsListView.swift b/SportsTime/Features/Polls/Views/PollsListView.swift index f4448c3..1fe5d02 100644 --- a/SportsTime/Features/Polls/Views/PollsListView.swift +++ b/SportsTime/Features/Polls/Views/PollsListView.swift @@ -12,9 +12,10 @@ struct PollsListView: View { @State private var polls: [TripPoll] = [] @State private var isLoading = false @State private var hasLoadedInitially = false - @State private var error: PollError? + @State private var errorMessage: String? @State private var showJoinPoll = false @State private var joinCode = "" + @State private var pendingJoinCode: IdentifiableShareCode? var body: some View { Group { @@ -50,10 +51,17 @@ struct PollsListView: View { TextField("Enter code", text: $joinCode) .textInputAutocapitalization(.characters) Button("Join") { - // Navigation will be handled by deep link - if !joinCode.isEmpty { - // TODO: Navigate to poll detail + let normalizedCode = joinCode + .trimmingCharacters(in: .whitespacesAndNewlines) + .uppercased() + + guard normalizedCode.count == 6 else { + errorMessage = "Share code must be exactly 6 characters." + return } + + pendingJoinCode = IdentifiableShareCode(id: normalizedCode) + joinCode = "" } Button("Cancel", role: .cancel) { joinCode = "" @@ -61,14 +69,21 @@ struct PollsListView: View { } message: { Text("Enter the 6-character poll code") } - .alert("Error", isPresented: .constant(error != nil)) { - Button("OK") { - error = nil + .navigationDestination(item: $pendingJoinCode) { code in + PollDetailView(shareCode: code.value) + } + .alert( + "Error", + isPresented: Binding( + get: { errorMessage != nil }, + set: { if !$0 { errorMessage = nil } } + ) + ) { + Button("OK", role: .cancel) { + errorMessage = nil } } message: { - if let error { - Text(error.localizedDescription) - } + Text(errorMessage ?? "Please try again.") } } @@ -99,14 +114,14 @@ struct PollsListView: View { private func loadPolls() async { isLoading = true - error = nil + errorMessage = nil do { polls = try await PollService.shared.fetchMyPolls() } catch let pollError as PollError { - error = pollError + errorMessage = pollError.localizedDescription } catch { - self.error = .unknown(error) + self.errorMessage = PollError.unknown(error).localizedDescription } isLoading = false diff --git a/SportsTime/Features/Progress/ViewModels/ProgressViewModel.swift b/SportsTime/Features/Progress/ViewModels/ProgressViewModel.swift index 79a1695..e1237c0 100644 --- a/SportsTime/Features/Progress/ViewModels/ProgressViewModel.swift +++ b/SportsTime/Features/Progress/ViewModels/ProgressViewModel.swift @@ -54,12 +54,12 @@ final class ProgressViewModel { /// Count of trips for the selected sport (stub - can be enhanced) var tripCount: Int { - // TODO: Fetch saved trips count from SwiftData - 0 + savedTripCount } /// Recent visits sorted by date (cached, recomputed on data change) private(set) var recentVisits: [VisitSummary] = [] + private(set) var savedTripCount: Int = 0 // MARK: - Configuration @@ -89,6 +89,7 @@ final class ProgressViewModel { sortBy: [SortDescriptor(\.visitDate, order: .reverse)] ) visits = try context.fetch(descriptor) + savedTripCount = try context.fetchCount(FetchDescriptor()) } } catch { self.error = error diff --git a/SportsTime/Features/Schedule/ViewModels/ScheduleViewModel.swift b/SportsTime/Features/Schedule/ViewModels/ScheduleViewModel.swift index a35e521..c1d4c1f 100644 --- a/SportsTime/Features/Schedule/ViewModels/ScheduleViewModel.swift +++ b/SportsTime/Features/Schedule/ViewModels/ScheduleViewModel.swift @@ -16,8 +16,8 @@ final class ScheduleViewModel { // MARK: - Filter State var selectedSports: Set = Set(Sport.supported) - var startDate: Date = Calendar.current.startOfDay(for: Date()) - var endDate: Date = Calendar.current.date(byAdding: .day, value: 14, to: Calendar.current.startOfDay(for: Date())) ?? Date() + var startDate: Date = ScheduleViewModel.defaultDateRange().start + var endDate: Date = ScheduleViewModel.defaultDateRange().end var searchText: String = "" // MARK: - Data State @@ -28,7 +28,9 @@ final class ScheduleViewModel { private(set) var errorMessage: String? private let dataProvider = AppDataProvider.shared + @ObservationIgnored nonisolated(unsafe) private var loadTask: Task? + private var latestLoadRequestID = UUID() // MARK: - Pre-computed Groupings (avoid computed property overhead) @@ -48,14 +50,43 @@ final class ScheduleViewModel { } var hasFilters: Bool { - selectedSports.count < Sport.supported.count || !searchText.isEmpty + let defaults = Self.defaultDateRange() + let calendar = Calendar.current + let hasDateFilter = !calendar.isDate(startDate, inSameDayAs: defaults.start) || + !calendar.isDate(endDate, inSameDayAs: defaults.end) + return selectedSports.count < Sport.supported.count || !searchText.isEmpty || hasDateFilter + } + + private static func defaultDateRange() -> (start: Date, end: Date) { + let calendar = Calendar.current + let start = calendar.startOfDay(for: Date()) + let endDay = calendar.date(byAdding: .day, value: 14, to: start) ?? start + let end = calendar.date(bySettingHour: 23, minute: 59, second: 59, of: endDay) ?? endDay + return (start, end) + } + + private static func normalizedDateRange(start: Date, end: Date) -> (start: Date, end: Date) { + let calendar = Calendar.current + let normalizedStart = calendar.startOfDay(for: start) + let endDay = calendar.startOfDay(for: end) + let normalizedEnd = calendar.date(bySettingHour: 23, minute: 59, second: 59, of: endDay) ?? endDay + return (normalizedStart, normalizedEnd) } // MARK: - Actions func loadGames() async { - guard !selectedSports.isEmpty else { + let requestID = beginLoadRequest() + let queryStartDate = startDate + let queryEndDate = endDate + let querySports = selectedSports + + guard !querySports.isEmpty else { + guard !isStaleLoad(requestID) else { return } games = [] + isLoading = false + error = nil + errorMessage = nil updateFilteredGames() return } @@ -64,11 +95,6 @@ final class ScheduleViewModel { error = nil errorMessage = nil - // Start diagnostics - let queryStartDate = startDate - let queryEndDate = endDate - let querySports = selectedSports - logger.info("📅 Loading games: \(querySports.map(\.rawValue).joined(separator: ", "))") logger.info("📅 Date range: \(queryStartDate.formatted()) to \(queryEndDate.formatted())") @@ -78,9 +104,11 @@ final class ScheduleViewModel { logger.info("📅 Teams empty, loading initial data...") await dataProvider.loadInitialData() } + guard !isStaleLoad(requestID) else { return } // Check if data provider had an error if let providerError = dataProvider.errorMessage { + guard !isStaleLoad(requestID) else { return } self.errorMessage = providerError self.error = dataProvider.error isLoading = false @@ -92,11 +120,13 @@ final class ScheduleViewModel { // Log team/stadium counts for diagnostics logger.info("📅 Loaded \(self.dataProvider.teams.count) teams, \(self.dataProvider.stadiums.count) stadiums") - games = try await dataProvider.filterRichGames( - sports: selectedSports, - startDate: startDate, - endDate: endDate + let loadedGames = try await dataProvider.filterRichGames( + sports: querySports, + startDate: queryStartDate, + endDate: queryEndDate ) + guard !isStaleLoad(requestID) else { return } + games = loadedGames // Update diagnostics var newDiagnostics = ScheduleDiagnostics() @@ -116,7 +146,7 @@ final class ScheduleViewModel { self.diagnostics = newDiagnostics - AnalyticsManager.shared.track(.scheduleViewed(sports: Array(selectedSports).map(\.rawValue))) + AnalyticsManager.shared.track(.scheduleViewed(sports: Array(querySports).map(\.rawValue))) logger.info("📅 Returned \(self.games.count) games") for (sport, count) in sportCounts.sorted(by: { $0.key.rawValue < $1.key.rawValue }) { logger.info("📅 \(sport.rawValue): \(count) games") @@ -133,15 +163,18 @@ final class ScheduleViewModel { #endif } catch let cloudKitError as CloudKitError { + guard !isStaleLoad(requestID) else { return } self.error = cloudKitError self.errorMessage = cloudKitError.errorDescription logger.error("📅 CloudKit error: \(cloudKitError.errorDescription ?? "unknown")") } catch { + guard !isStaleLoad(requestID) else { return } self.error = error self.errorMessage = error.localizedDescription logger.error("📅 Error loading games: \(error.localizedDescription)") } + guard !isStaleLoad(requestID) else { return } isLoading = false updateFilteredGames() } @@ -165,15 +198,17 @@ final class ScheduleViewModel { func resetFilters() { selectedSports = Set(Sport.supported) searchText = "" - startDate = Date() - endDate = Calendar.current.date(byAdding: .day, value: 14, to: Date()) ?? Date() + let defaults = Self.defaultDateRange() + startDate = defaults.start + endDate = defaults.end loadTask?.cancel() loadTask = Task { await loadGames() } } func updateDateRange(start: Date, end: Date) { - startDate = start - endDate = end + let normalized = Self.normalizedDateRange(start: start, end: end) + startDate = normalized.start + endDate = normalized.end loadTask?.cancel() loadTask = Task { await loadGames() } } @@ -212,6 +247,16 @@ final class ScheduleViewModel { return lhs.game.dateTime < rhs.game.dateTime }) } } + + private func beginLoadRequest() -> UUID { + let requestID = UUID() + latestLoadRequestID = requestID + return requestID + } + + private func isStaleLoad(_ requestID: UUID) -> Bool { + Task.isCancelled || latestLoadRequestID != requestID + } } // MARK: - Diagnostics Model diff --git a/SportsTime/Features/Schedule/Views/ScheduleListView.swift b/SportsTime/Features/Schedule/Views/ScheduleListView.swift index 92c95fa..01dc4bc 100644 --- a/SportsTime/Features/Schedule/Views/ScheduleListView.swift +++ b/SportsTime/Features/Schedule/Views/ScheduleListView.swift @@ -362,18 +362,15 @@ struct DateRangePickerSheet: View { Section { Button("Next 7 Days") { - startDate = Date() - endDate = Calendar.current.date(byAdding: .day, value: 7, to: Date()) ?? Date() + applyQuickRange(days: 7) } Button("Next 14 Days") { - startDate = Date() - endDate = Calendar.current.date(byAdding: .day, value: 14, to: Date()) ?? Date() + applyQuickRange(days: 14) } Button("Next 30 Days") { - startDate = Date() - endDate = Calendar.current.date(byAdding: .day, value: 30, to: Date()) ?? Date() + applyQuickRange(days: 30) } } } @@ -394,6 +391,15 @@ struct DateRangePickerSheet: View { } } } + + private func applyQuickRange(days: Int) { + let calendar = Calendar.current + let start = calendar.startOfDay(for: Date()) + let endDay = calendar.date(byAdding: .day, value: days, to: start) ?? start + let end = calendar.date(bySettingHour: 23, minute: 59, second: 59, of: endDay) ?? endDay + startDate = start + endDate = end + } } // MARK: - Schedule Diagnostics Sheet diff --git a/SportsTime/Features/Settings/SportsIconImageGenerator.swift b/SportsTime/Features/Settings/SportsIconImageGenerator.swift index 650a8e4..712ef65 100644 --- a/SportsTime/Features/Settings/SportsIconImageGenerator.swift +++ b/SportsTime/Features/Settings/SportsIconImageGenerator.swift @@ -216,10 +216,10 @@ struct SportsIconImageGeneratorView: View { .disabled(isGenerating) // Share button (only visible when image exists) - if generatedImage != nil { + if let generatedImage { ShareLink( - item: Image(uiImage: generatedImage!), - preview: SharePreview("Sports Icons", image: Image(uiImage: generatedImage!)) + item: Image(uiImage: generatedImage), + preview: SharePreview("Sports Icons", image: Image(uiImage: generatedImage)) ) { HStack { Image(systemName: "square.and.arrow.up") diff --git a/SportsTime/Features/Settings/Views/SettingsView.swift b/SportsTime/Features/Settings/Views/SettingsView.swift index 95eba1f..7752702 100644 --- a/SportsTime/Features/Settings/Views/SettingsView.swift +++ b/SportsTime/Features/Settings/Views/SettingsView.swift @@ -494,7 +494,7 @@ struct SettingsView: View { Button { Task { let syncService = CanonicalSyncService() - await syncService.resumeSync(context: modelContext) + syncService.resumeSync(context: modelContext) syncActionMessage = "Sync has been re-enabled." } } label: { @@ -670,7 +670,7 @@ struct SettingsView: View { Button { Task { let syncService = CanonicalSyncService() - await syncService.resumeSync(context: modelContext) + syncService.resumeSync(context: modelContext) print("[SyncDebug] Sync re-enabled by user") } } label: { diff --git a/SportsTime/Features/Trip/ViewModels/TripWizardViewModel.swift b/SportsTime/Features/Trip/ViewModels/TripWizardViewModel.swift index 5029bad..963a06a 100644 --- a/SportsTime/Features/Trip/ViewModels/TripWizardViewModel.swift +++ b/SportsTime/Features/Trip/ViewModels/TripWizardViewModel.swift @@ -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 { diff --git a/SportsTime/Features/Trip/Views/AddItem/PlaceSearchSheet.swift b/SportsTime/Features/Trip/Views/AddItem/PlaceSearchSheet.swift index 600ee2d..5c11e76 100644 --- a/SportsTime/Features/Trip/Views/AddItem/PlaceSearchSheet.swift +++ b/SportsTime/Features/Trip/Views/AddItem/PlaceSearchSheet.swift @@ -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 } } diff --git a/SportsTime/Features/Trip/Views/AddItem/QuickAddItemSheet.swift b/SportsTime/Features/Trip/Views/AddItem/QuickAddItemSheet.swift index eadd536..5407dfc 100644 --- a/SportsTime/Features/Trip/Views/AddItem/QuickAddItemSheet.swift +++ b/SportsTime/Features/Trip/Views/AddItem/QuickAddItemSheet.swift @@ -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 diff --git a/SportsTime/Features/Trip/Views/AddItemSheet.swift b/SportsTime/Features/Trip/Views/AddItemSheet.swift index 211626b..7bdbd9a 100644 --- a/SportsTime/Features/Trip/Views/AddItemSheet.swift +++ b/SportsTime/Features/Trip/Views/AddItemSheet.swift @@ -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 } -} diff --git a/SportsTime/Features/Trip/Views/ItineraryReorderingLogic.swift b/SportsTime/Features/Trip/Views/ItineraryReorderingLogic.swift index 2e54483..167db46 100644 --- a/SportsTime/Features/Trip/Views/ItineraryReorderingLogic.swift +++ b/SportsTime/Features/Trip/Views/ItineraryReorderingLogic.swift @@ -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 ) diff --git a/SportsTime/Features/Trip/Views/ItineraryTableViewWrapper.swift b/SportsTime/Features/Trip/Views/ItineraryTableViewWrapper.swift index 391445b..bc9b965 100644 --- a/SportsTime/Features/Trip/Views/ItineraryTableViewWrapper.swift +++ b/SportsTime/Features/Trip/Views/ItineraryTableViewWrapper.swift @@ -320,7 +320,7 @@ struct ItineraryTableViewWrapper: 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)" diff --git a/SportsTime/Features/Trip/Views/TripDetailView.swift b/SportsTime/Features/Trip/Views/TripDetailView.swift index c73cf43..b7d44c6 100644 --- a/SportsTime/Features/Trip/Views/TripDetailView.swift +++ b/SportsTime/Features/Trip/Views/TripDetailView.swift @@ -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( 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 = []) { let tripId = trip.id let descriptor = FetchDescriptor( 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 diff --git a/SportsTime/Features/Trip/Views/Wizard/Steps/GamePickerStep.swift b/SportsTime/Features/Trip/Views/Wizard/Steps/GamePickerStep.swift index c3bd4a2..be1b395 100644 --- a/SportsTime/Features/Trip/Views/Wizard/Steps/GamePickerStep.swift +++ b/SportsTime/Features/Trip/Views/Wizard/Steps/GamePickerStep.swift @@ -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) diff --git a/SportsTime/Features/Trip/Views/Wizard/TripWizardView.swift b/SportsTime/Features/Trip/Views/Wizard/TripWizardView.swift index f8d4e67..4163a45 100644 --- a/SportsTime/Features/Trip/Views/Wizard/TripWizardView.swift +++ b/SportsTime/Features/Trip/Views/Wizard/TripWizardView.swift @@ -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 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, diff --git a/SportsTime/Planning/Engine/RouteFilters.swift b/SportsTime/Planning/Engine/RouteFilters.swift index c3cf54d..fb2dbb0 100644 --- a/SportsTime/Planning/Engine/RouteFilters.swift +++ b/SportsTime/Planning/Engine/RouteFilters.swift @@ -58,7 +58,8 @@ enum RouteFilters { var cityDays: [String: Set] = [:] for stop in option.stops { - let city = stop.city + let city = normalizedCityKey(stop.city) + guard !city.isEmpty else { continue } let day = calendar.startOfDay(for: stop.arrivalDate) cityDays[city, default: []].insert(day) } @@ -83,18 +84,34 @@ enum RouteFilters { for option in options { var cityDays: [String: Set] = [:] + var displayNames: [String: String] = [:] for stop in option.stops { + let normalized = normalizedCityKey(stop.city) + guard !normalized.isEmpty else { continue } + + if displayNames[normalized] == nil { + displayNames[normalized] = stop.city + } let day = calendar.startOfDay(for: stop.arrivalDate) - cityDays[stop.city, default: []].insert(day) + cityDays[normalized, default: []].insert(day) } - for (city, days) in cityDays where days.count > 1 { - violatingCities.insert(city) + for (normalized, days) in cityDays where days.count > 1 { + violatingCities.insert(displayNames[normalized] ?? normalized) } } return Array(violatingCities).sorted() } + private static func normalizedCityKey(_ city: String) -> String { + let cityPart = city.split(separator: ",", maxSplits: 1).first.map(String.init) ?? city + return cityPart + .lowercased() + .replacingOccurrences(of: ".", with: "") + .split(whereSeparator: \.isWhitespace) + .joined(separator: " ") + } + // MARK: - Trip List Filters /// Filters trips by sport. @@ -133,8 +150,11 @@ enum RouteFilters { /// - Uses calendar start-of-day for comparison static func filterByDateRange(_ trips: [Trip], start: Date, end: Date) -> [Trip] { let calendar = Calendar.current - let rangeStart = calendar.startOfDay(for: start) - let rangeEnd = calendar.startOfDay(for: end) + // Be tolerant of inverted inputs from UI/state restores by normalizing bounds. + let normalizedStart = min(start, end) + let normalizedEnd = max(start, end) + let rangeStart = calendar.startOfDay(for: normalizedStart) + let rangeEnd = calendar.startOfDay(for: normalizedEnd) return trips.filter { trip in let tripStart = calendar.startOfDay(for: trip.startDate) diff --git a/SportsTime/Planning/Engine/ScenarioAPlanner.swift b/SportsTime/Planning/Engine/ScenarioAPlanner.swift index e679026..b0b82f2 100644 --- a/SportsTime/Planning/Engine/ScenarioAPlanner.swift +++ b/SportsTime/Planning/Engine/ScenarioAPlanner.swift @@ -270,7 +270,7 @@ final class ScenarioAPlanner: ScenarioPlanner { travelSegments: itinerary.travelSegments, totalDrivingHours: itinerary.totalDrivingHours, totalDistanceMiles: itinerary.totalDistanceMiles, - geographicRationale: "\(stops.count) games: \(cities)" + geographicRationale: "\(routeGames.count) games: \(cities)" ) itineraryOptions.append(option) } diff --git a/SportsTime/Planning/Engine/ScenarioBPlanner.swift b/SportsTime/Planning/Engine/ScenarioBPlanner.swift index 7acf197..b024618 100644 --- a/SportsTime/Planning/Engine/ScenarioBPlanner.swift +++ b/SportsTime/Planning/Engine/ScenarioBPlanner.swift @@ -298,16 +298,9 @@ final class ScenarioBPlanner: ScenarioPlanner { to: lastGameDate ).day ?? 0 - // If selected games span more days than trip duration, can't fit + // If selected games span more days than trip duration, no valid window exists. if gameSpanDays >= duration { - // Just return one window that exactly covers the games - let start = firstGameDate - let end = Calendar.current.date( - byAdding: .day, - value: gameSpanDays + 1, - to: start - ) ?? lastGameDate - return [DateInterval(start: start, end: end)] + return [] } // Generate sliding windows diff --git a/SportsTime/SportsTimeApp.swift b/SportsTime/SportsTimeApp.swift index e695bb8..6bbd4f0 100644 --- a/SportsTime/SportsTimeApp.swift +++ b/SportsTime/SportsTimeApp.swift @@ -73,7 +73,17 @@ struct SportsTimeApp: App { do { return try ModelContainer(for: schema, configurations: [modelConfiguration]) } catch { - fatalError("Could not create ModelContainer: \(error)") + assertionFailure("Could not create persistent ModelContainer: \(error)") + do { + let fallbackConfiguration = ModelConfiguration( + schema: schema, + isStoredInMemoryOnly: true, + cloudKitDatabase: .none + ) + return try ModelContainer(for: schema, configurations: [fallbackConfiguration]) + } catch { + preconditionFailure("Could not create fallback ModelContainer: \(error)") + } } }() diff --git a/SportsTimeTests/Domain/TripPollTests.swift b/SportsTimeTests/Domain/TripPollTests.swift index 20ca076..db84c12 100644 --- a/SportsTimeTests/Domain/TripPollTests.swift +++ b/SportsTimeTests/Domain/TripPollTests.swift @@ -229,6 +229,24 @@ struct PollVoteTests { // Index 5 is ignored since it's >= tripCount } + @Test("calculateScores: ignores negative trip indices") + func calculateScores_negativeIndex() { + let scores = PollVote.calculateScores(rankings: [0, -1, 1], tripCount: 3) + + #expect(scores[0] == 3) + #expect(scores[1] == 1) + #expect(scores[2] == 0) + } + + @Test("calculateScores: ignores duplicate trip indices after first occurrence") + func calculateScores_duplicateIndices() { + let scores = PollVote.calculateScores(rankings: [1, 1, 0], tripCount: 3) + + #expect(scores[1] == 3) + #expect(scores[0] == 1) + #expect(scores[2] == 0) + } + @Test("calculateScores: empty rankings returns zeros") func calculateScores_emptyRankings() { let scores = PollVote.calculateScores(rankings: [], tripCount: 3) diff --git a/SportsTimeTests/Features/Trip/ItineraryCustomItemTests.swift b/SportsTimeTests/Features/Trip/ItineraryCustomItemTests.swift index 1ba4d00..badf4d8 100644 --- a/SportsTimeTests/Features/Trip/ItineraryCustomItemTests.swift +++ b/SportsTimeTests/Features/Trip/ItineraryCustomItemTests.swift @@ -10,6 +10,7 @@ import XCTest private typealias H = ItineraryTestHelpers +@MainActor final class ItineraryCustomItemTests: XCTestCase { private let testTripId = H.testTripId diff --git a/SportsTimeTests/Features/Trip/ItineraryEdgeCaseTests.swift b/SportsTimeTests/Features/Trip/ItineraryEdgeCaseTests.swift index cec4031..4bc9366 100644 --- a/SportsTimeTests/Features/Trip/ItineraryEdgeCaseTests.swift +++ b/SportsTimeTests/Features/Trip/ItineraryEdgeCaseTests.swift @@ -10,6 +10,7 @@ import XCTest private typealias H = ItineraryTestHelpers +@MainActor final class ItineraryEdgeCaseTests: XCTestCase { private let testDate = H.testDate diff --git a/SportsTimeTests/Features/Trip/ItineraryReorderingLogicTests.swift b/SportsTimeTests/Features/Trip/ItineraryReorderingLogicTests.swift index fddac3c..05791cc 100644 --- a/SportsTimeTests/Features/Trip/ItineraryReorderingLogicTests.swift +++ b/SportsTimeTests/Features/Trip/ItineraryReorderingLogicTests.swift @@ -12,6 +12,7 @@ import XCTest private typealias H = ItineraryTestHelpers private typealias Logic = ItineraryReorderingLogic +@MainActor final class ItineraryReorderingLogicTests: XCTestCase { private let testDate = H.testDate @@ -302,13 +303,8 @@ final class ItineraryReorderingLogicTests: XCTestCase { func test_calculateSortOrder_emptyDay_returns1() { // Day with only header, no items - let items = buildFlatItems([ - .day(1), - .day(2) - ]) - - // Simulating drop right after day 1 header (row 0) - // After inserting at row 1, day 1 has no other items + // Simulating drop right after day 1 header (row 0). After inserting + // at row 1, day 1 has no other items. let mockItems = buildFlatItems([ .day(1), .custom("New", sortOrder: 999, day: 1), // Placeholder for dropped item @@ -810,16 +806,18 @@ final class ItineraryReorderingLogicTests: XCTestCase { ]) let customItem = H.makeCustomItem(day: 1, sortOrder: 1.0, title: "A") - let zones = Logic.calculateCustomItemDragZones(item: customItem, flatItems: items) + let zones = Logic.calculateCustomItemDragZones( + item: customItem, + sourceRow: 1, + flatItems: items, + constraints: nil, + findTravelSortOrder: { _ in nil } + ) - // Headers at rows 0, 2, 4 should be invalid - XCTAssertTrue(zones.invalidRowIndices.contains(0)) - XCTAssertTrue(zones.invalidRowIndices.contains(2)) - XCTAssertTrue(zones.invalidRowIndices.contains(4)) - - // Items at rows 1, 3 should be valid - XCTAssertTrue(zones.validDropRows.contains(1)) - XCTAssertTrue(zones.validDropRows.contains(3)) + // New API excludes source row from both valid and invalid sets. + XCTAssertEqual(zones.invalidRowIndices, Set([0])) + XCTAssertEqual(Set(zones.validDropRows), Set([2, 3, 4, 5])) + XCTAssertFalse(zones.validDropRows.contains(1)) } func test_calculateTravelDragZones_respectsDayRange() { @@ -834,17 +832,21 @@ final class ItineraryReorderingLogicTests: XCTestCase { let segment = H.makeTravelSegment(from: "CityA", to: "CityB") let travelValidRanges = ["travel:0:citya->cityb": 1...3] + let travelItem = H.makeTravelItem(from: "CityA", to: "CityB", day: 2, sortOrder: 1.0) let zones = Logic.calculateTravelDragZones( segment: segment, + sourceRow: 3, flatItems: items, travelValidRanges: travelValidRanges, constraints: nil, - findTravelItem: { _ in nil } + findTravelItem: { _ in travelItem }, + makeTravelItem: { _ in travelItem }, + findTravelSortOrder: { _ in travelItem.sortOrder } ) - // All days 1-3 should be valid (6 rows total) - XCTAssertEqual(zones.validDropRows.count, 6) - XCTAssertTrue(zones.invalidRowIndices.isEmpty) + XCTAssertEqual(Set(zones.validDropRows), Set([1, 2, 4, 5, 6])) + XCTAssertEqual(zones.invalidRowIndices, Set([0])) + XCTAssertFalse(zones.validDropRows.contains(3)) } } diff --git a/SportsTimeTests/Features/Trip/ItineraryReorderingTests.swift b/SportsTimeTests/Features/Trip/ItineraryReorderingTests.swift index 46048ae..ede6af8 100644 --- a/SportsTimeTests/Features/Trip/ItineraryReorderingTests.swift +++ b/SportsTimeTests/Features/Trip/ItineraryReorderingTests.swift @@ -10,6 +10,7 @@ import XCTest private typealias H = ItineraryTestHelpers +@MainActor final class ItineraryReorderingTests: XCTestCase { private let testDate = H.testDate diff --git a/SportsTimeTests/Features/Trip/ItineraryRowFlatteningTests.swift b/SportsTimeTests/Features/Trip/ItineraryRowFlatteningTests.swift index 05b3606..9ed23f1 100644 --- a/SportsTimeTests/Features/Trip/ItineraryRowFlatteningTests.swift +++ b/SportsTimeTests/Features/Trip/ItineraryRowFlatteningTests.swift @@ -10,6 +10,7 @@ import XCTest private typealias H = ItineraryTestHelpers +@MainActor final class ItineraryRowFlatteningTests: XCTestCase { private let testDate = H.testDate diff --git a/SportsTimeTests/Features/Trip/ItinerarySortOrderTests.swift b/SportsTimeTests/Features/Trip/ItinerarySortOrderTests.swift index ee179b5..7656811 100644 --- a/SportsTimeTests/Features/Trip/ItinerarySortOrderTests.swift +++ b/SportsTimeTests/Features/Trip/ItinerarySortOrderTests.swift @@ -10,6 +10,7 @@ import XCTest private typealias H = ItineraryTestHelpers +@MainActor final class ItinerarySortOrderTests: XCTestCase { private let testDate = H.testDate diff --git a/SportsTimeTests/Features/Trip/ItineraryTravelConstraintTests.swift b/SportsTimeTests/Features/Trip/ItineraryTravelConstraintTests.swift index 3388669..6603553 100644 --- a/SportsTimeTests/Features/Trip/ItineraryTravelConstraintTests.swift +++ b/SportsTimeTests/Features/Trip/ItineraryTravelConstraintTests.swift @@ -10,6 +10,7 @@ import XCTest private typealias H = ItineraryTestHelpers +@MainActor final class ItineraryTravelConstraintTests: XCTestCase { private let testTripId = H.testTripId @@ -176,10 +177,6 @@ final class ItineraryTravelConstraintTests: XCTestCase { ) let controller = ItineraryTableViewController(style: .plain) - var capturedSortOrder: Double = 0 - controller.onTravelMoved = { _, _, sortOrder in - capturedSortOrder = sortOrder - } controller.reloadData( days: [dayData], travelValidRanges: ["travel:0:chicago->detroit": 1...1], diff --git a/SportsTimeTests/Features/Trip/RegionMapSelectorTests.swift b/SportsTimeTests/Features/Trip/RegionMapSelectorTests.swift index 7d8b6d2..89334d6 100644 --- a/SportsTimeTests/Features/Trip/RegionMapSelectorTests.swift +++ b/SportsTimeTests/Features/Trip/RegionMapSelectorTests.swift @@ -13,6 +13,7 @@ import Testing @testable import SportsTime @Suite("RegionMapSelector — regionForCoordinate") +@MainActor struct RegionMapSelectorTests { // MARK: - West Region (longitude < -102) diff --git a/SportsTimeTests/Features/Trip/TravelPlacementTests.swift b/SportsTimeTests/Features/Trip/TravelPlacementTests.swift index 53bc2e3..9beee4d 100644 --- a/SportsTimeTests/Features/Trip/TravelPlacementTests.swift +++ b/SportsTimeTests/Features/Trip/TravelPlacementTests.swift @@ -10,6 +10,7 @@ import XCTest @testable import SportsTime +@MainActor final class TravelPlacementTests: XCTestCase { // MARK: - Helpers @@ -417,14 +418,14 @@ final class TravelPlacementTests: XCTestCase { // Follow Team pattern: Houston→Cincinnati→Houston→Cincinnati // Two segments share the same city pair (Houston→Cincinnati) // Each must get a unique travel anchor ID so overrides don't collide. - let stops = [ + _ = [ makeStop(city: "Houston", arrival: may(1), departure: may(2)), // Stop 0 makeStop(city: "Cincinnati", arrival: may(4), departure: may(5)), // Stop 1 makeStop(city: "Houston", arrival: may(7), departure: may(8)), // Stop 2 makeStop(city: "Cincinnati", arrival: may(10), departure: may(11)) // Stop 3 ] - let segments = [ + _ = [ makeSegment(from: "Houston", to: "Cincinnati"), // Seg 0: stops[0] → stops[1] makeSegment(from: "Cincinnati", to: "Houston"), // Seg 1: stops[1] → stops[2] makeSegment(from: "Houston", to: "Cincinnati") // Seg 2: stops[2] → stops[3] diff --git a/SportsTimeTests/Planning/GameDAGRouterTests.swift b/SportsTimeTests/Planning/GameDAGRouterTests.swift index fdff2e7..97718c6 100644 --- a/SportsTimeTests/Planning/GameDAGRouterTests.swift +++ b/SportsTimeTests/Planning/GameDAGRouterTests.swift @@ -235,7 +235,7 @@ struct GameDAGRouterTests { let (game1, stadium1) = makeGameAndStadium(city: "New York", date: dates[0], coord: nycCoord) let (game2, stadium2) = makeGameAndStadium(city: "Boston", date: dates[1], coord: bostonCoord) - let (game3, stadium3) = makeGameAndStadium(city: "New York", date: dates[2], coord: nycCoord) // Same city + let (game3, _) = makeGameAndStadium(city: "New York", date: dates[2], coord: nycCoord) // Same city // Use same stadium ID for same city let nyStadium = stadium1 diff --git a/SportsTimeTests/Planning/PlanningPipelineBugRegressionTests.swift b/SportsTimeTests/Planning/PlanningPipelineBugRegressionTests.swift index dd68e64..0133176 100644 --- a/SportsTimeTests/Planning/PlanningPipelineBugRegressionTests.swift +++ b/SportsTimeTests/Planning/PlanningPipelineBugRegressionTests.swift @@ -73,7 +73,6 @@ struct Bug2_InfiniteLoopTests { @Test("calculateRestDays does not hang for normal multi-day stop") func calculateRestDays_normalMultiDay_terminates() { - let calendar = TestClock.calendar let arrival = TestFixtures.date(year: 2026, month: 6, day: 10, hour: 12) let departure = TestFixtures.date(year: 2026, month: 6, day: 14, hour: 12) diff --git a/SportsTimeTests/Planning/RouteFiltersTests.swift b/SportsTimeTests/Planning/RouteFiltersTests.swift index 3b2d98d..e5d5298 100644 --- a/SportsTimeTests/Planning/RouteFiltersTests.swift +++ b/SportsTimeTests/Planning/RouteFiltersTests.swift @@ -257,6 +257,17 @@ struct RouteFiltersTests { #expect(result.count == 1) } + @Test("filterByDateRange: inverted range bounds are normalized") + func filterByDateRange_invertedBounds_normalized() { + let dayAfterTomorrow = calendar.date(byAdding: .day, value: 2, to: today)! + let trip = makeTrip(startDate: tomorrow, endDate: dayAfterTomorrow) + + // Callers can accidentally pass (end, start); filter should still behave as expected. + let result = RouteFilters.filterByDateRange([trip], start: nextWeek, end: today) + + #expect(result.count == 1) + } + // MARK: - Specification Tests: filterByStatus @Test("filterByStatus: returns matching status only") @@ -369,15 +380,24 @@ struct RouteFiltersTests { // MARK: - Edge Case Tests - @Test("Edge: city names are case-sensitive") - func edge_cityNamesCaseSensitive() { + @Test("Edge: city names are normalized case-insensitively") + func edge_cityNamesCaseInsensitive() { let option = makeOption(stops: [ makeStop(city: "New York", date: today), makeStop(city: "new york", date: tomorrow) // Different case ]) - // Currently case-sensitive, so these are different cities - #expect(!RouteFilters.hasRepeatCityViolation(option)) + #expect(RouteFilters.hasRepeatCityViolation(option)) + } + + @Test("Edge: city names are normalized for punctuation and spacing") + func edge_cityNamesNormalizedForPunctuationAndSpacing() { + let option = makeOption(stops: [ + makeStop(city: "St. Louis", date: today), + makeStop(city: "st louis", date: tomorrow) + ]) + + #expect(RouteFilters.hasRepeatCityViolation(option)) } @Test("Edge: very long trip with many stops") diff --git a/SportsTimeTests/Services/PollServiceTests.swift b/SportsTimeTests/Services/PollServiceTests.swift index 07e4653..18c2d85 100644 --- a/SportsTimeTests/Services/PollServiceTests.swift +++ b/SportsTimeTests/Services/PollServiceTests.swift @@ -48,6 +48,14 @@ struct PollErrorTests { #expect(error.errorDescription!.lowercased().contains("owner")) } + /// - Expected Behavior: notVoteOwner explains vote ownership requirement + @Test("errorDescription: notVoteOwner mentions owner") + func errorDescription_notVoteOwner() { + let error = PollError.notVoteOwner + #expect(error.errorDescription != nil) + #expect(error.errorDescription!.lowercased().contains("owner")) + } + /// - Expected Behavior: networkUnavailable explains connection issue @Test("errorDescription: networkUnavailable mentions connection") func errorDescription_networkUnavailable() { @@ -83,6 +91,7 @@ struct PollErrorTests { .pollNotFound, .alreadyVoted, .notPollOwner, + .notVoteOwner, .networkUnavailable, .encodingError, .unknown(NSError(domain: "", code: 0)) @@ -102,6 +111,7 @@ struct PollErrorTests { .pollNotFound, .alreadyVoted, .notPollOwner, + .notVoteOwner, .networkUnavailable, .encodingError ] diff --git a/SportsTimeUITests/Framework/BaseUITestCase.swift b/SportsTimeUITests/Framework/BaseUITestCase.swift index 689b00e..8a61817 100644 --- a/SportsTimeUITests/Framework/BaseUITestCase.swift +++ b/SportsTimeUITests/Framework/BaseUITestCase.swift @@ -63,6 +63,7 @@ class BaseUITestCase: XCTestCase { // MARK: - Wait Helpers +@MainActor extension XCUIElement { /// Waits until the element exists, failing with a descriptive message. diff --git a/SportsTimeUITests/Framework/Screens.swift b/SportsTimeUITests/Framework/Screens.swift index 7a89a6f..ac116aa 100644 --- a/SportsTimeUITests/Framework/Screens.swift +++ b/SportsTimeUITests/Framework/Screens.swift @@ -11,6 +11,7 @@ import XCTest // MARK: - Home Screen +@MainActor struct HomeScreen { let app: XCUIApplication @@ -124,6 +125,7 @@ struct HomeScreen { // MARK: - Trip Wizard Screen +@MainActor struct TripWizardScreen { let app: XCUIApplication @@ -343,6 +345,7 @@ struct TripWizardScreen { // MARK: - Trip Options Screen +@MainActor struct TripOptionsScreen { let app: XCUIApplication @@ -397,6 +400,7 @@ struct TripOptionsScreen { // MARK: - Trip Detail Screen +@MainActor struct TripDetailScreen { let app: XCUIApplication @@ -508,6 +512,7 @@ struct TripDetailScreen { // MARK: - My Trips Screen +@MainActor struct MyTripsScreen { let app: XCUIApplication @@ -572,6 +577,7 @@ struct MyTripsScreen { // MARK: - Schedule Screen +@MainActor struct ScheduleScreen { let app: XCUIApplication @@ -612,6 +618,7 @@ struct ScheduleScreen { // MARK: - Settings Screen +@MainActor struct SettingsScreen { let app: XCUIApplication @@ -702,6 +709,7 @@ struct SettingsScreen { // MARK: - Progress Screen +@MainActor struct ProgressScreen { let app: XCUIApplication @@ -783,6 +791,7 @@ struct ProgressScreen { // MARK: - Stadium Visit Sheet Screen +@MainActor struct StadiumVisitSheetScreen { let app: XCUIApplication @@ -842,6 +851,7 @@ struct StadiumVisitSheetScreen { // MARK: - Quick Add Item Sheet Screen +@MainActor struct QuickAddItemSheetScreen { let app: XCUIApplication @@ -904,6 +914,7 @@ struct QuickAddItemSheetScreen { // MARK: - Games History Screen +@MainActor struct GamesHistoryScreen { let app: XCUIApplication @@ -931,6 +942,7 @@ struct GamesHistoryScreen { // MARK: - Polls Screen +@MainActor struct PollsScreen { let app: XCUIApplication @@ -980,6 +992,7 @@ struct PollsScreen { // MARK: - Paywall Screen +@MainActor struct PaywallScreen { let app: XCUIApplication @@ -1031,6 +1044,7 @@ struct PaywallScreen { /// Reusable multi-step flows that multiple tests share. /// Avoids duplicating the full wizard sequence across test files. +@MainActor enum TestFlows { /// Opens the wizard, plans a date-range trip (June 11-16 2026, MLB, Central), and diff --git a/SportsTimeUITests/SportsTimeUITests.swift b/SportsTimeUITests/SportsTimeUITests.swift index 5352d1b..8d43374 100644 --- a/SportsTimeUITests/SportsTimeUITests.swift +++ b/SportsTimeUITests/SportsTimeUITests.swift @@ -7,6 +7,7 @@ import XCTest +@MainActor final class SportsTimeUITests: XCTestCase { override func setUpWithError() throws {