summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-23 17:39:13 -0500
committerChristian Cleberg <[email protected]>2026-04-23 17:39:13 -0500
commit66601a8091a73f3e31f4fb534c38b3eca4756e93 (patch)
treef85cd883e63177d25dfe5993a4d545efa4812923
parent380fb866d090c5f13b2ad3f88f1fe23c4ade4801 (diff)
downloadhutch-3.2.1.tar.gz
hutch-3.2.1.tar.bz2
hutch-3.2.1.zip
fix: profile loading error and user-timeline prefs updatev3.2.1
-rw-r--r--Hutch.xcodeproj/project.pbxproj16
-rw-r--r--Hutch/App/AppStorageKeys.swift1
-rw-r--r--Hutch/Models/Meta.swift8
-rw-r--r--Hutch/Views/Builds/BuildListView.swift26
-rw-r--r--Hutch/Views/Builds/BuildListViewModel.swift28
-rw-r--r--Hutch/Views/Home/HomeView.swift3
-rw-r--r--Hutch/Views/Home/HomeViewModel.swift4
-rw-r--r--Hutch/Views/More/ProfileView.swift4
-rw-r--r--Hutch/Views/Settings/SettingsViewModel.swift4
-rw-r--r--HutchTests/BuildListViewModelTests.swift58
-rw-r--r--HutchTests/SettingsViewModelTests.swift55
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"))
+ }
}