Files
Sportstime/SportsTime/Core/Services/ItineraryItemService.swift
Trey t 5511e07538 fix: 13 audit fixes — memory, concurrency, performance, accessibility
Critical:
- ProgressViewModel: use single stored ModelContext instead of creating
  new ones per operation (deleteVisit silently no-op'd)
- ProgressViewModel: convert expensive computed properties to stored
  with explicit recompute after mutations (3x recomputation per render)

Memory:
- AnimatedSportsIcon: replace recursive GCD asyncAfter with Task loop,
  cancelled in onDisappear (19 unkillable timer chains)
- ItineraryItemService: remove [weak self] from actor Task (semantically
  wrong, silently drops flushPendingUpdates)
- VisitPhotoService: remove [weak self] from @MainActor Task closures

Concurrency:
- StoreManager: replace nested MainActor.run{Task{}} with direct await
  in listenForTransactions (fire-and-forget race)
- VisitPhotoService: move JPEG encoding/file writing off MainActor via
  nonisolated static helper + Task.detached
- SportsIconImageGenerator: replace GCD dispatch with Task.detached for
  structured concurrency compliance

Performance:
- Game/RichGame: cache DateFormatters as static lets instead of
  allocating per-call (hundreds of allocations in schedule view)
- TripDetailView: wrap ~10 routeWaypoints print() in #if DEBUG, remove
  2 let _ = print() from TripMapView.body (fires every render)

Accessibility:
- GameRow: add combined VoiceOver label (was reading abbreviations
  letter-by-letter)
- Sport badges: add accessibilityLabel to prevent SF symbol name readout
- SportsTimeApp: post UIAccessibility.screenChanged after bootstrap
  completes so VoiceOver users know app is ready

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 22:09:06 -06:00

219 lines
7.4 KiB
Swift

import Foundation
import CloudKit
/// Service for persisting and syncing ItineraryItems to CloudKit
actor ItineraryItemService {
static let shared = ItineraryItemService()
private let container = CloudKitContainerConfig.makeContainer()
private var database: CKDatabase { container.privateCloudDatabase }
private let recordType = "ItineraryItem"
// Debounce tracking
private var pendingUpdates: [UUID: ItineraryItem] = [:]
private var retryCount: [UUID: Int] = [:]
private var debounceTask: Task<Void, Never>?
private let maxRetries = 3
private let debounceInterval: Duration = .seconds(1.5)
private init() {}
/// Cancel any pending debounce task (for cleanup)
func cancelPendingSync() {
debounceTask?.cancel()
debounceTask = nil
}
// MARK: - CRUD Operations
/// Fetch all items for a trip
func fetchItems(forTripId tripId: UUID) async throws -> [ItineraryItem] {
let predicate = NSPredicate(format: "tripId == %@", tripId.uuidString)
let query = CKQuery(recordType: recordType, predicate: predicate)
let (results, _) = try await database.records(matching: query)
return results.compactMap { _, result in
guard case .success(let record) = result else { return nil }
return ItineraryItem(from: record)
}
}
/// Create a new item
func createItem(_ item: ItineraryItem) async throws -> ItineraryItem {
let record = item.toCKRecord()
let savedRecord = try await database.save(record)
return ItineraryItem(from: savedRecord) ?? item
}
/// Update an existing item (debounced)
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()
}
}
/// Force immediate sync of pending updates
func flushPendingUpdates() async {
let updates = pendingUpdates
pendingUpdates.removeAll()
guard !updates.isEmpty else { return }
let records = updates.values.map { $0.toCKRecord() }
do {
// Use .changedKeys save policy - CloudKit handles conflicts automatically
_ = try await database.modifyRecords(saving: records, deleting: [], savePolicy: .changedKeys)
// Success - clear retry counts for saved items
for item in updates.values {
retryCount[item.id] = nil
}
} catch {
// Add back to pending for retry, respecting max retry limit
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
}
}
}
/// Delete an item
func deleteItem(_ itemId: UUID) async throws {
let recordId = CKRecord.ID(recordName: itemId.uuidString)
try await database.deleteRecord(withID: recordId)
}
/// Delete all items for a trip
func deleteItems(forTripId tripId: UUID) async throws {
let items = try await fetchItems(forTripId: tripId)
for item in items {
let recordId = CKRecord.ID(recordName: item.id.uuidString)
try? await database.deleteRecord(withID: recordId)
}
}
}
// MARK: - CloudKit Conversion
extension ItineraryItem {
init?(from record: CKRecord) {
guard let idString = record["itemId"] as? String,
let id = UUID(uuidString: idString),
let tripIdString = record["tripId"] as? String,
let tripId = UUID(uuidString: tripIdString),
let day = record["day"] as? Int,
let sortOrder = record["sortOrder"] as? Double,
let kindString = record["kind"] as? String,
let modifiedAt = record["modifiedAt"] as? Date else {
return nil
}
self.id = id
self.tripId = tripId
self.day = day
self.sortOrder = sortOrder
self.modifiedAt = modifiedAt
// Parse kind
switch kindString {
case "game":
guard let gameId = record["gameId"] as? String,
let gameCity = record["gameCity"] as? String else { return nil }
self.kind = .game(gameId: gameId, city: gameCity)
case "travel":
guard let fromCity = record["travelFromCity"] as? String,
let toCity = record["travelToCity"] as? String else { return nil }
let info = TravelInfo(
fromCity: fromCity,
toCity: toCity,
segmentIndex: record["travelSegmentIndex"] as? Int,
distanceMeters: record["travelDistanceMeters"] as? Double,
durationSeconds: record["travelDurationSeconds"] as? Double
)
self.kind = .travel(info)
case "custom":
guard let title = record["customTitle"] as? String,
let icon = record["customIcon"] as? String else { return nil }
let info = CustomInfo(
title: title,
icon: icon,
time: record["customTime"] as? Date,
latitude: record["latitude"] as? Double,
longitude: record["longitude"] as? Double,
address: record["address"] as? String
)
self.kind = .custom(info)
default:
return nil
}
}
func toCKRecord() -> CKRecord {
let recordId = CKRecord.ID(recordName: id.uuidString)
let record = CKRecord(recordType: "ItineraryItem", recordID: recordId)
record["itemId"] = id.uuidString
record["tripId"] = tripId.uuidString
record["day"] = day
record["sortOrder"] = sortOrder
record["modifiedAt"] = modifiedAt
switch kind {
case .game(let gameId, let city):
record["kind"] = "game"
record["gameId"] = gameId
record["gameCity"] = city
case .travel(let info):
record["kind"] = "travel"
record["travelFromCity"] = info.fromCity
record["travelToCity"] = info.toCity
record["travelSegmentIndex"] = info.segmentIndex
record["travelDistanceMeters"] = info.distanceMeters
record["travelDurationSeconds"] = info.durationSeconds
case .custom(let info):
record["kind"] = "custom"
record["customTitle"] = info.title
record["customIcon"] = info.icon
record["customTime"] = info.time
record["latitude"] = info.latitude
record["longitude"] = info.longitude
record["address"] = info.address
}
return record
}
}