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

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

View File

@@ -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<Int>()
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

View File

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

View File

@@ -227,7 +227,7 @@ final class AchievementEngine {
}
private func checkDivisionComplete(_ divisionId: String, visitedStadiumIds: Set<String>) -> 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<String>) -> 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)

View File

@@ -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..<min(start + size, items.count)])
}
}
private static func makeMapItem(name: String, coordinate: CLLocationCoordinate2D) -> 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
}
}

View File

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

View File

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

View File

@@ -10,7 +10,7 @@ import SwiftUI
// MARK: - Identifiable Share Code
struct IdentifiableShareCode: Identifiable {
struct IdentifiableShareCode: Identifiable, Hashable {
let id: String
var value: String { id }
}

View File

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

View File

@@ -13,7 +13,7 @@ actor ItineraryItemService {
// Debounce tracking
private var pendingUpdates: [UUID: ItineraryItem] = [:]
private var retryCount: [UUID: Int] = [:]
private var debounceTask: Task<Void, Never>?
private var flushTask: Task<Void, Never>?
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)
}
}
}

View File

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

View File

@@ -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<CKSubscription.ID> = []
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)
}
}

View File

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

View File

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

View File

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

View File

@@ -75,6 +75,7 @@ final class StoreManager {
// MARK: - Transaction Listener
@ObservationIgnored
nonisolated(unsafe) private var transactionListenerTask: Task<Void, Never>?
// MARK: - Initialization

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<SavedTrip>())
}
} catch {
self.error = error

View File

@@ -16,8 +16,8 @@ final class ScheduleViewModel {
// MARK: - Filter State
var selectedSports: Set<Sport> = 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<Void, Never>?
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

View File

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

View File

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

View File

@@ -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: {

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,

View File

@@ -58,7 +58,8 @@ enum RouteFilters {
var cityDays: [String: Set<Date>] = [:]
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<Date>] = [:]
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)

View File

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

View File

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

View File

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