05/15 Update: inspector issue view

This commit is contained in:
Nguyen Ngo
2026-05-15 16:02:24 -04:00
parent 7320e88dec
commit 1b90c23b3e
3 changed files with 79 additions and 22 deletions
+44 -16
View File
@@ -33,8 +33,8 @@ class SyncManager: ObservableObject {
// poll only retrieves newer records. Nil on first launch server returns
// last 50 unread. Reset to nil on logout.
private var lastNotificationFetch: Date?
private var pollTimer: Timer?
private let pollInterval: TimeInterval = 60 // seconds
private var pollTask: Task<Void, Never>? // replaces Timer Task.sleep works correctly
private let pollInterval: UInt64 = 60_000_000_000 // 60 seconds in nanoseconds
static let shared = SyncManager()
private init() {}
@@ -51,36 +51,45 @@ class SyncManager: ObservableObject {
if wasOffline {
await self.triggerSync()
}
self.startPollTimer()
self.startPollTask()
} else {
self.stopPollTimer()
self.stopPollTask()
}
}
}
monitor.start(queue: monitorQueue)
}
// Notification poll timer
// Notification poll task
// Timer.scheduledTimer requires RunLoop.main to be ticking. When called
// from inside a Swift Concurrency Task { @MainActor } the current RunLoop
// is NOT RunLoop.main the timer is added to a runloop that never runs,
// so it silently fires never. Task + Task.sleep has no such dependency.
private func startPollTimer() {
guard pollTimer == nil else { return } // already running
pollTimer = Timer.scheduledTimer(withTimeInterval: pollInterval, repeats: true) { [weak self] _ in
Task { @MainActor [weak self] in
guard let self, self.isOnline, AuthManager.shared.isAuthenticated else { return }
await self.pollNotifications()
private func startPollTask() {
guard pollTask == nil else { return } // already running
pollTask = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 60_000_000_000)
guard !Task.isCancelled else { break }
await MainActor.run { [weak self] in
guard let self, self.isOnline,
AuthManager.shared.isAuthenticated else { return }
Task { await self.pollNotifications() }
}
}
}
}
private func stopPollTimer() {
pollTimer?.invalidate()
pollTimer = nil
private func stopPollTask() {
pollTask?.cancel()
pollTask = nil
}
/// Called on logout so the next login starts a clean fetch.
func resetNotificationPoller() {
lastNotificationFetch = nil
stopPollTimer()
stopPollTask()
}
// Notification polling
@@ -405,7 +414,8 @@ class SyncManager: ObservableObject {
guard isOnline, AuthManager.shared.isAuthenticated else { return }
do {
let apiIssues = try await APIClient.shared.fetchAssignedIssues()
guard !apiIssues.isEmpty else { return }
// Do not return early on empty deletion still needs to run
// to remove issues that were unassigned from this inspector.
// Build a map of existing LocalIssues by serverId for upsert
let allLocal = (try? context.fetch(FetchDescriptor<LocalIssue>())) ?? []
@@ -455,6 +465,24 @@ class SyncManager: ObservableObject {
context.insert(local)
}
}
// Remove server-pulled records that are no longer in the response.
// This happens when an issue is reassigned to a different inspector
// the server stops returning it for this user, so the local copy must
// be deleted. Only remove records that were pulled from the server
// (syncStatus == "synced" AND serverId != nil AND inspectionLocalId == "").
// Device-created issues (inspectionLocalId != "") are never touched.
let returnedServerIds = Set(apiIssues.map { $0.id })
for local in allLocal {
guard let sid = local.serverId,
local.syncStatus == "synced",
local.inspectionLocalId == ""
else { continue }
if !returnedServerIds.contains(sid) {
context.delete(local)
}
}
try? context.save()
} catch APIError.notAuthenticated {