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 @@
534 "$(inherited)", 534 "$(inherited)",
535 "@executable_path/Frameworks", 535 "@executable_path/Frameworks",
536 ); 536 );
537 MARKETING_VERSION = 3.1.12; 537 MARKETING_VERSION = 3.2.0;
538 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; 538 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
539 PRODUCT_NAME = "$(TARGET_NAME)"; 539 PRODUCT_NAME = "$(TARGET_NAME)";
540 STRING_CATALOG_GENERATE_SYMBOLS = YES; 540 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -571,7 +571,7 @@
571 "$(inherited)", 571 "$(inherited)",
572 "@executable_path/Frameworks", 572 "@executable_path/Frameworks",
573 ); 573 );
574 MARKETING_VERSION = 3.1.12; 574 MARKETING_VERSION = 3.2.0;
575 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; 575 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
576 PRODUCT_NAME = "$(TARGET_NAME)"; 576 PRODUCT_NAME = "$(TARGET_NAME)";
577 STRING_CATALOG_GENERATE_SYMBOLS = YES; 577 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -644,7 +644,7 @@
644 "@executable_path/Frameworks", 644 "@executable_path/Frameworks",
645 "@executable_path/../../Frameworks", 645 "@executable_path/../../Frameworks",
646 ); 646 );
647 MARKETING_VERSION = 3.1.12; 647 MARKETING_VERSION = 3.2.0;
648 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; 648 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
649 PRODUCT_NAME = "$(TARGET_NAME)"; 649 PRODUCT_NAME = "$(TARGET_NAME)";
650 SKIP_INSTALL = YES; 650 SKIP_INSTALL = YES;
@@ -673,7 +673,7 @@
673 "@executable_path/Frameworks", 673 "@executable_path/Frameworks",
674 "@executable_path/../../Frameworks", 674 "@executable_path/../../Frameworks",
675 ); 675 );
676 MARKETING_VERSION = 3.1.12; 676 MARKETING_VERSION = 3.2.0;
677 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; 677 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
678 PRODUCT_NAME = "$(TARGET_NAME)"; 678 PRODUCT_NAME = "$(TARGET_NAME)";
679 SKIP_INSTALL = YES; 679 SKIP_INSTALL = YES;
Hutch/App/AppStorageKeys.swift +1
@@ -23,4 +23,5 @@ enum AppStorageKeys {
23 static let appTheme = "appTheme" 23 static let appTheme = "appTheme"
24 static let displayDensity = "displayDensity" 24 static let displayDensity = "displayDensity"
25 static let debugModeEnabled = "debugModeEnabled" 25 static let debugModeEnabled = "debugModeEnabled"
26 nonisolated static let homeFailedBuildLookbackDays = "homeFailedBuildLookbackDays"
26} 27}
Hutch/Views/Home/HomeView.swift +18 −15
@@ -3,6 +3,8 @@ import SwiftUI
3struct HomeView: View { 3struct HomeView: View {
4 @Environment(AppState.self) private var appState 4 @Environment(AppState.self) private var appState
5 @Environment(\.scenePhase) private var scenePhase 5 @Environment(\.scenePhase) private var scenePhase
6 @AppStorage(AppStorageKeys.homeFailedBuildLookbackDays, store: .standard)
7 private var failedBuildLookbackDays = HomeViewModel.defaultFailedBuildLookbackDays
6 @State private var viewModel: HomeViewModel? 8 @State private var viewModel: HomeViewModel?
7 @State private var recentItems: [RecentActivityEntry] = [] 9 @State private var recentItems: [RecentActivityEntry] = []
8 @State private var isOpeningRecentItem = false 10 @State private var isOpeningRecentItem = false
@@ -142,7 +144,7 @@ struct HomeView: View {
142 title: buildsTitle(viewModel), 144 title: buildsTitle(viewModel),
143 summary: buildsSummary(viewModel), 145 summary: buildsSummary(viewModel),
144 systemImage: "hammer", 146 systemImage: "hammer",
145 tint: viewModel.failedBuildCount > 0 ? .orange : .secondary, 147 tint: failedBuildCount(viewModel) > 0 ? .orange : .secondary,
146 emphasis: .monitoring 148 emphasis: .monitoring
147 ) 149 )
148 } 150 }
@@ -208,7 +210,7 @@ struct HomeView: View {
208 } 210 }
209 211
210 private func buildsTitle(_ viewModel: HomeViewModel) -> String { 212 private func buildsTitle(_ viewModel: HomeViewModel) -> String {
211 let failed = viewModel.failedBuildCount 213 let failed = failedBuildCount(viewModel)
212 let running = viewModel.activeBuildCount 214 let running = viewModel.activeBuildCount
213 215
214 if failed == 0 && running == 0 { 216 if failed == 0 && running == 0 {
@@ -221,18 +223,18 @@ struct HomeView: View {
221 } 223 }
222 224
223 private func buildsSummary(_ viewModel: HomeViewModel) -> String { 225 private func buildsSummary(_ viewModel: HomeViewModel) -> String {
224 let failed = viewModel.failedBuildCount 226 let failed = failedBuildCount(viewModel)
225 let running = viewModel.activeBuildCount 227 let running = viewModel.activeBuildCount
226 if failed == 0 && running == 0 { 228 if failed == 0 && running == 0 {
227 return "No failures • \(buildTimeframeLabel(viewModel))" 229 return "No failures • \(buildTimeframeLabel())"
228 } 230 }
229 if failed > 0 && running > 0 { 231 if failed > 0 && running > 0 {
230 return "\(failed) failed • \(running) running • \(buildTimeframeLabel(viewModel))" 232 return "\(failed) failed • \(running) running • \(buildTimeframeLabel())"
231 } 233 }
232 if failed > 0 { 234 if failed > 0 {
233 return "\(failed) failed • \(buildTimeframeLabel(viewModel))" 235 return "\(failed) failed • \(buildTimeframeLabel())"
234 } 236 }
235 return "\(running) running • \(buildTimeframeLabel(viewModel))" 237 return "\(running) running now"
236 } 238 }
237 239
238 private func pinnedItems(_ viewModel: HomeViewModel) -> [HomePinnedItem] { 240 private func pinnedItems(_ viewModel: HomeViewModel) -> [HomePinnedItem] {
@@ -251,15 +253,12 @@ struct HomeView: View {
251 } 253 }
252 } 254 }
253 255
254 private func buildTimeframeLabel(_ viewModel: HomeViewModel) -> String { 256 private func buildTimeframeLabel() -> String {
255 let calendar = Calendar.current 257 HomeViewModel.failedBuildLookbackLabel(days: failedBuildLookbackDays)
256 let buildDates = viewModel.recentBuilds.map(\.job.updated) 258 }
257
258 guard !buildDates.isEmpty else {
259 return "today"
260 }
261 259
262 return buildDates.allSatisfy(calendar.isDateInToday) ? "today" : "this week" 260 private func failedBuildCount(_ viewModel: HomeViewModel) -> Int {
261 viewModel.recentFailedBuilds(lookbackDays: failedBuildLookbackDays).count
263 } 262 }
264 263
265 private func hasHomeContent(_ viewModel: HomeViewModel) -> Bool { 264 private func hasHomeContent(_ viewModel: HomeViewModel) -> Bool {
@@ -464,6 +463,8 @@ private struct HomeSummaryRow: View {
464 463
465 Spacer(minLength: 8) 464 Spacer(minLength: 8)
466 } 465 }
466 .frame(maxWidth: .infinity, alignment: .leading)
467 .contentShape(Rectangle())
467 .padding(.vertical, verticalPadding) 468 .padding(.vertical, verticalPadding)
468 } 469 }
469 470
@@ -508,6 +509,8 @@ private struct HomeRecentRow: View {
508 509
509 Spacer(minLength: 8) 510 Spacer(minLength: 8)
510 } 511 }
512 .frame(maxWidth: .infinity, alignment: .leading)
513 .contentShape(Rectangle())
511 .padding(.vertical, 1) 514 .padding(.vertical, 1)
512 } 515 }
513 516
Hutch/Views/Home/HomeViewModel.swift +60 −18
@@ -172,6 +172,9 @@ struct HomeBuildItem: Identifiable, Hashable, Sendable {
172@Observable 172@Observable
173@MainActor 173@MainActor
174final class HomeViewModel { 174final class HomeViewModel {
175 nonisolated static let defaultFailedBuildLookbackDays = 7
176 nonisolated static let allowedFailedBuildLookbackDays = [1, 3, 7, 14, 30]
177
175 private(set) var projects: [Project] = [] 178 private(set) var projects: [Project] = []
176 var assignedTickets: [HomeAssignedTicket] = [] 179 var assignedTickets: [HomeAssignedTicket] = []
177 var recentBuilds: [HomeBuildItem] = [] 180 var recentBuilds: [HomeBuildItem] = []
@@ -434,14 +437,7 @@ final class HomeViewModel {
434 } 437 }
435 438
436 var failedBuildCount: Int { 439 var failedBuildCount: Int {
437 recentBuilds.filter { 440 recentFailedBuilds().count
438 switch $0.job.status {
439 case .failed, .timeout:
440 return true
441 default:
442 return false
443 }
444 }.count
445 } 441 }
446 442
447 var activeBuildCount: Int { 443 var activeBuildCount: Int {
@@ -532,6 +528,19 @@ final class HomeViewModel {
532 return parts.joined(separator: " • ") 528 return parts.joined(separator: " • ")
533 } 529 }
534 530
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
535 var systemSummaryText: String { 544 var systemSummaryText: String {
536 guard let systemStatusSnapshot else { 545 guard let systemStatusSnapshot else {
537 return systemStatusErrorMessage ?? "System status unavailable" 546 return systemStatusErrorMessage ?? "System status unavailable"
@@ -949,20 +958,12 @@ final class HomeViewModel {
949 } 958 }
950 959
951 private func persistNeedsAttentionSnapshot() { 960 private func persistNeedsAttentionSnapshot() {
961 let failedBuildCount = recentFailedBuilds().count
952 NeedsAttentionSnapshotStore.save( 962 NeedsAttentionSnapshotStore.save(
953 NeedsAttentionSnapshot( 963 NeedsAttentionSnapshot(
954 unreadInboxThreads: unreadInboxThreadCount, 964 unreadInboxThreads: unreadInboxThreadCount,
955 assignedOpenTickets: assignedTicketsError == nil ? assignedTickets.count : nil, 965 assignedOpenTickets: assignedTicketsError == nil ? assignedTickets.count : nil,
956 failedBuilds: recentBuildsError == nil 966 failedBuilds: recentBuildsError == nil ? failedBuildCount : 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 updatedAt: .now 967 updatedAt: .now
967 ), 968 ),
968 accountID: accountID 969 accountID: accountID
@@ -1013,6 +1014,47 @@ final class HomeViewModel {
1013 } 1014 }
1014 } 1015 }
1015 1016
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
1016 nonisolated static func sortBuildItemsForTriage(_ lhs: HomeBuildItem, _ rhs: HomeBuildItem) -> Bool { 1058 nonisolated static func sortBuildItemsForTriage(_ lhs: HomeBuildItem, _ rhs: HomeBuildItem) -> Bool {
1017 let lhsPriority = buildPriority(for: lhs.job.status) 1059 let lhsPriority = buildPriority(for: lhs.job.status)
1018 let rhsPriority = buildPriority(for: rhs.job.status) 1060 let rhsPriority = buildPriority(for: rhs.job.status)
Hutch/Views/Settings/SettingsView.swift +16 −2
@@ -6,6 +6,8 @@ struct SettingsView: View {
6 @AppStorage(AppStorageKeys.displayDensity, store: .standard) private var displayDensity: DisplayDensity = .standard 6 @AppStorage(AppStorageKeys.displayDensity, store: .standard) private var displayDensity: DisplayDensity = .standard
7 @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true 7 @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true
8 @AppStorage(AppStorageKeys.contributionGraphsEnabled, store: .standard) private var contributionGraphsEnabled = true 8 @AppStorage(AppStorageKeys.contributionGraphsEnabled, store: .standard) private var contributionGraphsEnabled = true
9 @AppStorage(AppStorageKeys.homeFailedBuildLookbackDays, store: .standard)
10 private var failedBuildLookbackDays = HomeViewModel.defaultFailedBuildLookbackDays
9 @State private var pendingDestructiveAction: SettingsDestructiveAction? 11 @State private var pendingDestructiveAction: SettingsDestructiveAction?
10 @State private var showAccountSwitcher = false 12 @State private var showAccountSwitcher = false
11 13
@@ -85,10 +87,16 @@ struct SettingsView: View {
85 ContributionWidgetContextStore.setEnabled(newValue) 87 ContributionWidgetContextStore.setEnabled(newValue)
86 } 88 }
87 .themedRow() 89 .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()
88 } header: { 96 } header: {
89 Text("Behavior") 97 Text("Behavior")
90 } footer: { 98 } 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.")
92 } 100 }
93 } 101 }
94 102
@@ -143,6 +151,13 @@ struct SettingsView: View {
143 151
144} 152}
145 153
154private func failedBuildWindowLabel(_ days: Int) -> String {
155 if days == 1 {
156 return "Today only"
157 }
158 return "Last \(days) days"
159}
160
146func settingsBioAttributedString(_ markdown: String) -> AttributedString { 161func settingsBioAttributedString(_ markdown: String) -> AttributedString {
147 profileBioAttributedString(markdown) 162 profileBioAttributedString(markdown)
148} 163}
@@ -178,4 +193,3 @@ private enum SettingsDestructiveAction {
178 } 193 }
179 } 194 }
180} 195}
181
HutchTests/HomeViewModelTests.swift +71
@@ -18,6 +18,77 @@ struct HomeViewModelTests {
18 #expect(failedBuilds.map(\.job.id) == [2, 3]) 18 #expect(failedBuilds.map(\.job.id) == [2, 3])
19 } 19 }
20 20
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
21 @Test 92 @Test
22 func matchesCurrentUserAssigneeNormalizesCanonicalNameAndUsername() { 93 func matchesCurrentUserAssigneeNormalizesCanonicalNameAndUsername() {
23 let currentUser = User( 94 let currentUser = User(