import Foundation /// Handles optimistic UI updates — mutations happen locally first, /// then get pushed to the server with automatic retry. /// /// Example: starring a song updates the local cache immediately, /// then queues the API call. If the API fails (offline), it retries /// on next sync or when connectivity returns. class OptimisticActionQueue: ObservableObject { static let shared = OptimisticActionQueue() /// Pending actions that haven't been confirmed by the server @Published var pendingCount: Int = 0 private var pendingActions: [PendingAction] = [] private let storageKey = "optimistic_pending_actions" private var retryTask: Task? private init() { loadPending() } // MARK: - Star / Unstar /// Star a song optimistically — updates cache immediately, queues API call func star(songId: String) { queueAction(.star(id: songId, type: .song)) DebugLogger.shared.log("Optimistic: starred song \(songId)", category: "Sync") } func unstar(songId: String) { // Check if there's a pending star for this — just remove it instead if removePending(matching: .star(id: songId, type: .song)) { DebugLogger.shared.log("Optimistic: cancelled pending star for \(songId)", category: "Sync") return } queueAction(.unstar(id: songId, type: .song)) DebugLogger.shared.log("Optimistic: unstarred song \(songId)", category: "Sync") } func starAlbum(albumId: String) { queueAction(.star(id: albumId, type: .album)) } func unstarAlbum(albumId: String) { if removePending(matching: .star(id: albumId, type: .album)) { return } queueAction(.unstar(id: albumId, type: .album)) } // MARK: - Scrobble func scrobble(songId: String) { queueAction(.scrobble(id: songId, timestamp: Int64(Date().timeIntervalSince1970 * 1000))) } // MARK: - Process Queue /// Try to flush all pending actions to the server. /// Call this on app launch, on connectivity change, or after sync. func flush() { guard !pendingActions.isEmpty else { return } retryTask?.cancel() // @MainActor ensures pendingActions is only touched on the main thread — // eliminates the data race between queueAction (main) and the flush Task (pool). retryTask = Task { @MainActor [weak self] in guard let self else { return } let actions = self.pendingActions var remaining: [PendingAction] = [] for action in actions { do { try await self.execute(action) DebugLogger.shared.log("Flushed: \(action.description)", category: "Sync") } catch { if action.retryCount < 5 { var retry = action retry.retryCount += 1 remaining.append(retry) } else { DebugLogger.shared.log("Dropped after 5 retries: \(action.description)", category: "Sync") } } } self.pendingActions = remaining self.pendingCount = remaining.count self.savePending() } } // MARK: - Execution private func execute(_ action: PendingAction) async throws { let client = ServerManager.shared.client switch action.kind { case .star(let id, let type): switch type { case .song: try await client.star(id: id) case .album: try await client.star(albumId: id) case .artist: try await client.star(artistId: id) } case .unstar(let id, let type): switch type { case .song: try await client.unstar(id: id) case .album: try await client.unstar(albumId: id) case .artist: try await client.unstar(artistId: id) } case .scrobble(let id, let timestamp): try await client.scrobble(id: id, time: timestamp) } } // MARK: - Queue Management private func queueAction(_ kind: ActionKind) { let action = PendingAction(kind: kind, createdAt: Date(), retryCount: 0) pendingActions.append(action) pendingCount = pendingActions.count savePending() // Try immediately flush() } private func removePending(matching kind: ActionKind) -> Bool { if let idx = pendingActions.firstIndex(where: { $0.kind == kind }) { pendingActions.remove(at: idx) pendingCount = pendingActions.count savePending() return true } return false } // MARK: - Persistence private func savePending() { if let data = try? JSONEncoder().encode(pendingActions) { UserDefaults.standard.set(data, forKey: storageKey) } } private func loadPending() { if let data = UserDefaults.standard.data(forKey: storageKey), let actions = try? JSONDecoder().decode([PendingAction].self, from: data) { pendingActions = actions pendingCount = actions.count } } } // MARK: - Models struct PendingAction: Codable { let kind: ActionKind let createdAt: Date var retryCount: Int var description: String { switch kind { case .star(let id, let type): return "star \(type.rawValue) \(id)" case .unstar(let id, let type): return "unstar \(type.rawValue) \(id)" case .scrobble(let id, _): return "scrobble \(id)" } } } enum ActionKind: Codable, Equatable { case star(id: String, type: ItemType) case unstar(id: String, type: ItemType) case scrobble(id: String, timestamp: Int64) enum ItemType: String, Codable { case song, album, artist } }