krz/hutch

an ios client for sourcehut

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

9050405b4f33b2b9b70458fcf3e43b80c940eef7

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-12T05:55:42Z

add shortcuts widgets and power-user workflows
 Hutch.xcodeproj/project.pbxproj                  |  16 +-
 Hutch/App/AppState.swift                         |   2 +
 Hutch/App/DeepLink.swift                         |  25 ++++
 Hutch/App/HutchApp.swift                         |  15 ++
 Hutch/App/HutchIntents.swift                     | 154 ++++++++++++++++++++
 Hutch/App/RootView.swift                         |  71 +++++++++
 Hutch/Views/Home/HomeViewModel.swift             |  22 +++
 HutchTests/DeepLinkTests.swift                   |  90 ++++++++++++
 HutchTests/HutchIntentsTests.swift               |  75 ++++++++++
 HutchTests/SystemStatusWidgetSnapshotTests.swift |  73 ++++++++++
 HutchWidgetExtension/NeedsAttentionWidget.swift  |  79 +++++-----
 HutchWidgetExtension/SystemStatusWidget.swift    | 177 +++++++++++++++++++++++
 Shared/SystemStatusWidgetSnapshot.swift          |  67 +++++++++
 13 files changed, 825 insertions(+), 41 deletions(-)

diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj
index ca3476e..1020bb0 100644
--- a/Hutch.xcodeproj/project.pbxproj
+++ b/Hutch.xcodeproj/project.pbxproj
@@ -515,7 +515,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 31;
+				CURRENT_PROJECT_VERSION = 32;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -532,7 +532,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 2.14.0;
+				MARKETING_VERSION = 2.15.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -552,7 +552,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 31;
+				CURRENT_PROJECT_VERSION = 32;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -569,7 +569,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 2.14.0;
+				MARKETING_VERSION = 2.15.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -632,7 +632,7 @@
 				APPLICATION_EXTENSION_API_ONLY = YES;
 				CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 31;
+				CURRENT_PROJECT_VERSION = 32;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = NO;
 				INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -642,7 +642,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 2.14.0;
+				MARKETING_VERSION = 2.15.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
@@ -661,7 +661,7 @@
 				APPLICATION_EXTENSION_API_ONLY = YES;
 				CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 31;
+				CURRENT_PROJECT_VERSION = 32;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = NO;
 				INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -671,7 +671,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 2.14.0;
+				MARKETING_VERSION = 2.15.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
diff --git a/Hutch/App/AppState.swift b/Hutch/App/AppState.swift
index c2e2744..35ac77f 100644
--- a/Hutch/App/AppState.swift
+++ b/Hutch/App/AppState.swift
@@ -203,6 +203,7 @@ final class AppState {
         await clearWebData()
         clearWebContentRenderCaches()
         NeedsAttentionSnapshotStore.clear()
+        SystemStatusWidgetSnapshotStore.clear()
         authPhase = .unauthenticated
         selectedTab = .home
     }
@@ -218,6 +219,7 @@ final class AppState {
         await clearWebData()
         clearWebContentRenderCaches()
         NeedsAttentionSnapshotStore.clear()
+        SystemStatusWidgetSnapshotStore.clear()
 
         authPhase = .unauthenticated
         selectedTab = .home
diff --git a/Hutch/App/DeepLink.swift b/Hutch/App/DeepLink.swift
index e42bd6b..bbf8885 100644
--- a/Hutch/App/DeepLink.swift
+++ b/Hutch/App/DeepLink.swift
@@ -9,6 +9,16 @@ enum DeepLink: Equatable {
     case ticket(owner: String, tracker: String, ticketId: Int)
     /// hutch://builds/<jobId>
     case build(jobId: Int)
+    /// hutch://builds (tab-level)
+    case buildsTab
+    /// hutch://repositories (tab-level)
+    case repositoriesTab
+    /// hutch://trackers (tab-level)
+    case trackersTab
+    /// hutch://status
+    case systemStatus
+    /// hutch://lookup
+    case lookup
 
     /// Attempt to parse a URL into a DeepLink.
     /// Expected format: hutch://<path>
@@ -38,6 +48,21 @@ enum DeepLink: Equatable {
             guard let jobId = Int(components[1]) else { return nil }
             self = .build(jobId: jobId)
 
+        case "builds":
+            self = .buildsTab
+
+        case "repositories":
+            self = .repositoriesTab
+
+        case "trackers":
+            self = .trackersTab
+
+        case "status":
+            self = .systemStatus
+
+        case "lookup":
+            self = .lookup
+
         default:
             return nil
         }
diff --git a/Hutch/App/HutchApp.swift b/Hutch/App/HutchApp.swift
index fda0f06..e65833c 100644
--- a/Hutch/App/HutchApp.swift
+++ b/Hutch/App/HutchApp.swift
@@ -15,6 +15,21 @@ struct HutchApp: App {
                         appState.pendingDeepLink = link
                     }
                 }
+                .onChange(of: HutchIntentNavigator.shared.pendingDestination) { _, destination in
+                    guard let destination else { return }
+                    HutchIntentNavigator.shared.pendingDestination = nil
+                    let link: DeepLink
+                    switch destination {
+                    case .home: link = .home
+                    case .inbox: link = .home
+                    case .builds: link = .buildsTab
+                    case .repositories: link = .repositoriesTab
+                    case .trackers: link = .trackersTab
+                    case .systemStatus: link = .systemStatus
+                    case .lookup: link = .lookup
+                    }
+                    appState.pendingDeepLink = link
+                }
         }
     }
 }
