Stabilize beta release with warning cleanup and edge-case fixes
This commit is contained in:
@@ -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
|
||||
//
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import SwiftUI
|
||||
|
||||
// MARK: - Identifiable Share Code
|
||||
|
||||
struct IdentifiableShareCode: Identifiable {
|
||||
struct IdentifiableShareCode: Identifiable, Hashable {
|
||||
let id: String
|
||||
var value: String { id }
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +75,7 @@ final class StoreManager {
|
||||
|
||||
// MARK: - Transaction Listener
|
||||
|
||||
@ObservationIgnored
|
||||
nonisolated(unsafe) private var transactionListenerTask: Task<Void, Never>?
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user