Commit 17d0f6db92

17d0f6db9291ee9edd1372155dee6d87bce91bf4

parent: 53ade2af0a

Unsigned

cmc <hello@cleberg.net> · 2026-07-16 03:51 UTC

fix: let pull to refresh actually refresh Home

New mail showed as unread inside a mailing list but not on Home.

The list view reads threads with client.execute, so it always sees the server.
Home reads them with executeCached under cacheFirstThenRefresh, which returns
the cached value and only schedules a background fetch. loadDashboard had no way
to bypass that, so pulling to refresh could not surface mail that arrived after
the last fetch — it returned the same cache it already had and Home stayed a
cycle behind.

Thread forceRefresh through loadDashboard to the cached reads, and use it from
the pull to refresh on Home and Work. Matches loadSystemStatus(forceRefresh:),
which MoreView already refreshes this way.

The staleness predates the unread baseline. It was invisible while every thread
counted as unread, because Home always had a large number to show; now that Home
correctly ignores mail older than sign-in, a stale cache reads as a truthful 0.

Layout: unified · split

Hutch/Views/Home/HomeView.swift +1 −1
@@ -74,7 +74,7 @@ struct HomeView: View {
74 .listStyle(.insetGrouped) 74 .listStyle(.insetGrouped)
75 .listSectionSpacing(.compact) 75 .listSectionSpacing(.compact)
76 .refreshable { 76 .refreshable {
77 await viewModel.loadDashboard() 77 await viewModel.loadDashboard(forceRefresh: true)
78 } 78 }
79 .connectivityOverlay(hasContent: hasHomeContent(viewModel)) { 79 .connectivityOverlay(hasContent: hasHomeContent(viewModel)) {
80 await viewModel.loadDashboard() 80 await viewModel.loadDashboard()
Hutch/Views/Home/HomeViewModel.swift +33 −28
@@ -343,7 +343,12 @@ final class HomeViewModel {
343 self.accountID = accountID 343 self.accountID = accountID
344 } 344 }
345 345
346 func loadDashboard() async { 346 /// Loads the dashboard.
347 ///
348 /// `forceRefresh` bypasses the cache. Without it, a pull to refresh returns
349 /// whatever is already cached and only schedules a background fetch, so new
350 /// mail cannot show up on the first pull.
351 func loadDashboard(forceRefresh: Bool = false) async {
347 isLoadingProjects = true 352 isLoadingProjects = true
348 isLoadingAssignedTickets = true 353 isLoadingAssignedTickets = true
349 isLoadingRecentBuilds = true 354 isLoadingRecentBuilds = true
@@ -354,11 +359,11 @@ final class HomeViewModel {
354 isShowingStaleSystemStatus = false 359 isShowingStaleSystemStatus = false
355 systemStatusErrorMessage = nil 360 systemStatusErrorMessage = nil
356 361
357 async let projectsTask = loadProjects() 362 async let projectsTask = loadProjects(forceRefresh: forceRefresh)
358 async let jobsTask = loadRecentJobs() 363 async let jobsTask = loadRecentJobs(forceRefresh: forceRefresh)
359 async let assignedTicketsTask = loadAssignedTickets() 364 async let assignedTicketsTask = loadAssignedTickets(forceRefresh: forceRefresh)
360 async let inboxUnreadTask = loadInboxUnreadSnapshot() 365 async let inboxUnreadTask = loadInboxUnreadSnapshot(forceRefresh: forceRefresh)
361 async let systemStatusTask = loadSystemStatusSnapshot() 366 async let systemStatusTask = loadSystemStatusSnapshot(forceRefresh: forceRefresh)
362 367
363 let projectsResult = await projectsTask 368 let projectsResult = await projectsTask
364 switch projectsResult { 369 switch projectsResult {
@@ -676,7 +681,7 @@ final class HomeViewModel {
676 persistNeedsAttentionSnapshot() 681 persistNeedsAttentionSnapshot()
677 } 682 }
678 683
679 private func loadProjects() async -> Result<[Project], Error> { 684 private func loadProjects(forceRefresh: Bool) async -> Result<[Project], Error> {
680 do { 685 do {
681 return .success(try await projectService.fetchProjects()) 686 return .success(try await projectService.fetchProjects())
682 } catch { 687 } catch {
@@ -684,7 +689,7 @@ final class HomeViewModel {
684 } 689 }
685 } 690 }
686 691
687 private func loadRecentJobs() async -> Result<[HomeJobPayload], Error> { 692 private func loadRecentJobs(forceRefresh: Bool) async -> Result<[HomeJobPayload], Error> {
688 do { 693 do {
689 let cached = try await client.executeCached( 694 let cached = try await client.executeCached(
690 service: .builds, 695 service: .builds,
@@ -693,7 +698,7 @@ final class HomeViewModel {
693 cacheKey: APICacheKeys.homeJobs(actor: currentUser.canonicalName), 698 cacheKey: APICacheKeys.homeJobs(actor: currentUser.canonicalName),
694 resourceType: .buildList, 699 resourceType: .buildList,
695 ttl: APICacheTTLs.homeDashboard, 700 ttl: APICacheTTLs.homeDashboard,
696 policy: .cacheFirstThenRefresh 701 policy: forceRefresh ? .refreshIgnoringCache : .cacheFirstThenRefresh
697 ) 702 )
698 return .success(cached.value.jobs.results) 703 return .success(cached.value.jobs.results)
699 } catch { 704 } catch {
@@ -701,15 +706,15 @@ final class HomeViewModel {
701 } 706 }
702 } 707 }
703 708
704 private func loadInboxUnreadSnapshot() async -> HomeInboxUnreadSnapshot? { 709 private func loadInboxUnreadSnapshot(forceRefresh: Bool) async -> HomeInboxUnreadSnapshot? {
705 do { 710 do {
706 return try await fetchUnreadInboxSnapshot() 711 return try await fetchUnreadInboxSnapshot(forceRefresh: forceRefresh)
707 } catch { 712 } catch {
708 return nil 713 return nil
709 } 714 }
710 } 715 }
711 716
712 private func loadSystemStatusSnapshot() async -> Result<CachedSystemStatusValue<SystemStatusSnapshot>, Error> { 717 private func loadSystemStatusSnapshot(forceRefresh: Bool) async -> Result<CachedSystemStatusValue<SystemStatusSnapshot>, Error> {
713 do { 718 do {
714 return .success(try await systemStatusRepository.snapshotResult()) 719 return .success(try await systemStatusRepository.snapshotResult())
715 } catch { 720 } catch {
@@ -717,8 +722,8 @@ final class HomeViewModel {
717 } 722 }
718 } 723 }
719 724
720 private func fetchUnreadInboxSnapshot() async throws -> HomeInboxUnreadSnapshot { 725 private func fetchUnreadInboxSnapshot(forceRefresh: Bool) async throws -> HomeInboxUnreadSnapshot {
721 let mailingLists = try await fetchInboxMailingLists() 726 let mailingLists = try await fetchInboxMailingLists(forceRefresh: forceRefresh)
722 guard !mailingLists.isEmpty else { return HomeInboxUnreadSnapshot(unreadCount: 0, threads: []) } 727 guard !mailingLists.isEmpty else { return HomeInboxUnreadSnapshot(unreadCount: 0, threads: []) }
723 728
724 var startIndex = mailingLists.startIndex 729 var startIndex = mailingLists.startIndex
@@ -737,7 +742,7 @@ final class HomeViewModel {
737 for mailingList in batch { 742 for mailingList in batch {
738 group.addTask { 743 group.addTask {
739 do { 744 do {
740 return .success(try await self.fetchUnreadThreadSnapshot(for: mailingList)) 745 return .success(try await self.fetchUnreadThreadSnapshot(for: mailingList, forceRefresh: forceRefresh))
741 } catch { 746 } catch {
742 return .failure(error) 747 return .failure(error)
743 } 748 }
@@ -776,7 +781,7 @@ final class HomeViewModel {
776 ) 781 )
777 } 782 }
778 783
779 private func fetchInboxMailingLists() async throws -> [InboxMailingListReference] { 784 private func fetchInboxMailingLists(forceRefresh: Bool) async throws -> [InboxMailingListReference] {
780 var subscriptions: [HomeInboxSubscription] = [] 785 var subscriptions: [HomeInboxSubscription] = []
781 var cursor: String? 786 var cursor: String?
782 787
@@ -794,7 +799,7 @@ final class HomeViewModel {
794 cacheKey: APICacheKeys.inboxSubscriptions(cursor: cursor), 799 cacheKey: APICacheKeys.inboxSubscriptions(cursor: cursor),
795 resourceType: .ticketList, 800 resourceType: .ticketList,
796 ttl: APICacheTTLs.inboxSummary, 801 ttl: APICacheTTLs.inboxSummary,
797 policy: .cacheFirstThenRefresh 802 policy: forceRefresh ? .refreshIgnoringCache : .cacheFirstThenRefresh
798 ) 803 )
799 let response = cached.value 804 let response = cached.value
800 805
@@ -809,7 +814,7 @@ final class HomeViewModel {
809 return subscriptions.compactMap(\.list).filter { seen.insert($0.rid).inserted } 814 return subscriptions.compactMap(\.list).filter { seen.insert($0.rid).inserted }
810 } 815 }
811 816
812 private func fetchUnreadThreadSnapshot(for mailingList: InboxMailingListReference) async throws -> HomeInboxUnreadSnapshot { 817 private func fetchUnreadThreadSnapshot(for mailingList: InboxMailingListReference, forceRefresh: Bool) async throws -> HomeInboxUnreadSnapshot {
813 var unreadCount = 0 818 var unreadCount = 0
814 var cursor: String? 819 var cursor: String?
815 var unreadThreads: [InboxThreadSummary] = [] 820 var unreadThreads: [InboxThreadSummary] = []
@@ -828,7 +833,7 @@ final class HomeViewModel {
828 cacheKey: APICacheKeys.inboxThreads(listRid: mailingList.rid, cursor: cursor), 833 cacheKey: APICacheKeys.inboxThreads(listRid: mailingList.rid, cursor: cursor),
829 resourceType: .ticketList, 834 resourceType: .ticketList,
830 ttl: APICacheTTLs.inboxSummary, 835 ttl: APICacheTTLs.inboxSummary,
831 policy: .cacheFirstThenRefresh 836 policy: forceRefresh ? .refreshIgnoringCache : .cacheFirstThenRefresh
832 ) 837 )
833 let response = cached.value 838 let response = cached.value
834 839
@@ -871,10 +876,10 @@ final class HomeViewModel {
871 ) 876 )
872 } 877 }
873 878
874 private func loadAssignedTickets() async -> Result<[HomeAssignedTicket], Error> { 879 private func loadAssignedTickets(forceRefresh: Bool) async -> Result<[HomeAssignedTicket], Error> {
875 do { 880 do {
876 let trackers = try await fetchAllTrackers() 881 let trackers = try await fetchAllTrackers(forceRefresh: forceRefresh)
877 let tickets = try await fetchAssignedTickets(for: trackers) 882 let tickets = try await fetchAssignedTickets(for: trackers, forceRefresh: forceRefresh)
878 .sorted(by: Self.sortAssignedTicketsForTriage) 883 .sorted(by: Self.sortAssignedTicketsForTriage)
879 return .success(tickets) 884 return .success(tickets)
880 } catch { 885 } catch {
@@ -882,7 +887,7 @@ final class HomeViewModel {
882 } 887 }
883 } 888 }
884 889
885 private func fetchAllTrackers() async throws -> [TrackerSummary] { 890 private func fetchAllTrackers(forceRefresh: Bool) async throws -> [TrackerSummary] {
886 var allTrackers: [TrackerSummary] = [] 891 var allTrackers: [TrackerSummary] = []
887 var cursor: String? 892 var cursor: String?
888 893
@@ -900,7 +905,7 @@ final class HomeViewModel {
900 cacheKey: APICacheKeys.trackers(cursor: cursor), 905 cacheKey: APICacheKeys.trackers(cursor: cursor),
901 resourceType: .ticketList, 906 resourceType: .ticketList,
902 ttl: APICacheTTLs.ticketList, 907 ttl: APICacheTTLs.ticketList,
903 policy: .cacheFirstThenRefresh 908 policy: forceRefresh ? .refreshIgnoringCache : .cacheFirstThenRefresh
904 ) 909 )
905 let response = cached.value 910 let response = cached.value
906 911
@@ -914,7 +919,7 @@ final class HomeViewModel {
914 return allTrackers 919 return allTrackers
915 } 920 }
916 921
917 private func fetchAssignedTickets(for trackers: [TrackerSummary]) async throws -> [HomeAssignedTicket] { 922 private func fetchAssignedTickets(for trackers: [TrackerSummary], forceRefresh: Bool) async throws -> [HomeAssignedTicket] {
918 guard !trackers.isEmpty else { return [] } 923 guard !trackers.isEmpty else { return [] }
919 924
920 var assignedTickets: [HomeAssignedTicket] = [] 925 var assignedTickets: [HomeAssignedTicket] = []
@@ -927,7 +932,7 @@ final class HomeViewModel {
927 let batchTickets = try await withThrowingTaskGroup(of: [HomeAssignedTicket].self) { group in 932 let batchTickets = try await withThrowingTaskGroup(of: [HomeAssignedTicket].self) { group in
928 for tracker in batch { 933 for tracker in batch {
929 group.addTask { 934 group.addTask {
930 try await self.fetchAssignedTickets(for: tracker) 935 try await self.fetchAssignedTickets(for: tracker, forceRefresh: forceRefresh)
931 } 936 }
932 } 937 }
933 938
@@ -945,7 +950,7 @@ final class HomeViewModel {
945 return assignedTickets 950 return assignedTickets
946 } 951 }
947 952
948 private func fetchAssignedTickets(for tracker: TrackerSummary) async throws -> [HomeAssignedTicket] { 953 private func fetchAssignedTickets(for tracker: TrackerSummary, forceRefresh: Bool) async throws -> [HomeAssignedTicket] {
949 let cached = try await client.executeCached( 954 let cached = try await client.executeCached(
950 service: .todo, 955 service: .todo,
951 query: Self.trackerTicketsQuery, 956 query: Self.trackerTicketsQuery,
@@ -964,7 +969,7 @@ final class HomeViewModel {
964 ), 969 ),
965 resourceType: .ticketList, 970 resourceType: .ticketList,
966 ttl: APICacheTTLs.ticketList, 971 ttl: APICacheTTLs.ticketList,
967 policy: .cacheFirstThenRefresh 972 policy: forceRefresh ? .refreshIgnoringCache : .cacheFirstThenRefresh
968 ) 973 )
969 let response = cached.value 974 let response = cached.value
970 975
Hutch/Views/Work/WorkView.swift +1 −1
@@ -72,7 +72,7 @@ struct WorkView: View {
72 .themedList() 72 .themedList()
73 .listStyle(.insetGrouped) 73 .listStyle(.insetGrouped)
74 .refreshable { 74 .refreshable {
75 await viewModel.loadDashboard() 75 await viewModel.loadDashboard(forceRefresh: true)
76 } 76 }
77 .connectivityOverlay(hasContent: hasWorkContent(viewModel)) { 77 .connectivityOverlay(hasContent: hasWorkContent(viewModel)) {
78 await viewModel.loadDashboard() 78 await viewModel.loadDashboard()