diff --git a/Hutch/App/HutchIntents.swift b/Hutch/App/HutchIntents.swift
new file mode 100644
index 0000000..652c07b
--- /dev/null
+++ b/Hutch/App/HutchIntents.swift
@@ -0,0 +1,154 @@
+import AppIntents
+import Foundation
+
+// MARK: - Open Hutch Intent
+
+enum HutchDestination: String, AppEnum {
+    case home
+    case inbox
+    case builds
+    case repositories
+    case trackers
+    case systemStatus
+    case lookup
+
+    static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Hutch Section")
+
+    static var caseDisplayRepresentations: [HutchDestination: DisplayRepresentation] = [
+        .home: "Home",
+        .inbox: "Inbox",
+        .builds: "Builds",
+        .repositories: "Repositories",
+        .trackers: "Trackers",
+        .systemStatus: "System Status",
+        .lookup: "Look Up"
+    ]
+}
+
+struct OpenHutchIntent: AppIntent {
+    static var title: LocalizedStringResource = "Open Hutch"
+    static var description = IntentDescription("Opens Hutch to a specific section.")
+    static var openAppWhenRun = true
+
+    @Parameter(title: "Section", default: .home)
+    var destination: HutchDestination
+
+    @MainActor
+    func perform() async throws -> some IntentResult {
+        HutchIntentNavigator.shared.pendingDestination = destination
+        return .result()
+    }
+}
+
+// MARK: - Check System Status Intent
+
+struct CheckSystemStatusIntent: AppIntent {
+    static var title: LocalizedStringResource = "Check SourceHut Status"
+    static var description = IntentDescription("Returns the current SourceHut system status.")
+
+    @MainActor
+    func perform() async throws -> some IntentResult & ReturnsValue<String> {
+        guard let snapshot = SystemStatusWidgetSnapshotStore.load() else {
+            return .result(value: "System status is unavailable. Open Hutch to refresh.")
+        }
+
+        if snapshot.hasDisruption {
+            let disrupted = snapshot.services
+                .filter { $0.requiresAttention }
+                .map { "\($0.name): \($0.status)" }
+                .joined(separator: ", ")
+            return .result(value: "SourceHut disruption detected: \(disrupted)")
+        }
+
+        return .result(value: "All SourceHut services operational.")
+    }
+}
+
+// MARK: - Check Builds Intent
+
+struct CheckBuildsIntent: AppIntent {
+    static var title: LocalizedStringResource = "Check Hutch Builds"
+    static var description = IntentDescription("Returns a summary of your recent build status.")
+
+    @MainActor
+    func perform() async throws -> some IntentResult & ReturnsValue<String> {
+        guard let snapshot = NeedsAttentionSnapshotStore.load() else {
+            return .result(value: "Build status unavailable. Open Hutch to refresh.")
+        }
+
+        var parts: [String] = []
+
+        if let failed = snapshot.failedBuilds {
+            if failed > 0 {
+                parts.append("\(failed) failed build\(failed == 1 ? "" : "s")")
+            } else {
+                parts.append("No failed builds")
+            }
+        }
+
+        if let unread = snapshot.unreadInboxThreads, unread > 0 {
+            parts.append("\(unread) unread thread\(unread == 1 ? "" : "s")")
+        }
+
+        if let assigned = snapshot.assignedOpenTickets, assigned > 0 {
+            parts.append("\(assigned) assigned ticket\(assigned == 1 ? "" : "s")")
+        }
+
+        if parts.isEmpty {
+            return .result(value: "No recent data. Open Hutch to refresh.")
+        }
+
+        return .result(value: parts.joined(separator: ". ") + ".")
+    }
+}
+
+// MARK: - Shortcuts Provider
+
+struct HutchShortcuts: AppShortcutsProvider {
+    static var appShortcuts: [AppShortcut] {
+        AppShortcut(
+            intent: OpenHutchIntent(),
+            phrases: [
+                "Open \(.applicationName)",
+                "Open \(.applicationName) \(\.$destination)",
+                "Show my \(.applicationName) \(\.$destination)",
+                "Go to \(\.$destination) in \(.applicationName)"
+            ],
+            shortTitle: "Open Hutch",
+            systemImageName: "house"
+        )
+
+        AppShortcut(
+            intent: CheckSystemStatusIntent(),
+            phrases: [
+                "Check \(.applicationName) status",
+                "Is SourceHut up in \(.applicationName)",
+                "SourceHut status in \(.applicationName)"
+            ],
+            shortTitle: "Check SourceHut Status",
+            systemImageName: "server.rack"
+        )
+
+        AppShortcut(
+            intent: CheckBuildsIntent(),
+            phrases: [
+                "Check my \(.applicationName) builds",
+                "How are my builds in \(.applicationName)",
+                "Build status in \(.applicationName)"
+            ],
+            shortTitle: "Check Builds",
+            systemImageName: "hammer"
+        )
+    }
+}
+
+// MARK: - Intent Navigator
+
+@MainActor
+@Observable
+final class HutchIntentNavigator {
+    static let shared = HutchIntentNavigator()
+    var pendingDestination: HutchDestination?
+
+    private init() {}
+}
diff --git a/Hutch/App/RootView.swift b/Hutch/App/RootView.swift
index c210d9d..80532a0 100644
--- a/Hutch/App/RootView.swift
+++ b/Hutch/App/RootView.swift
@@ -113,6 +113,11 @@ struct RootView: View {
                 Label("More", systemImage: "ellipsis.circle")
             }
         }
