summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-03-24 18:28:21 -0500
committerChristian Cleberg <[email protected]>2026-03-24 18:28:21 -0500
commit94d0b581bbd2be7b63c07fe7f86d736daa96e1f9 (patch)
treee0fb93129ab426c2ebdabd2ea3a84b713f843656
parent28c5b36bed9e3a4217415079827f657205a080f7 (diff)
downloadhutch-2.4.0.tar.gz
hutch-2.4.0.tar.bz2
hutch-2.4.0.zip
v2.4.0: widgetsv2.4.0
-rw-r--r--Hutch.xcodeproj/project.pbxproj176
-rw-r--r--Hutch.xcodeproj/project.xcworkspace/contents.xcworkspacedata8
-rw-r--r--Hutch/App/AppState.swift15
-rw-r--r--Hutch/App/DeepLink.swift4
-rw-r--r--Hutch/App/RootView.swift7
-rw-r--r--Hutch/Hutch.entitlements10
-rw-r--r--Hutch/Views/Home/HomeViewModel.swift127
-rw-r--r--Hutch/Views/Inbox/InboxViewModel.swift3
-rw-r--r--Hutch/Views/Projects/ProjectMailingListView.swift2
-rw-r--r--HutchWidgetExtension/HutchWidgetExtension.entitlements10
-rw-r--r--HutchWidgetExtension/Info.plist29
-rw-r--r--HutchWidgetExtension/NeedsAttentionWidget.swift226
-rw-r--r--Shared/NeedsAttentionSnapshot.swift109
13 files changed, 687 insertions, 39 deletions
diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj
index be22f3f..ee8e2d6 100644
--- a/Hutch.xcodeproj/project.pbxproj
+++ b/Hutch.xcodeproj/project.pbxproj
@@ -10,6 +10,7 @@
8B2F89662F69DEB900FC0253 /* LICENSE in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89632F69DEB900FC0253 /* LICENSE */; };
8B2F89672F69DEB900FC0253 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89642F69DEB900FC0253 /* README.md */; };
8B2F89682F69DEB900FC0253 /* SECURITY.md in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89652F69DEB900FC0253 /* SECURITY.md */; };
+ 8BE082012F80000100000001 /* HutchWidgetExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 8BE081F62F80000100000001 /* HutchWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -20,6 +21,13 @@
remoteGlobalIDString = 8B4B28D02F6704280045FA19;
remoteInfo = Hutch;
};
+ 8BE082022F80000100000001 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 8B4B28C92F6704280045FA19 /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 8BE081F72F80000100000001;
+ remoteInfo = HutchWidgetExtension;
+ };
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
@@ -30,6 +38,7 @@
8B8182B72F6B73F3000AE049 /* HutchTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = HutchTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
8B8182C22F6B742B000AE049 /* HutchTests */ = {isa = PBXFileReference; lastKnownFileType = folder; path = HutchTests; sourceTree = "<group>"; };
8BDCA5272F6B76B20066AA29 /* HutchTests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = HutchTests.xctestplan; sourceTree = "<group>"; };
+ 8BE081F62F80000100000001 /* HutchWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = HutchWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -40,6 +49,13 @@
);
target = 8B4B28D02F6704280045FA19 /* Hutch */;
};
+ 8BE082032F80000100000001 /* Exceptions for "HutchWidgetExtension" folder in "HutchWidgetExtension" target */ = {
+ isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
+ membershipExceptions = (
+ Info.plist,
+ );
+ target = 8BE081F72F80000100000001 /* HutchWidgetExtension */;
+ };
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
@@ -56,8 +72,35 @@
path = HutchTests;
sourceTree = "<group>";
};
+ 8BE081F42F80000100000001 /* Shared */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ path = Shared;
+ sourceTree = "<group>";
+ };
+ 8BE081F52F80000100000001 /* HutchWidgetExtension */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ exceptions = (
+ 8BE082032F80000100000001 /* Exceptions for "HutchWidgetExtension" folder in "HutchWidgetExtension" target */,
+ );
+ path = HutchWidgetExtension;
+ sourceTree = "<group>";
+ };
/* End PBXFileSystemSynchronizedRootGroup section */
+/* Begin PBXCopyFilesBuildPhase section */
+ 8BE081FA2F80000100000001 /* Embed App Extensions */ = {
+ isa = PBXCopyFilesBuildPhase;
+ buildActionMask = 2147483647;
+ dstPath = "";
+ dstSubfolderSpec = 13;
+ files = (
+ 8BE082012F80000100000001 /* HutchWidgetExtension.appex in Embed App Extensions */,
+ );
+ name = "Embed App Extensions";
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXCopyFilesBuildPhase section */
+
/* Begin PBXFrameworksBuildPhase section */
8B4B28CE2F6704280045FA19 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
@@ -73,6 +116,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ 8BE081F92F80000100000001 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
@@ -85,6 +135,8 @@
8B2F89642F69DEB900FC0253 /* README.md */,
8B2F89652F69DEB900FC0253 /* SECURITY.md */,
8B4B28D32F6704280045FA19 /* Hutch */,
+ 8BE081F42F80000100000001 /* Shared */,
+ 8BE081F52F80000100000001 /* HutchWidgetExtension */,
8B8182B82F6B73F3000AE049 /* HutchTests */,
8B4B28D22F6704280045FA19 /* Products */,
);
@@ -95,6 +147,7 @@
children = (
8B4B28D12F6704280045FA19 /* Hutch.app */,
8B8182B72F6B73F3000AE049 /* HutchTests.xctest */,
+ 8BE081F62F80000100000001 /* HutchWidgetExtension.appex */,
);
name = Products;
sourceTree = "<group>";
@@ -109,13 +162,16 @@
8B4B28CD2F6704280045FA19 /* Sources */,
8B4B28CE2F6704280045FA19 /* Frameworks */,
8B4B28CF2F6704280045FA19 /* Resources */,
+ 8BE081FA2F80000100000001 /* Embed App Extensions */,
);
buildRules = (
);
dependencies = (
+ 8BE082042F80000100000001 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
8B4B28D32F6704280045FA19 /* Hutch */,
+ 8BE081F42F80000100000001 /* Shared */,
);
name = Hutch;
packageProductDependencies = (
@@ -147,6 +203,29 @@
productReference = 8B8182B72F6B73F3000AE049 /* HutchTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
+ 8BE081F72F80000100000001 /* HutchWidgetExtension */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 8BE081FE2F80000100000001 /* Build configuration list for PBXNativeTarget "HutchWidgetExtension" */;
+ buildPhases = (
+ 8BE081F82F80000100000001 /* Sources */,
+ 8BE081F92F80000100000001 /* Frameworks */,
+ 8BE081FB2F80000100000001 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ fileSystemSynchronizedGroups = (
+ 8BE081F42F80000100000001 /* Shared */,
+ 8BE081F52F80000100000001 /* HutchWidgetExtension */,
+ );
+ name = HutchWidgetExtension;
+ packageProductDependencies = (
+ );
+ productName = HutchWidgetExtension;
+ productReference = 8BE081F62F80000100000001 /* HutchWidgetExtension.appex */;
+ productType = "com.apple.product-type.app-extension";
+ };
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -182,6 +261,7 @@
targets = (
8B4B28D02F6704280045FA19 /* Hutch */,
8B8182B62F6B73F3000AE049 /* HutchTests */,
+ 8BE081F72F80000100000001 /* HutchWidgetExtension */,
);
};
/* End PBXProject section */
@@ -204,6 +284,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ 8BE081FB2F80000100000001 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@@ -221,6 +308,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ 8BE081F82F80000100000001 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
@@ -229,6 +323,11 @@
target = 8B4B28D02F6704280045FA19 /* Hutch */;
targetProxy = 8B8182BB2F6B73F3000AE049 /* PBXContainerItemProxy */;
};
+ 8BE082042F80000100000001 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 8BE081F72F80000100000001 /* HutchWidgetExtension */;
+ targetProxy = 8BE082022F80000100000001 /* PBXContainerItemProxy */;
+ };
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
@@ -358,8 +457,9 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 9;
+ CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -376,7 +476,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 2.3.1;
+ MARKETING_VERSION = 2.4.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -394,8 +494,9 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 9;
+ CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -412,7 +513,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 2.3.1;
+ MARKETING_VERSION = 2.4.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -469,6 +570,64 @@
};
name = Release;
};
+ 8BE081FC2F80000100000001 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ APPLICATION_EXTENSION_API_ONLY = YES;
+ CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 10;
+ DEVELOPMENT_TEAM = ZCNAX3VL9D;
+ GENERATE_INFOPLIST_FILE = NO;
+ INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
+ IPHONEOS_DEPLOYMENT_TARGET = 17.6;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ "@executable_path/../../Frameworks",
+ );
+ MARKETING_VERSION = 2.4.0;
+ PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SKIP_INSTALL = YES;
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ 8BE081FD2F80000100000001 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ APPLICATION_EXTENSION_API_ONLY = YES;
+ CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 10;
+ DEVELOPMENT_TEAM = ZCNAX3VL9D;
+ GENERATE_INFOPLIST_FILE = NO;
+ INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
+ IPHONEOS_DEPLOYMENT_TARGET = 17.6;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ "@executable_path/../../Frameworks",
+ );
+ MARKETING_VERSION = 2.4.0;
+ PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SKIP_INSTALL = YES;
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Release;
+ };
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@@ -499,6 +658,15 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
+ 8BE081FE2F80000100000001 /* Build configuration list for PBXNativeTarget "HutchWidgetExtension" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 8BE081FC2F80000100000001 /* Debug */,
+ 8BE081FD2F80000100000001 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
/* End XCConfigurationList section */
};
rootObject = 8B4B28C92F6704280045FA19 /* Project object */;
diff --git a/Hutch.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Hutch.xcodeproj/project.xcworkspace/contents.xcworkspacedata
index 919434a..24c0c84 100644
--- a/Hutch.xcodeproj/project.xcworkspace/contents.xcworkspacedata
+++ b/Hutch.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -4,4 +4,12 @@
<FileRef
location = "self:">
</FileRef>
+ <Group
+ location = "group:Shared"
+ name = "Shared">
+ </Group>
+ <Group
+ location = "group:HutchWidgetExtension"
+ name = "HutchWidgetExtension">
+ </Group>
</Workspace>
diff --git a/Hutch/App/AppState.swift b/Hutch/App/AppState.swift
index e74a58b..9788c85 100644
--- a/Hutch/App/AppState.swift
+++ b/Hutch/App/AppState.swift
@@ -77,11 +77,13 @@ final class AppState {
let user = try await fetchMe()
currentUser = user
authPhase = .authenticated
+ await refreshNeedsAttentionSnapshot()
} catch {
try? KeychainHelper.deleteToken()
client.setToken(nil)
currentUser = nil
authPhase = .unauthenticated
+ NeedsAttentionSnapshotStore.clear()
}
}
@@ -98,6 +100,7 @@ final class AppState {
try KeychainHelper.saveToken(token)
currentUser = user
authPhase = .authenticated
+ await refreshNeedsAttentionSnapshot()
} catch {
// Roll back — don't leave an invalid token in the client.
client.setToken(nil)
@@ -111,6 +114,7 @@ final class AppState {
HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) }
await clearWebData()
clearWebContentRenderCaches()
+ NeedsAttentionSnapshotStore.clear()
authPhase = .unauthenticated
selectedTab = .home
}
@@ -125,6 +129,7 @@ final class AppState {
HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) }
await clearWebData()
clearWebContentRenderCaches()
+ NeedsAttentionSnapshotStore.clear()
authPhase = .unauthenticated
selectedTab = .home
@@ -270,6 +275,16 @@ final class AppState {
selectedTab = .home
}
+ private func refreshNeedsAttentionSnapshot() async {
+ guard let currentUser else {
+ NeedsAttentionSnapshotStore.clear()
+ return
+ }
+
+ let viewModel = HomeViewModel(currentUser: currentUser, client: client)
+ await viewModel.loadDashboard()
+ }
+
private func clearWebData() async {
await withCheckedContinuation { continuation in
let dataTypes = WKWebsiteDataStore.allWebsiteDataTypes()
diff --git a/Hutch/App/DeepLink.swift b/Hutch/App/DeepLink.swift
index a4b8269..e42bd6b 100644
--- a/Hutch/App/DeepLink.swift
+++ b/Hutch/App/DeepLink.swift
@@ -2,6 +2,7 @@ import Foundation
/// Represents a parsed `hutch://` deep link.
enum DeepLink: Equatable {
+ case home
/// hutch://git/<owner>/<repo>
case repository(owner: String, repo: String)
/// hutch://todo/<owner>/<tracker>/<ticketId>
@@ -19,6 +20,9 @@ enum DeepLink: Equatable {
let components = url.pathComponents(fromScheme: "hutch")
switch components.first {
+ case "home", nil:
+ self = .home
+
case "git" where components.count >= 3:
let owner = components[1]
let repo = components[2]
diff --git a/Hutch/App/RootView.swift b/Hutch/App/RootView.swift
index 9c4e71a..8d4eef1 100644
--- a/Hutch/App/RootView.swift
+++ b/Hutch/App/RootView.swift
@@ -161,6 +161,10 @@ struct RootView: View {
guard appState.isAuthenticated else { return }
switch link {
+ case .home:
+ homePath = NavigationPath()
+ appState.selectedTab = .home
+
case .repository(let owner, let repo):
resolveRepositoryLink(owner: owner, repo: repo)
@@ -284,12 +288,15 @@ private struct MoreNavigationRoot: View {
thread: thread,
onViewed: {
InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
+ NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
},
onMarkRead: {
InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
+ NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
},
onMarkUnread: {
InboxReadStateStore.markUnread(for: thread.id)
+ NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1)
}
)
}
diff --git a/Hutch/Hutch.entitlements b/Hutch/Hutch.entitlements
new file mode 100644
index 0000000..9ed683a
--- /dev/null
+++ b/Hutch/Hutch.entitlements
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>com.apple.security.application-groups</key>
+ <array>
+ <string>group.net.cleberg.Hutch</string>
+ </array>
+</dict>
+</plist>
diff --git a/Hutch/Views/Home/HomeViewModel.swift b/Hutch/Views/Home/HomeViewModel.swift
index 50b1854..8b4e7aa 100644
--- a/Hutch/Views/Home/HomeViewModel.swift
+++ b/Hutch/Views/Home/HomeViewModel.swift
@@ -59,6 +59,7 @@ private struct HomeInboxMailingListThreads: Decodable, Sendable {
private struct HomeInboxThreadPage: Decodable, Sendable {
let results: [HomeInboxThreadPayload]
+ let cursor: String?
}
private struct HomeInboxThreadPayload: Decodable, Sendable {
@@ -143,6 +144,7 @@ final class HomeViewModel {
var assignedTickets: [HomeAssignedTicket] = []
var recentBuilds: [HomeBuildItem] = []
private(set) var hasUnreadInboxThreads = false
+ private(set) var unreadInboxThreadCount: Int?
private(set) var isLoadingProjects = false
private(set) var isLoadingAssignedTickets = false
private(set) var isLoadingRecentBuilds = false
@@ -154,7 +156,6 @@ final class HomeViewModel {
private let projectService: ProjectService
private let ticketFetchConcurrencyLimit = 6
private let inboxUnreadConcurrencyLimit = 4
- private let inboxUnreadThreadPreviewLimit = 10
private static let jobsQuery = """
query jobs {
@@ -232,13 +233,14 @@ final class HomeViewModel {
"""
private static let inboxListThreadsQuery = """
- query inboxListThreads($rid: ID!) {
+ query inboxListThreads($rid: ID!, $cursor: Cursor) {
list(rid: $rid) {
- threads {
+ threads(cursor: $cursor) {
results {
updated
subject
}
+ cursor
}
}
}
@@ -280,7 +282,7 @@ final class HomeViewModel {
async let projectsTask = loadProjects()
async let jobsTask = loadRecentJobs()
async let assignedTicketsTask = loadAssignedTickets()
- async let inboxUnreadTask = loadInboxUnreadState()
+ async let inboxUnreadTask = loadInboxUnreadCount()
let projectsResult = await projectsTask
switch projectsResult {
@@ -316,7 +318,9 @@ final class HomeViewModel {
}
isLoadingAssignedTickets = false
- hasUnreadInboxThreads = (await inboxUnreadTask) ?? false
+ unreadInboxThreadCount = await inboxUnreadTask
+ hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0
+ persistNeedsAttentionSnapshot()
}
func resolveTicket(_ ticket: HomeAssignedTicket) async {
@@ -347,6 +351,7 @@ final class HomeViewModel {
responseType: UnassignResponse.self
)
assignedTickets.removeAll { $0.id == ticket.id }
+ persistNeedsAttentionSnapshot()
} catch {
homeLogger.error("Unassign from me failed: \(error, privacy: .public)")
}
@@ -380,6 +385,7 @@ final class HomeViewModel {
repositoryOwner: build.repositoryOwner
)
}
+ persistNeedsAttentionSnapshot()
} catch {
homeLogger.error("Cancel build failed: \(error, privacy: .public)")
}
@@ -406,19 +412,21 @@ final class HomeViewModel {
}
}
- private func loadInboxUnreadState() async -> Bool? {
+ private func loadInboxUnreadCount() async -> Int? {
do {
- return try await fetchHasUnreadInboxThreads()
+ return try await fetchUnreadInboxThreadCount()
} catch {
return nil
}
}
- private func fetchHasUnreadInboxThreads() async throws -> Bool {
+ private func fetchUnreadInboxThreadCount() async throws -> Int {
let mailingLists = try await fetchInboxMailingLists()
- guard !mailingLists.isEmpty else { return false }
+ guard !mailingLists.isEmpty else { return 0 }
var startIndex = mailingLists.startIndex
+ var unreadCount = 0
+ var successfulFetchCount = 0
while startIndex < mailingLists.endIndex {
let endIndex = mailingLists.index(
startIndex,
@@ -427,30 +435,41 @@ final class HomeViewModel {
) ?? mailingLists.endIndex
let batch = Array(mailingLists[startIndex..<endIndex])
- let batchHasUnread = await withTaskGroup(of: Bool.self) { group in
+ let batchResult = await withTaskGroup(of: Result<Int, Error>.self) { group in
for mailingList in batch {
group.addTask {
- (try? await self.fetchHasUnreadThreads(for: mailingList)) ?? false
+ do {
+ return .success(try await self.fetchUnreadThreadCount(for: mailingList))
+ } catch {
+ return .failure(error)
+ }
}
}
- for await hasUnread in group {
- if hasUnread {
- group.cancelAll()
- return true
+ var counts: [Int] = []
+ var errors: [Error] = []
+ for await result in group {
+ switch result {
+ case .success(let count):
+ counts.append(count)
+ case .failure(let error):
+ errors.append(error)
}
}
- return false
+ return (counts, errors)
}
- if batchHasUnread {
- return true
- }
+ unreadCount += batchResult.0.reduce(0, +)
+ successfulFetchCount += batchResult.0.count
startIndex = endIndex
}
- return false
+ guard successfulFetchCount > 0 else {
+ throw SRHTError.graphQLErrors([GraphQLError(message: "Failed to load inbox threads", locations: nil)])
+ }
+
+ return unreadCount
}
private func fetchInboxMailingLists() async throws -> [InboxMailingListReference] {
@@ -481,23 +500,40 @@ final class HomeViewModel {
return subscriptions.compactMap(\.list).filter { seen.insert($0.rid).inserted }
}
- private func fetchHasUnreadThreads(for mailingList: InboxMailingListReference) async throws -> Bool {
- let response = try await client.execute(
- service: .lists,
- query: Self.inboxListThreadsQuery,
- variables: ["rid": mailingList.rid],
- responseType: HomeInboxListThreadsResponse.self
- )
+ private func fetchUnreadThreadCount(for mailingList: InboxMailingListReference) async throws -> Int {
+ var unreadCount = 0
+ var cursor: String?
+
+ while true {
+ var variables: [String: any Sendable] = ["rid": mailingList.rid]
+ if let cursor {
+ variables["cursor"] = cursor
+ }
- return response.list.threads.results.prefix(inboxUnreadThreadPreviewLimit).contains { thread in
- let normalizedSubject = thread.subject
- .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
- .trimmingCharacters(in: .whitespacesAndNewlines)
- .replacingOccurrences(of: #"^(?:(?:re|fwd?)\s*:\s*)+"#, with: "", options: [.regularExpression, .caseInsensitive])
- .lowercased()
- let threadID = "\(mailingList.rid)#\(normalizedSubject)"
- return InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: thread.updated)
+ let response = try await client.execute(
+ service: .lists,
+ query: Self.inboxListThreadsQuery,
+ variables: variables,
+ responseType: HomeInboxListThreadsResponse.self
+ )
+
+ unreadCount += response.list.threads.results.filter { thread in
+ let normalizedSubject = thread.subject
+ .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ .replacingOccurrences(of: #"^(?:(?:re|fwd?)\s*:\s*)+"#, with: "", options: [.regularExpression, .caseInsensitive])
+ .lowercased()
+ let threadID = "\(mailingList.rid)#\(normalizedSubject)"
+ return InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: thread.updated)
+ }.count
+
+ guard let nextCursor = response.list.threads.cursor else {
+ break
+ }
+ cursor = nextCursor
}
+
+ return unreadCount
}
private func loadAssignedTickets() async -> Result<[HomeAssignedTicket], Error> {
@@ -616,11 +652,32 @@ final class HomeViewModel {
responseType: StatusEventResponse.self
)
assignedTickets.removeAll { $0.id == ticket.id }
+ persistNeedsAttentionSnapshot()
} catch {
homeLogger.error("Ticket status update failed: \(error, privacy: .public)")
}
}
+ private func persistNeedsAttentionSnapshot() {
+ 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,
+ updatedAt: .now
+ )
+ )
+ }
+
nonisolated static func buildItems(from jobs: [HomeJobPayload]) -> [HomeBuildItem] {
jobs.map { job in
let repository = primaryRepositoryReference(in: job.manifest)
diff --git a/Hutch/Views/Inbox/InboxViewModel.swift b/Hutch/Views/Inbox/InboxViewModel.swift
index 42ce223..1d4cfd3 100644
--- a/Hutch/Views/Inbox/InboxViewModel.swift
+++ b/Hutch/Views/Inbox/InboxViewModel.swift
@@ -136,6 +136,7 @@ final class InboxViewModel {
}
return lhs.lastActivityAt > rhs.lastActivityAt
}
+ NeedsAttentionSnapshotStore.update(unreadInboxThreads: threads.count)
} catch {
inboxListLogger.error("Inbox request failed")
self.error = "Failed to load inbox"
@@ -146,11 +147,13 @@ final class InboxViewModel {
let viewedAt = max(Date(), thread.lastActivityAt)
InboxReadStateStore.markViewed(viewedAt, for: thread.id)
threads.removeAll { $0.id == thread.id }
+ NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
}
func markThreadUnread(_ thread: InboxThreadSummary) {
InboxReadStateStore.markUnread(for: thread.id)
updateThread(thread, isUnread: true)
+ NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1)
}
func toggleThreadReadState(_ thread: InboxThreadSummary) {
diff --git a/Hutch/Views/Projects/ProjectMailingListView.swift b/Hutch/Views/Projects/ProjectMailingListView.swift
index e044efb..39645be 100644
--- a/Hutch/Views/Projects/ProjectMailingListView.swift
+++ b/Hutch/Views/Projects/ProjectMailingListView.swift
@@ -92,11 +92,13 @@ final class MailingListDetailViewModel {
let viewedAt = max(Date(), thread.lastActivityAt)
InboxReadStateStore.markViewed(viewedAt, for: thread.id)
updateThread(thread, isUnread: false)
+ NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
}
func markThreadUnread(_ thread: InboxThreadSummary) {
InboxReadStateStore.markUnread(for: thread.id)
updateThread(thread, isUnread: true)
+ NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1)
}
private func makeSummary(from thread: ProjectMailingListThreadPayload) -> InboxThreadSummary {
diff --git a/HutchWidgetExtension/HutchWidgetExtension.entitlements b/HutchWidgetExtension/HutchWidgetExtension.entitlements
new file mode 100644
index 0000000..9ed683a
--- /dev/null
+++ b/HutchWidgetExtension/HutchWidgetExtension.entitlements
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>com.apple.security.application-groups</key>
+ <array>
+ <string>group.net.cleberg.Hutch</string>
+ </array>
+</dict>
+</plist>
diff --git a/HutchWidgetExtension/Info.plist b/HutchWidgetExtension/Info.plist
new file mode 100644
index 0000000..7b41344
--- /dev/null
+++ b/HutchWidgetExtension/Info.plist
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>CFBundleDevelopmentRegion</key>
+ <string>$(DEVELOPMENT_LANGUAGE)</string>
+ <key>CFBundleDisplayName</key>
+ <string>Needs Attention</string>
+ <key>CFBundleExecutable</key>
+ <string>$(EXECUTABLE_NAME)</string>
+ <key>CFBundleIdentifier</key>
+ <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
+ <key>CFBundleInfoDictionaryVersion</key>
+ <string>6.0</string>
+ <key>CFBundleName</key>
+ <string>$(PRODUCT_NAME)</string>
+ <key>CFBundlePackageType</key>
+ <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
+ <key>CFBundleShortVersionString</key>
+ <string>$(MARKETING_VERSION)</string>
+ <key>CFBundleVersion</key>
+ <string>$(CURRENT_PROJECT_VERSION)</string>
+ <key>NSExtension</key>
+ <dict>
+ <key>NSExtensionPointIdentifier</key>
+ <string>com.apple.widgetkit-extension</string>
+ </dict>
+</dict>
+</plist>
diff --git a/HutchWidgetExtension/NeedsAttentionWidget.swift b/HutchWidgetExtension/NeedsAttentionWidget.swift
new file mode 100644
index 0000000..fff030f
--- /dev/null
+++ b/HutchWidgetExtension/NeedsAttentionWidget.swift
@@ -0,0 +1,226 @@
+import SwiftUI
+import WidgetKit
+
+struct NeedsAttentionEntry: TimelineEntry {
+ let date: Date
+ let snapshot: NeedsAttentionSnapshot?
+}
+
+struct NeedsAttentionTimelineProvider: TimelineProvider {
+ func placeholder(in context: Context) -> NeedsAttentionEntry {
+ NeedsAttentionEntry(
+ date: .now,
+ snapshot: NeedsAttentionSnapshot(
+ unreadInboxThreads: 3,
+ assignedOpenTickets: 2,
+ failedBuilds: 1,
+ updatedAt: .now
+ )
+ )
+ }
+
+ func getSnapshot(in context: Context, completion: @escaping (NeedsAttentionEntry) -> Void) {
+ completion(
+ NeedsAttentionEntry(
+ date: .now,
+ snapshot: NeedsAttentionSnapshotStore.load()
+ )
+ )
+ }
+
+ func getTimeline(in context: Context, completion: @escaping (Timeline<NeedsAttentionEntry>) -> Void) {
+ let refreshDate = Calendar.current.date(byAdding: .minute, value: 20, to: .now) ?? .now.addingTimeInterval(1200)
+ let entry = NeedsAttentionEntry(date: .now, snapshot: NeedsAttentionSnapshotStore.load())
+ completion(Timeline(entries: [entry], policy: .after(refreshDate)))
+ }
+}
+
+struct NeedsAttentionWidget: Widget {
+ static let kind = NeedsAttentionWidgetConfiguration.kind
+
+ var body: some WidgetConfiguration {
+ StaticConfiguration(kind: Self.kind, provider: NeedsAttentionTimelineProvider()) { entry in
+ NeedsAttentionWidgetView(entry: entry)
+ }
+ .configurationDisplayName("Needs Attention")
+ .description("Unread inbox threads, assigned tickets, and failed builds.")
+ .supportedFamilies([.systemSmall, .systemMedium])
+ }
+}
+
+private struct NeedsAttentionWidgetView: View {
+ let entry: NeedsAttentionEntry
+ @Environment(\.widgetFamily) private var family
+
+ var body: some View {
+ Group {
+ if let snapshot = entry.snapshot, snapshot.hasAnyData {
+ if snapshot.allCountsAreZero {
+ clearState
+ } else {
+ switch family {
+ case .systemMedium:
+ mediumView(snapshot: snapshot)
+ default:
+ smallView(snapshot: snapshot)
+ }
+ }
+ } else {
+ fallbackState
+ }
+ }
+ .widgetURL(URL(string: "hutch://home"))
+ .containerBackground(for: .widget) {
+ Color(.systemBackground)
+ }
+ }
+
+ private var header: some View {
+ VStack(alignment: .leading, spacing: 1) {
+ Text("Hutch")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.secondary)
+ Text("Needs Attention")
+ .font(.headline)
+ .lineLimit(1)
+ }
+ }
+
+ private func smallView(snapshot: NeedsAttentionSnapshot) -> some View {
+ VStack(alignment: .leading, spacing: 12) {
+ Spacer(minLength: 0)
+ VStack(alignment: .leading, spacing: 8) {
+ compactMetric(
+ count: snapshot.unreadInboxThreads,
+ label: "Unread",
+ tint: unreadTint(for: snapshot.unreadInboxThreads)
+ )
+ compactMetric(count: snapshot.assignedOpenTickets, label: "Assigned")
+ compactMetric(
+ count: snapshot.failedBuilds,
+ label: "Failed",
+ tint: failedTint(for: snapshot.failedBuilds)
+ )
+ }
+ Spacer(minLength: 0)
+ }
+ .padding()
+ }
+
+ private func mediumView(snapshot: NeedsAttentionSnapshot) -> some View {
+ VStack(alignment: .leading, spacing: 18) {
+ header
+ .padding(.top, 6)
+ HStack(alignment: .top, spacing: 12) {
+ metricColumn(
+ count: snapshot.unreadInboxThreads,
+ label: "Unread",
+ systemImage: "tray",
+ tint: unreadTint(for: snapshot.unreadInboxThreads)
+ )
+ metricColumn(
+ count: snapshot.assignedOpenTickets,
+ label: "Assigned",
+ systemImage: "ticket"
+ )
+ metricColumn(
+ count: snapshot.failedBuilds,
+ label: "Failed",
+ systemImage: "hammer",
+ tint: failedTint(for: snapshot.failedBuilds)
+ )
+ }
+ Spacer(minLength: 0)
+ }
+ .padding(.horizontal, 16)
+ .padding(.top, 18)
+ .padding(.bottom, 16)
+ }
+
+ private var clearState: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ header
+ Spacer(minLength: 0)
+ Text("All clear")
+ .font(.title3.weight(.semibold))
+ Text("No unread threads, assigned tickets, or failed builds.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ .padding()
+ }
+
+ private var fallbackState: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ header
+ Spacer(minLength: 0)
+ Text("Open Hutch")
+ .font(.title3.weight(.semibold))
+ Text("Unable to load recent counts.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ .padding()
+ }
+
+ private func compactMetric(count: Int?, label: String, tint: Color = .primary) -> some View {
+ HStack(alignment: .center, spacing: 6) {
+ Text(displayCount(count))
+ .font(.title2.weight(.semibold))
+ .monospacedDigit()
+ .foregroundStyle(tint)
+ Text(label)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ Spacer(minLength: 0)
+ }
+ }
+
+ private func metricColumn(count: Int?, label: String, systemImage: String, tint: Color = .primary) -> some View {
+ VStack(alignment: .leading, spacing: 8) {
+ HStack(alignment: .center, spacing: 10) {
+ Image(systemName: systemImage)
+ .font(.title3.weight(.semibold))
+ .foregroundStyle(tint)
+ .frame(width: 24, alignment: .leading)
+ .padding(.trailing, 4)
+ Text(displayCount(count))
+ .font(.system(size: 28, weight: .semibold, design: .rounded))
+ .monospacedDigit()
+ .lineLimit(1)
+ .minimumScaleFactor(0.8)
+ .foregroundStyle(tint)
+ }
+ Text(label)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ .minimumScaleFactor(0.85)
+ .frame(height: 16, alignment: .topLeading)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+ }
+
+ private func displayCount(_ count: Int?) -> String {
+ guard let count else { return "—" }
+ return "\(count)"
+ }
+
+ private func unreadTint(for count: Int?) -> Color {
+ guard let count, count > 0 else { return .primary }
+ return .blue
+ }
+
+ private func failedTint(for count: Int?) -> Color {
+ guard let count else { return .primary }
+ return count > 0 ? .red : .green
+ }
+}
+
+@main
+struct HutchWidgets: WidgetBundle {
+ var body: some Widget {
+ NeedsAttentionWidget()
+ }
+}
diff --git a/Shared/NeedsAttentionSnapshot.swift b/Shared/NeedsAttentionSnapshot.swift
new file mode 100644
index 0000000..f386af6
--- /dev/null
+++ b/Shared/NeedsAttentionSnapshot.swift
@@ -0,0 +1,109 @@
+import Foundation
+#if canImport(WidgetKit)
+import WidgetKit
+#endif
+
+enum HutchAppGroup {
+ static let identifier = "group.net.cleberg.Hutch"
+}
+
+enum NeedsAttentionWidgetConfiguration {
+ static let kind = "NeedsAttentionWidget"
+}
+
+struct NeedsAttentionSnapshot: Codable, Sendable {
+ let unreadInboxThreads: Int?
+ let assignedOpenTickets: Int?
+ let failedBuilds: Int?
+ let updatedAt: Date
+
+ static let unavailable = NeedsAttentionSnapshot(
+ unreadInboxThreads: nil,
+ assignedOpenTickets: nil,
+ failedBuilds: nil,
+ updatedAt: .now
+ )
+
+ var hasAnyData: Bool {
+ unreadInboxThreads != nil || assignedOpenTickets != nil || failedBuilds != nil
+ }
+
+ var allCountsAreZero: Bool {
+ let counts = [unreadInboxThreads, assignedOpenTickets, failedBuilds].compactMap { $0 }
+ return !counts.isEmpty && counts.allSatisfy { $0 == 0 }
+ }
+}
+
+enum NeedsAttentionSnapshotStore {
+ private static let snapshotKey = "needsAttention.snapshot"
+
+ static func load(defaults: UserDefaults? = sharedDefaults()) -> NeedsAttentionSnapshot? {
+ guard let defaults,
+ let data = defaults.data(forKey: snapshotKey) else {
+ return nil
+ }
+
+ return try? JSONDecoder().decode(NeedsAttentionSnapshot.self, from: data)
+ }
+
+ static func save(_ snapshot: NeedsAttentionSnapshot, defaults: UserDefaults? = sharedDefaults()) {
+ guard let defaults,
+ let data = try? JSONEncoder().encode(snapshot) else {
+ return
+ }
+
+ defaults.set(data, forKey: snapshotKey)
+ reloadWidgetTimelines()
+ }
+
+ static func update(
+ unreadInboxThreads: Int? = nil,
+ assignedOpenTickets: Int? = nil,
+ failedBuilds: Int? = nil,
+ defaults: UserDefaults? = sharedDefaults()
+ ) {
+ let existing = load(defaults: defaults)
+ let snapshot = NeedsAttentionSnapshot(
+ unreadInboxThreads: unreadInboxThreads ?? existing?.unreadInboxThreads,
+ assignedOpenTickets: assignedOpenTickets ?? existing?.assignedOpenTickets,
+ failedBuilds: failedBuilds ?? existing?.failedBuilds,
+ updatedAt: .now
+ )
+ save(snapshot, defaults: defaults)
+ }
+
+ static func adjustUnreadInboxThreads(
+ by delta: Int,
+ defaults: UserDefaults? = sharedDefaults()
+ ) {
+ guard let existing = load(defaults: defaults),
+ let unreadInboxThreads = existing.unreadInboxThreads else {
+ return
+ }
+
+ save(
+ NeedsAttentionSnapshot(
+ unreadInboxThreads: max(0, unreadInboxThreads + delta),
+ assignedOpenTickets: existing.assignedOpenTickets,
+ failedBuilds: existing.failedBuilds,
+ updatedAt: .now
+ ),
+ defaults: defaults
+ )
+ }
+
+ static func clear(defaults: UserDefaults? = sharedDefaults()) {
+ defaults?.removeObject(forKey: snapshotKey)
+ reloadWidgetTimelines()
+ }
+
+ private static func sharedDefaults() -> UserDefaults? {
+ UserDefaults(suiteName: HutchAppGroup.identifier)
+ }
+
+ private static func reloadWidgetTimelines() {
+ #if canImport(WidgetKit)
+ WidgetCenter.shared.reloadTimelines(ofKind: NeedsAttentionWidgetConfiguration.kind)
+ #endif
+ }
+}