krz/hutch

an ios client for sourcehut

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

66601a8091a73f3e31f4fb534c38b3eca4756e93

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-23T22:39:13Z

fix: profile loading error and user-timeline prefs update
 Hutch.xcodeproj/project.pbxproj              | 16 ++++----
 Hutch/App/AppStorageKeys.swift               |  1 +
 Hutch/Models/Meta.swift                      |  8 +++-
 Hutch/Views/Builds/BuildListView.swift       | 26 +++++++++++++
 Hutch/Views/Builds/BuildListViewModel.swift  | 28 ++++++++++++--
 Hutch/Views/Home/HomeView.swift              |  3 ++
 Hutch/Views/Home/HomeViewModel.swift         |  4 ++
 Hutch/Views/More/ProfileView.swift           |  4 +-
 Hutch/Views/Settings/SettingsViewModel.swift |  4 +-
 HutchTests/BuildListViewModelTests.swift     | 58 +++++++++++++++++++++++++---
 HutchTests/SettingsViewModelTests.swift      | 55 ++++++++++++++++++++++++++
 11 files changed, 186 insertions(+), 21 deletions(-)

diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj
index 3d4f7a2..bf0dee0 100644
--- a/Hutch.xcodeproj/project.pbxproj
+++ b/Hutch.xcodeproj/project.pbxproj
@@ -517,7 +517,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 80;
+				CURRENT_PROJECT_VERSION = 81;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -534,7 +534,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 3.2.0;
+				MARKETING_VERSION = 3.2.1;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -554,7 +554,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 80;
+				CURRENT_PROJECT_VERSION = 81;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -571,7 +571,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 3.2.0;
+				MARKETING_VERSION = 3.2.1;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -634,7 +634,7 @@
 				APPLICATION_EXTENSION_API_ONLY = YES;
 				CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 80;
+				CURRENT_PROJECT_VERSION = 81;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = NO;
 				INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -644,7 +644,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 3.2.0;
+				MARKETING_VERSION = 3.2.1;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
@@ -663,7 +663,7 @@
 				APPLICATION_EXTENSION_API_ONLY = YES;
 				CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 80;
+				CURRENT_PROJECT_VERSION = 81;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = NO;
 				INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -673,7 +673,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 3.2.0;
