summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-20 12:42:09 -0500
committerChristian Cleberg <[email protected]>2026-04-20 12:42:09 -0500
commit454c459394437f346a2d08d59a5504c0f6ec299f (patch)
treedd7dc1521aec859cb7b093c922ea61ea5efac7a9
parent32c0e55b0713a15094c1640e7af70e0ed19d78d6 (diff)
downloadhutch-454c459394437f346a2d08d59a5504c0f6ec299f.tar.gz
hutch-454c459394437f346a2d08d59a5504c0f6ec299f.tar.bz2
hutch-454c459394437f346a2d08d59a5504c0f6ec299f.zip
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
-rw-r--r--Hutch.xcodeproj/project.pbxproj8
-rw-r--r--Hutch/App/AppStorageKeys.swift1
-rw-r--r--Hutch/Views/Home/HomeView.swift33
-rw-r--r--Hutch/Views/Home/HomeViewModel.swift78
-rw-r--r--Hutch/Views/Settings/SettingsView.swift18
-rw-r--r--HutchTests/HomeViewModelTests.swift71
6 files changed, 170 insertions, 39 deletions
diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj
index 13c8b7b..3d4f7a2 100644
--- a/Hutch.xcodeproj/project.pbxproj
+++ b/Hutch.xcodeproj/project.pbxproj
@@ -534,7 +534,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 3.1.12;
+ MARKETING_VERSION = 3.2.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -571,7 +571,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 3.1.12;
+ MARKETING_VERSION = 3.2.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -644,7 +644,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 3.1.12;
+ MARKETING_VERSION = 3.2.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -673,7 +673,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 3.1.12;
+ MARKETING_VERSION = 3.2.0;
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 d348f41..30cd0d0 100644
--- a/Hutch/App/AppStorageKeys.swift
+++ b/Hutch/App/AppStorageKeys.swift
@@ -23,4 +23,5 @@ enum AppStorageKeys {
static let appTheme = "appTheme"
static let displayDensity = "displayDensity"
static let debugModeEnabled = "debugModeEnabled"
+ nonisolated static let homeFailedBuildLookbackDays = "homeFailedBuildLookbackDays"
}
diff --git a/Hutch/Views/Home/HomeView.swift b/Hutch/Views/Home/HomeView.swift
index 242eeac..406e2ad 100644
--- a/Hutch/Views/Home/HomeView.swift
+++ b/Hutch/Views/Home/HomeView.swift
@@ -3,6 +3,8 @@ import SwiftUI
struct HomeView: View {
@Environment(AppState.self) private var appState
@Environment(\.scenePhase) private var scenePhase
+ @AppStorage(AppStorageKeys.homeFailedBuildLookbackDays, store: .standard)
+ private var failedBuildLookbackDays = HomeViewModel.defaultFailedBuildLookbackDays
@State private var viewModel: HomeViewModel?
@State private var recentItems: [RecentActivityEntry] = []
@State private var isOpeningRecentItem = false
@@ -142,7 +144,7 @@ struct HomeView: View {
title: buildsTitle(viewModel),
summary: buildsSummary(viewModel),
systemImage: "hammer",
- tint: viewModel.failedBuildCount > 0 ? .orange : .secondary,
+ tint: failedBuildCount(viewModel) > 0 ? .orange : .secondary,
emphasis: .monitoring
)
}
@@ -208,7 +210,7 @@ struct HomeView: View {
}
private func buildsTitle(_ viewModel: HomeViewModel) -> String {
- let failed = viewModel.failedBuildCount
+ let failed = failedBuildCount(viewModel)
let running = viewModel.activeBuildCount
if failed == 0 && running == 0 {
@@ -221,18 +223,18 @@ struct HomeView: View {
}
private func buildsSummary(_ viewModel: HomeViewModel) -> String {
- let failed = viewModel.failedBuildCount
+ let failed = failedBuildCount(viewModel)
let running = viewModel.activeBuildCount
if failed == 0 && running == 0 {
- return "No failures • \(buildTimeframeLabel(viewModel))"
+ return "No failures • \(buildTimeframeLabel())"
}
if failed > 0 && running > 0 {
- return "\(failed) failed • \(running) running • \(buildTimeframeLabel(viewModel))"
+ return "\(failed) failed • \(running) running • \(buildTimeframeLabel())"
}
if failed > 0 {
- return "\(failed) failed • \(buildTimeframeLabel(viewModel))"
+ return "\(failed) failed • \(buildTimeframeLabel())"
}
- return "\(running) running • \(buildTimeframeLabel(viewModel))"
+ return "\(running) running now"
}
private func pinnedItems(_ viewModel: HomeViewModel) -> [HomePinnedItem] {
@@ -251,15 +253,12 @@ struct HomeView: View {
}
}
- private func buildTimeframeLabel(_ viewModel: HomeViewModel) -> String {
- let calendar = Calendar.current
- let buildDates = viewModel.recentBuilds.map(\.job.updated)
-
- guard !buildDates.isEmpty else {
- return "today"
- }
+ private func buildTimeframeLabel() -> String {
+ HomeViewModel.failedBuildLookbackLabel(days: failedBuildLookbackDays)
+ }
- return buildDates.allSatisfy(calendar.isDateInToday) ? "today" : "this week"
+ private func failedBuildCount(_ viewModel: HomeViewModel) -> Int {
+ viewModel.recentFailedBuilds(lookbackDays: failedBuildLookbackDays).count
}
private func hasHomeContent(_ viewModel: HomeViewModel) -> Bool {
@@ -464,6 +463,8 @@ private struct HomeSummaryRow: View {
Spacer(minLength: 8)
}
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .contentShape(Rectangle())
.padding(.vertical, verticalPadding)
}
@@ -508,6 +509,8 @@ private struct HomeRecentRow: View {
Spacer(minLength: 8)
}
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .contentShape(Rectangle())
.padding(.vertical, 1)
}
diff --git a/Hutch/Views/Home/HomeViewModel.swift b/Hutch/Views/Home/HomeViewModel.swift
index ec54759..4bd4f32 100644
--- a/Hutch/Views/Home/HomeViewModel.swift
+++ b/Hutch/Views/Home/HomeViewModel.swift
@@ -172,6 +172,9 @@ struct HomeBuildItem: Identifiable, Hashable, Sendable {
@Observable
@MainActor
final class HomeViewModel {
+ nonisolated static let defaultFailedBuildLookbackDays = 7
+ nonisolated static let allowedFailedBuildLookbackDays = [1, 3, 7, 14, 30]
+
private(set) var projects: [Project] = []
var assignedTickets: [HomeAssignedTicket] = []
var recentBuilds: [HomeBuildItem] = []
@@ -434,14 +437,7 @@ final class HomeViewModel {
}
var failedBuildCount: Int {
- recentBuilds.filter {
- switch $0.job.status {
- case .failed, .timeout:
- return true
- default:
- return false
- }
- }.count
+ recentFailedBuilds().count
}
var activeBuildCount: Int {
@@ -532,6 +528,19 @@ final class HomeViewModel {
return parts.joined(separator: " • ")
}
+ func recentFailedBuilds(
+ lookbackDays: Int? = nil,
+ now: Date = .now,
+ calendar: Calendar = .current
+ ) -> [HomeBuildItem] {
+ Self.failedBuilds(
+ in: recentBuilds,
+ lookbackDays: lookbackDays ?? Self.failedBuildLookbackDays(),
+ now: now,
+ calendar: calendar
+ )
+ }
+
var systemSummaryText: String {
guard let systemStatusSnapshot else {
return systemStatusErrorMessage ?? "System status unavailable"
@@ -949,20 +958,12 @@ final class HomeViewModel {
}
private func persistNeedsAttentionSnapshot() {
+ let failedBuildCount = recentFailedBuilds().count
NeedsAttentionSnapshotStore.save(
NeedsAttentionSnapshot(
unreadInboxThreads: unreadInboxThreadCount,
assignedOpenTickets: assignedTicketsError == nil ? assignedTickets.count : nil,
- failedBuilds: recentBuildsError == nil
- ? recentBuilds.filter {
- switch $0.job.status {
- case .failed, .timeout:
- true
- default:
- false
- }
- }.count
- : nil,
+ failedBuilds: recentBuildsError == nil ? failedBuildCount : nil,
updatedAt: .now
),
accountID: accountID
@@ -1013,6 +1014,47 @@ final class HomeViewModel {
}
}
+ nonisolated static func failedBuildLookbackDays(defaults: UserDefaults = .standard) -> Int {
+ let value = defaults.object(forKey: AppStorageKeys.homeFailedBuildLookbackDays) as? Int
+ guard let value, allowedFailedBuildLookbackDays.contains(value) else {
+ return defaultFailedBuildLookbackDays
+ }
+ return value
+ }
+
+ nonisolated static func failedBuilds(
+ in builds: [HomeBuildItem],
+ lookbackDays: Int,
+ now: Date = .now,
+ calendar: Calendar = .current
+ ) -> [HomeBuildItem] {
+ let normalizedLookbackDays = allowedFailedBuildLookbackDays.contains(lookbackDays)
+ ? lookbackDays
+ : defaultFailedBuildLookbackDays
+ let startOfToday = calendar.startOfDay(for: now)
+ let windowStart = calendar.date(byAdding: .day, value: -(normalizedLookbackDays - 1), to: startOfToday) ?? startOfToday
+
+ return builds.filter { build in
+ guard build.job.updated >= windowStart else { return false }
+ switch build.job.status {
+ case .failed, .timeout:
+ return true
+ default:
+ return false
+ }
+ }
+ }
+
+ nonisolated static func failedBuildLookbackLabel(days: Int) -> String {
+ let normalizedDays = allowedFailedBuildLookbackDays.contains(days)
+ ? days
+ : defaultFailedBuildLookbackDays
+ if normalizedDays == 1 {
+ return "today"
+ }
+ return "last \(normalizedDays) days"
+ }
+
nonisolated static func sortBuildItemsForTriage(_ lhs: HomeBuildItem, _ rhs: HomeBuildItem) -> Bool {
let lhsPriority = buildPriority(for: lhs.job.status)
let rhsPriority = buildPriority(for: rhs.job.status)
diff --git a/Hutch/Views/Settings/SettingsView.swift b/Hutch/Views/Settings/SettingsView.swift
index 0046f92..ce980e2 100644
--- a/Hutch/Views/Settings/SettingsView.swift
+++ b/Hutch/Views/Settings/SettingsView.swift
@@ -6,6 +6,8 @@ struct SettingsView: View {
@AppStorage(AppStorageKeys.displayDensity, store: .standard) private var displayDensity: DisplayDensity = .standard
@AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true
@AppStorage(AppStorageKeys.contributionGraphsEnabled, store: .standard) private var contributionGraphsEnabled = true
+ @AppStorage(AppStorageKeys.homeFailedBuildLookbackDays, store: .standard)
+ private var failedBuildLookbackDays = HomeViewModel.defaultFailedBuildLookbackDays
@State private var pendingDestructiveAction: SettingsDestructiveAction?
@State private var showAccountSwitcher = false
@@ -85,10 +87,16 @@ struct SettingsView: View {
ContributionWidgetContextStore.setEnabled(newValue)
}
.themedRow()
+ Picker("Failed build window", selection: $failedBuildLookbackDays) {
+ ForEach(HomeViewModel.allowedFailedBuildLookbackDays, id: \.self) { days in
+ Text(failedBuildWindowLabel(days)).tag(days)
+ }
+ }
+ .themedRow()
} header: {
Text("Behavior")
} footer: {
- 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.")
+ 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.")
}
}
@@ -143,6 +151,13 @@ struct SettingsView: View {
}
+private func failedBuildWindowLabel(_ days: Int) -> String {
+ if days == 1 {
+ return "Today only"
+ }
+ return "Last \(days) days"
+}
+
func settingsBioAttributedString(_ markdown: String) -> AttributedString {
profileBioAttributedString(markdown)
}
@@ -178,4 +193,3 @@ private enum SettingsDestructiveAction {
}
}
}
-
diff --git a/HutchTests/HomeViewModelTests.swift b/HutchTests/HomeViewModelTests.swift
index 72f2e71..fe4229f 100644
--- a/HutchTests/HomeViewModelTests.swift
+++ b/HutchTests/HomeViewModelTests.swift
@@ -19,6 +19,77 @@ struct HomeViewModelTests {
}
@Test
+ func failedBuildsWithinLookbackExcludeOlderFailures() {
+ let now = Date(timeIntervalSince1970: 60 * 60 * 24 * 20)
+ let recentFailure = HomeBuildItem(
+ job: JobSummary(
+ id: 1,
+ created: now.addingTimeInterval(-(60 * 60 * 24)),
+ updated: now.addingTimeInterval(-(60 * 60 * 24)),
+ status: .failed,
+ note: nil,
+ tags: [],
+ visibility: nil,
+ image: nil,
+ tasks: []
+ ),
+ repositoryName: nil,
+ repositoryOwner: nil
+ )
+ let oldFailure = HomeBuildItem(
+ job: JobSummary(
+ id: 2,
+ created: now.addingTimeInterval(-(60 * 60 * 24 * 10)),
+ updated: now.addingTimeInterval(-(60 * 60 * 24 * 10)),
+ status: .timeout,
+ note: nil,
+ tags: [],
+ visibility: nil,
+ image: nil,
+ tasks: []
+ ),
+ repositoryName: nil,
+ repositoryOwner: nil
+ )
+ let recentSuccess = HomeBuildItem(
+ job: JobSummary(
+ id: 3,
+ created: now.addingTimeInterval(-(60 * 60 * 24)),
+ updated: now.addingTimeInterval(-(60 * 60 * 24)),
+ status: .success,
+ note: nil,
+ tags: [],
+ visibility: nil,
+ image: nil,
+ tasks: []
+ ),
+ repositoryName: nil,
+ repositoryOwner: nil
+ )
+
+ let filtered = HomeViewModel.failedBuilds(
+ in: [recentFailure, oldFailure, recentSuccess],
+ lookbackDays: 7,
+ now: now,
+ calendar: Calendar(identifier: .gregorian)
+ )
+
+ #expect(filtered.map(\.job.id) == [1])
+ }
+
+ @Test
+ func failedBuildLookbackDaysFallsBackToDefaultWhenUnsetOrInvalid() {
+ let defaults = UserDefaults(suiteName: #function)!
+ defaults.removePersistentDomain(forName: #function)
+
+ #expect(HomeViewModel.failedBuildLookbackDays(defaults: defaults) == HomeViewModel.defaultFailedBuildLookbackDays)
+
+ defaults.set(99, forKey: AppStorageKeys.homeFailedBuildLookbackDays)
+
+ #expect(HomeViewModel.failedBuildLookbackDays(defaults: defaults) == HomeViewModel.defaultFailedBuildLookbackDays)
+ }
+
+ @Test
func matchesCurrentUserAssigneeNormalizesCanonicalNameAndUsername() {
let currentUser = User(
id: 42,