diff options
| author | Christian Cleberg <[email protected]> | 2026-03-18 22:56:34 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-03-18 22:56:34 -0500 |
| commit | ffc0ef0988b5e7d01951c23af85a1cf026b3da9f (patch) | |
| tree | 868cef24e91ddaa5e7d5e39362d97ec6a94107c9 /Hutch/Views/Home | |
| parent | ed48e72520c51e4635cd3840dbc569bdd74c950f (diff) | |
| download | hutch-ffc0ef0988b5e7d01951c23af85a1cf026b3da9f.tar.gz hutch-ffc0ef0988b5e7d01951c23af85a1cf026b3da9f.tar.bz2 hutch-ffc0ef0988b5e7d01951c23af85a1cf026b3da9f.zip | |
add Home dashboard and repository build status indicators
Diffstat (limited to 'Hutch/Views/Home')
| -rw-r--r-- | Hutch/Views/Home/HomeView.swift | 297 | ||||
| -rw-r--r-- | Hutch/Views/Home/HomeViewModel.swift | 443 |
2 files changed, 740 insertions, 0 deletions
diff --git a/Hutch/Views/Home/HomeView.swift b/Hutch/Views/Home/HomeView.swift new file mode 100644 index 0000000..a72853f --- /dev/null +++ b/Hutch/Views/Home/HomeView.swift @@ -0,0 +1,297 @@ +import SwiftUI + +struct HomeView: View { + @Environment(AppState.self) private var appState + @State private var viewModel: HomeViewModel? + private let previewLimit = 4 + + var body: some View { + Group { + if let viewModel { + content(viewModel) + } else { + SRHTLoadingStateView(message: "Loading Home…") + } + } + .navigationTitle("Home") + .task { + if viewModel == nil, let currentUser = appState.currentUser { + let vm = HomeViewModel(currentUser: currentUser, client: appState.client) + viewModel = vm + await vm.loadDashboard() + } + } + } + + @ViewBuilder + private func content(_ viewModel: HomeViewModel) -> some View { + List { + assignedTicketsSection(viewModel) + recentBuildsSection(viewModel) + } + .listStyle(.insetGrouped) + .overlay { + if viewModel.isLoadingAssignedTickets && viewModel.isLoadingRecentBuilds && + viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty { + SRHTLoadingStateView(message: "Loading Home…") + } else if !viewModel.isLoadingAssignedTickets && !viewModel.isLoadingRecentBuilds && + viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.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.") + ) + } + } + .refreshable { + await viewModel.loadDashboard() + } + } + + @ViewBuilder + private func assignedTicketsSection(_ viewModel: HomeViewModel) -> some View { + Section { + if viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty { + HomeSectionLoadingRow(label: "Loading assigned tickets") + } else if let error = viewModel.assignedTicketsError, viewModel.assignedTickets.isEmpty { + HomeSectionMessageRow( + text: "Couldn’t load assigned tickets.", + systemImage: "exclamationmark.triangle", + emphasized: true, + accessibilityHint: error + ) + } else if viewModel.assignedTickets.isEmpty { + HomeSectionMessageRow( + text: "No open tickets assigned to you.", + systemImage: "person.crop.circle.badge.checkmark" + ) + } else { + ForEach(viewModel.assignedTickets.prefix(previewLimit)) { ticket in + NavigationLink { + TicketDetailView( + ownerUsername: ticket.ownerUsername, + trackerName: ticket.trackerName, + trackerId: ticket.trackerId, + trackerRid: ticket.trackerRid, + ticketId: ticket.ticket.id + ) + } label: { + HomeAssignedTicketRow(ticket: ticket) + } + } + } + } header: { + HomeSectionHeader("Assigned Tickets") { + HomeAssignedTicketsListView(viewModel: viewModel) + } + } + } + + @ViewBuilder + private func recentBuildsSection(_ viewModel: HomeViewModel) -> some View { + Section { + if viewModel.isLoadingRecentBuilds && viewModel.recentBuilds.isEmpty { + HomeSectionLoadingRow(label: "Loading recent builds") + } else if let error = viewModel.recentBuildsError, viewModel.recentBuilds.isEmpty { + HomeSectionMessageRow( + text: "Couldn’t load recent builds.", + systemImage: "exclamationmark.triangle", + emphasized: true, + accessibilityHint: error + ) + } else if viewModel.recentBuilds.isEmpty { + HomeSectionMessageRow( + text: "No recent builds.", + systemImage: "clock" + ) + } else { + ForEach(viewModel.recentBuilds.prefix(previewLimit)) { build in + NavigationLink { + BuildDetailView(jobId: build.job.id) + } label: { + HomeBuildRow(build: build) + } + } + } + } header: { + HomeSectionHeader("Recent Builds") { + BuildListView() + } + } + } + +} + +private struct HomeBuildRow: View { + 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) + + 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() + } + } + } + .padding(.vertical, 2) + } + + private var primaryTitle: String { + if let repositoryDisplayName = build.repositoryDisplayName { + return repositoryDisplayName + } + return build.job.displayLabel + } +} + +private struct HomeAssignedTicketRow: View { + let ticket: HomeAssignedTicket + + var body: some View { + 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) + + 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) + .fixedSize() + } + .padding(.vertical, 2) + } +} + +private struct HomeSectionLoadingRow: View { + let label: String + + var body: some View { + HStack(spacing: 10) { + ProgressView() + .controlSize(.small) + Text(label) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +private struct HomeSectionHeader<Destination: View>: View { + let title: String + let destination: Destination + + init(_ title: String, @ViewBuilder destination: () -> Destination) { + self.title = title + self.destination = destination() + } + + var body: some View { + HStack { + Text(title) + Spacer() + NavigationLink { + destination + } label: { + Text("See All") + .font(.caption.weight(.medium)) + } + .buttonStyle(.plain) + } + .textCase(nil) + } +} + +private struct HomeAssignedTicketsListView: View { + let viewModel: HomeViewModel + + var body: some View { + List { + ForEach(viewModel.assignedTickets) { ticket in + NavigationLink { + TicketDetailView( + ownerUsername: ticket.ownerUsername, + trackerName: ticket.trackerName, + trackerId: ticket.trackerId, + trackerRid: ticket.trackerRid, + ticketId: ticket.ticket.id + ) + } label: { + HomeAssignedTicketRow(ticket: ticket) + } + } + + if !viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty { + HomeSectionMessageRow( + text: "No open tickets assigned to you.", + systemImage: "person.crop.circle.badge.checkmark" + ) + } + } + .navigationTitle("Assigned Tickets") + .navigationBarTitleDisplayMode(.inline) + .refreshable { + await viewModel.loadDashboard() + } + .overlay { + if viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty { + SRHTLoadingStateView(message: "Loading assigned tickets…") + } + } + } +} + +private struct HomeSectionMessageRow: View { + let text: String + let systemImage: String + var emphasized = false + var accessibilityHint: String? = nil + + var body: some View { + Label(text, systemImage: systemImage) + .font(.subheadline) + .foregroundStyle(emphasized ? .secondary : .tertiary) + .accessibilityHint(accessibilityHint ?? "") + } +} diff --git a/Hutch/Views/Home/HomeViewModel.swift b/Hutch/Views/Home/HomeViewModel.swift new file mode 100644 index 0000000..ce27bb0 --- /dev/null +++ b/Hutch/Views/Home/HomeViewModel.swift @@ -0,0 +1,443 @@ +import Foundation + +private struct HomeJobsResponse: Decodable, Sendable { + let jobs: HomeJobsPage +} + +private struct HomeJobsPage: Decodable, Sendable { + let results: [HomeJobPayload] +} + +private struct HomeTrackersResponse: Decodable, Sendable { + let trackers: HomeTrackersPage +} + +private struct HomeTrackersPage: Decodable, Sendable { + let results: [TrackerSummary] + let cursor: String? +} + +private struct HomeTrackerTicketsResponse: Decodable, Sendable { + let user: HomeTrackerTicketsUser +} + +private struct HomeTrackerTicketsUser: Decodable, Sendable { + let tracker: HomeTrackerTicketsTracker +} + +private struct HomeTrackerTicketsTracker: Decodable, Sendable { + let tickets: HomeTrackerTicketsPage +} + +private struct HomeTrackerTicketsPage: Decodable, Sendable { + let results: [HomeTicketPayload] +} + +private struct HomeTicketPayload: Decodable, Sendable { + let id: Int + let title: String + let status: TicketStatus + let resolution: TicketResolution? + let created: Date + let submitter: Entity + let labels: [TicketLabel] + let assignees: [Entity] + + enum CodingKeys: String, CodingKey { + case id + case title = "subject" + case status + case resolution + case created + case submitter + case labels + case assignees + } + + var ticketSummary: TicketSummary { + TicketSummary( + id: id, + title: title, + status: status, + resolution: resolution, + created: created, + submitter: submitter, + labels: labels, + assignees: assignees + ) + } +} + +struct HomeAssignedTicket: Identifiable, Hashable, Sendable { + let trackerId: Int + let trackerRid: String + let trackerName: String + let ownerCanonicalName: String + let ticket: TicketSummary + + var id: String { + "\(trackerRid)#\(ticket.id)" + } + + var ownerUsername: String { + if ownerCanonicalName.hasPrefix("~") { + return String(ownerCanonicalName.dropFirst()) + } + return ownerCanonicalName + } +} + +struct HomeBuildItem: Identifiable, Hashable, Sendable { + let job: JobSummary + let repositoryName: String? + let repositoryOwner: String? + + var id: Int { job.id } + + var repositoryDisplayName: String? { + guard let repositoryName else { return nil } + if let repositoryOwner { + return "\(repositoryOwner)/\(repositoryName)" + } + return repositoryName + } +} + +@Observable +@MainActor +final class HomeViewModel { + private(set) var failedBuilds: [HomeBuildItem] = [] + private(set) var assignedTickets: [HomeAssignedTicket] = [] + private(set) var recentBuilds: [HomeBuildItem] = [] + private(set) var isLoadingFailedBuilds = false + private(set) var isLoadingAssignedTickets = false + private(set) var isLoadingRecentBuilds = false + private(set) var failedBuildsError: String? + private(set) var assignedTicketsError: String? + private(set) var recentBuildsError: String? + + private let currentUser: User + private let client: SRHTClient + private let ticketFetchConcurrencyLimit = 6 + + private static let jobsQuery = """ + query jobs { + jobs { + results { + id + created + updated + status + note + tags + visibility + image + tasks { name status } + manifest + } + } + } + """ + + private static let trackersQuery = """ + query trackers($cursor: Cursor) { + trackers(cursor: $cursor) { + results { + id + rid + name + description + visibility + updated + owner { canonicalName } + } + cursor + } + } + """ + + private static let trackerTicketsQuery = """ + query tickets($owner: String!, $tracker: String!) { + user(username: $owner) { + tracker(name: $tracker) { + tickets { + results { + id + subject + status + resolution + created + submitter { canonicalName } + labels { id name backgroundColor foregroundColor } + assignees { canonicalName } + } + } + } + } + } + """ + + init(currentUser: User, client: SRHTClient) { + self.currentUser = currentUser + self.client = client + } + + func loadDashboard() async { + isLoadingFailedBuilds = true + isLoadingAssignedTickets = true + isLoadingRecentBuilds = true + failedBuildsError = nil + assignedTicketsError = nil + recentBuildsError = nil + + async let jobsTask = loadRecentJobs() + async let assignedTicketsTask = loadAssignedTickets() + + let recentJobsResult = await jobsTask + + switch recentJobsResult { + case .success(let recentJobs): + let buildItems = Self.buildItems(from: recentJobs) + self.recentBuilds = buildItems + self.failedBuilds = Self.failedBuilds(from: buildItems) + self.failedBuildsError = nil + self.recentBuildsError = nil + case .failure(let error): + self.recentBuilds = [] + self.failedBuilds = [] + self.failedBuildsError = error.localizedDescription + self.recentBuildsError = error.localizedDescription + } + isLoadingFailedBuilds = false + isLoadingRecentBuilds = false + + let assignedTicketsResult = await assignedTicketsTask + + switch assignedTicketsResult { + case .success(let assignedTickets): + self.assignedTickets = assignedTickets + self.assignedTicketsError = nil + case .failure(let error): + self.assignedTickets = [] + self.assignedTicketsError = error.localizedDescription + } + isLoadingAssignedTickets = false + } + + private func loadRecentJobs() async -> Result<[HomeJobPayload], Error> { + do { + let response = try await client.execute( + service: .builds, + query: Self.jobsQuery, + responseType: HomeJobsResponse.self + ) + return .success(response.jobs.results) + } catch { + return .failure(error) + } + } + + 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 } + return .success(tickets) + } catch { + return .failure(error) + } + } + + private func fetchAllTrackers() async throws -> [TrackerSummary] { + var allTrackers: [TrackerSummary] = [] + var cursor: String? + + while true { + var variables: [String: any Sendable] = [:] + if let cursor { + variables["cursor"] = cursor + } + + let response = try await client.execute( + service: .todo, + query: Self.trackersQuery, + variables: variables.isEmpty ? nil : variables, + responseType: HomeTrackersResponse.self + ) + + allTrackers.append(contentsOf: response.trackers.results) + guard let nextCursor = response.trackers.cursor else { + break + } + cursor = nextCursor + } + + return allTrackers + } + + private func fetchAssignedTickets(for trackers: [TrackerSummary]) async throws -> [HomeAssignedTicket] { + guard !trackers.isEmpty else { return [] } + + var assignedTickets: [HomeAssignedTicket] = [] + var startIndex = trackers.startIndex + + while startIndex < trackers.endIndex { + let endIndex = trackers.index(startIndex, offsetBy: ticketFetchConcurrencyLimit, limitedBy: trackers.endIndex) ?? trackers.endIndex + let batch = Array(trackers[startIndex..<endIndex]) + + let batchTickets = try await withThrowingTaskGroup(of: [HomeAssignedTicket].self) { group in + for tracker in batch { + group.addTask { + try await self.fetchAssignedTickets(for: tracker) + } + } + + var ticketsForBatch: [HomeAssignedTicket] = [] + for try await tickets in group { + ticketsForBatch.append(contentsOf: tickets) + } + return ticketsForBatch + } + + assignedTickets.append(contentsOf: batchTickets) + startIndex = endIndex + } + + return assignedTickets + } + + private func fetchAssignedTickets(for tracker: TrackerSummary) async throws -> [HomeAssignedTicket] { + let response = try await client.execute( + service: .todo, + query: Self.trackerTicketsQuery, + variables: [ + "owner": tracker.owner.canonicalName.hasPrefix("~") + ? String(tracker.owner.canonicalName.dropFirst()) + : tracker.owner.canonicalName, + "tracker": tracker.name + ], + responseType: HomeTrackerTicketsResponse.self + ) + + return response.user.tracker.tickets.results.compactMap { payload in + guard payload.status.isOpen else { + return nil + } + guard payload.assignees.contains(where: { Self.matchesCurrentUserAssignee($0, currentUser: currentUser) }) else { + return nil + } + + return HomeAssignedTicket( + trackerId: tracker.id, + trackerRid: tracker.rid, + trackerName: tracker.name, + ownerCanonicalName: tracker.owner.canonicalName, + ticket: payload.ticketSummary + ) + } + } + + nonisolated static func buildItems(from jobs: [HomeJobPayload]) -> [HomeBuildItem] { + jobs.map { job in + let repository = primaryRepositoryReference(in: job.manifest) + return HomeBuildItem( + job: job.jobSummary, + repositoryName: repository?.name, + repositoryOwner: repository?.ownerCanonicalName + ) + } + } + + nonisolated static func failedBuilds(from builds: [HomeBuildItem]) -> [HomeBuildItem] { + builds.filter { build in + switch build.job.status { + case .failed, .timeout: + true + default: + false + } + } + } + + nonisolated static func failedBuilds(from jobs: [HomeJobPayload]) -> [HomeBuildItem] { + failedBuilds(from: buildItems(from: jobs)) + } + + nonisolated static func matchesCurrentUserAssignee(_ entity: Entity, currentUser: User) -> Bool { + let assigneeCanonical = normalizedCanonicalName(entity.canonicalName) + let currentCanonical = normalizedCanonicalName(currentUser.canonicalName) + if assigneeCanonical == currentCanonical { + return true + } + + let assigneeUsername = normalizedUsername(entity.canonicalName) + let currentUsername = normalizedUsername(currentUser.username) + return assigneeUsername == currentUsername + } + + nonisolated static func primaryRepositoryReference(in manifest: String?) -> (ownerCanonicalName: String, name: String)? { + guard let manifest else { return nil } + let pattern = #"(?:https://|ssh://(?:git|hg)@|(?:git|hg)@)(?:git|hg)\.sr\.ht[:/]([~][^/\s]+)/([^\s"'#]+)"# + guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { + return nil + } + let nsRange = NSRange(manifest.startIndex..<manifest.endIndex, in: manifest) + guard let match = regex.firstMatch(in: manifest, options: [], range: nsRange), + let ownerRange = Range(match.range(at: 1), in: manifest), + let nameRange = Range(match.range(at: 2), in: manifest) else { + return nil + } + + let owner = String(manifest[ownerRange]) + var name = String(manifest[nameRange]) + if let suffixRange = name.range(of: ".git", options: [.backwards, .anchored]) { + name.removeSubrange(suffixRange) + } + name = name.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + guard !name.isEmpty else { return nil } + return (owner, name) + } + + private nonisolated static func normalizedCanonicalName(_ value: String) -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return trimmed } + if trimmed.hasPrefix("~") { + return trimmed + } + return "~\(trimmed)" + } + + private nonisolated static func normalizedUsername(_ value: String) -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasPrefix("~") { + return String(trimmed.dropFirst()) + } + return trimmed + } +} +struct HomeJobPayload: Decodable, Sendable { + let id: Int + let created: Date + let updated: Date + let status: JobStatus + let note: String? + let tags: [String] + let visibility: Visibility? + let image: String? + let tasks: [JobTaskSummary] + let manifest: String? + + nonisolated var jobSummary: JobSummary { + JobSummary( + id: id, + created: created, + updated: updated, + status: status, + note: note, + tags: tags, + visibility: visibility, + image: image, + tasks: tasks + ) + } +} |