+				MARKETING_VERSION = 3.2.1;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
diff --git a/Hutch/App/AppStorageKeys.swift b/Hutch/App/AppStorageKeys.swift
index 30cd0d0..de3ce54 100644
--- a/Hutch/App/AppStorageKeys.swift
+++ b/Hutch/App/AppStorageKeys.swift
@@ -18,6 +18,7 @@ enum AppStorageKeys {
     static let homeBuildsExpanded = "homeBuildsExpanded"
     static let buildsAutoRefreshInterval = "buildsAutoRefreshInterval"
     static let buildsRepoFilter = "buildsRepoFilter"
+    static let buildsLookbackDays = "buildsLookbackDays"
     static let ticketFilterState = "ticketFilterState"
     static let ticketSavedFilters = "ticketSavedFilters"
     static let appTheme = "appTheme"
diff --git a/Hutch/Models/Meta.swift b/Hutch/Models/Meta.swift
index c0434bc..91bd7e3 100644
--- a/Hutch/Models/Meta.swift
+++ b/Hutch/Models/Meta.swift
@@ -22,10 +22,16 @@ struct UserProfile: Codable, Sendable {
 
 struct SSHKey: Codable, Sendable, Identifiable {
     let id: Int
-    let fingerprint: String
     let comment: String?
     let created: Date
     let lastUsed: Date?
+
+    var displayLabel: String {
+        if let comment, !comment.isEmpty {
+            return comment
+        }
+        return "SSH key #\(id)"
+    }
 }
 
 struct SSHKeyPage: Codable, Sendable {
diff --git a/Hutch/Views/Builds/BuildListView.swift b/Hutch/Views/Builds/BuildListView.swift
index 8de2220..20414d6 100644
--- a/Hutch/Views/Builds/BuildListView.swift
+++ b/Hutch/Views/Builds/BuildListView.swift
@@ -4,6 +4,8 @@ struct BuildListView: View {
     @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true
     @AppStorage(AppStorageKeys.buildsAutoRefreshInterval) private var autoRefreshRawValue = 0
     @AppStorage(AppStorageKeys.buildsRepoFilter) private var savedRepoFilter = ""
+    @AppStorage(AppStorageKeys.buildsLookbackDays, store: .standard)
+    private var lookbackDays = BuildListViewModel.defaultLookbackDays
     @Environment(AppState.self) private var appState
     @Environment(\.isAMOLEDTheme) private var isAMOLED
     @State private var viewModel: BuildListViewModel?
@@ -65,6 +67,20 @@ struct BuildListView: View {
                                 }
                             }
                         }
+                        Section("Timeframe") {
+                            ForEach(HomeViewModel.allowedFailedBuildLookbackDays, id: \.self) { days in
+                                Button {
+                                    lookbackDays = days
+                                    viewModel.lookbackDays = days
+                                } label: {
+                                    if lookbackDays == days {
+                                        Label(HomeViewModel.failedBuildLookbackLabel(days: days), systemImage: "checkmark")
+                                    } else {
+                                        Text(HomeViewModel.failedBuildLookbackLabel(days: days))
+                                    }
+                                }
+                            }
+                        }
                     } label: {
                         Image(systemName: "line.3.horizontal.decrease.circle")
                     }
@@ -107,6 +123,7 @@ struct BuildListView: View {
             if viewModel == nil {
                 let vm = BuildListViewModel(client: appState.client, defaults: appState.accountDefaults)
                 vm.repoFilter = savedRepoFilter
+                vm.lookbackDays = lookbackDays
                 viewModel = vm
                 await vm.loadJobs()
             }
@@ -114,6 +131,9 @@ struct BuildListView: View {
             // onDisappear stops it when navigating away.
             viewModel?.startAutoRefresh(interval: autoRefreshInterval)
         }
+        .onChange(of: lookbackDays) { _, newValue in
+            viewModel?.lookbackDays = newValue
+        }
         .onDisappear {
             viewModel?.stopAutoRefresh()
         }
@@ -236,6 +256,12 @@ struct BuildListView: View {
                     systemImage: "magnifyingglass",
                     description: Text("No builds matched “\(viewModel.searchText)”.")
                 )
+            } else if !viewModel.jobs.isEmpty, viewModel.filteredJobs.isEmpty {
+                ContentUnavailableView(
+                    "No Builds In Timeframe",
+                    systemImage: "calendar.badge.clock",
+                    description: Text("No builds were updated \(HomeViewModel.failedBuildLookbackLabel(days: lookbackDays)).")
+                )
             } else if viewModel.jobs.isEmpty, viewModel.error == nil {
                 ContentUnavailableView(
                     "No Builds",
diff --git a/Hutch/Views/Builds/BuildListViewModel.swift b/Hutch/Views/Builds/BuildListViewModel.swift
index cf5d786..14f50fd 100644
--- a/Hutch/Views/Builds/BuildListViewModel.swift
+++ b/Hutch/Views/Builds/BuildListViewModel.swift
@@ -45,6 +45,7 @@ enum AutoRefreshInterval: Int, CaseIterable, Sendable {
 @MainActor
 final class BuildListViewModel {
     private static let searchHistoryScopeID = "builds"
+    nonisolated static let defaultLookbackDays = HomeViewModel.defaultFailedBuildLookbackDays
 
     private(set) var jobs: [JobSummary] = [] {
         didSet { updateFilteredJobs() }
@@ -68,6 +69,9 @@ final class BuildListViewModel {
             updateFilteredJobs()
         }
     }
+    var lookbackDays = defaultLookbackDays {
+        didSet { updateFilteredJobs() }
+    }
     // Cached filtered result. Updated whenever jobs, filter, searchText, or
     // repoFilter changes. Only notifies observers when the content actually
     // differs, which prevents the list from re-rendering on auto-refresh when
@@ -99,7 +103,7 @@ final class BuildListViewModel {
     }
 
     private func updateFilteredJobs() {
-        var result = Self.filterJobs(jobs, filter: filter)
+        var result = Self.filterJobs(jobs, filter: filter, lookbackDays: lookbackDays)
         if !repoFilter.isEmpty {
             result = result.filter { $0.tags.contains(repoFilter) }
         }
@@ -382,8 +386,14 @@ final class BuildListViewModel {
         let cancel: CancelResult
     }
 
-    nonisolated static func filterJobs(_ jobs: [JobSummary], filter: BuildListFilter) -> [JobSummary] {
-        jobs.filter { job in
+    nonisolated static func filterJobs(
+        _ jobs: [JobSummary],
+        filter: BuildListFilter,
+        lookbackDays: Int,
+        now: Date = .now,
+        calendar: Calendar = .current
+    ) -> [JobSummary] {
+        let filteredByStatus = jobs.filter { job in
             switch filter {
             case .attention:
                 switch job.status {
@@ -403,6 +413,18 @@ final class BuildListViewModel {
                 return true
             }
         }
+
+        let normalizedLookbackDays = HomeViewModel.allowedFailedBuildLookbackDays.contains(lookbackDays)
+            ? lookbackDays
+            : defaultLookbackDays
+        let startOfToday = calendar.startOfDay(for: now)
+        let windowStart = calendar.date(
+            byAdding: .day,
+            value: -(normalizedLookbackDays - 1),
+            to: startOfToday
+        ) ?? startOfToday
+
+        return filteredByStatus.filter { $0.updated >= windowStart }
     }
 
     nonisolated static func searchJobs(_ jobs: [JobSummary], matching query: String) -> [JobSummary] {
diff --git a/Hutch/Views/Home/HomeView.swift b/Hutch/Views/Home/HomeView.swift
index 406e2ad..9e06a00 100644
--- a/Hutch/Views/Home/HomeView.swift
+++ b/Hutch/Views/Home/HomeView.swift
@@ -56,6 +56,9 @@ struct HomeView: View {
                 loadRecentActivity()
             }
         }
+        .onChange(of: failedBuildLookbackDays) { _, _ in
+            viewModel?.refreshNeedsAttentionSnapshot()
+        }
     }
 
     @ViewBuilder
diff --git a/Hutch/Views/Home/HomeViewModel.swift b/Hutch/Views/Home/HomeViewModel.swift
index 7ef82e9..142eee4 100644
--- a/Hutch/Views/Home/HomeViewModel.swift
+++ b/Hutch/Views/Home/HomeViewModel.swift
@@ -670,6 +670,10 @@ final class HomeViewModel {
         persistNeedsAttentionSnapshot()
     }
 
+    func refreshNeedsAttentionSnapshot() {
+        persistNeedsAttentionSnapshot()
+    }
+
     private func loadProjects() async -> Result<[Project], Error> {
         do {
             return .success(try await projectService.fetchProjects())
diff --git a/Hutch/Views/More/ProfileView.swift b/Hutch/Views/More/ProfileView.swift
index 8f353ee..45f7d06 100644
--- a/Hutch/Views/More/ProfileView.swift
+++ b/Hutch/Views/More/ProfileView.swift
@@ -243,7 +243,7 @@ struct ProfileView: View {
         Section {
             ForEach(viewModel.sshKeys) { key in
                 VStack(alignment: .leading, spacing: 2) {
-                    Text(key.fingerprint)
+                    Text(key.displayLabel)
                         .font(.caption.monospaced())
                         .lineLimit(1)
                         .truncationMode(.middle)
@@ -677,7 +677,7 @@ private enum ProfileDestructiveAction {
     var message: String {
         switch self {
         case .deleteSSHKey(let key):
-            "Remove SSH key \(key.fingerprint) from your account?"
+            "Remove \(key.displayLabel) from your account?"
         case .deletePGPKey(let key):
             "Remove PGP key \(key.fingerprint) from your account?"
         }
diff --git a/Hutch/Views/Settings/SettingsViewModel.swift b/Hutch/Views/Settings/SettingsViewModel.swift
index 0b55e6a..edfaad4 100644
--- a/Hutch/Views/Settings/SettingsViewModel.swift
+++ b/Hutch/Views/Settings/SettingsViewModel.swift
@@ -90,7 +90,7 @@ final class SettingsViewModel {
             avatar
             userType
             sshKeys {
-                results { id fingerprint comment created lastUsed }
+                results { id comment created lastUsed }
                 cursor
             }
             pgpKeys {
@@ -114,7 +114,7 @@ final class SettingsViewModel {
     private static let createSSHKeyMutation = """
     mutation createSSHKey($key: String!) {
         createSSHKey(key: $key) {
-            id fingerprint comment created lastUsed
+            id comment created lastUsed
         }
     }
     """
diff --git a/HutchTests/BuildListViewModelTests.swift b/HutchTests/BuildListViewModelTests.swift
index fccd7cf..aa2e46b 100644
--- a/HutchTests/BuildListViewModelTests.swift
+++ b/HutchTests/BuildListViewModelTests.swift
@@ -49,22 +49,70 @@ struct BuildListViewModelTests {
             makeJob(id: 4, status: .cancelled, tags: [])
         ]
 
-        let attention = BuildListViewModel.filterJobs(jobs, filter: .attention)
-        let active = BuildListViewModel.filterJobs(jobs, filter: .active)
+        let attention = BuildListViewModel.filterJobs(
+            jobs,
+            filter: .attention,
+            lookbackDays: BuildListViewModel.defaultLookbackDays
+        )
+        let active = BuildListViewModel.filterJobs(
+            jobs,
+            filter: .active,
+            lookbackDays: BuildListViewModel.defaultLookbackDays
+        )
 
         #expect(attention.map(\.id) == [2, 3])
         #expect(active.map(\.id) == [3])
     }
 
+    @Test
+    func buildFilterRestrictsJobsToSelectedLookbackWindow() {
+        let now = Date(timeIntervalSince1970: 60 * 60 * 24 * 20)
+        let jobs = [
+            makeJob(
+                id: 1,
+                status: .failed,
+                tags: [],
+                updated: now.addingTimeInterval(-(60 * 60 * 24))
+            ),
+            makeJob(
+                id: 2,
+                status: .running,
+                tags: [],
+                updated: now.addingTimeInterval(-(60 * 60 * 24 * 8))
+            ),
+            makeJob(
+                id: 3,
+                status: .success,
+                tags: [],
+                updated: now.addingTimeInterval(-(60 * 60 * 24 * 2))
+            ),
+        ]
+
+        let filtered = BuildListViewModel.filterJobs(
+            jobs,
+            filter: .all,
+            lookbackDays: 3,
+            now: now,
+            calendar: Calendar(identifier: .gregorian)
+        )
+
+        #expect(filtered.map(\.id) == [1, 3])
+    }
+
     private func filterJobs(_ jobs: [JobSummary], query: String) -> [JobSummary] {
         BuildListViewModel.searchJobs(jobs, matching: query)
     }
 
-    private func makeJob(id: Int, status: JobStatus = .success, tags: [String]) -> JobSummary {
+    private func makeJob(
+        id: Int,
+        status: JobStatus = .success,
+        tags: [String],
+        updated: Date = Date()
+    ) -> JobSummary {
         JobSummary(
             id: id,
-            created: Date(),
-            updated: Date(),
+            created: updated,
+            updated: updated,
             status: status,
             note: nil,
             tags: tags,
diff --git a/HutchTests/SettingsViewModelTests.swift b/HutchTests/SettingsViewModelTests.swift
index fc18162..9e4b912 100644
--- a/HutchTests/SettingsViewModelTests.swift
+++ b/HutchTests/SettingsViewModelTests.swift
@@ -2,6 +2,37 @@ import Foundation
 import Testing
 @testable import Hutch
 
+private final class SettingsViewModelCapturingURLProtocol: URLProtocol, @unchecked Sendable {
+    nonisolated(unsafe) static var capturedRequests: [URLRequest] = []
+
+    override class func canInit(with _: URLRequest) -> Bool { true }
+    override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
+
+    override func startLoading() {
+        Self.capturedRequests.append(request)
+
+        let response = HTTPURLResponse(
+            url: request.url!,
+            statusCode: 401,
+            httpVersion: nil,
+            headerFields: nil
+        )!
+        client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
+        client?.urlProtocol(self, didLoad: Data())
+        client?.urlProtocolDidFinishLoading(self)
+    }
+
+    override func stopLoading() {
+        // No cleanup is needed because the stub responds immediately in `startLoading()`.
+    }
+
+    static func makeSession() -> URLSession {
+        let config = URLSessionConfiguration.ephemeral
+        config.protocolClasses = [Self.self]
+        return URLSession(configuration: config)
+    }
+}
+
 private struct DeletePGPKeyEnvelope: Decodable {
     let deletePGPKey: DeleteResultPayload?
 }
@@ -10,6 +41,7 @@ private struct DeleteResultPayload: Decodable {
     let id: Int?
 }
 
+@Suite(.serialized)
 struct SettingsViewModelTests {
 
     @Test
@@ -36,4 +68,27 @@ struct SettingsViewModelTests {
         #expect(decoded.data?.deletePGPKey == nil)
         #expect(decoded.errors?.first?.message.contains("preferred PGP key") == true)
     }
+
+    @Test
+    @MainActor
+    func loadProfileDoesNotRequestSSHKeyFingerprintField() async throws {
+        SettingsViewModelCapturingURLProtocol.capturedRequests = []
+
+        let client = SRHTClient(
+            session: SettingsViewModelCapturingURLProtocol.makeSession(),
+            token: "test-token"
+        )
+        let viewModel = SettingsViewModel(client: client)
+
+        await viewModel.loadProfile()
+
+        let request = try #require(SettingsViewModelCapturingURLProtocol.capturedRequests.first)
+        let body = try #require(request.httpBody)
+        let jsonObject = try #require(JSONSerialization.jsonObject(with: body) as? [String: Any])
+        let query = try #require(jsonObject["query"] as? String)
+
+        #expect(query.contains("sshKeys"))
+        #expect(!query.contains("results { id fingerprint comment created lastUsed }"))
+        #expect(!query.contains("fingerprint comment created lastUsed"))
+    }
 }