+        .modifier(SidebarAdaptableTabStyle())
+        .modifier(TabKeyboardShortcuts(selectedTab: Binding(
+            get: { appState.selectedTab },
+            set: { appState.selectedTab = $0 }
+        )))
         .overlay {
             if isResolvingDeepLink {
                 ZStack {
@@ -178,6 +183,29 @@ struct RootView: View {
 
         case .ticket(let owner, let tracker, let ticketId):
             resolveTicketLink(owner: owner, tracker: tracker, ticketId: ticketId)
+
+        case .buildsTab:
+            buildsPath = NavigationPath()
+            appState.selectedTab = .builds
+
+        case .repositoriesTab:
+            repoPath = NavigationPath()
+            appState.selectedTab = .repositories
+
+        case .trackersTab:
+            ticketsPath = NavigationPath()
+            appState.selectedTab = .tickets
+
+        case .systemStatus:
+            appState.navigateToSystemStatus()
+
+        case .lookup:
+            morePath = NavigationPath()
+            appState.selectedTab = .more
+            Task {
+                await settleNavigationTransition()
+                morePath.append(MoreRoute.lookup)
+            }
         }
     }
 
@@ -336,3 +364,46 @@ struct TicketDeepLinkTarget: Hashable {
     let trackerRid: String
     let ticketId: Int
 }
+
+// MARK: - Keyboard Shortcuts for iPad + Hardware Keyboard
+
+/// Adds Cmd+1 through Cmd+5 keyboard shortcuts for tab switching on iPad.
+private struct TabKeyboardShortcuts: ViewModifier {
+    @Binding var selectedTab: AppState.Tab
+
+    private static let tabMap: [String: AppState.Tab] = [
+        "1": .home,
+        "2": .repositories,
+        "3": .tickets,
+        "4": .builds,
+        "5": .more,
+    ]
+
+    func body(content: Content) -> some View {
+        content
+            .onKeyPress(characters: .decimalDigits, phases: .down) { press in
+                guard press.modifiers == .command else { return .ignored }
+                let key = String(press.characters)
+                if let tab = Self.tabMap[key] {
+                    selectedTab = tab
+                    return .handled
+                }
+                return .ignored
+            }
+    }
+}
+
+// MARK: - iPad Sidebar Adaptable
+
+/// Applies `.tabViewStyle(.sidebarAdaptable)` on iOS 18+ so the tab bar
+/// becomes a full sidebar on iPad, while falling back to the standard tab
+/// bar on earlier releases.
+private struct SidebarAdaptableTabStyle: ViewModifier {
+    func body(content: Content) -> some View {
+        if #available(iOS 18.0, *) {
+            content.tabViewStyle(.sidebarAdaptable)
+        } else {
+            content
+        }
+    }
+}
diff --git a/Hutch/Views/Home/HomeViewModel.swift b/Hutch/Views/Home/HomeViewModel.swift
index 8762393..ec2b324 100644
--- a/Hutch/Views/Home/HomeViewModel.swift
+++ b/Hutch/Views/Home/HomeViewModel.swift
@@ -388,6 +388,7 @@ final class HomeViewModel {
         }
         isLoadingSystemStatus = false
         persistNeedsAttentionSnapshot()
+        persistSystemStatusWidgetSnapshot()
     }
 
     var hasDashboardContent: Bool {
@@ -928,6 +929,27 @@ final class HomeViewModel {
         )
     }
 
