import Foundation import ManaCore import Observation import SwiftData /// Persistente Offline-Queue für Grade-Aktionen. Drain-Loop kann /// vom UI ausgelöst werden (bei Reconnect oder App-Foreground). @MainActor @Observable final class GradeQueue { private(set) var isDraining = false private(set) var lastDrainError: String? private let api: CardsAPI private let context: ModelContext init(api: CardsAPI, context: ModelContext) { self.api = api self.context = context } /// Enqueue + sofort versuchen zu senden. Bei Fehler bleibt der /// Eintrag in der Queue. func submit(cardId: String, subIndex: Int, rating: Rating, reviewedAt: Date = .now) async { let grade = PendingGrade( cardId: cardId, subIndex: subIndex, rating: rating, reviewedAt: reviewedAt ) context.insert(grade) try? context.save() Log.study.info( "Queued grade for \(cardId, privacy: .public)/\(subIndex, privacy: .public): \(rating.rawValue, privacy: .public)" ) await drain() } /// Schickt alle pending grades in FIFO-Reihenfolge ab. Bei Server- /// Erfolg: aus Queue löschen. Bei Netzfehler: Loop abbrechen /// (kommender Drain probiert es nochmal). func drain() async { guard !isDraining else { return } isDraining = true defer { isDraining = false } let descriptor = FetchDescriptor( sortBy: [SortDescriptor(\.queuedAt, order: .forward)] ) let pending = (try? context.fetch(descriptor)) ?? [] guard !pending.isEmpty else { lastDrainError = nil return } for grade in pending { guard let rating = grade.rating else { context.delete(grade) continue } do { _ = try await api.gradeReview( cardId: grade.cardId, subIndex: grade.subIndex, rating: rating, reviewedAt: grade.reviewedAt ) context.delete(grade) try? context.save() } catch { let msg = (error as? LocalizedError)?.errorDescription ?? String(describing: error) grade.lastTryAt = .now grade.lastError = msg try? context.save() lastDrainError = msg Log.study.notice( "Drain stopped for \(grade.cardId, privacy: .public)/\(grade.subIndex, privacy: .public): \(msg, privacy: .public)" ) return } } lastDrainError = nil Log.study.info("Drain complete") } /// Wie viele Grades hängen aktuell offline? func pendingCount() -> Int { let descriptor = FetchDescriptor() return (try? context.fetchCount(descriptor)) ?? 0 } }