summaryrefslogtreecommitdiff
path: root/Hutch/Views/Home
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-13 13:09:57 -0500
committerChristian Cleberg <[email protected]>2026-04-13 13:09:57 -0500
commitf5f5757d0e1b78470429ae7cb9f742c95662849b (patch)
tree758f67965a985f4f1f52a456d9b672f8256851de /Hutch/Views/Home
parent0e461a382257979037e8927e5f6b468635b0a7fe (diff)
downloadhutch-3.0.0.tar.gz
hutch-3.0.0.tar.bz2
hutch-3.0.0.zip
release: v3.0.0 — navigation overhaul, Work view, and multi-pin supportv3.0.0
Reworks the app's core navigation and home screen for v3.0.0: - Replace Inbox with Work view: unified dashboard showing unread threads and assigned tickets, with All/Unread/Assigned scope picker - Overhaul Home screen: redesigned with pinned items grid (trackers, repos, mailing lists, users), recent activity section, and system status banner; backed by HomePinStore supporting all resource types - Simplify navigation bars across list screens: default toolbar shows only the primary action (+) and an overflow menu (…); pin, share, and select moved into the overflow menu; selection mode gets its own nav bar with Cancel / "N Selected" / All - Consolidate repo detail toolbars: pin and share moved into the existing actions menu for both Git and Mercurial detail views - Add recent activity tracking via RecentActivityStore - Add work and inbox deep link aliases (hutch://work, hutch://inbox) Implements: https://todo.sr.ht/~ccleberg/hutch/55
Diffstat (limited to 'Hutch/Views/Home')
-rw-r--r--Hutch/Views/Home/HomePinStore.swift207
-rw-r--r--Hutch/Views/Home/HomePrototypeView.swift7
-rw-r--r--Hutch/Views/Home/HomeView.swift1116
-rw-r--r--Hutch/Views/Home/HomeViewModel.swift6
-rw-r--r--Hutch/Views/Home/RecentActivityStore.swift148
5 files changed, 746 insertions, 738 deletions
diff --git a/Hutch/Views/Home/HomePinStore.swift b/Hutch/Views/Home/HomePinStore.swift
new file mode 100644
index 0000000..e9bffd1
--- /dev/null
+++ b/Hutch/Views/Home/HomePinStore.swift
@@ -0,0 +1,207 @@
+import Foundation
+
+enum HomePinKind: String, Codable, Sendable {
+ case project
+ case repository
+ case tracker
+ case mailingList
+ case user
+}
+
+struct HomePinRecord: Codable, Hashable, Identifiable, Sendable {
+ let kind: HomePinKind
+ let value: String
+ let title: String
+ let subtitle: String
+ let ownerUsername: String?
+ let service: SRHTService?
+
+ var id: String {
+ switch kind {
+ case .project:
+ return "\(kind.rawValue):\(value)"
+ case .repository:
+ return "\(kind.rawValue):\(service?.rawValue ?? "git"):\(ownerUsername ?? "")/\(value)"
+ case .tracker, .mailingList:
+ return "\(kind.rawValue):\(ownerUsername ?? "")/\(value)"
+ case .user:
+ return "\(kind.rawValue):\(ownerUsername ?? value)"
+ }
+ }
+
+ static func project(_ project: Project) -> HomePinRecord {
+ HomePinRecord(
+ kind: .project,
+ value: project.id,
+ title: project.displayName,
+ subtitle: "Project",
+ ownerUsername: nil,
+ service: nil
+ )
+ }
+
+ static func repository(_ repository: RepositorySummary) -> HomePinRecord {
+ HomePinRecord(
+ kind: .repository,
+ value: repository.name,
+ title: repository.name,
+ subtitle: repository.service == .hg ? "Mercurial Repo" : "Git Repo",
+ ownerUsername: repository.owner.canonicalName.srhtUsername,
+ service: repository.service
+ )
+ }
+
+ static func tracker(_ tracker: TrackerSummary) -> HomePinRecord {
+ HomePinRecord(
+ kind: .tracker,
+ value: tracker.name,
+ title: tracker.name,
+ subtitle: "Tracker",
+ ownerUsername: tracker.owner.canonicalName.srhtUsername,
+ service: nil
+ )
+ }
+
+ static func mailingList(_ mailingList: InboxMailingListReference) -> HomePinRecord {
+ HomePinRecord(
+ kind: .mailingList,
+ value: mailingList.rid,
+ title: mailingList.name,
+ subtitle: "Mailing List",
+ ownerUsername: mailingList.owner.canonicalName.srhtUsername,
+ service: nil
+ )
+ }
+
+ static func user(_ user: User) -> HomePinRecord {
+ HomePinRecord(
+ kind: .user,
+ value: user.username,
+ title: user.canonicalName,
+ subtitle: "User",
+ ownerUsername: user.username,
+ service: nil
+ )
+ }
+}
+
+enum HomePinStore {
+ static func loadPins(
+ for userKey: String,
+ defaults: UserDefaults = .standard
+ ) -> [HomePinRecord] {
+ let normalizedUserKey = normalizedUserKey(userKey)
+ guard !normalizedUserKey.isEmpty else { return [] }
+
+ let storedPins = loadAll(defaults: defaults)
+ if let storedPinsForUser = storedPins[normalizedUserKey] {
+ return normalizedPins(storedPinsForUser)
+ }
+
+ let legacyProjectIDs = ProjectPinStore.loadLegacyPinnedProjectIDs(for: normalizedUserKey, defaults: defaults)
+ guard !legacyProjectIDs.isEmpty else { return [] }
+
+ let migratedPins = legacyProjectIDs.map {
+ HomePinRecord(
+ kind: .project,
+ value: $0,
+ title: "Pinned Project",
+ subtitle: "Project",
+ ownerUsername: nil,
+ service: nil
+ )
+ }
+ savePins(migratedPins, for: normalizedUserKey, defaults: defaults)
+ return migratedPins
+ }
+
+ static func isPinned(
+ _ pin: HomePinRecord,
+ for userKey: String,
+ defaults: UserDefaults = .standard
+ ) -> Bool {
+ loadPins(for: userKey, defaults: defaults).contains(pin)
+ }
+
+ static func togglePin(
+ _ pin: HomePinRecord,
+ for userKey: String,
+ defaults: UserDefaults = .standard
+ ) {
+ let normalizedUserKey = normalizedUserKey(userKey)
+ guard !normalizedUserKey.isEmpty else { return }
+
+ var pinsByUser = loadAll(defaults: defaults)
+ var pins = normalizedPins(pinsByUser[normalizedUserKey] ?? loadPins(for: normalizedUserKey, defaults: defaults))
+
+ if let index = pins.firstIndex(of: pin) {
+ pins.remove(at: index)
+ } else {
+ pins.append(pin)
+ }
+
+ pinsByUser[normalizedUserKey] = pins
+ saveAll(pinsByUser, defaults: defaults)
+ }
+
+ static func pinnedProjectIDs(
+ for userKey: String,
+ defaults: UserDefaults = .standard
+ ) -> [String] {
+ loadPins(for: userKey, defaults: defaults)
+ .filter { $0.kind == .project }
+ .map(\.value)
+ }
+
+ private static func loadAll(defaults: UserDefaults) -> [String: [HomePinRecord]] {
+ guard let data = defaults.data(forKey: AppStorageKeys.pinnedHomeItems) else {
+ return [:]
+ }
+
+ let decoded = (try? JSONDecoder().decode([String: [HomePinRecord]].self, from: data)) ?? [:]
+ return decoded.reduce(into: [String: [HomePinRecord]]()) { result, entry in
+ let key = normalizedUserKey(entry.key)
+ guard !key.isEmpty else { return }
+ let pins = normalizedPins(entry.value)
+ if !pins.isEmpty {
+ result[key] = pins
+ }
+ }
+ }
+
+ private static func savePins(_ pins: [HomePinRecord], for userKey: String, defaults: UserDefaults) {
+ var allPins = loadAll(defaults: defaults)
+ allPins[userKey] = normalizedPins(pins)
+ saveAll(allPins, defaults: defaults)
+ }
+
+ private static func saveAll(_ pinsByUser: [String: [HomePinRecord]], defaults: UserDefaults) {
+ guard let data = try? JSONEncoder().encode(pinsByUser) else { return }
+ defaults.set(data, forKey: AppStorageKeys.pinnedHomeItems)
+ }
+
+ private static func normalizedPins(_ pins: [HomePinRecord]) -> [HomePinRecord] {
+ var seen = Set<String>()
+ return pins.compactMap { pin in
+ let title = pin.title.trimmingCharacters(in: .whitespacesAndNewlines)
+ let subtitle = pin.subtitle.trimmingCharacters(in: .whitespacesAndNewlines)
+ let value = pin.value.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !title.isEmpty, !subtitle.isEmpty, !value.isEmpty else { return nil }
+
+ let normalized = HomePinRecord(
+ kind: pin.kind,
+ value: value,
+ title: title,
+ subtitle: subtitle,
+ ownerUsername: pin.ownerUsername?.trimmingCharacters(in: .whitespacesAndNewlines),
+ service: pin.service
+ )
+ guard seen.insert(normalized.id).inserted else { return nil }
+ return normalized
+ }
+ }
+
+ private static func normalizedUserKey(_ userKey: String) -> String {
+ userKey.trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+}
diff --git a/Hutch/Views/Home/HomePrototypeView.swift b/Hutch/Views/Home/HomePrototypeView.swift
new file mode 100644
index 0000000..77c360a
--- /dev/null
+++ b/Hutch/Views/Home/HomePrototypeView.swift
@@ -0,0 +1,7 @@
+import SwiftUI
+
+struct HomePrototypeView: View {
+ var body: some View {
+ HomeView()
+ }
+}
diff --git a/Hutch/Views/Home/HomeView.swift b/Hutch/Views/Home/HomeView.swift
index 69e47da..1a68498 100644
--- a/Hutch/Views/Home/HomeView.swift
+++ b/Hutch/Views/Home/HomeView.swift
@@ -1,15 +1,13 @@
import SwiftUI
struct HomeView: View {
- @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true
- @AppStorage(AppStorageKeys.homeProjectsExpanded) private var projectsExpanded = true
- @AppStorage(AppStorageKeys.homeAssignedTicketsExpanded) private var assignedTicketsExpanded = true
- @AppStorage(AppStorageKeys.homeBuildsExpanded) private var buildsExpanded = true
@Environment(AppState.self) private var appState
@Environment(\.scenePhase) private var scenePhase
@State private var viewModel: HomeViewModel?
- private let previewLimit = 4
- private let projectPreviewLimit = 3
+ @State private var recentItems: [RecentActivityEntry] = []
+ @State private var isOpeningRecentItem = false
+ @State private var selectedPinnedProject: Project?
+ @State private var selectedPinnedUser: User?
var body: some View {
Group {
@@ -20,39 +18,40 @@ struct HomeView: View {
}
}
.navigationTitle("Home")
- .toolbar {
- ToolbarItem(placement: .topBarTrailing) {
- NavigationLink {
- InboxView()
- } label: {
- HomeInboxToolbarIcon(hasUnreadThreads: viewModel?.hasUnreadInboxThreads == true)
+ .navigationDestination(isPresented: Binding(
+ get: { selectedPinnedProject != nil },
+ set: { isPresented in
+ if !isPresented {
+ selectedPinnedProject = nil
}
}
+ )) {
+ if let selectedPinnedProject {
+ ProjectDetailView(project: selectedPinnedProject)
+ }
+ }
+ .navigationDestination(isPresented: Binding(
+ get: { selectedPinnedUser != nil },
+ set: { isPresented in
+ if !isPresented {
+ selectedPinnedUser = nil
+ }
+ }
+ )) {
+ if let selectedPinnedUser {
+ UserProfileView(user: selectedPinnedUser)
+ }
}
.task {
guard let currentUser = appState.currentUser else { return }
-
- let vm: HomeViewModel
- if let viewModel {
- vm = viewModel
- } else {
- let newViewModel = HomeViewModel(
- currentUser: currentUser,
- client: appState.client,
- systemStatusRepository: appState.systemStatusRepository,
- defaults: appState.accountDefaults,
- accountID: appState.activeAccountID
- )
- viewModel = newViewModel
- vm = newViewModel
- }
-
- await vm.loadDashboard()
+ await ensureViewModel(currentUser: currentUser).loadDashboard()
+ loadRecentActivity()
}
.onChange(of: scenePhase) { _, newPhase in
guard newPhase == .active, let viewModel else { return }
Task {
await viewModel.loadDashboard()
+ loadRecentActivity()
}
}
}
@@ -60,850 +59,497 @@ struct HomeView: View {
@ViewBuilder
private func content(_ viewModel: HomeViewModel) -> some View {
List {
- attentionSection(viewModel)
- systemStatusBannerSection(viewModel)
- inboxSection(viewModel)
- projectsSection(viewModel)
- assignedTicketsSection(viewModel)
- recentBuildsSection(viewModel)
+ systemStatusSection(viewModel)
+ workSection(viewModel)
+ recentSection
+ buildsSection(viewModel)
+ pinnedSection(viewModel)
}
.themedList()
.listStyle(.insetGrouped)
- .overlay {
- if viewModel.isLoadingProjects && viewModel.isLoadingAssignedTickets && viewModel.isLoadingRecentBuilds &&
- viewModel.pinnedProjects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty &&
- viewModel.unreadInboxThreads.isEmpty {
- SRHTLoadingStateView(message: "Loading Home…")
- }
- }
+ .listSectionSpacing(.compact)
.refreshable {
await viewModel.loadDashboard()
}
- .connectivityOverlay(hasContent: viewModel.hasDashboardContent) {
+ .connectivityOverlay(hasContent: hasHomeContent(viewModel)) {
await viewModel.loadDashboard()
}
- }
-
- @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.navigateToBuildsList()
- }
- )
+ .onAppear {
+ loadRecentActivity()
}
}
@ViewBuilder
- private func systemStatusBannerSection(_ viewModel: HomeViewModel) -> some View {
- Section {
- NavigationLink {
- SystemStatusView()
- } label: {
- SystemStatusSummaryRow(
- snapshot: viewModel.systemStatusSnapshot,
- isLoading: viewModel.isLoadingSystemStatus,
- errorMessage: viewModel.systemStatusErrorMessage,
- isShowingStaleData: viewModel.isShowingStaleSystemStatus
- )
+ private func systemStatusSection(_ viewModel: HomeViewModel) -> some View {
+ if viewModel.systemStatusSnapshot?.hasDisruption == true {
+ Section {
+ NavigationLink {
+ SystemStatusView()
+ } label: {
+ SystemStatusSummaryRow(
+ snapshot: viewModel.systemStatusSnapshot,
+ isLoading: viewModel.isLoadingSystemStatus,
+ errorMessage: viewModel.systemStatusErrorMessage,
+ isShowingStaleData: viewModel.isShowingStaleSystemStatus
+ )
+ }
+ .buttonStyle(.plain)
}
- .buttonStyle(.plain)
}
}
- @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"
+ private func workSection(_ viewModel: HomeViewModel) -> some View {
+ Section("Work") {
+ NavigationLink(value: HomeRoute.work) {
+ HomeSummaryRow(
+ title: workTitle(viewModel),
+ summary: workSummary(viewModel),
+ systemImage: "tray.full",
+ tint: workCount(viewModel) > 0 ? .blue : .secondary,
+ emphasis: .action
)
- } 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.hasPinnedProjects {
- HomeSectionView("Pinned Projects", isExpanded: $projectsExpanded) {
- NavigationLink {
- ProjectsListView()
- } label: {
- Text("See All")
- .font(.caption.weight(.medium))
- }
- .buttonStyle(.plain)
- } content: {
- if viewModel.isLoadingProjects && viewModel.pinnedProjects.isEmpty {
- HomeSectionLoadingRow(label: "Loading pinned projects")
- } else if let error = viewModel.projectsError, viewModel.pinnedProjects.isEmpty {
- HomeSectionMessageRow(
- text: "Couldn’t load pinned projects.",
- systemImage: "exclamationmark.triangle",
- emphasized: true,
- accessibilityHint: error
- )
- } else if viewModel.pinnedProjects.isEmpty {
- HomeSectionMessageRow(
- text: "Pinned projects will appear here when they’re available.",
- systemImage: "pin"
- )
- } else {
- ForEach(viewModel.pinnedProjects.prefix(projectPreviewLimit)) { project in
- NavigationLink {
- ProjectDetailView(project: project)
- } label: {
- HomeProjectRow(project: project)
- }
+ private var recentSection: some View {
+ if !recentItems.isEmpty {
+ Section("Recent") {
+ ForEach(recentItems) { item in
+ Button {
+ openRecentItem(item)
+ } label: {
+ HomeRecentRow(item: item)
}
+ .buttonStyle(.plain)
+ .disabled(isOpeningRecentItem)
+ .listRowSeparator(.hidden)
}
}
}
}
- @ViewBuilder
- private func assignedTicketsSection(_ viewModel: HomeViewModel) -> some View {
- HomeSectionView("Tickets", isExpanded: $assignedTicketsExpanded) {
+ private func buildsSection(_ viewModel: HomeViewModel) -> some View {
+ Section("Builds") {
NavigationLink {
- HomeAssignedTicketsListView(viewModel: viewModel)
+ BuildListView()
} label: {
- Text("See All")
- .font(.caption.weight(.medium))
- }
- .buttonStyle(.plain)
- } content: {
- 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"
+ HomeSummaryRow(
+ title: buildsTitle(viewModel),
+ summary: buildsSummary(viewModel),
+ systemImage: "hammer",
+ tint: viewModel.failedBuildCount > 0 ? .orange : .secondary,
+ emphasis: .monitoring
)
- } 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)
- }
- .swipeActions(edge: .leading, allowsFullSwipe: true) {
- if swipeActionsEnabled {
- ticketLeadingSwipeAction(ticket, viewModel: viewModel)
- }
- }
- .swipeActions(edge: .trailing, allowsFullSwipe: false) {
- if swipeActionsEnabled {
- Button {
- Task {
- await viewModel.unassignFromMe(ticket)
- }
- } label: {
- Label("Unassign Me", systemImage: "person.badge.minus")
- }
- .tint(.orange)
- }
- }
- }
}
}
}
- @ViewBuilder
- private func recentBuildsSection(_ viewModel: HomeViewModel) -> some View {
- HomeSectionView("Builds", isExpanded: $buildsExpanded) {
- Button("See All") {
- appState.navigateToBuildsList()
- }
- .font(.caption.weight(.medium))
- .buttonStyle(.plain)
- } content: {
- 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(buildGroups(for: viewModel.recentBuilds)) { group in
- if let repositoryDisplayName = group.repositoryDisplayName {
- HomeBuildGroupHeader(
- repositoryDisplayName: repositoryDisplayName,
- buildCount: group.builds.count,
- latestStatus: group.latestStatus
- )
- }
+ private func pinnedSection(_ viewModel: HomeViewModel) -> some View {
+ let items = pinnedItems(viewModel)
- ForEach(group.builds) { build in
- NavigationLink {
- BuildDetailView(jobId: build.job.id)
+ return Section("Pinned") {
+ if items.isEmpty {
+ NavigationLink {
+ ProjectsListView()
+ } label: {
+ HomeCompactMessageRow(text: "Pin projects for quick access", systemImage: "pin")
+ }
+ } else {
+ LazyVGrid(
+ columns: [
+ GridItem(.flexible(), spacing: 10),
+ GridItem(.flexible(), spacing: 10),
+ ],
+ spacing: 10
+ ) {
+ ForEach(items) { item in
+ Button {
+ openPinnedItem(item, viewModel: viewModel)
} label: {
- HomeBuildRow(
- build: build,
- showsRepositoryLink: group.repositoryDisplayName == nil
- )
- }
- .swipeActions(edge: .leading, allowsFullSwipe: true) {
- if swipeActionsEnabled, build.job.status.isCancellable {
- Button {
- Task {
- await viewModel.cancelBuild(build)
- }
- }
- label: {
- Label("Cancel", systemImage: "xmark.circle")
- }
- .tint(.red)
- }
+ HomePinnedCard(item: item)
}
+ .buttonStyle(.plain)
}
}
+ .padding(.vertical, 2)
}
}
}
- private func buildGroups(for builds: [HomeBuildItem]) -> [HomeBuildGroup] {
- let previewBuilds = Array(builds.prefix(previewLimit))
- guard let firstBuild = previewBuilds.first else { return [] }
-
- var groups: [HomeBuildGroup] = []
- var currentIdentity = HomeBuildGroup.Identity(build: firstBuild)
- var currentBuilds: [HomeBuildItem] = []
-
- for build in previewBuilds {
- let identity = HomeBuildGroup.Identity(build: build)
- if identity == currentIdentity {
- currentBuilds.append(build)
- } else {
- groups.append(HomeBuildGroup(identity: currentIdentity, builds: currentBuilds))
- currentIdentity = identity
- currentBuilds = [build]
- }
- }
-
- if !currentBuilds.isEmpty {
- groups.append(HomeBuildGroup(identity: currentIdentity, builds: currentBuilds))
- }
+ private func workCount(_ viewModel: HomeViewModel) -> Int {
+ unreadCount(viewModel) + viewModel.assignedTickets.count
+ }
- return groups
+ private func unreadCount(_ viewModel: HomeViewModel) -> Int {
+ viewModel.unreadInboxThreadCount ?? viewModel.unreadInboxThreads.count
}
- @ViewBuilder
- private func ticketLeadingSwipeAction(
- _ ticket: HomeAssignedTicket,
- viewModel: HomeViewModel
- ) -> some View {
- if ticket.ticket.status.isOpen {
- Button {
- Task {
- await viewModel.resolveTicket(ticket)
- }
- } label: {
- Label("Resolve", systemImage: "checkmark.circle")
- }
- .tint(.green)
- } else {
- Button {
- Task {
- await viewModel.reopenTicket(ticket)
- }
- } label: {
- Label("Reopen", systemImage: "arrow.uturn.backward")
- }
- .tint(.blue)
+ private func workTitle(_ viewModel: HomeViewModel) -> String {
+ let count = workCount(viewModel)
+ if count == 0 {
+ return "Queue clear"
}
+ return "\(count) item\(count == 1 ? "" : "s") need attention"
}
-}
-
-private struct HomeInboxToolbarIcon: View {
- let hasUnreadThreads: Bool
-
- var body: some View {
- Image(systemName: hasUnreadThreads ? "tray.fill" : "tray")
- .accessibilityLabel(hasUnreadThreads ? "Inbox, unread messages" : "Inbox")
+ private func workSummary(_ viewModel: HomeViewModel) -> String {
+ let unread = unreadCount(viewModel)
+ let assigned = viewModel.assignedTickets.count
+ return "\(unread) unread • \(assigned) assigned"
}
-}
-
-private struct HomeProjectRow: View {
- let project: Project
-
- var body: some View {
- VStack(alignment: .leading, spacing: 8) {
- HStack(alignment: .top, spacing: 10) {
- VStack(alignment: .leading, spacing: 4) {
- Text(project.displayName)
- .font(.subheadline.weight(.medium))
- .foregroundStyle(.primary)
- .lineLimit(1)
-
- if let description = project.displayDescription {
- Text(description)
- .font(.caption)
- .foregroundStyle(.secondary)
- .lineLimit(2)
- }
- }
- Spacer(minLength: 8)
+ private func buildsTitle(_ viewModel: HomeViewModel) -> String {
+ let failed = viewModel.failedBuildCount
+ let running = viewModel.activeBuildCount
- VisibilityBadge(visibility: project.visibility)
- }
-
- Text(project.metadataLine)
- .font(.caption)
- .foregroundStyle(.secondary)
- .lineLimit(1)
+ if failed == 0 && running == 0 {
+ return "Build monitoring clear"
}
- .contentShape(Rectangle())
- .padding(.vertical, 4)
- }
-}
-
-private struct HomeBuildRow: View {
- @Environment(AppState.self) private var appState
- let build: HomeBuildItem
- var showsRepositoryLink = true
-
- var body: some View {
- VStack(alignment: .leading, spacing: 6) {
- HStack(spacing: 12) {
- JobStatusIcon(status: build.job.status)
- .frame(width: 20)
-
- VStack(alignment: .leading, spacing: 4) {
- Text(build.job.displayLabel)
- .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.displayTitle)
- .font(.caption)
- .foregroundStyle(.secondary)
-
- Text("•")
- .font(.caption)
- .foregroundStyle(.tertiary)
-
- Text(build.job.created.relativeDescription)
- .font(.caption)
- .foregroundStyle(.tertiary)
-
- Spacer()
- }
- }
- }
-
- if showsRepositoryLink, let repositoryDisplayName = build.repositoryDisplayName {
- Button {
- openRepository()
- } label: {
- Label(repositoryDisplayName, systemImage: "book.closed")
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- .buttonStyle(.plain)
- }
+ if failed > 0 {
+ return "\(failed) failed build\(failed == 1 ? "" : "s")"
}
- .padding(.vertical, 2)
+ return "\(running) running build\(running == 1 ? "" : "s")"
}
- 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 func buildsSummary(_ viewModel: HomeViewModel) -> String {
+ let failed = viewModel.failedBuildCount
+ let running = viewModel.activeBuildCount
+ if failed == 0 && running == 0 {
+ return "No failures • \(buildTimeframeLabel(viewModel))"
+ }
+ if failed > 0 && running > 0 {
+ return "\(failed) failed • \(running) running • \(buildTimeframeLabel(viewModel))"
}
+ if failed > 0 {
+ return "\(failed) failed • \(buildTimeframeLabel(viewModel))"
+ }
+ return "\(running) running • \(buildTimeframeLabel(viewModel))"
}
-}
-private struct HomeBuildGroup: Identifiable {
- enum Identity: Hashable {
- case repository(owner: String?, name: String)
- case standalone(Int)
+ private func pinnedItems(_ viewModel: HomeViewModel) -> [HomePinnedItem] {
+ let currentUserKey = appState.currentUser?.canonicalName ?? ""
+ let pins = HomePinStore.loadPins(for: currentUserKey, defaults: appState.accountDefaults)
+ let projectsByID = Dictionary(uniqueKeysWithValues: viewModel.projects.map { ($0.id, $0) })
- init(build: HomeBuildItem) {
- if let repositoryName = build.repositoryName {
- self = .repository(owner: build.repositoryOwner, name: repositoryName)
- } else {
- self = .standalone(build.id)
+ return pins.compactMap { pin in
+ switch pin.kind {
+ case .project:
+ guard let project = projectsByID[pin.value] else { return nil }
+ return HomePinnedItem(pin: pin, project: project)
+ case .repository, .tracker, .mailingList, .user:
+ return HomePinnedItem(pin: pin, project: nil)
}
}
}
- let identity: Identity
- let builds: [HomeBuildItem]
+ private func buildTimeframeLabel(_ viewModel: HomeViewModel) -> String {
+ let calendar = Calendar.current
+ let buildDates = viewModel.recentBuilds.map(\.job.updated)
- var id: String {
- switch identity {
- case .repository(let owner, let name):
- return "\(owner ?? "_")/\(name)#\(builds.first?.id ?? 0)"
- case .standalone(let jobId):
- return "job-\(jobId)"
+ guard !buildDates.isEmpty else {
+ return "today"
}
- }
- var repositoryDisplayName: String? {
- builds.first?.repositoryDisplayName
+ return buildDates.allSatisfy(calendar.isDateInToday) ? "today" : "this week"
}
- var latestStatus: JobStatus {
- builds.first?.job.status ?? .pending
+ private func hasHomeContent(_ viewModel: HomeViewModel) -> Bool {
+ viewModel.systemStatusSnapshot?.hasDisruption == true ||
+ workCount(viewModel) > 0 ||
+ !recentItems.isEmpty ||
+ !pinnedItems(viewModel).isEmpty
}
-}
-
-private struct HomeBuildGroupHeader: View {
- let repositoryDisplayName: String
- let buildCount: Int
- let latestStatus: JobStatus
- var body: some View {
- HStack(spacing: 12) {
- Label(repositoryDisplayName, systemImage: "book.closed")
- .font(.caption.weight(.medium))
- .foregroundStyle(.secondary)
- .lineLimit(1)
-
- Spacer(minLength: 8)
-
- Text("\(buildCount) \(buildCount == 1 ? "build" : "builds")")
- .font(.caption2.weight(.medium))
- .foregroundStyle(.tertiary)
-
- JobStatusBadge(status: latestStatus)
- }
- .padding(.top, 4)
- .listRowInsets(EdgeInsets(top: 8, leading: 20, bottom: 0, trailing: 20))
- .listRowSeparator(.hidden)
- .accessibilityElement(children: .combine)
+ private func loadRecentActivity() {
+ recentItems = RecentActivityStore.load(defaults: appState.accountDefaults)
}
-}
-private struct HomeAssignedTicketRow: View {
- @Environment(AppState.self) private var appState
- let ticket: HomeAssignedTicket
+ private func openRecentItem(_ item: RecentActivityEntry) {
+ guard !isOpeningRecentItem else { return }
- var body: some View {
- 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)
-
- 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()
+ switch item.kind {
+ case .build:
+ guard let jobId = item.buildJobId else { return }
+ appState.navigateToBuild(jobId: jobId)
+ case .ticket:
+ guard
+ let ownerUsername = item.ticketOwnerUsername,
+ let trackerName = item.ticketTrackerName,
+ let ticketId = item.ticketId
+ else {
+ return
}
-
- Button {
- openTracker()
- } label: {
- Label("\(ticket.ownerCanonicalName)/\(ticket.trackerName)", systemImage: "checklist")
- .font(.caption)
- .foregroundStyle(.secondary)
+ appState.navigateToTicket(ownerUsername: ownerUsername, trackerName: trackerName, ticketId: ticketId)
+ case .repository:
+ guard
+ let owner = item.repositoryOwner,
+ let name = item.repositoryName
+ else {
+ return
}
- .buttonStyle(.plain)
- }
- .padding(.vertical, 2)
- }
- private func openTracker() {
- Task {
- do {
- let tracker = try await appState.resolveTracker(owner: ticket.ownerUsername, name: ticket.trackerName)
- appState.navigateToTracker(tracker)
- } catch {
- appState.presentTicketDeepLinkError()
+ isOpeningRecentItem = true
+ Task {
+ defer { isOpeningRecentItem = false }
+ do {
+ let repository = try await appState.resolveRepository(
+ owner: owner,
+ name: name,
+ service: item.repositoryService ?? .git
+ )
+ appState.navigateToRepository(repository)
+ } catch {
+ appState.presentRepositoryDeepLinkError()
+ }
}
}
}
-}
-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)
+ private func openPinnedItem(_ item: HomePinnedItem, viewModel: HomeViewModel) {
+ switch item.pin.kind {
+ case .project:
+ guard let project = item.project else { return }
+ selectedPinnedProject = project
+ case .repository:
+ guard
+ let owner = item.pin.ownerUsername,
+ let service = item.pin.service
+ else {
+ return
+ }
+ isOpeningRecentItem = true
+ Task {
+ defer { isOpeningRecentItem = false }
+ do {
+ let repository = try await appState.resolveRepository(owner: owner, name: item.pin.value, service: service)
+ appState.navigateToRepository(repository)
+ } catch {
+ appState.presentRepositoryDeepLinkError()
}
}
-
- 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)
+ case .tracker:
+ guard let owner = item.pin.ownerUsername else { return }
+ isOpeningRecentItem = true
+ Task {
+ defer { isOpeningRecentItem = false }
+ do {
+ let tracker = try await appState.resolveTracker(owner: owner, name: item.pin.value)
+ appState.navigateToTracker(tracker)
+ } catch {
+ appState.presentTicketDeepLinkError()
}
- .buttonStyle(.plain)
-
- if let repo = thread.repo {
- Button {
- openRepository(named: repo)
- } label: {
- Label(repo, systemImage: "book.closed")
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- .buttonStyle(.plain)
+ }
+ case .mailingList:
+ guard let ownerUsername = item.pin.ownerUsername else { return }
+ appState.openMailingList(
+ InboxMailingListReference(
+ id: 0,
+ rid: item.pin.value,
+ name: item.pin.title,
+ owner: Entity(canonicalName: "~\(ownerUsername)")
+ )
+ )
+ case .user:
+ guard let ownerUsername = item.pin.ownerUsername else { return }
+ isOpeningRecentItem = true
+ Task {
+ defer { isOpeningRecentItem = false }
+ if let user = try? await resolvePinnedUser(username: ownerUsername) {
+ selectedPinnedUser = user
}
}
}
- .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 func resolvePinnedUser(username: String) async throws -> User {
+ struct Response: Decodable, Sendable {
+ let user: User
+ }
+
+ let query = """
+ query userLookup($username: String!) {
+ user: userByName(username: $username) {
+ id
+ created
+ updated
+ canonicalName
+ username
+ email
+ url
+ location
+ bio
+ avatar
+ pronouns
+ userType
}
}
- }
-}
+ """
-private struct HomeSectionLoadingRow: View {
- let label: String
+ let result = try await appState.client.execute(
+ service: .meta,
+ query: query,
+ variables: ["username": username],
+ responseType: Response.self
+ )
+ return result.user
+ }
- var body: some View {
- HStack(spacing: 10) {
- ProgressView()
- .controlSize(.small)
- Text(label)
- .foregroundStyle(.secondary)
+ @MainActor
+ private func ensureViewModel(currentUser: User) -> HomeViewModel {
+ if let viewModel {
+ return viewModel
}
- .frame(maxWidth: .infinity, alignment: .leading)
+
+ let newViewModel = HomeViewModel(
+ currentUser: currentUser,
+ client: appState.client,
+ systemStatusRepository: appState.systemStatusRepository,
+ defaults: appState.accountDefaults,
+ accountID: appState.activeAccountID
+ )
+ viewModel = newViewModel
+ return newViewModel
}
}
-private struct HomeSectionHeader<Destination: View>: View {
- let title: String
- let destination: Destination
+enum HomeRoute: Hashable {
+ case work
+}
- init(_ title: String, @ViewBuilder destination: () -> Destination) {
- self.title = title
- self.destination = destination()
- }
+private enum HomeSummaryEmphasis {
+ case action
+ case monitoring
+}
- var body: some View {
- HStack {
- Text(title)
- Spacer()
- NavigationLink {
- destination
- } label: {
- Text("See All")
- .font(.caption.weight(.medium))
- }
- .buttonStyle(.plain)
- }
- .textCase(nil)
- }
+private struct HomePinnedItem: Identifiable {
+ let pin: HomePinRecord
+ let project: Project?
+
+ var id: String { pin.id }
+ var title: String { project?.displayName ?? pin.title }
+ var detail: String { pin.subtitle }
}
-private struct HomeAttentionSummaryRow: View {
+private struct HomeSummaryRow: View {
let title: String
let summary: String
+ let systemImage: String
+ let tint: Color
+ let emphasis: HomeSummaryEmphasis
var body: some View {
- VStack(alignment: .leading, spacing: 4) {
- Text(title)
+ HStack(spacing: 10) {
+ Image(systemName: systemImage)
.font(.subheadline.weight(.semibold))
- Text(summary)
- .font(.caption)
- .foregroundStyle(.secondary)
- .lineLimit(2)
+ .foregroundStyle(iconColor)
+ .frame(width: 18)
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text(title)
+ .font(.subheadline.weight(.semibold))
+ Text(summary)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+
+ Spacer(minLength: 8)
}
- .padding(.vertical, 2)
+ .padding(.vertical, verticalPadding)
}
-}
-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
+ private var iconColor: Color {
+ switch emphasis {
+ case .action:
+ return tint
+ case .monitoring:
+ return tint.opacity(0.9)
+ }
}
- 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
+ private var verticalPadding: CGFloat {
+ switch emphasis {
+ case .action:
+ return 3
+ case .monitoring:
+ return 2
+ }
}
+}
+
+private struct HomeRecentRow: View {
+ let item: RecentActivityEntry
var body: some View {
- Group {
- if let destination {
- NavigationLink {
- destination
- } label: {
- content
- }
- } else if let action {
- Button(action: action) {
- content
- }
- .buttonStyle(.plain)
- }
- }
- }
+ HStack(spacing: 10) {
+ Image(systemName: iconName)
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.secondary)
+ .frame(width: 16)
- private var content: some View {
- HStack(spacing: 12) {
- VStack(alignment: .leading, spacing: 4) {
- Text(title)
+ VStack(alignment: .leading, spacing: 1) {
+ Text(item.title)
.font(.subheadline.weight(.medium))
- Text(summary)
+ .lineLimit(1)
+ Text(item.detailText)
.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())
- if action != nil {
- Image(systemName: "chevron.right")
- .font(.caption.weight(.semibold))
- .foregroundStyle(.tertiary)
- }
+ Spacer(minLength: 8)
+ }
+ .padding(.vertical, 1)
+ }
+
+ private var iconName: String {
+ switch item.kind {
+ case .repository:
+ return "book.closed"
+ case .ticket:
+ return "number"
+ case .build:
+ return "hammer"
}
- .frame(maxWidth: .infinity, alignment: .leading)
- .contentShape(Rectangle())
- .padding(.vertical, 2)
}
}
-private struct HomeAssignedTicketsListView: View {
- let viewModel: HomeViewModel
- @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true
+private struct HomePinnedCard: View {
+ let item: HomePinnedItem
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)
- }
- .swipeActions(edge: .leading, allowsFullSwipe: true) {
- if swipeActionsEnabled {
- if ticket.ticket.status.isOpen {
- Button {
- Task { await viewModel.resolveTicket(ticket) }
- } label: {
- Label("Resolve", systemImage: "checkmark.circle")
- }
- .tint(.green)
- } else {
- Button {
- Task { await viewModel.reopenTicket(ticket) }
- } label: {
- Label("Reopen", systemImage: "arrow.uturn.backward")
- }
- .tint(.blue)
- }
- }
- }
- .swipeActions(edge: .trailing, allowsFullSwipe: false) {
- if swipeActionsEnabled {
- Button {
- Task { await viewModel.unassignFromMe(ticket) }
- } label: {
- Label("Unassign Me", systemImage: "person.badge.minus")
- }
- .tint(.orange)
- }
- }
+ VStack(alignment: .leading, spacing: 6) {
+ HStack(spacing: 6) {
+ Image(systemName: "square.stack.3d.up")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.secondary)
+ Text(item.detail)
+ .font(.caption2.weight(.semibold))
+ .foregroundStyle(.secondary)
}
- 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…")
- }
+ Text(item.title)
+ .font(.subheadline.weight(.semibold))
+ .lineLimit(2)
+
+ Spacer(minLength: 0)
}
+ .frame(maxWidth: .infinity, minHeight: 64, alignment: .leading)
+ .padding(10)
+ .background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 12))
}
}
-private struct HomeSectionMessageRow: View {
+private struct HomeCompactMessageRow: 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 ?? "")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .padding(.vertical, 2)
}
}
diff --git a/Hutch/Views/Home/HomeViewModel.swift b/Hutch/Views/Home/HomeViewModel.swift
index a28f731..f0c14dd 100644
--- a/Hutch/Views/Home/HomeViewModel.swift
+++ b/Hutch/Views/Home/HomeViewModel.swift
@@ -393,7 +393,7 @@ final class HomeViewModel {
let inboxUnreadSnapshot = await inboxUnreadTask
unreadInboxThreadCount = inboxUnreadSnapshot?.unreadCount
- unreadInboxThreads = Array(inboxUnreadSnapshot?.threads.prefix(4) ?? [])
+ unreadInboxThreads = inboxUnreadSnapshot?.threads ?? []
hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0
let systemStatusResult = await systemStatusTask
switch systemStatusResult {
@@ -414,7 +414,7 @@ final class HomeViewModel {
}
var pinnedProjects: [Project] {
- let pinnedIDs = ProjectPinStore.loadPinnedProjectIDs(for: currentUserKey, defaults: defaults)
+ let pinnedIDs = HomePinStore.pinnedProjectIDs(for: currentUserKey, defaults: defaults)
guard !pinnedIDs.isEmpty else { return [] }
let projectsByID = Dictionary(uniqueKeysWithValues: projects.map { ($0.id, $0) })
@@ -422,7 +422,7 @@ final class HomeViewModel {
}
var hasPinnedProjects: Bool {
- !ProjectPinStore.loadPinnedProjectIDs(for: currentUserKey, defaults: defaults).isEmpty
+ !HomePinStore.loadPins(for: currentUserKey, defaults: defaults).isEmpty
}
var failedBuildCount: Int {
diff --git a/Hutch/Views/Home/RecentActivityStore.swift b/Hutch/Views/Home/RecentActivityStore.swift
new file mode 100644
index 0000000..464a2c1
--- /dev/null
+++ b/Hutch/Views/Home/RecentActivityStore.swift
@@ -0,0 +1,148 @@
+import Foundation
+
+enum RecentActivityKind: String, Codable, Sendable {
+ case repository
+ case ticket
+ case build
+}
+
+struct RecentActivityEntry: Codable, Hashable, Identifiable, Sendable {
+ let kind: RecentActivityKind
+ let title: String
+ let viewedAt: Date
+ let repositoryOwner: String?
+ let repositoryName: String?
+ let repositoryService: SRHTService?
+ let ticketOwnerUsername: String?
+ let ticketTrackerName: String?
+ let ticketId: Int?
+ let buildJobId: Int?
+
+ var id: String {
+ switch kind {
+ case .repository:
+ let service = repositoryService?.rawValue ?? SRHTService.git.rawValue
+ return "repo:\(service):\(repositoryOwner ?? "")/\(repositoryName ?? "")"
+ case .ticket:
+ return "ticket:\(ticketOwnerUsername ?? "")/\(ticketTrackerName ?? "")#\(ticketId ?? 0)"
+ case .build:
+ return "build:\(buildJobId ?? 0)"
+ }
+ }
+
+ var detailText: String {
+ switch kind {
+ case .repository:
+ let serviceName = repositoryService?.displayName ?? "Repository"
+ return "\(serviceName) • Viewed \(viewedAt.relativeDescription)"
+ case .ticket:
+ return "Ticket • Viewed \(viewedAt.relativeDescription)"
+ case .build:
+ return "Build • Viewed \(viewedAt.relativeDescription)"
+ }
+ }
+}
+
+enum RecentActivityStore {
+ private static let maximumEntries = 5
+
+ static func load(defaults: UserDefaults) -> [RecentActivityEntry] {
+ guard let data = defaults.data(forKey: AppStorageKeys.recentActivity) else {
+ return []
+ }
+
+ do {
+ return try JSONDecoder().decode([RecentActivityEntry].self, from: data)
+ } catch {
+ defaults.removeObject(forKey: AppStorageKeys.recentActivity)
+ return []
+ }
+ }
+
+ static func recordRepository(
+ _ repository: RepositorySummary,
+ defaults: UserDefaults,
+ now: Date = .now
+ ) {
+ record(
+ RecentActivityEntry(
+ kind: .repository,
+ title: "\(repository.owner.canonicalName)/\(repository.name)",
+ viewedAt: now,
+ repositoryOwner: repository.owner.canonicalName.srhtUsername,
+ repositoryName: repository.name,
+ repositoryService: repository.service,
+ ticketOwnerUsername: nil,
+ ticketTrackerName: nil,
+ ticketId: nil,
+ buildJobId: nil
+ ),
+ defaults: defaults
+ )
+ }
+
+ static func recordTicket(
+ ownerUsername: String,
+ trackerName: String,
+ ticketId: Int,
+ title: String,
+ defaults: UserDefaults,
+ now: Date = .now
+ ) {
+ record(
+ RecentActivityEntry(
+ kind: .ticket,
+ title: "#\(ticketId) \(title)",
+ viewedAt: now,
+ repositoryOwner: nil,
+ repositoryName: nil,
+ repositoryService: nil,
+ ticketOwnerUsername: ownerUsername,
+ ticketTrackerName: trackerName,
+ ticketId: ticketId,
+ buildJobId: nil
+ ),
+ defaults: defaults
+ )
+ }
+
+ static func recordBuild(
+ jobId: Int,
+ title: String,
+ defaults: UserDefaults,
+ now: Date = .now
+ ) {
+ record(
+ RecentActivityEntry(
+ kind: .build,
+ title: title,
+ viewedAt: now,
+ repositoryOwner: nil,
+ repositoryName: nil,
+ repositoryService: nil,
+ ticketOwnerUsername: nil,
+ ticketTrackerName: nil,
+ ticketId: nil,
+ buildJobId: jobId
+ ),
+ defaults: defaults
+ )
+ }
+
+ private static func record(_ entry: RecentActivityEntry, defaults: UserDefaults) {
+ var entries = load(defaults: defaults)
+ entries.removeAll { $0.id == entry.id }
+ entries.insert(entry, at: 0)
+
+ if entries.count > maximumEntries {
+ entries = Array(entries.prefix(maximumEntries))
+ }
+
+ save(entries, defaults: defaults)
+ }
+
+ private static func save(_ entries: [RecentActivityEntry], defaults: UserDefaults) {
+ guard let data = try? JSONEncoder().encode(entries) else { return }
+ defaults.set(data, forKey: AppStorageKeys.recentActivity)
+ }
+}