+    private func persistSystemStatusWidgetSnapshot() {
+        guard let snapshot = systemStatusSnapshot else {
+            return
+        }
+        let widgetSnapshot = SystemStatusWidgetSnapshot(
+            services: snapshot.services.map { service in
+                SystemStatusWidgetSnapshot.ServiceEntry(
+                    id: service.id,
+                    name: service.name,
+                    status: service.status.displayName,
+                    requiresAttention: service.status.requiresAttention
+                )
+            },
+            hasDisruption: snapshot.hasDisruption,
+            overallStatusText: snapshot.overallStatusText,
+            bannerSummary: snapshot.bannerSummary,
+            updatedAt: .now
+        )
+        SystemStatusWidgetSnapshotStore.save(widgetSnapshot)
+    }
+
     nonisolated static func buildItems(from jobs: [HomeJobPayload]) -> [HomeBuildItem] {
         jobs.map { job in
             let repository = primaryRepositoryReference(in: job.manifest)
diff --git a/HutchTests/DeepLinkTests.swift b/HutchTests/DeepLinkTests.swift
new file mode 100644
index 0000000..12f759b
--- /dev/null
+++ b/HutchTests/DeepLinkTests.swift
@@ -0,0 +1,90 @@
+import Foundation
+import Testing
+@testable import Hutch
+
+struct DeepLinkTests {
+
+    @Test
+    func parsesHomeLink() {
+        let link = DeepLink(url: URL(string: "hutch://home")!)
+        #expect(link == .home)
+    }
+
+    @Test
+    func parsesNilPathAsHome() {
+        let link = DeepLink(url: URL(string: "hutch://")!)
+        #expect(link == .home)
+    }
+
+    @Test
+    func parsesRepositoryLink() {
+        let link = DeepLink(url: URL(string: "hutch://git/~user/repo")!)
+        #expect(link == .repository(owner: "~user", repo: "repo"))
+    }
+
+    @Test
+    func parsesTicketLink() {
+        let link = DeepLink(url: URL(string: "hutch://todo/~owner/tracker/42")!)
+        #expect(link == .ticket(owner: "~owner", tracker: "tracker", ticketId: 42))
+    }
+
+    @Test
+    func parsesBuildJobLink() {
+        let link = DeepLink(url: URL(string: "hutch://builds/12345")!)
+        #expect(link == .build(jobId: 12345))
+    }
+
+    @Test
+    func parsesBuildsTabLink() {
+        let link = DeepLink(url: URL(string: "hutch://builds")!)
+        #expect(link == .buildsTab)
+    }
+
+    @Test
+    func parsesRepositoriesTabLink() {
+        let link = DeepLink(url: URL(string: "hutch://repositories")!)
+        #expect(link == .repositoriesTab)
+    }
+
+    @Test
+    func parsesTrackersTabLink() {
+        let link = DeepLink(url: URL(string: "hutch://trackers")!)
+        #expect(link == .trackersTab)
+    }
+
+    @Test
+    func parsesSystemStatusLink() {
+        let link = DeepLink(url: URL(string: "hutch://status")!)
+        #expect(link == .systemStatus)
+    }
+
+    @Test
+    func parsesLookupLink() {
+        let link = DeepLink(url: URL(string: "hutch://lookup")!)
+        #expect(link == .lookup)
+    }
+
+    @Test
+    func rejectsNonHutchScheme() {
+        let link = DeepLink(url: URL(string: "https://example.com/home")!)
+        #expect(link == nil)
+    }
+
+    @Test
+    func rejectsUnknownPath() {
+        let link = DeepLink(url: URL(string: "hutch://unknown")!)
+        #expect(link == nil)
+    }
+
+    @Test
+    func rejectsTicketLinkWithNonNumericId() {
+        let link = DeepLink(url: URL(string: "hutch://todo/~owner/tracker/abc")!)
+        #expect(link == nil)
+    }
+
+    @Test
+    func rejectsBuildLinkWithNonNumericId() {
+        let link = DeepLink(url: URL(string: "hutch://builds/abc")!)
+        #expect(link == nil)
+    }
+}
diff --git a/HutchTests/HutchIntentsTests.swift b/HutchTests/HutchIntentsTests.swift
new file mode 100644
index 0000000..94ecbf0
--- /dev/null
+++ b/HutchTests/HutchIntentsTests.swift
@@ -0,0 +1,75 @@
+import Foundation
+import Testing
+@testable import Hutch
+
+@MainActor
+struct HutchIntentsTests {
+
+    @Test
+    func checkSystemStatusReturnsOperationalWhenNoDisruption() async throws {
+        let defaultsName = "HutchIntentsTests-status-\(UUID().uuidString)"
+        let defaults = UserDefaults(suiteName: defaultsName)!
+        defer { defaults.removePersistentDomain(forName: defaultsName) }
+
+        let snapshot = SystemStatusWidgetSnapshot(
+            services: [
+                .init(id: "git", name: "git.sr.ht", status: "Operational", requiresAttention: false),
+            ],
+            hasDisruption: false,
+            overallStatusText: "All monitored services operational",
+            bannerSummary: "",
+            updatedAt: .now
+        )
+        SystemStatusWidgetSnapshotStore.save(snapshot, defaults: defaults)
+
+        let loaded = SystemStatusWidgetSnapshotStore.load(defaults: defaults)
+        #expect(loaded != nil)
+        #expect(loaded?.hasDisruption == false)
+    }
+
+    @Test
+    func checkSystemStatusReturnsDisruptionDetails() async throws {
+        let defaultsName = "HutchIntentsTests-disruption-\(UUID().uuidString)"
+        let defaults = UserDefaults(suiteName: defaultsName)!
+        defer { defaults.removePersistentDomain(forName: defaultsName) }
+
+        let snapshot = SystemStatusWidgetSnapshot(
+            services: [
+                .init(id: "git", name: "git.sr.ht", status: "Degraded", requiresAttention: true),
+                .init(id: "meta", name: "meta.sr.ht", status: "Operational", requiresAttention: false),
+            ],
+            hasDisruption: true,
+            overallStatusText: "Experiencing disruptions",
+            bannerSummary: "git.sr.ht disrupted",
+            updatedAt: .now
+        )
+        SystemStatusWidgetSnapshotStore.save(snapshot, defaults: defaults)
+
+        let loaded = SystemStatusWidgetSnapshotStore.load(defaults: defaults)
+        #expect(loaded?.hasDisruption == true)
+
+        let disrupted = loaded!.services.filter(\.requiresAttention)
+        #expect(disrupted.count == 1)
+        #expect(disrupted.first?.name == "git.sr.ht")
+    }
+
+    @Test
+    func needsAttentionSnapshotSupportsBuildsCheck() {
+        let defaultsName = "HutchIntentsTests-builds-\(UUID().uuidString)"
+        let defaults = UserDefaults(suiteName: defaultsName)!
+        defer { defaults.removePersistentDomain(forName: defaultsName) }
+
+        let snapshot = NeedsAttentionSnapshot(
+            unreadInboxThreads: 5,
+            assignedOpenTickets: 3,
+            failedBuilds: 2,
+            updatedAt: .now
+        )
+        NeedsAttentionSnapshotStore.save(snapshot, defaults: defaults)
+
+        let loaded = NeedsAttentionSnapshotStore.load(defaults: defaults)
+        #expect(loaded?.failedBuilds == 2)
+        #expect(loaded?.unreadInboxThreads == 5)
+        #expect(loaded?.assignedOpenTickets == 3)
+    }
+}
diff --git a/HutchTests/SystemStatusWidgetSnapshotTests.swift b/HutchTests/SystemStatusWidgetSnapshotTests.swift
new file mode 100644
index 0000000..ddb74b1
--- /dev/null
+++ b/HutchTests/SystemStatusWidgetSnapshotTests.swift
@@ -0,0 +1,73 @@
+import Foundation
+import Testing
+@testable import Hutch
+
+@MainActor
+struct SystemStatusWidgetSnapshotTests {
+
+    @Test
+    func saveAndLoadRoundTrips() {
+        let defaultsName = "SystemStatusWidgetSnapshotTests-\(UUID().uuidString)"
+        let defaults = UserDefaults(suiteName: defaultsName)!
+        defer { defaults.removePersistentDomain(forName: defaultsName) }
+
+        let snapshot = SystemStatusWidgetSnapshot(
+            services: [
+                .init(id: "git", name: "git.sr.ht", status: "Operational", requiresAttention: false),
+                .init(id: "builds", name: "builds.sr.ht", status: "Degraded", requiresAttention: true),
+            ],
+            hasDisruption: true,
+            overallStatusText: "Experiencing disruptions",
+            bannerSummary: "builds.sr.ht disrupted",
+            updatedAt: Date(timeIntervalSince1970: 1000)
+        )
+
+        SystemStatusWidgetSnapshotStore.save(snapshot, defaults: defaults)
+        let loaded = SystemStatusWidgetSnapshotStore.load(defaults: defaults)
+
+        #expect(loaded != nil)
+        #expect(loaded?.services.count == 2)
+        #expect(loaded?.hasDisruption == true)
+        #expect(loaded?.bannerSummary == "builds.sr.ht disrupted")
+        #expect(loaded?.services[0].name == "git.sr.ht")
+        #expect(loaded?.services[1].requiresAttention == true)
+    }
+
+    @Test
+    func loadReturnsNilWhenEmpty() {
+        let defaultsName = "SystemStatusWidgetSnapshotTests-empty-\(UUID().uuidString)"
+        let defaults = UserDefaults(suiteName: defaultsName)!
+        defer { defaults.removePersistentDomain(forName: defaultsName) }
+
+        let loaded = SystemStatusWidgetSnapshotStore.load(defaults: defaults)
+        #expect(loaded == nil)
+    }
+
+    @Test
+    func clearRemovesSnapshot() {
+        let defaultsName = "SystemStatusWidgetSnapshotTests-clear-\(UUID().uuidString)"
+        let defaults = UserDefaults(suiteName: defaultsName)!
+        defer { defaults.removePersistentDomain(forName: defaultsName) }
+
+        let snapshot = SystemStatusWidgetSnapshot(
+            services: [.init(id: "meta", name: "meta.sr.ht", status: "Operational", requiresAttention: false)],
+            hasDisruption: false,
+            overallStatusText: "All monitored services operational",
+            bannerSummary: "",
+            updatedAt: .now
+        )
+
+        SystemStatusWidgetSnapshotStore.save(snapshot, defaults: defaults)
+        #expect(SystemStatusWidgetSnapshotStore.load(defaults: defaults) != nil)
+
+        SystemStatusWidgetSnapshotStore.clear(defaults: defaults)
+        #expect(SystemStatusWidgetSnapshotStore.load(defaults: defaults) == nil)
+    }
+
+    @Test
+    func unavailableSnapshotHasEmptyServices() {
+        let snapshot = SystemStatusWidgetSnapshot.unavailable
+        #expect(snapshot.services.isEmpty)
+        #expect(snapshot.hasDisruption == false)
+    }
+}
diff --git a/HutchWidgetExtension/NeedsAttentionWidget.swift b/HutchWidgetExtension/NeedsAttentionWidget.swift
index e212af6..574e9eb 100644
--- a/HutchWidgetExtension/NeedsAttentionWidget.swift
+++ b/HutchWidgetExtension/NeedsAttentionWidget.swift
@@ -71,7 +71,7 @@ private struct NeedsAttentionWidgetView: View {
                 fallbackState
             }
         }
-        .widgetURL(URL(string: "hutch://home"))
+        .widgetURL(family == .systemSmall ? URL(string: "hutch://home") : nil)
         .containerBackground(for: .widget) {
             Color(.systemBackground)
         }
@@ -101,52 +101,64 @@ private struct NeedsAttentionWidgetView: View {
     private func mediumView(snapshot: NeedsAttentionSnapshot) -> some View {
         VStack(alignment: .leading, spacing: 0) {
             HStack(alignment: .top, spacing: 12) {
-                metricColumn(
+                Link(destination: URL(string: "hutch://home")!) {
+                    metricColumn(
+                        count: snapshot.unreadInboxThreads,
+                        label: "Unread",
+                        systemImage: "tray",
+                        tint: unreadTint(for: snapshot.unreadInboxThreads)
+                    )
+                }
+                Link(destination: URL(string: "hutch://trackers")!) {
+                    metricColumn(
+                        count: snapshot.assignedOpenTickets,
+                        label: "Assigned",
+                        systemImage: "ticket"
+                    )
+                }
+                Link(destination: URL(string: "hutch://builds")!) {
+                    metricColumn(
+                        count: snapshot.failedBuilds,
+                        label: "Failed",
+                        systemImage: "hammer",
+                        tint: failedTint(for: snapshot.failedBuilds)
+                    )
+                }
+            }
+            Spacer(minLength: 0)
+        }
+        .padding(.horizontal, 16)
+        .padding(.top, 18)
+        .padding(.bottom, 16)
+    }
+
+    private func largeView(snapshot: NeedsAttentionSnapshot) -> some View {
+        VStack(alignment: .leading, spacing: 18) {
+            Link(destination: URL(string: "hutch://home")!) {
+                metricRow(
                     count: snapshot.unreadInboxThreads,
-                    label: "Unread",
+                    label: "Unread threads",
                     systemImage: "tray",
                     tint: unreadTint(for: snapshot.unreadInboxThreads)
                 )
-                metricColumn(
+            }
+            Link(destination: URL(string: "hutch://trackers")!) {
+                metricRow(
                     count: snapshot.assignedOpenTickets,
-                    label: "Assigned",
+                    label: "Assigned tickets",
                     systemImage: "ticket"
                 )
-                metricColumn(
+            }
+            Link(destination: URL(string: "hutch://builds")!) {
+                metricRow(
                     count: snapshot.failedBuilds,
-                    label: "Failed",
+                    label: "Failed builds",
                     systemImage: "hammer",
                     tint: failedTint(for: snapshot.failedBuilds)
                 )
             }
             Spacer(minLength: 0)
         }
-        .padding(.horizontal, 16)
-        .padding(.top, 18)
-        .padding(.bottom, 16)
-    }
-
-    private func largeView(snapshot: NeedsAttentionSnapshot) -> some View {
-        VStack(alignment: .leading, spacing: 18) {
-            metricRow(
-                count: snapshot.unreadInboxThreads,
-                label: "Unread threads",
-                systemImage: "tray",
-                tint: unreadTint(for: snapshot.unreadInboxThreads)
-            )
-            metricRow(
-                count: snapshot.assignedOpenTickets,
-                label: "Assigned tickets",
-                systemImage: "ticket"
-            )
-            metricRow(
-                count: snapshot.failedBuilds,
-                label: "Failed builds",
-                systemImage: "hammer",
-                tint: failedTint(for: snapshot.failedBuilds)
-            )
-            Spacer(minLength: 0)
-        }
         .padding(20)
     }
 
@@ -303,5 +315,6 @@ struct HutchWidgets: WidgetBundle {
     var body: some Widget {
         NeedsAttentionWidget()
         ContributionGraphWidget()
+        SystemStatusWidget()
     }
 }
diff --git a/HutchWidgetExtension/SystemStatusWidget.swift b/HutchWidgetExtension/SystemStatusWidget.swift
new file mode 100644
index 0000000..4a3c1e5
--- /dev/null
+++ b/HutchWidgetExtension/SystemStatusWidget.swift
@@ -0,0 +1,177 @@
+import SwiftUI
+import WidgetKit
+
+struct SystemStatusEntry: TimelineEntry {
+    let date: Date
+    let snapshot: SystemStatusWidgetSnapshot?
+}
+
+struct SystemStatusTimelineProvider: TimelineProvider {
+    func placeholder(in _: Context) -> SystemStatusEntry {
+        SystemStatusEntry(
+            date: .now,
+            snapshot: SystemStatusWidgetSnapshot(
+                services: [
+                    .init(id: "git", name: "git.sr.ht", status: "Operational", requiresAttention: false),
+                    .init(id: "builds", name: "builds.sr.ht", status: "Operational", requiresAttention: false),
+                    .init(id: "lists", name: "lists.sr.ht", status: "Operational", requiresAttention: false),
+                    .init(id: "todo", name: "todo.sr.ht", status: "Operational", requiresAttention: false),
+                    .init(id: "meta", name: "meta.sr.ht", status: "Operational", requiresAttention: false),
+                    .init(id: "hg", name: "hg.sr.ht", status: "Operational", requiresAttention: false),
+                ],
+                hasDisruption: false,
+                overallStatusText: "All monitored services operational",
+                bannerSummary: "",
+                updatedAt: .now
+            )
+        )
+    }
+
+    func getSnapshot(in _: Context, completion: @escaping (SystemStatusEntry) -> Void) {
+        completion(
+            SystemStatusEntry(
+                date: .now,
+                snapshot: SystemStatusWidgetSnapshotStore.load()
+            )
+        )
+    }
+
+    func getTimeline(in _: Context, completion: @escaping (Timeline<SystemStatusEntry>) -> Void) {
+        let refreshDate = Calendar.current.date(byAdding: .minute, value: 15, to: .now) ?? .now.addingTimeInterval(900)
+        let entry = SystemStatusEntry(date: .now, snapshot: SystemStatusWidgetSnapshotStore.load())
+        completion(Timeline(entries: [entry], policy: .after(refreshDate)))
+    }
+}
+
+struct SystemStatusWidget: Widget {
+    static let kind = SystemStatusWidgetConfiguration.kind
+
+    var body: some WidgetConfiguration {
+        StaticConfiguration(kind: Self.kind, provider: SystemStatusTimelineProvider()) { entry in
+            SystemStatusWidgetView(entry: entry)
+        }
+        .configurationDisplayName("SourceHut Status")
+        .description("Current status of SourceHut services at a glance.")
+        .supportedFamilies([.systemSmall, .systemMedium])
+    }
+}
+
+private struct SystemStatusWidgetView: View {
+    let entry: SystemStatusEntry
+    @Environment(\.widgetFamily) private var family
+
+    var body: some View {
+        Group {
+            if let snapshot = entry.snapshot, !snapshot.services.isEmpty {
+                switch family {
+                case .systemMedium:
+                    mediumView(snapshot: snapshot)
+                default:
+                    smallView(snapshot: snapshot)
+                }
+            } else {
+                fallbackView
+            }
+        }
+        .widgetURL(URL(string: "hutch://status"))
+        .containerBackground(for: .widget) {
+            Color(.systemBackground)
+        }
+    }
+
+    private func smallView(snapshot: SystemStatusWidgetSnapshot) -> some View {
+        VStack(alignment: .leading, spacing: 8) {
+            HStack(spacing: 6) {
+                Image(systemName: snapshot.hasDisruption ? "exclamationmark.triangle.fill" : "checkmark.circle.fill")
+                    .font(.title3.weight(.semibold))
+                    .foregroundStyle(snapshot.hasDisruption ? .orange : .green)
+                Spacer()
+            }
+
+            Text(snapshot.hasDisruption ? snapshot.bannerSummary : "All Clear")
+                .font(.subheadline.weight(.semibold))
+                .lineLimit(2)
+
+            Spacer(minLength: 0)
+
+            if snapshot.hasDisruption {
+                let disrupted = snapshot.services.filter(\.requiresAttention)
+                Text(disrupted.map(\.name).joined(separator: ", "))
+                    .font(.caption2)
+                    .foregroundStyle(.secondary)
+                    .lineLimit(2)
+            } else {
+                Text("\(snapshot.services.count) services monitored")
+                    .font(.caption2)
+                    .foregroundStyle(.secondary)
+            }
+        }
+        .padding()
+    }
+
+    private func mediumView(snapshot: SystemStatusWidgetSnapshot) -> some View {
+        VStack(alignment: .leading, spacing: 6) {
+            HStack(spacing: 8) {
+                Image(systemName: snapshot.hasDisruption ? "exclamationmark.triangle.fill" : "checkmark.circle.fill")
+                    .font(.title3.weight(.semibold))
+                    .foregroundStyle(snapshot.hasDisruption ? .orange : .green)
+
+                Text(snapshot.hasDisruption ? snapshot.bannerSummary : "All Services Operational")
+                    .font(.subheadline.weight(.semibold))
+                    .lineLimit(1)
+
+                Spacer()
+            }
+
+            Divider()
+
+            let columns = Array(snapshot.services.prefix(8))
+            let half = (columns.count + 1) / 2
+            let leftColumn = Array(columns.prefix(half))
+            let rightColumn = Array(columns.suffix(from: half))
+
+            HStack(alignment: .top, spacing: 16) {
+                VStack(alignment: .leading, spacing: 4) {
+                    ForEach(leftColumn) { service in
+                        serviceRow(service)
+                    }
+                }
+                VStack(alignment: .leading, spacing: 4) {
+                    ForEach(rightColumn) { service in
+                        serviceRow(service)
+                    }
+                }
+                Spacer(minLength: 0)
+            }
+
+            Spacer(minLength: 0)
+        }
+        .padding(.horizontal, 16)
+        .padding(.top, 14)
+        .padding(.bottom, 12)
+    }
+
+    private func serviceRow(_ service: SystemStatusWidgetSnapshot.ServiceEntry) -> some View {
+        HStack(spacing: 6) {
+            Circle()
+                .fill(service.requiresAttention ? Color.orange : Color.green)
+                .frame(width: 6, height: 6)
+            Text(service.name)
+                .font(.caption2)
+                .foregroundStyle(service.requiresAttention ? .primary : .secondary)
+                .lineLimit(1)
+        }
+    }
+
+    private var fallbackView: some View {
+        VStack(alignment: .leading, spacing: 8) {
+            Spacer(minLength: 0)
+            Text("Open Hutch")
+                .font(.title3.weight(.semibold))
+            Text("Sign in to load system status.")
+                .font(.caption)
+                .foregroundStyle(.secondary)
+        }
+        .padding()
+    }
+}
diff --git a/Shared/SystemStatusWidgetSnapshot.swift b/Shared/SystemStatusWidgetSnapshot.swift
new file mode 100644
index 0000000..8489547
--- /dev/null
+++ b/Shared/SystemStatusWidgetSnapshot.swift
@@ -0,0 +1,67 @@
+import Foundation
+#if canImport(WidgetKit)
+import WidgetKit
+#endif
+
+enum SystemStatusWidgetConfiguration {
+    static let kind = "SystemStatusWidget"
+}
+
+struct SystemStatusWidgetSnapshot: Codable, Sendable {
+    let services: [ServiceEntry]
+    let hasDisruption: Bool
+    let overallStatusText: String
+    let bannerSummary: String
+    let updatedAt: Date
+
+    struct ServiceEntry: Codable, Sendable, Identifiable {
+        let id: String
+        let name: String
+        let status: String
+        let requiresAttention: Bool
+    }
+
+    static let unavailable = SystemStatusWidgetSnapshot(
+        services: [],
+        hasDisruption: false,
+        overallStatusText: "Unavailable",
+        bannerSummary: "",
+        updatedAt: .now
+    )
+}
+
+enum SystemStatusWidgetSnapshotStore {
+    private static let snapshotKey = "systemStatus.widgetSnapshot"
+
+    static func load(defaults: UserDefaults? = sharedDefaults()) -> SystemStatusWidgetSnapshot? {
+        guard let defaults,
+              let data = defaults.data(forKey: snapshotKey) else {
+            return nil
+        }
+        return try? JSONDecoder().decode(SystemStatusWidgetSnapshot.self, from: data)
+    }
+
+    static func save(_ snapshot: SystemStatusWidgetSnapshot, defaults: UserDefaults? = sharedDefaults()) {
+        guard let defaults,
+              let data = try? JSONEncoder().encode(snapshot) else {
+            return
+        }
+        defaults.set(data, forKey: snapshotKey)
+        reloadWidgetTimelines()
+    }
+
+    static func clear(defaults: UserDefaults? = sharedDefaults()) {
+        defaults?.removeObject(forKey: snapshotKey)
+        reloadWidgetTimelines()
+    }
+
+    private static func sharedDefaults() -> UserDefaults? {
+        UserDefaults(suiteName: HutchAppGroup.identifier)
+    }
+
+    private static func reloadWidgetTimelines() {
+        #if canImport(WidgetKit)
+        WidgetCenter.shared.reloadTimelines(ofKind: SystemStatusWidgetConfiguration.kind)
+        #endif
+    }
+}