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()
|
||||
|
||||
Reference in New Issue
Block a user