Commit e137533ecf

e137533ecfdae7bd96e94a1485fb9ffa25f1acf3

parent: 730b81a465

Unsigned

cmc <hello@cleberg.net> · 2026-04-20 17:42 UTC

feat: user-defined time period for recent builds card

- limit Home failed-build counts to a configurable lookback window
and add the setting under Behavior.
- make the Recent and Builds rows fully tappable across the
entire cell and add coverage for the new failed-build filtering.

Fixes: https://todo.sr.ht/~ccleberg/hutch/67

Layout: unified · split

Hutch.xcodeproj/project.pbxproj +4 −4
@@ -534,7 +534,7 @@
534534 "$(inherited)",
535535 "@executable_path/Frameworks",
536536 );
537 MARKETING_VERSION = 3.1.12;
537 MARKETING_VERSION = 3.2.0;
538538 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
539539 PRODUCT_NAME = "$(TARGET_NAME)";
540540 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -571,7 +571,7 @@
571571 "$(inherited)",
572572 "@executable_path/Frameworks",
573573 );
574 MARKETING_VERSION = 3.1.12;
574 MARKETING_VERSION = 3.2.0;
575575 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
576576 PRODUCT_NAME = "$(TARGET_NAME)";
577577 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -644,7 +644,7 @@
644644 "@executable_path/Frameworks",
645645 "@executable_path/../../Frameworks",
646646 );
647 MARKETING_VERSION = 3.1.12;
647 MARKETING_VERSION = 3.2.0;
648648 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
649649 PRODUCT_NAME = "$(TARGET_NAME)";
650650 SKIP_INSTALL = YES;
@@ -673,7 +673,7 @@
673673 "@executable_path/Frameworks",
674674 "@executable_path/../../Frameworks",
675675 );
676 MARKETING_VERSION = 3.1.12;
676 MARKETING_VERSION = 3.2.0;
677677 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
678678 PRODUCT_NAME = "$(TARGET_NAME)";
679679 SKIP_INSTALL = YES;
Hutch/App/AppStorageKeys.swift +1
@@ -23,4 +23,5 @@ enum AppStorageKeys {
2323 static let appTheme = "appTheme"
2424 static let displayDensity = "displayDensity"
2525 static let debugModeEnabled = "debugModeEnabled"
26 nonisolated static let homeFailedBuildLookbackDays = "homeFailedBuildLookbackDays"
2627}
Hutch/Views/Home/HomeView.swift +18 −15
@@ -3,6 +3,8 @@ import SwiftUI
33struct HomeView: View {
44 @Environment(AppState.self) private var appState
55 @Environment(\.scenePhase) private var scenePhase
6 @AppStorage(AppStorageKeys.homeFailedBuildLookbackDays, store: .standard)
7 private var failedBuildLookbackDays = HomeViewModel.defaultFailedBuildLookbackDays
68 @State private var viewModel: HomeViewModel?
79 @State private var recentItems: [RecentActivityEntry] = []
810 @State private var isOpeningRecentItem = false
@@ -142,7 +144,7 @@ struct HomeView: View {
142144 title: buildsTitle(viewModel),
143145 summary: buildsSummary(viewModel),
144146 systemImage: "hammer",
145 tint: viewModel.failedBuildCount > 0 ? .orange : .secondary,
147 tint: failedBuildCount(viewModel) > 0 ? .orange : .secondary,
146148 emphasis: .monitoring
147149 )
148150 }
@@ -208,7 +210,7 @@ struct HomeView: View {
208210 }
209211
210212 private func buildsTitle(_ viewModel: HomeViewModel) -> String {
211 let failed = viewModel.failedBuildCount
213 let failed = failedBuildCount(viewModel)
212214 let running = viewModel.activeBuildCount
213215
214216 if failed == 0 && running == 0 {
@@ -221,18 +223,18 @@ struct HomeView: View {
221223 }
222224
223225 private func buildsSummary(_ viewModel: HomeViewModel) -> String {
224 let failed = viewModel.failedBuildCount
226 let failed = failedBuildCount(viewModel)
225227 let running = viewModel.activeBuildCount
226228 if failed == 0 && running == 0 {
227 return "No failures • \(buildTimeframeLabel(viewModel))"
229 return "No failures • \(buildTimeframeLabel())"
228230 }
229231 if failed > 0 && running > 0 {
230 return "\(failed) failed • \(running) running • \(buildTimeframeLabel(viewModel))"
232 return "\(failed) failed • \(running) running • \(buildTimeframeLabel())"
231233 }
232234 if failed > 0 {
233 return "\(failed) failed • \(buildTimeframeLabel(viewModel))"
235 return "\(failed) failed • \(buildTimeframeLabel())"
234236 }
235 return "\(running) running • \(buildTimeframeLabel(viewModel))"
237 return "\(running) running now"
236238 }
237239
238240 private func pinnedItems(_ viewModel: HomeViewModel) -> [HomePinnedItem] {
@@ -251,15 +253,12 @@ struct HomeView: View {
251253 }
252254 }
253255
254 private func buildTimeframeLabel(_ viewModel: HomeViewModel) -> String {
255 let calendar = Calendar.current
256 let buildDates = viewModel.recentBuilds.map(\.job.updated)
257
258 guard !buildDates.isEmpty else {
259 return "today"
260 }
256 private func buildTimeframeLabel() -> String {
257 HomeViewModel.failedBuildLookbackLabel(days: failedBuildLookbackDays)
258 }
261259
262 return buildDates.allSatisfy(calendar.isDateInToday) ? "today" : "this week"
260 private func failedBuildCount(_ viewModel: HomeViewModel) -> Int {
261 viewModel.recentFailedBuilds(lookbackDays: failedBuildLookbackDays).count
263262 }
264263
265264 private func hasHomeContent(_ viewModel: HomeViewModel) -> Bool {
@@ -464,6 +463,8 @@ private struct HomeSummaryRow: View {
464463
465464 Spacer(minLength: 8)
466465 }
466 .frame(maxWidth: .infinity, alignment: .leading)
467 .contentShape(Rectangle())
467468 .padding(.vertical, verticalPadding)
468469 }
469470
@@ -508,6 +509,8 @@ private struct HomeRecentRow: View {
508509
509510 Spacer(minLength: 8)
510511 }
512 .frame(maxWidth: .infinity, alignment: .leading)
513 .contentShape(Rectangle())
511514 .padding(.vertical, 1)
512515 }
513516
Hutch/Views/Home/HomeViewModel.swift +60 −18
@@ -172,6 +172,9 @@ struct HomeBuildItem: Identifiable, Hashable, Sendable {
172172@Observable
173173@MainActor
174174final class HomeViewModel {
175 nonisolated static let defaultFailedBuildLookbackDays = 7
176 nonisolated static let allowedFailedBuildLookbackDays = [1, 3, 7, 14, 30]
177
175178 private(set) var projects: [Project] = []
176179 var assignedTickets: [HomeAssignedTicket] = []
177180 var recentBuilds: [HomeBuildItem] = []
@@ -434,14 +437,7 @@ final class HomeViewModel {
434437 }
435438
436439 var failedBuildCount: Int {
437 recentBuilds.filter {
438 switch $0.job.status {
439 case .failed, .timeout:
440 return true
441 default:
442 return false
443 }
444 }.count
440 recentFailedBuilds().count
445441 }
446442
447443 var activeBuildCount: Int {
@@ -532,6 +528,19 @@ final class HomeViewModel {
532528 return parts.joined(separator: " • ")
533529 }
534530
531 func recentFailedBuilds(
532 lookbackDays: Int? = nil,
533 now: Date = .now,
534 calendar: Calendar = .current
535 ) -> [HomeBuildItem] {
536 Self.failedBuilds(
537 in: recentBuilds,
538 lookbackDays: lookbackDays ?? Self.failedBuildLookbackDays(),
539 now: now,
540 calendar: calendar
541 )
542 }
543
535544 var systemSummaryText: String {
536545 guard let systemStatusSnapshot else {
537546 return systemStatusErrorMessage ?? "System status unavailable"
@@ -949,20 +958,12 @@ final class HomeViewModel {
949958 }
950959
951960 private func persistNeedsAttentionSnapshot() {
961 let failedBuildCount = recentFailedBuilds().count
952962 NeedsAttentionSnapshotStore.save(
953963 NeedsAttentionSnapshot(
954964 unreadInboxThreads: unreadInboxThreadCount,
955965 assignedOpenTickets: assignedTicketsError == nil ? assignedTickets.count : nil,
956 failedBuilds: recentBuildsError == nil
957 ? recentBuilds.filter {
958 switch $0.job.status {
959 case .failed, .timeout:
960 true
961 default:
962 false
963 }
964 }.count
965 : nil,
966 failedBuilds: recentBuildsError == nil ? failedBuildCount : nil,
966967 updatedAt: .now
967968 ),
968969 accountID: accountID
@@ -1013,6 +1014,47 @@ final class HomeViewModel {
10131014 }
10141015 }
10151016
1017 nonisolated static func failedBuildLookbackDays(defaults: UserDefaults = .standard) -> Int {
1018 let value = defaults.object(forKey: AppStorageKeys.homeFailedBuildLookbackDays) as? Int
1019 guard let value, allowedFailedBuildLookbackDays.contains(value) else {
1020 return defaultFailedBuildLookbackDays
1021 }
1022 return value
1023 }
1024
1025 nonisolated static func failedBuilds(
1026 in builds: [HomeBuildItem],
1027 lookbackDays: Int,
1028 now: Date = .now,
1029 calendar: Calendar = .current
1030 ) -> [HomeBuildItem] {
1031 let normalizedLookbackDays = allowedFailedBuildLookbackDays.contains(lookbackDays)
1032 ? lookbackDays
1033 : defaultFailedBuildLookbackDays
1034 let startOfToday = calendar.startOfDay(for: now)
1035 let windowStart = calendar.date(byAdding: .day, value: -(normalizedLookbackDays - 1), to: startOfToday) ?? startOfToday
1036
1037 return builds.filter { build in
1038 guard build.job.updated >= windowStart else { return false }
1039 switch build.job.status {
1040 case .failed, .timeout:
1041 return true
1042 default:
1043 return false
1044 }
1045 }
1046 }
1047
1048 nonisolated static func failedBuildLookbackLabel(days: Int) -> String {
1049 let normalizedDays = allowedFailedBuildLookbackDays.contains(days)
1050 ? days
1051 : defaultFailedBuildLookbackDays
1052 if normalizedDays == 1 {
1053 return "today"
1054 }
1055 return "last \(normalizedDays) days"
1056 }
1057
10161058 nonisolated static func sortBuildItemsForTriage(_ lhs: HomeBuildItem, _ rhs: HomeBuildItem) -> Bool {
10171059 let lhsPriority = buildPriority(for: lhs.job.status)
10181060 let rhsPriority = buildPriority(for: rhs.job.status)
Hutch/Views/Settings/SettingsView.swift +16 −2
@@ -6,6 +6,8 @@ struct SettingsView: View {
66 @AppStorage(AppStorageKeys.displayDensity, store: .standard) private var displayDensity: DisplayDensity = .standard
77 @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true
88 @AppStorage(AppStorageKeys.contributionGraphsEnabled, store: .standard) private var contributionGraphsEnabled = true
9 @AppStorage(AppStorageKeys.homeFailedBuildLookbackDays, store: .standard)
10 private var failedBuildLookbackDays = HomeViewModel.defaultFailedBuildLookbackDays
911 @State private var pendingDestructiveAction: SettingsDestructiveAction?
1012 @State private var showAccountSwitcher = false
1113
@@ -85,10 +87,16 @@ struct SettingsView: View {
8587 ContributionWidgetContextStore.setEnabled(newValue)
8688 }
8789 .themedRow()
90 Picker("Failed build window", selection: $failedBuildLookbackDays) {
91 ForEach(HomeViewModel.allowedFailedBuildLookbackDays, id: \.self) { days in
92 Text(failedBuildWindowLabel(days)).tag(days)
93 }
94 }
95 .themedRow()
8896 } header: {
8997 Text("Behavior")
9098 } footer: {
91 Text("When enabled, swipe list rows to quickly take actions like resolving tickets, cancelling builds, and deleting pastes. Contribution graphs controls whether SourceHut activity heatmaps appear in lookup profiles.")
99 Text("When enabled, swipe list rows to quickly take actions like resolving tickets, cancelling builds, and deleting pastes. Contribution graphs controls whether SourceHut activity heatmaps appear in lookup profiles. Failed build window controls how far back the Home tab counts failed builds.")
92100 }
93101 }
94102
@@ -143,6 +151,13 @@ struct SettingsView: View {
143151
144152}
145153
154private func failedBuildWindowLabel(_ days: Int) -> String {
155 if days == 1 {
156 return "Today only"
157 }
158 return "Last \(days) days"
159}
160
146161func settingsBioAttributedString(_ markdown: String) -> AttributedString {
147162 profileBioAttributedString(markdown)
148163}
@@ -178,4 +193,3 @@ private enum SettingsDestructiveAction {
178193 }
179194 }
180195}
181
HutchTests/HomeViewModelTests.swift +71
@@ -18,6 +18,77 @@ struct HomeViewModelTests {
1818 #expect(failedBuilds.map(\.job.id) == [2, 3])
1919 }
2020
21 @Test
22 func failedBuildsWithinLookbackExcludeOlderFailures() {
23 let now = Date(timeIntervalSince1970: 60 * 60 * 24 * 20)
24 let recentFailure = HomeBuildItem(
25 job: JobSummary(
26 id: 1,
27 created: now.addingTimeInterval(-(60 * 60 * 24)),
28 updated: now.addingTimeInterval(-(60 * 60 * 24)),
29 status: .failed,
30 note: nil,
31 tags: [],
32 visibility: nil,
33 image: nil,
34 tasks: []
35 ),
36 repositoryName: nil,
37 repositoryOwner: nil
38 )
39 let oldFailure = HomeBuildItem(
40 job: JobSummary(
41 id: 2,
42 created: now.addingTimeInterval(-(60 * 60 * 24 * 10)),
43 updated: now.addingTimeInterval(-(60 * 60 * 24 * 10)),
44 status: .timeout,
45 note: nil,
46 tags: [],
47 visibility: nil,
48 image: nil,
49 tasks: []
50 ),
51 repositoryName: nil,
52 repositoryOwner: nil
53 )
54 let recentSuccess = HomeBuildItem(
55 job: JobSummary(
56 id: 3,
57 created: now.addingTimeInterval(-(60 * 60 * 24)),
58 updated: now.addingTimeInterval(-(60 * 60 * 24)),
59 status: .success,
60 note: nil,
61 tags: [],
62 visibility: nil,
63 image: nil,
64 tasks: []
65 ),
66 repositoryName: nil,
67 repositoryOwner: nil
68 )
69
70 let filtered = HomeViewModel.failedBuilds(
71 in: [recentFailure, oldFailure, recentSuccess],
72 lookbackDays: 7,
73 now: now,
74 calendar: Calendar(identifier: .gregorian)
75 )
76
77 #expect(filtered.map(\.job.id) == [1])
78 }
79
80 @Test
81 func failedBuildLookbackDaysFallsBackToDefaultWhenUnsetOrInvalid() {
82 let defaults = UserDefaults(suiteName: #function)!
83 defaults.removePersistentDomain(forName: #function)
84
85 #expect(HomeViewModel.failedBuildLookbackDays(defaults: defaults) == HomeViewModel.defaultFailedBuildLookbackDays)
86
87 defaults.set(99, forKey: AppStorageKeys.homeFailedBuildLookbackDays)
88
89 #expect(HomeViewModel.failedBuildLookbackDays(defaults: defaults) == HomeViewModel.defaultFailedBuildLookbackDays)
90 }
91
2192 @Test
2293 func matchesCurrentUserAssigneeNormalizesCanonicalNameAndUsername() {
2394 let currentUser = User(