diff options
| author | Christian Cleberg <[email protected]> | 2026-04-12 00:34:54 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-12 00:34:54 -0500 |
| commit | 5f6d545eb3455a9e4da5a3cb4460811e3a48d939 (patch) | |
| tree | 7b7591146da4e02bd617ddb9f26e47b1c4474ef6 /Hutch/Views/Home | |
| parent | cb1ea5ea4f163d87053285d3fb7999b12a3558b9 (diff) | |
| download | hutch-5f6d545eb3455a9e4da5a3cb4460811e3a48d939.tar.gz hutch-5f6d545eb3455a9e4da5a3cb4460811e3a48d939.tar.bz2 hutch-5f6d545eb3455a9e4da5a3cb4460811e3a48d939.zip | |
improve home attention flows and cross-linkingv2.14.0
Diffstat (limited to 'Hutch/Views/Home')
| -rw-r--r-- | Hutch/Views/Home/HomeView.swift | 378 | ||||
| -rw-r--r-- | Hutch/Views/Home/HomeViewModel.swift | 322 |
2 files changed, 627 insertions, 73 deletions
diff --git a/Hutch/Views/Home/HomeView.swift b/Hutch/Views/Home/HomeView.swift index 632e804..3efef7a 100644 --- a/Hutch/Views/Home/HomeView.swift +++ b/Hutch/Views/Home/HomeView.swift @@ -55,7 +55,9 @@ struct HomeView: View { @ViewBuilder private func content(_ viewModel: HomeViewModel) -> some View { List { + attentionSection(viewModel) systemStatusBannerSection(viewModel) + inboxSection(viewModel) projectsSection(viewModel) assignedTicketsSection(viewModel) recentBuildsSection(viewModel) @@ -63,15 +65,17 @@ struct HomeView: View { .listStyle(.insetGrouped) .overlay { if viewModel.isLoadingProjects && viewModel.isLoadingAssignedTickets && viewModel.isLoadingRecentBuilds && - viewModel.projects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty { + viewModel.projects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty && + viewModel.unreadInboxThreads.isEmpty { SRHTLoadingStateView(message: "Loading Home…") } else if !viewModel.isLoadingProjects && !viewModel.isLoadingAssignedTickets && !viewModel.isLoadingRecentBuilds && viewModel.projects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty && + viewModel.unreadInboxThreads.isEmpty && viewModel.assignedTicketsError == nil && viewModel.recentBuildsError == nil { ContentUnavailableView( "All Clear", systemImage: "checkmark.circle", - description: Text("There are no assigned tickets or recent builds right now.") + description: Text("There are no unread threads, assigned tickets, or urgent builds right now.") ) } } @@ -84,6 +88,41 @@ struct HomeView: View { } @ViewBuilder + private func attentionSection(_ viewModel: HomeViewModel) -> some View { + Section("Needs Attention") { + HomeAttentionSummaryRow( + title: viewModel.needsAttentionCount == 0 ? "All clear" : "\(viewModel.needsAttentionCount) things need attention", + summary: viewModel.attentionSummaryText + ) + + HomeAttentionLinkRow( + title: "Inbox", + summary: viewModel.inboxSummaryText, + countText: viewModel.unreadInboxThreadCount.map(String.init) ?? "?" + ) { + InboxView() + } + + HomeAttentionLinkRow( + title: "Assigned Tickets", + summary: viewModel.ticketsSummaryText, + countText: String(viewModel.assignedTickets.count) + ) { + HomeAssignedTicketsListView(viewModel: viewModel) + } + + HomeAttentionLinkRow( + title: "Builds", + summary: viewModel.buildsSummaryText, + countText: String(viewModel.failedBuildCount + viewModel.activeBuildCount), + action: { + appState.selectedTab = .builds + } + ) + } + } + + @ViewBuilder private func systemStatusBannerSection(_ viewModel: HomeViewModel) -> some View { Section { NavigationLink { @@ -101,6 +140,40 @@ struct HomeView: View { } @ViewBuilder + private func inboxSection(_ viewModel: HomeViewModel) -> some View { + Section { + if let unreadCount = viewModel.unreadInboxThreadCount, unreadCount == 0 { + HomeSectionMessageRow( + text: "No unread inbox threads.", + systemImage: "tray" + ) + } else if viewModel.unreadInboxThreads.isEmpty { + HomeSectionMessageRow( + text: viewModel.inboxSummaryText, + systemImage: "tray" + ) + } else { + ForEach(viewModel.unreadInboxThreads.prefix(previewLimit)) { thread in + NavigationLink { + ThreadDetailView( + thread: thread, + onViewed: { viewModel.markInboxThreadRead(thread) }, + onMarkRead: { viewModel.markInboxThreadRead(thread) }, + onMarkUnread: { viewModel.markInboxThreadUnread(thread) } + ) + } label: { + HomeInboxThreadRow(thread: thread) + } + } + } + } header: { + HomeSectionHeader("Inbox") { + InboxView() + } + } + } + + @ViewBuilder private func projectsSection(_ viewModel: HomeViewModel) -> some View { if !viewModel.projects.isEmpty { Section { @@ -312,41 +385,55 @@ private struct HomeProjectsListView: View { } private struct HomeBuildRow: View { + @Environment(AppState.self) private var appState let build: HomeBuildItem var body: some View { - HStack(spacing: 12) { - JobStatusIcon(status: build.job.status) - .frame(width: 20) - - VStack(alignment: .leading, spacing: 4) { - Text(primaryTitle) - .font(.subheadline.weight(.medium)) - .lineLimit(1) - - HStack(spacing: 8) { - Text("Job #\(build.job.id)") - .font(.caption) - .foregroundStyle(.secondary) - - Text("•") - .font(.caption) - .foregroundStyle(.tertiary) + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 12) { + JobStatusIcon(status: build.job.status) + .frame(width: 20) + + VStack(alignment: .leading, spacing: 4) { + Text(primaryTitle) + .font(.subheadline.weight(.medium)) + .lineLimit(1) + + HStack(spacing: 8) { + Text("Job #\(build.job.id)") + .font(.caption) + .foregroundStyle(.secondary) + + Text("•") + .font(.caption) + .foregroundStyle(.tertiary) + + Text(build.job.status.rawValue.capitalized) + .font(.caption) + .foregroundStyle(.secondary) + + Text("•") + .font(.caption) + .foregroundStyle(.tertiary) + + Text(build.job.created.relativeDescription) + .font(.caption) + .foregroundStyle(.tertiary) + + Spacer() + } + } + } - Text(build.job.status.rawValue.capitalized) + if let repositoryDisplayName = build.repositoryDisplayName { + Button { + openRepository() + } label: { + Label(repositoryDisplayName, systemImage: "book.closed") .font(.caption) .foregroundStyle(.secondary) - - Text("•") - .font(.caption) - .foregroundStyle(.tertiary) - - Text(build.job.created.relativeDescription) - .font(.caption) - .foregroundStyle(.tertiary) - - Spacer() } + .buttonStyle(.plain) } } .padding(.vertical, 2) @@ -358,38 +445,148 @@ private struct HomeBuildRow: View { } return build.job.displayLabel } + + private func openRepository() { + guard let repositoryName = build.repositoryName, + let repositoryOwner = build.repositoryOwner else { return } + Task { + do { + let repository = try await appState.resolveRepository( + owner: repositoryOwner.hasPrefix("~") ? String(repositoryOwner.dropFirst()) : repositoryOwner, + name: repositoryName + ) + appState.navigateToRepository(repository) + } catch { + appState.presentRepositoryDeepLinkError() + } + } + } } private struct HomeAssignedTicketRow: View { + @Environment(AppState.self) private var appState let ticket: HomeAssignedTicket var body: some View { - HStack(alignment: .top, spacing: 12) { - TicketStatusIcon(status: ticket.ticket.status) - .frame(width: 20) + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .top, spacing: 12) { + TicketStatusIcon(status: ticket.ticket.status) + .frame(width: 20) - VStack(alignment: .leading, spacing: 4) { - Text(ticket.ticket.title) - .font(.subheadline.weight(.medium)) - .lineLimit(2) + VStack(alignment: .leading, spacing: 4) { + Text(ticket.ticket.title) + .font(.subheadline.weight(.medium)) + .lineLimit(2) - Text("\(ticket.ownerCanonicalName)/\(ticket.trackerName) • #\(ticket.ticket.id) • \(ticket.ticket.created.relativeDescription)") - .font(.caption) + Text("\(ticket.ownerCanonicalName)/\(ticket.trackerName) • #\(ticket.ticket.id) • \(ticket.ticket.created.relativeDescription)") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + } + + Spacer(minLength: 8) + + Text(ticket.ticket.status.displayName) + .font(.caption2.weight(.medium)) .foregroundStyle(.secondary) .lineLimit(1) - .truncationMode(.tail) + .fixedSize() } - Spacer(minLength: 8) + Button { + openTracker() + } label: { + Label("\(ticket.ownerCanonicalName)/\(ticket.trackerName)", systemImage: "checklist") + .font(.caption) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + .padding(.vertical, 2) + } - Text(ticket.ticket.status.displayName) - .font(.caption2.weight(.medium)) - .foregroundStyle(.secondary) - .lineLimit(1) - .fixedSize() + private func openTracker() { + Task { + do { + let tracker = try await appState.resolveTracker(owner: ticket.ownerUsername, name: ticket.trackerName) + appState.navigateToTracker(tracker) + } catch { + appState.presentTicketDeepLinkError() + } + } + } +} + +private struct HomeInboxThreadRow: View { + @Environment(AppState.self) private var appState + let thread: InboxThreadSummary + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .top, spacing: 10) { + Circle() + .fill(.blue) + .frame(width: 8, height: 8) + .padding(.top, 6) + + VStack(alignment: .leading, spacing: 4) { + Text(thread.displaySubject) + .font(.subheadline.weight(.medium)) + .lineLimit(2) + + Text(thread.metadataLine) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + + HStack(spacing: 10) { + Button { + appState.navigateToMailingList( + InboxMailingListReference( + id: thread.listID, + rid: thread.listRID, + name: thread.listName, + owner: thread.listOwner + ) + ) + } label: { + Label(thread.listName, systemImage: "list.bullet") + .font(.caption) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + + if let repo = thread.repo { + Button { + openRepository(named: repo) + } label: { + Label(repo, systemImage: "book.closed") + .font(.caption) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + } } .padding(.vertical, 2) } + + private func openRepository(named repositoryName: String) { + Task { + do { + let ownerUsername = thread.listOwner.canonicalName.hasPrefix("~") + ? String(thread.listOwner.canonicalName.dropFirst()) + : thread.listOwner.canonicalName + let repository = try await appState.resolveRepository(owner: ownerUsername, name: repositoryName) + appState.navigateToRepository(repository) + } catch { + appState.presentRepositoryDeepLinkError() + } + } + } } private struct HomeSectionLoadingRow: View { @@ -431,6 +628,95 @@ private struct HomeSectionHeader<Destination: View>: View { } } +private struct HomeAttentionSummaryRow: View { + let title: String + let summary: String + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.subheadline.weight(.semibold)) + Text(summary) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + .padding(.vertical, 2) + } +} + +private struct HomeAttentionLinkRow<Destination: View>: View { + let title: String + let summary: String + let countText: String + let destination: Destination? + let action: (() -> Void)? + + init( + title: String, + summary: String, + countText: String, + @ViewBuilder destination: () -> Destination + ) { + self.title = title + self.summary = summary + self.countText = countText + self.destination = destination() + self.action = nil + } + + init( + title: String, + summary: String, + countText: String, + action: @escaping () -> Void + ) where Destination == EmptyView { + self.title = title + self.summary = summary + self.countText = countText + self.destination = nil + self.action = action + } + + var body: some View { + Group { + if let destination { + NavigationLink { + destination + } label: { + content + } + } else if let action { + Button(action: action) { + content + } + .buttonStyle(.plain) + } + } + } + + private var content: some View { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.subheadline.weight(.medium)) + Text(summary) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer() + Text(countText) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color(.secondarySystemFill), in: Capsule()) + } + .padding(.vertical, 2) + } +} + private struct HomeSectionActionHeader: View { let title: String let action: () -> Void diff --git a/Hutch/Views/Home/HomeViewModel.swift b/Hutch/Views/Home/HomeViewModel.swift index ba00a36..8762393 100644 --- a/Hutch/Views/Home/HomeViewModel.swift +++ b/Hutch/Views/Home/HomeViewModel.swift @@ -63,8 +63,31 @@ private struct HomeInboxThreadPage: Decodable, Sendable { } private struct HomeInboxThreadPayload: Decodable, Sendable { + let created: Date let updated: Date let subject: String + let replies: Int + let sender: Entity + let root: HomeInboxEmailPreview +} + +private struct HomeInboxEmailPreview: Decodable, Sendable { + let id: Int + let subject: String + let date: Date? + let received: Date + let messageID: String + let body: String + let patch: HomeInboxPatchPreview? +} + +private struct HomeInboxPatchPreview: Decodable, Sendable { + let subject: String? +} + +private struct HomeInboxUnreadSnapshot: Sendable { + let unreadCount: Int + let threads: [InboxThreadSummary] } private struct HomeTicketPayload: Decodable, Sendable { @@ -135,6 +158,15 @@ struct HomeBuildItem: Identifiable, Hashable, Sendable { } return repositoryName } + + var requiresAttention: Bool { + switch job.status { + case .failed, .timeout, .running, .queued, .pending: + true + case .success, .cancelled: + false + } + } } @Observable @@ -143,6 +175,7 @@ final class HomeViewModel { private(set) var projects: [Project] = [] var assignedTickets: [HomeAssignedTicket] = [] var recentBuilds: [HomeBuildItem] = [] + var unreadInboxThreads: [InboxThreadSummary] = [] private(set) var systemStatusSnapshot: SystemStatusSnapshot? private(set) var isLoadingSystemStatus = false private(set) var isShowingStaleSystemStatus = false @@ -242,8 +275,20 @@ final class HomeViewModel { list(rid: $rid) { threads(cursor: $cursor) { results { + created updated subject + replies + sender { canonicalName } + root { + id + subject + date + received + messageID + body + patch { subject } + } } cursor } @@ -291,7 +336,7 @@ final class HomeViewModel { async let projectsTask = loadProjects() async let jobsTask = loadRecentJobs() async let assignedTicketsTask = loadAssignedTickets() - async let inboxUnreadTask = loadInboxUnreadCount() + async let inboxUnreadTask = loadInboxUnreadSnapshot() async let systemStatusTask = loadSystemStatusSnapshot() let projectsResult = await projectsTask @@ -328,7 +373,9 @@ final class HomeViewModel { } isLoadingAssignedTickets = false - unreadInboxThreadCount = await inboxUnreadTask + let inboxUnreadSnapshot = await inboxUnreadTask + unreadInboxThreadCount = inboxUnreadSnapshot?.unreadCount + unreadInboxThreads = Array(inboxUnreadSnapshot?.threads.prefix(4) ?? []) hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0 let systemStatusResult = await systemStatusTask switch systemStatusResult { @@ -344,7 +391,116 @@ final class HomeViewModel { } var hasDashboardContent: Bool { - !projects.isEmpty || !assignedTickets.isEmpty || !recentBuilds.isEmpty || systemStatusSnapshot != nil + !projects.isEmpty || !assignedTickets.isEmpty || !recentBuilds.isEmpty || !unreadInboxThreads.isEmpty || systemStatusSnapshot != nil + } + + var failedBuildCount: Int { + recentBuilds.filter { + switch $0.job.status { + case .failed, .timeout: + return true + default: + return false + } + }.count + } + + var activeBuildCount: Int { + recentBuilds.filter { + switch $0.job.status { + case .pending, .queued, .running: + return true + default: + return false + } + }.count + } + + var activeIncidentCount: Int { + systemStatusSnapshot?.activeIncidents.count ?? 0 + } + + var disruptedServiceCount: Int { + systemStatusSnapshot?.disruptedServices.count ?? 0 + } + + var needsAttentionCount: Int { + var count = 0 + if let unreadInboxThreadCount { + count += unreadInboxThreadCount + } + count += assignedTickets.count + count += failedBuildCount + count += activeBuildCount + if let snapshot = systemStatusSnapshot, snapshot.hasDisruption { + count += max(snapshot.disruptedServices.count, snapshot.activeIncidents.count) + } + return count + } + + var attentionSummaryText: String { + if needsAttentionCount == 0 { + return "All clear" + } + var parts: [String] = [] + if let unreadInboxThreadCount, unreadInboxThreadCount > 0 { + parts.append(Self.countLabel(unreadInboxThreadCount, singular: "unread thread")) + } + if !assignedTickets.isEmpty { + parts.append(Self.countLabel(assignedTickets.count, singular: "assigned ticket")) + } + if failedBuildCount > 0 { + parts.append(Self.countLabel(failedBuildCount, singular: "failed build")) + } + if activeBuildCount > 0 { + parts.append(Self.countLabel(activeBuildCount, singular: "active build")) + } + if disruptedServiceCount > 0 { + parts.append(Self.countLabel(disruptedServiceCount, singular: "service issue")) + } + return parts.joined(separator: " • ") + } + + var inboxSummaryText: String { + guard let unreadInboxThreadCount else { return "Inbox status unavailable" } + if unreadInboxThreadCount == 0 { + return "Inbox zero" + } + return "\(Self.countLabel(unreadInboxThreadCount, singular: "unread thread")) across your lists" + } + + var ticketsSummaryText: String { + if assignedTickets.isEmpty { + return "No open tickets assigned to you" + } + return Self.countLabel(assignedTickets.count, singular: "open assigned ticket") + } + + var buildsSummaryText: String { + if recentBuilds.isEmpty { + return "No recent builds" + } + var parts: [String] = [] + if failedBuildCount > 0 { + parts.append(Self.countLabel(failedBuildCount, singular: "failed build")) + } + if activeBuildCount > 0 { + parts.append(Self.countLabel(activeBuildCount, singular: "active build")) + } + if parts.isEmpty { + return "Recent builds are clear" + } + return parts.joined(separator: " • ") + } + + var systemSummaryText: String { + guard let systemStatusSnapshot else { + return systemStatusErrorMessage ?? "System status unavailable" + } + if systemStatusSnapshot.hasDisruption { + return systemStatusSnapshot.bannerSummary + } + return systemStatusSnapshot.overallStatusText } func resolveTicket(_ ticket: HomeAssignedTicket) async { @@ -415,6 +571,43 @@ final class HomeViewModel { } } + func markInboxThreadRead(_ thread: InboxThreadSummary) { + InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id) + unreadInboxThreads.removeAll { $0.id == thread.id } + unreadInboxThreadCount = max((unreadInboxThreadCount ?? 1) - 1, 0) + hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0 + persistNeedsAttentionSnapshot() + } + + func markInboxThreadUnread(_ thread: InboxThreadSummary) { + InboxReadStateStore.markUnread(for: thread.id) + if unreadInboxThreads.contains(where: { $0.id == thread.id }) == false { + unreadInboxThreads.append( + InboxThreadSummary( + rootEmailID: thread.rootEmailID, + rootMessageID: thread.rootMessageID, + threadRootEmailIDs: thread.threadRootEmailIDs, + threadRootMessageIDs: thread.threadRootMessageIDs, + listID: thread.listID, + listRID: thread.listRID, + listName: thread.listName, + listOwner: thread.listOwner, + subject: thread.subject, + latestSender: thread.latestSender, + lastActivityAt: thread.lastActivityAt, + messageCount: thread.messageCount, + repo: thread.repo, + containsPatch: thread.containsPatch, + isUnread: true + ) + ) + unreadInboxThreads.sort(by: Self.sortInboxThreadsForTriage) + } + unreadInboxThreadCount = (unreadInboxThreadCount ?? 0) + 1 + hasUnreadInboxThreads = true + persistNeedsAttentionSnapshot() + } + private func loadProjects() async -> Result<[Project], Error> { do { return .success(try await projectService.fetchProjects()) @@ -436,9 +629,9 @@ final class HomeViewModel { } } - private func loadInboxUnreadCount() async -> Int? { + private func loadInboxUnreadSnapshot() async -> HomeInboxUnreadSnapshot? { do { - return try await fetchUnreadInboxThreadCount() + return try await fetchUnreadInboxSnapshot() } catch { return nil } @@ -452,13 +645,14 @@ final class HomeViewModel { } } - private func fetchUnreadInboxThreadCount() async throws -> Int { + private func fetchUnreadInboxSnapshot() async throws -> HomeInboxUnreadSnapshot { let mailingLists = try await fetchInboxMailingLists() - guard !mailingLists.isEmpty else { return 0 } + guard !mailingLists.isEmpty else { return HomeInboxUnreadSnapshot(unreadCount: 0, threads: []) } var startIndex = mailingLists.startIndex var unreadCount = 0 var successfulFetchCount = 0 + var unreadThreads: [InboxThreadSummary] = [] while startIndex < mailingLists.endIndex { let endIndex = mailingLists.index( startIndex, @@ -467,31 +661,32 @@ final class HomeViewModel { ) ?? mailingLists.endIndex let batch = Array(mailingLists[startIndex..<endIndex]) - let batchResult = await withTaskGroup(of: Result<Int, Error>.self) { group in + let batchResult = await withTaskGroup(of: Result<HomeInboxUnreadSnapshot, Error>.self) { group in for mailingList in batch { group.addTask { do { - return .success(try await self.fetchUnreadThreadCount(for: mailingList)) + return .success(try await self.fetchUnreadThreadSnapshot(for: mailingList)) } catch { return .failure(error) } } } - var counts: [Int] = [] + var snapshots: [HomeInboxUnreadSnapshot] = [] var errors: [Error] = [] for await result in group { switch result { - case .success(let count): - counts.append(count) + case .success(let snapshot): + snapshots.append(snapshot) case .failure(let error): errors.append(error) } } - return (counts, errors) + return (snapshots, errors) } - unreadCount += batchResult.0.reduce(0, +) + unreadCount += batchResult.0.reduce(0) { $0 + $1.unreadCount } + unreadThreads.append(contentsOf: batchResult.0.flatMap(\.threads)) successfulFetchCount += batchResult.0.count startIndex = endIndex @@ -501,7 +696,10 @@ final class HomeViewModel { throw SRHTError.graphQLErrors([GraphQLError(message: "Failed to load inbox threads", locations: nil)]) } - return unreadCount + return HomeInboxUnreadSnapshot( + unreadCount: unreadCount, + threads: unreadThreads.sorted(by: Self.sortInboxThreadsForTriage) + ) } private func fetchInboxMailingLists() async throws -> [InboxMailingListReference] { @@ -532,9 +730,10 @@ final class HomeViewModel { return subscriptions.compactMap(\.list).filter { seen.insert($0.rid).inserted } } - private func fetchUnreadThreadCount(for mailingList: InboxMailingListReference) async throws -> Int { + private func fetchUnreadThreadSnapshot(for mailingList: InboxMailingListReference) async throws -> HomeInboxUnreadSnapshot { var unreadCount = 0 var cursor: String? + var unreadThreads: [InboxThreadSummary] = [] while true { var variables: [String: any Sendable] = ["rid": mailingList.rid] @@ -549,15 +748,31 @@ final class HomeViewModel { 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 + let unreadThreadSummaries = response.list.threads.results.compactMap { thread -> InboxThreadSummary? in + let summary = InboxThreadSummary( + rootEmailID: thread.root.id, + rootMessageID: thread.root.messageID, + threadRootEmailIDs: [thread.root.id], + threadRootMessageIDs: [thread.root.messageID], + listID: mailingList.id, + listRID: mailingList.rid, + listName: mailingList.name, + listOwner: mailingList.owner, + subject: thread.subject, + latestSender: thread.sender, + lastActivityAt: thread.updated, + messageCount: thread.replies + 1, + repo: InboxViewModel.deriveRepositoryName(from: mailingList.name), + containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"), + isUnread: InboxReadStateStore.isUnread( + threadID: "\(mailingList.rid)#\(InboxThreadSummary.normalizationKey(for: thread.subject))", + lastActivityAt: thread.updated + ) + ) + return summary.isUnread ? summary : nil + } + unreadCount += unreadThreadSummaries.count + unreadThreads.append(contentsOf: unreadThreadSummaries) guard let nextCursor = response.list.threads.cursor else { break @@ -565,14 +780,17 @@ final class HomeViewModel { cursor = nextCursor } - return unreadCount + return HomeInboxUnreadSnapshot( + unreadCount: unreadCount, + threads: unreadThreads + ) } private func loadAssignedTickets() async -> Result<[HomeAssignedTicket], Error> { do { let trackers = try await fetchAllTrackers() let tickets = try await fetchAssignedTickets(for: trackers) - .sorted { $0.ticket.created > $1.ticket.created } + .sorted(by: Self.sortAssignedTicketsForTriage) return .success(tickets) } catch { return .failure(error) @@ -719,6 +937,7 @@ final class HomeViewModel { repositoryOwner: repository?.ownerCanonicalName ) } + .sorted(by: sortBuildItemsForTriage) } nonisolated static func failedBuilds(from jobs: [HomeJobPayload]) -> [HomeBuildItem] { @@ -732,6 +951,36 @@ final class HomeViewModel { } } + nonisolated static func sortBuildItemsForTriage(_ lhs: HomeBuildItem, _ rhs: HomeBuildItem) -> Bool { + let lhsPriority = buildPriority(for: lhs.job.status) + let rhsPriority = buildPriority(for: rhs.job.status) + if lhsPriority != rhsPriority { + return lhsPriority < rhsPriority + } + if lhs.job.updated != rhs.job.updated { + return lhs.job.updated > rhs.job.updated + } + return lhs.job.id > rhs.job.id + } + + nonisolated static func sortAssignedTicketsForTriage(_ lhs: HomeAssignedTicket, _ rhs: HomeAssignedTicket) -> Bool { + if lhs.ticket.created != rhs.ticket.created { + return lhs.ticket.created < rhs.ticket.created + } + return lhs.ticket.id < rhs.ticket.id + } + + nonisolated static func sortInboxThreadsForTriage(_ lhs: InboxThreadSummary, _ rhs: InboxThreadSummary) -> Bool { + if lhs.containsPatch != rhs.containsPatch { + return lhs.containsPatch && !rhs.containsPatch + } + if lhs.lastActivityAt != rhs.lastActivityAt { + return lhs.lastActivityAt > rhs.lastActivityAt + } + return InboxThreadSummary.normalizationKey(for: lhs.subject) + .localizedCaseInsensitiveCompare(InboxThreadSummary.normalizationKey(for: rhs.subject)) == .orderedAscending + } + nonisolated static func matchesCurrentUserAssignee(_ entity: Entity, currentUser: User) -> Bool { let assigneeCanonical = normalizedCanonicalName(entity.canonicalName) let currentCanonical = normalizedCanonicalName(currentUser.canonicalName) @@ -784,6 +1033,25 @@ final class HomeViewModel { return trimmed } + private nonisolated static func buildPriority(for status: JobStatus) -> Int { + switch status { + case .failed, .timeout: + return 0 + case .running: + return 1 + case .queued, .pending: + return 2 + case .cancelled: + return 3 + case .success: + return 4 + } + } + + private nonisolated static func countLabel(_ count: Int, singular: String) -> String { + count == 1 ? "1 \(singular)" : "\(count) \(singular)s" + } + private struct StatusEventResponse: Decodable, Sendable { struct EventRef: Decodable, Sendable { let eventType: String |
