Commit e7fbb03ed2

e7fbb03ed2305dbf1a1e764a99013e1f4471cd1d

parent: 56a1acaeb5

Unsigned

cmc <hello@cleberg.net> · 2026-08-07 06:44 UTC

Take inbox unread count off pull-to-refresh path (#9)

The inbox count paginates every thread of every subscribed list (~25s),
and .refreshable binds the spinner to loadDashboard returning, so pull
held for the whole time. Refresh the count in a cancellable background
task that updates the badge when it lands, so pull returns once the fast
branches settle. The widget / App Intents snapshot refresh, which builds
from a throwaway view model, opts back into awaiting inbox so its count
stays accurate.

Layout: unified · split

Hutch/App/AppState.swift +1 −1
@@ -623,7 +623,7 @@ final class AppState {
623623 defaults: accountDefaults,
624624 accountID: activeAccountID
625625 )
626 await viewModel.loadDashboard()
626 await viewModel.loadDashboard(awaitInboxUnread: true)
627627 }
628628
629629 private func clearWebData() async {
Hutch/Views/Home/HomeViewModel.swift +45 −6
@@ -199,6 +199,7 @@ final class HomeViewModel {
199199 private let projectService: ProjectService
200200 private let ticketFetchConcurrencyLimit = 6
201201 private let inboxUnreadConcurrencyLimit = 4
202 private var inboxRefreshTask: Task<Void, Never>?
202203
203204 private var currentUserKey: String {
204205 currentUser.canonicalName
@@ -348,7 +349,19 @@ final class HomeViewModel {
348349 /// `forceRefresh` bypasses the cache. Without it, a pull to refresh returns
349350 /// whatever is already cached and only schedules a background fetch, so new
350351 /// mail cannot show up on the first pull.
351 func loadDashboard(forceRefresh: Bool = false) async {
352 ///
353 /// The inbox unread count is deliberately kept off this method's critical
354 /// path: it paginates every thread of every subscribed list and costs
355 /// ~25s, which — because `.refreshable` binds the spinner to this method
356 /// returning — would hold the pull-to-refresh spinner for the whole time.
357 /// Instead it refreshes in a detached background task and updates the badge
358 /// when it lands, so the spinner returns as soon as the fast branches settle.
359 ///
360 /// Callers that build a persisted snapshot from a throwaway view model —
361 /// the widget / App Intents needs-attention refresh — cannot let the inbox
362 /// fetch outlive them, so they pass `awaitInboxUnread: true` to fold it back
363 /// onto the critical path and get an accurate count before this returns.
364 func loadDashboard(forceRefresh: Bool = false, awaitInboxUnread: Bool = false) async {
352365 isLoadingProjects = true
353366 isLoadingAssignedTickets = true
354367 isLoadingRecentBuilds = true
@@ -362,7 +375,6 @@ final class HomeViewModel {
362375 async let projectsTask = loadProjects(forceRefresh: forceRefresh)
363376 async let jobsTask = loadRecentJobs(forceRefresh: forceRefresh)
364377 async let assignedTicketsTask = loadAssignedTickets(forceRefresh: forceRefresh)
365 async let inboxUnreadTask = loadInboxUnreadSnapshot(forceRefresh: forceRefresh)
366378 async let systemStatusTask = loadSystemStatusSnapshot(forceRefresh: forceRefresh)
367379
368380 // Resolve system status first so the Home title-bar status badge can
@@ -413,13 +425,40 @@ final class HomeViewModel {
413425 }
414426 isLoadingAssignedTickets = false
415427
416 let inboxUnreadSnapshot = await inboxUnreadTask
417 unreadInboxThreadCount = inboxUnreadSnapshot?.unreadCount
418 unreadInboxThreads = inboxUnreadSnapshot?.threads ?? []
419 hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0
420428 lastRefreshed = Date()
421429 persistNeedsAttentionSnapshot()
422430 persistSystemStatusWidgetSnapshot()
431
432 if awaitInboxUnread {
433 inboxRefreshTask?.cancel()
434 inboxRefreshTask = nil
435 applyInboxUnreadSnapshot(await loadInboxUnreadSnapshot(forceRefresh: forceRefresh))
436 } else {
437 refreshInboxUnreadInBackground(forceRefresh: forceRefresh)
438 }
439 }
440
441 /// Refreshes the inbox unread badge without blocking `loadDashboard`.
442 ///
443 /// Any in-flight refresh is cancelled so overlapping pulls don't stack
444 /// ~25s fetches. On cancellation the snapshot loader returns `nil`; the
445 /// early `Task.isCancelled` check then leaves the existing badge untouched
446 /// rather than wiping it, so a superseding refresh owns the final value.
447 private func refreshInboxUnreadInBackground(forceRefresh: Bool) {
448 inboxRefreshTask?.cancel()
449 inboxRefreshTask = Task { [weak self] in
450 guard let self else { return }
451 let snapshot = await self.loadInboxUnreadSnapshot(forceRefresh: forceRefresh)
452 guard !Task.isCancelled else { return }
453 self.applyInboxUnreadSnapshot(snapshot)
454 }
455 }
456
457 private func applyInboxUnreadSnapshot(_ snapshot: HomeInboxUnreadSnapshot?) {
458 unreadInboxThreadCount = snapshot?.unreadCount
459 unreadInboxThreads = snapshot?.threads ?? []
460 hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0
461 persistNeedsAttentionSnapshot()
423462 }
424463
425464 /// Returns true if sufficient time has elapsed since the last dashboard refresh.