Commit 972217316c

972217316ca14474ca11dd9190d0ee68e49aa4f6

parent: 882862f5f3

Unsigned

cmc <hello@cleberg.net> · 2026-07-16 21:59 UTC

Move Home system status into a title-bar status badge

Replace the disruption-only banner on the Home tab with a persistent
circular status indicator in the navigation bar, next to the "Home"
title. The badge reflects the existing status logic — green/check when
operational, orange/exclamation on disruption, gray otherwise — and taps
through to System Status. SystemStatusSummaryRow is unchanged and still
used by the More tab.

Resolve the system-status result first in HomeViewModel.loadDashboard so
the badge settles from cache immediately instead of waiting on the
slower projects/jobs/tickets/inbox loads, which had left it spinning for
several seconds.

Bump MARKETING_VERSION to 3.8.2 and record it in ROADMAP.

Layout: unified · split

Hutch.xcodeproj/project.pbxproj +6 −6
@@ -614,7 +614,7 @@
614 "$(inherited)", 614 "$(inherited)",
615 "@executable_path/Frameworks", 615 "@executable_path/Frameworks",
616 ); 616 );
617 MARKETING_VERSION = 3.8.1; 617 MARKETING_VERSION = 3.8.2;
618 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; 618 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
619 PRODUCT_NAME = "$(TARGET_NAME)"; 619 PRODUCT_NAME = "$(TARGET_NAME)";
620 STRING_CATALOG_GENERATE_SYMBOLS = YES; 620 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -651,7 +651,7 @@
651 "$(inherited)", 651 "$(inherited)",
652 "@executable_path/Frameworks", 652 "@executable_path/Frameworks",
653 ); 653 );
654 MARKETING_VERSION = 3.8.1; 654 MARKETING_VERSION = 3.8.2;
655 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; 655 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
656 PRODUCT_NAME = "$(TARGET_NAME)"; 656 PRODUCT_NAME = "$(TARGET_NAME)";
657 STRING_CATALOG_GENERATE_SYMBOLS = YES; 657 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -724,7 +724,7 @@
724 "@executable_path/Frameworks", 724 "@executable_path/Frameworks",
725 "@executable_path/../../Frameworks", 725 "@executable_path/../../Frameworks",
726 ); 726 );
727 MARKETING_VERSION = 3.8.1; 727 MARKETING_VERSION = 3.8.2;
728 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; 728 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
729 PRODUCT_NAME = "$(TARGET_NAME)"; 729 PRODUCT_NAME = "$(TARGET_NAME)";
730 SKIP_INSTALL = YES; 730 SKIP_INSTALL = YES;
@@ -753,7 +753,7 @@
753 "@executable_path/Frameworks", 753 "@executable_path/Frameworks",
754 "@executable_path/../../Frameworks", 754 "@executable_path/../../Frameworks",
755 ); 755 );
756 MARKETING_VERSION = 3.8.1; 756 MARKETING_VERSION = 3.8.2;
757 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; 757 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
758 PRODUCT_NAME = "$(TARGET_NAME)"; 758 PRODUCT_NAME = "$(TARGET_NAME)";
759 SKIP_INSTALL = YES; 759 SKIP_INSTALL = YES;
@@ -782,7 +782,7 @@
782 "@executable_path/Frameworks", 782 "@executable_path/Frameworks",
783 "@executable_path/../../Frameworks", 783 "@executable_path/../../Frameworks",
784 ); 784 );
785 MARKETING_VERSION = 3.8.1; 785 MARKETING_VERSION = 3.8.2;
786 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchSafariExtension; 786 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchSafariExtension;
787 PRODUCT_NAME = "$(TARGET_NAME)"; 787 PRODUCT_NAME = "$(TARGET_NAME)";
788 SKIP_INSTALL = YES; 788 SKIP_INSTALL = YES;
@@ -811,7 +811,7 @@
811 "@executable_path/Frameworks", 811 "@executable_path/Frameworks",
812 "@executable_path/../../Frameworks", 812 "@executable_path/../../Frameworks",
813 ); 813 );
814 MARKETING_VERSION = 3.8.1; 814 MARKETING_VERSION = 3.8.2;
815 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchSafariExtension; 815 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchSafariExtension;
816 PRODUCT_NAME = "$(TARGET_NAME)"; 816 PRODUCT_NAME = "$(TARGET_NAME)";
817 SKIP_INSTALL = YES; 817 SKIP_INSTALL = YES;
Hutch/Views/Home/HomeView.swift +52 −21
@@ -10,6 +10,7 @@ struct HomeView: View {
10 @State private var isOpeningRecentItem = false 10 @State private var isOpeningRecentItem = false
11 @State private var selectedPinnedProject: Project? 11 @State private var selectedPinnedProject: Project?
12 @State private var selectedPinnedUser: User? 12 @State private var selectedPinnedUser: User?
13 @State private var isShowingSystemStatus = false
13 14
14 var body: some View { 15 var body: some View {
15 Group { 16 Group {
@@ -20,6 +21,19 @@ struct HomeView: View {
20 } 21 }
21 } 22 }
22 .navigationTitle("Home") 23 .navigationTitle("Home")
24 .toolbar {
25 ToolbarItem(placement: .topBarTrailing) {
26 Button {
27 isShowingSystemStatus = true
28 } label: {
29 HomeSystemStatusIndicator(
30 snapshot: viewModel?.systemStatusSnapshot,
31 isLoading: viewModel?.isLoadingSystemStatus ?? false
32 )
33 }
34 .accessibilityLabel("System status")
35 }
36 }
23 .navigationDestination(isPresented: Binding( 37 .navigationDestination(isPresented: Binding(
24 get: { selectedPinnedProject != nil }, 38 get: { selectedPinnedProject != nil },
25 set: { isPresented in 39 set: { isPresented in
@@ -44,6 +58,9 @@ struct HomeView: View {
44 UserProfileView(user: selectedPinnedUser) 58 UserProfileView(user: selectedPinnedUser)
45 } 59 }
46 } 60 }
61 .navigationDestination(isPresented: $isShowingSystemStatus) {
62 SystemStatusView()
63 }
47 .task { 64 .task {
48 guard let currentUser = appState.currentUser else { return } 65 guard let currentUser = appState.currentUser else { return }
49 await ensureViewModel(currentUser: currentUser).loadDashboard() 66 await ensureViewModel(currentUser: currentUser).loadDashboard()
@@ -64,7 +81,6 @@ struct HomeView: View {
64 @ViewBuilder 81 @ViewBuilder
65 private func content(_ viewModel: HomeViewModel) -> some View { 82 private func content(_ viewModel: HomeViewModel) -> some View {
66 List { 83 List {
67 systemStatusSection(viewModel)
68 workSection(viewModel) 84 workSection(viewModel)
69 recentSection 85 recentSection
70 buildsSection(viewModel) 86 buildsSection(viewModel)
@@ -84,26 +100,6 @@ struct HomeView: View {
84 } 100 }
85 } 101 }
86 102
87 @ViewBuilder
88 private func systemStatusSection(_ viewModel: HomeViewModel) -> some View {
89 if viewModel.systemStatusSnapshot?.hasDisruption == true {
90 Section {
91 NavigationLink {
92 SystemStatusView()
93 } label: {
94 SystemStatusSummaryRow(
95 snapshot: viewModel.systemStatusSnapshot,
96 isLoading: viewModel.isLoadingSystemStatus,
97 errorMessage: viewModel.systemStatusErrorMessage,
98 isShowingStaleData: viewModel.isShowingStaleSystemStatus
99 )
100 }
101 .buttonStyle(.plain)
102 .themedRow()
103 }
104 }
105 }
106
107 private func workSection(_ viewModel: HomeViewModel) -> some View { 103 private func workSection(_ viewModel: HomeViewModel) -> some View {
108 Section("Work") { 104 Section("Work") {
109 NavigationLink(value: HomeRoute.work(scope: .all)) { 105 NavigationLink(value: HomeRoute.work(scope: .all)) {
@@ -490,6 +486,41 @@ private struct HomeSummaryRow: View {
490 } 486 }
491} 487}
492 488
489private struct HomeSystemStatusIndicator: View {
490 let snapshot: SystemStatusSnapshot?
491 let isLoading: Bool
492
493 var body: some View {
494 if isLoading && snapshot == nil {
495 ProgressView()
496 .controlSize(.small)
497 } else {
498 Circle()
499 .fill(color)
500 .frame(width: 22, height: 22)
501 .overlay {
502 Image(systemName: glyph)
503 .font(.system(size: 11, weight: .bold))
504 .foregroundStyle(.white)
505 }
506 }
507 }
508
509 private var color: Color {
510 if let snapshot {
511 return snapshot.hasDisruption ? .orange : .green
512 }
513 return .secondary
514 }
515
516 private var glyph: String {
517 if let snapshot {
518 return snapshot.hasDisruption ? "exclamationmark" : "checkmark"
519 }
520 return "questionmark"
521 }
522}
523
493private struct HomeRecentRow: View { 524private struct HomeRecentRow: View {
494 let item: RecentActivityEntry 525 let item: RecentActivityEntry
495 526
Hutch/Views/Home/HomeViewModel.swift +13 −10
@@ -365,6 +365,19 @@ final class HomeViewModel {
365 async let inboxUnreadTask = loadInboxUnreadSnapshot(forceRefresh: forceRefresh) 365 async let inboxUnreadTask = loadInboxUnreadSnapshot(forceRefresh: forceRefresh)
366 async let systemStatusTask = loadSystemStatusSnapshot(forceRefresh: forceRefresh) 366 async let systemStatusTask = loadSystemStatusSnapshot(forceRefresh: forceRefresh)
367 367
368 // Resolve system status first so the Home title-bar status badge can
369 // settle from cache without waiting on the slower list data below.
370 let systemStatusResult = await systemStatusTask
371 switch systemStatusResult {
372 case .success(let result):
373 systemStatusSnapshot = result.value
374 isShowingStaleSystemStatus = result.isStale
375 systemStatusErrorMessage = result.isStale ? result.refreshErrorMessage : nil
376 case .failure(let error):
377 systemStatusErrorMessage = error.userFacingMessage
378 }
379 isLoadingSystemStatus = false
380
368 let projectsResult = await projectsTask 381 let projectsResult = await projectsTask
369 switch projectsResult { 382 switch projectsResult {
370 case .success(let projects): 383 case .success(let projects):
@@ -404,16 +417,6 @@ final class HomeViewModel {
404 unreadInboxThreadCount = inboxUnreadSnapshot?.unreadCount 417 unreadInboxThreadCount = inboxUnreadSnapshot?.unreadCount
405 unreadInboxThreads = inboxUnreadSnapshot?.threads ?? [] 418 unreadInboxThreads = inboxUnreadSnapshot?.threads ?? []
406 hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0 419 hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0
407 let systemStatusResult = await systemStatusTask
408 switch systemStatusResult {
409 case .success(let result):
410 systemStatusSnapshot = result.value
411 isShowingStaleSystemStatus = result.isStale
412 systemStatusErrorMessage = result.isStale ? result.refreshErrorMessage : nil
413 case .failure(let error):
414 systemStatusErrorMessage = error.userFacingMessage
415 }
416 isLoadingSystemStatus = false
417 lastRefreshed = Date() 420 lastRefreshed = Date()
418 persistNeedsAttentionSnapshot() 421 persistNeedsAttentionSnapshot()
419 persistSystemStatusWidgetSnapshot() 422 persistSystemStatusWidgetSnapshot()
ROADMAP.md +1
@@ -142,6 +142,7 @@ so "breaking change" does not apply. These buckets track *user-visible scale*.
142| Version | Contents | Why here | 142| Version | Contents | Why here |
143| --- | --- | --- | 143| --- | --- | --- |
144| v3.8.1 | SonarCloud triage; housekeeping | No behaviour change at all | 144| v3.8.1 | SonarCloud triage; housekeeping | No behaviour change at all |
145| v3.8.2 | Home system status moved to a title-bar status badge | Small UI relocation, no new surface |
145| v3.9.0 | "What's cooking" ingest; doc truth-up; deploy keys | Ships one feature, corrects the map | 146| v3.9.0 | "What's cooking" ingest; doc truth-up; deploy keys | Ships one feature, corrects the map |
146| v3.10.0 | hub.sr.ht writes: projects, discovery, `mailingListSubscribe` | Provisional — gated on what v3.9.0 finds | 147| v3.10.0 | hub.sr.ht writes: projects, discovery, `mailingListSubscribe` | Provisional — gated on what v3.9.0 finds |
147| v3.11.0 | Accessibility | Independent, device-verified | 148| v3.11.0 | Accessibility | Independent, device-verified |