krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

7b817a1d2aed0e8b89f36b410f2779f4a6b35407

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-07-16T21:59:55Z

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.
 Hutch.xcodeproj/project.pbxproj      | 12 +++---
 Hutch/Views/Home/HomeView.swift      | 73 +++++++++++++++++++++++++-----------
 Hutch/Views/Home/HomeViewModel.swift | 23 +++++++-----
 ROADMAP.md                           |  1 +
 4 files changed, 72 insertions(+), 37 deletions(-)

diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj
index eba1ba7..409df4b 100644
--- a/Hutch.xcodeproj/project.pbxproj
+++ b/Hutch.xcodeproj/project.pbxproj
@@ -614,7 +614,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 3.8.1;
+				MARKETING_VERSION = 3.8.2;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -651,7 +651,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 3.8.1;
+				MARKETING_VERSION = 3.8.2;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -724,7 +724,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 3.8.1;
+				MARKETING_VERSION = 3.8.2;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
@@ -753,7 +753,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 3.8.1;
+				MARKETING_VERSION = 3.8.2;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
@@ -782,7 +782,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 3.8.1;
+				MARKETING_VERSION = 3.8.2;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchSafariExtension;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
@@ -811,7 +811,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 3.8.1;
+				MARKETING_VERSION = 3.8.2;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchSafariExtension;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
diff --git a/Hutch/Views/Home/HomeView.swift b/Hutch/Views/Home/HomeView.swift
index 3e95f0b..f0ec9ff 100644
--- a/Hutch/Views/Home/HomeView.swift
+++ b/Hutch/Views/Home/HomeView.swift
@@ -10,6 +10,7 @@ struct HomeView: View {
     @State private var isOpeningRecentItem = false
     @State private var selectedPinnedProject: Project?
     @State private var selectedPinnedUser: User?
+    @State private var isShowingSystemStatus = false
 
     var body: some View {
         Group {
@@ -20,6 +21,19 @@ struct HomeView: View {
             }
         }
         .navigationTitle("Home")
+        .toolbar {
+            ToolbarItem(placement: .topBarTrailing) {
+                Button {
+                    isShowingSystemStatus = true
+                } label: {
+                    HomeSystemStatusIndicator(
+                        snapshot: viewModel?.systemStatusSnapshot,
+                        isLoading: viewModel?.isLoadingSystemStatus ?? false
+                    )
+                }
+                .accessibilityLabel("System status")
+            }
+        }
         .navigationDestination(isPresented: Binding(
             get: { selectedPinnedProject != nil },
             set: { isPresented in
@@ -44,6 +58,9 @@ struct HomeView: View {
                 UserProfileView(user: selectedPinnedUser)
             }
         }
+        .navigationDestination(isPresented: $isShowingSystemStatus) {
+            SystemStatusView()
+        }
         .task {
             guard let currentUser = appState.currentUser else { return }
             await ensureViewModel(currentUser: currentUser).loadDashboard()
@@ -64,7 +81,6 @@ struct HomeView: View {
     @ViewBuilder
     private func content(_ viewModel: HomeViewModel) -> some View {
         List {
-            systemStatusSection(viewModel)
             workSection(viewModel)
             recentSection
             buildsSection(viewModel)
@@ -84,26 +100,6 @@ struct HomeView: View {
         }
     }
 
-    @ViewBuilder
-    private func systemStatusSection(_ viewModel: HomeViewModel) -> some View {
-        if viewModel.systemStatusSnapshot?.hasDisruption == true {
-            Section {
-                NavigationLink {
-                    SystemStatusView()
-                } label: {
-                    SystemStatusSummaryRow(
-                        snapshot: viewModel.systemStatusSnapshot,
-                        isLoading: viewModel.isLoadingSystemStatus,
-                        errorMessage: viewModel.systemStatusErrorMessage,
-                        isShowingStaleData: viewModel.isShowingStaleSystemStatus
-                    )
-                }
-                .buttonStyle(.plain)
-                .themedRow()
-            }
-        }
-    }
-
     private func workSection(_ viewModel: HomeViewModel) -> some View {
         Section("Work") {
             NavigationLink(value: HomeRoute.work(scope: .all)) {
@@ -490,6 +486,41 @@ private struct HomeSummaryRow: View {
     }
 }
 
+private struct HomeSystemStatusIndicator: View {
+    let snapshot: SystemStatusSnapshot?
+    let isLoading: Bool
+
+    var body: some View {
+        if isLoading && snapshot == nil {
+            ProgressView()
+                .controlSize(.small)
+        } else {
+            Circle()
+                .fill(color)
+                .frame(width: 22, height: 22)
+                .overlay {
+                    Image(systemName: glyph)
+                        .font(.system(size: 11, weight: .bold))
+                        .foregroundStyle(.white)
+                }
+        }
+    }
+
+    private var color: Color {
+        if let snapshot {
+            return snapshot.hasDisruption ? .orange : .green
+        }
+        return .secondary
+    }
+
+    private var glyph: String {
+        if let snapshot {
+            return snapshot.hasDisruption ? "exclamationmark" : "checkmark"
+        }
+        return "questionmark"
+    }
+}
+
 private struct HomeRecentRow: View {
     let item: RecentActivityEntry
 
diff --git a/Hutch/Views/Home/HomeViewModel.swift b/Hutch/Views/Home/HomeViewModel.swift
index 3789c11..7779bea 100644
--- a/Hutch/Views/Home/HomeViewModel.swift
+++ b/Hutch/Views/Home/HomeViewModel.swift
@@ -365,6 +365,19 @@ final class HomeViewModel {
         async let inboxUnreadTask = loadInboxUnreadSnapshot(forceRefresh: forceRefresh)
         async let systemStatusTask = loadSystemStatusSnapshot(forceRefresh: forceRefresh)
 
+        // Resolve system status first so the Home title-bar status badge can
+        // settle from cache without waiting on the slower list data below.
+        let systemStatusResult = await systemStatusTask
+        switch systemStatusResult {
+        case .success(let result):
+            systemStatusSnapshot = result.value
+            isShowingStaleSystemStatus = result.isStale
+            systemStatusErrorMessage = result.isStale ? result.refreshErrorMessage : nil
+        case .failure(let error):
+            systemStatusErrorMessage = error.userFacingMessage
+        }
+        isLoadingSystemStatus = false
+
         let projectsResult = await projectsTask
         switch projectsResult {
         case .success(let projects):
@@ -404,16 +417,6 @@ final class HomeViewModel {
         unreadInboxThreadCount = inboxUnreadSnapshot?.unreadCount
         unreadInboxThreads = inboxUnreadSnapshot?.threads ?? []
         hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0
-        let systemStatusResult = await systemStatusTask
-        switch systemStatusResult {
-        case .success(let result):
-            systemStatusSnapshot = result.value
-            isShowingStaleSystemStatus = result.isStale
-            systemStatusErrorMessage = result.isStale ? result.refreshErrorMessage : nil
-        case .failure(let error):
-            systemStatusErrorMessage = error.userFacingMessage
-        }
-        isLoadingSystemStatus = false
         lastRefreshed = Date()
         persistNeedsAttentionSnapshot()
         persistSystemStatusWidgetSnapshot()
diff --git a/ROADMAP.md b/ROADMAP.md
index 43c3e7c..53e8f6c 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -142,6 +142,7 @@ so "breaking change" does not apply. These buckets track *user-visible scale*.
 | Version | Contents | Why here |
 | --- | --- | --- |
 | v3.8.1 | SonarCloud triage; housekeeping | No behaviour change at all |
+| v3.8.2 | Home system status moved to a title-bar status badge | Small UI relocation, no new surface |
 | v3.9.0 | "What's cooking" ingest; doc truth-up; deploy keys | Ships one feature, corrects the map |
 | v3.10.0 | hub.sr.ht writes: projects, discovery, `mailingListSubscribe` | Provisional — gated on what v3.9.0 finds |
 | v3.11.0 | Accessibility | Independent, device-verified |