diff options
| author | Christian Cleberg <[email protected]> | 2026-04-11 19:47:40 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-11 19:47:40 -0500 |
| commit | 61164b6dd8343c8699d091dd5fccc55beb0b90b9 (patch) | |
| tree | 0a2befeb986966d42062802e2a29d795be907fee /Hutch/Views/Lookup | |
| parent | 27bfcbbed6acde158ec40c5bb46e45e76c81a319 (diff) | |
| download | hutch-2.11.0.tar.gz hutch-2.11.0.tar.bz2 hutch-2.11.0.zip | |
feat: add contribution calendar to user profilesv2.11.0
Add a trailing 365-day contribution heatmap to user profile views,
backed by HutchStatsService. Includes calendar model, view model,
SwiftUI calendar view, app configuration, and unit tests.
Diffstat (limited to 'Hutch/Views/Lookup')
| -rw-r--r-- | Hutch/Views/Lookup/ContributionCalendarView.swift | 224 | ||||
| -rw-r--r-- | Hutch/Views/Lookup/ContributionCalendarViewModel.swift | 204 | ||||
| -rw-r--r-- | Hutch/Views/Lookup/LookupView.swift | 2 | ||||
| -rw-r--r-- | Hutch/Views/Lookup/UserProfileView.swift | 45 | ||||
| -rw-r--r-- | Hutch/Views/Lookup/UserProfileViewModel.swift | 91 |
5 files changed, 561 insertions, 5 deletions
diff --git a/Hutch/Views/Lookup/ContributionCalendarView.swift b/Hutch/Views/Lookup/ContributionCalendarView.swift new file mode 100644 index 0000000..d52deab --- /dev/null +++ b/Hutch/Views/Lookup/ContributionCalendarView.swift @@ -0,0 +1,224 @@ +import SwiftUI + +struct ContributionProfileCard: View { + let actor: String + let weeks: [ContributionWeek] + let stats: ContributionStatsResponse? + let isLoading: Bool + let error: String? + var isIndexedButEmpty = false + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Contribution Activity") + .font(.headline) + Text(actor) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + } + + if isLoading && weeks.isEmpty { + HStack(spacing: 10) { + ProgressView() + .controlSize(.small) + Text("Loading activity…") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } else if let error, weeks.isEmpty { + Label(error, systemImage: "exclamationmark.triangle") + .font(.subheadline) + .foregroundStyle(.secondary) + } else if isIndexedButEmpty || weeks.isEmpty || stats?.totalEvents == 0 { + Text("Contribution activity may still be indexing.") + .font(.subheadline) + .foregroundStyle(.secondary) + } else { + ContributionScrollableHeatmap( + weeks: weeks, + squareSize: 10, + spacing: 3, + showsWeekdayLabels: false, + showsMonthLabels: false + ) + + ContributionLegendView() + + if let stats { + HStack(spacing: 14) { + ContributionMetricChip(value: "\(stats.totalEvents)", title: "Events") + ContributionMetricChip(value: "\(stats.activeDays)", title: "Days") + ContributionMetricChip(value: "\(stats.longestStreak)", title: "Streak") + } + } + } + } + .padding(.vertical, 4) + } +} + +private struct ContributionScrollableHeatmap: View { + let weeks: [ContributionWeek] + let squareSize: CGFloat + let spacing: CGFloat + let showsWeekdayLabels: Bool + let showsMonthLabels: Bool + + @State private var didApplyInitialScroll = false + + private let weekdayLabels = ["S", "M", "T", "W", "T", "F", "S"] + + var body: some View { + ScrollViewReader { proxy in + ScrollView(.horizontal, showsIndicators: false) { + VStack(alignment: .leading, spacing: 6) { + if showsMonthLabels { + HStack(spacing: spacing) { + if showsWeekdayLabels { + Color.clear + .frame(width: 10) + } + + ForEach(Array(weeks.enumerated()), id: \.element.startDate) { index, week in + Text(monthLabel(for: week, index: index)) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + .frame(width: squareSize, alignment: .leading) + } + } + } + + HStack(alignment: .top, spacing: 8) { + if showsWeekdayLabels { + VStack(alignment: .trailing, spacing: spacing) { + ForEach(Array(weekdayLabels.enumerated()), id: \.offset) { _, label in + Text(label) + .font(.caption2) + .foregroundStyle(.tertiary) + .frame(width: 10, height: squareSize, alignment: .center) + } + } + } + + HStack(alignment: .top, spacing: spacing) { + ForEach(weeks, id: \.startDate) { week in + VStack(spacing: spacing) { + ForEach(week.days) { day in + ContributionDayCell(day: day, size: squareSize) + } + } + .id(week.startDate) + } + } + } + } + .padding(.vertical, 2) + } + .onAppear { + guard !didApplyInitialScroll, let lastWeek = weeks.last?.startDate else { return } + didApplyInitialScroll = true + + DispatchQueue.main.async { + proxy.scrollTo(lastWeek, anchor: .trailing) + } + } + } + .accessibilityElement(children: .contain) + } + + private func monthLabel(for week: ContributionWeek, index: Int) -> String { + let month = week.startDate.formatted(.dateTime.month(.abbreviated)) + if index == 0 { + return month + } + + let previousMonth = weeks[index - 1].startDate.formatted(.dateTime.month(.abbreviated)) + return previousMonth == month ? "" : month + } +} + +private struct ContributionDayCell: View { + let day: ContributionDay + let size: CGFloat + + var body: some View { + RoundedRectangle(cornerRadius: 3, style: .continuous) + .fill(day.intensity.color) + .frame(width: size, height: size) + .overlay { + RoundedRectangle(cornerRadius: 3, style: .continuous) + .stroke(Color.primary.opacity(day.intensity == .empty ? 0.08 : 0), lineWidth: 0.5) + } + .accessibilityLabel(day.accessibilityLabel) + } +} + + +private struct ContributionMetricChip: View { + let value: String + let title: String + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(value) + .font(.headline) + Text(title) + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(Color.secondary.opacity(0.08), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + } +} + +private struct ContributionLegendView: View { + var body: some View { + HStack(spacing: 8) { + Text("Less") + .font(.caption) + .foregroundStyle(.secondary) + + ForEach(ContributionIntensity.allCases, id: \.rawValue) { intensity in + RoundedRectangle(cornerRadius: 3, style: .continuous) + .fill(intensity.color) + .frame(width: 12, height: 12) + } + + Text("More") + .font(.caption) + .foregroundStyle(.secondary) + } + } +} + +private extension ContributionIntensity { + var color: Color { + switch self { + case .empty: + Color(uiColor: .secondarySystemFill) + case .level1: + Color(red: 0.82, green: 0.92, blue: 0.83) + case .level2: + Color(red: 0.58, green: 0.83, blue: 0.61) + case .level3: + Color(red: 0.25, green: 0.69, blue: 0.36) + case .level4: + Color(red: 0.12, green: 0.47, blue: 0.21) + } + } +} + +private extension ContributionDay { + var accessibilityLabel: String { + let contributionLabel = count == 1 ? "1 contribution" : "\(count) contributions" + return "\(date.formatted(date: .long, time: .omitted)): \(contributionLabel), score \(score.formatted(.number.precision(.fractionLength(0...2))))" + } +} diff --git a/Hutch/Views/Lookup/ContributionCalendarViewModel.swift b/Hutch/Views/Lookup/ContributionCalendarViewModel.swift new file mode 100644 index 0000000..1ff789d --- /dev/null +++ b/Hutch/Views/Lookup/ContributionCalendarViewModel.swift @@ -0,0 +1,204 @@ +import Foundation + +@Observable +@MainActor +final class ContributionCalendarViewModel { + enum DisplayState: Equatable { + case populated + case indexing + case empty + case unavailable + } + + private(set) var calendar: ContributionCalendarResponse? + private(set) var stats: ContributionStatsResponse? + private(set) var isLoading = false + var loadErrorMessage: String? + var selectedEndDate: Date + + let actor: String + + private let service: any ContributionCalendarServing + private let currentEndDate: Date + + init( + actor: String, + service: any ContributionCalendarServing, + selectedEndDate: Date? = nil + ) { + self.actor = actor + self.service = service + let today = Calendar.contributionCalendar.startOfDay(for: Date()) + let resolvedEndDate = Calendar.contributionCalendar.startOfDay(for: selectedEndDate ?? today) + self.selectedEndDate = resolvedEndDate + self.currentEndDate = today + } + + var weekColumns: [ContributionWeek] { + ContributionCalendarLayout.weekColumns(from: calendar?.days ?? []) + } + + var recentWeekColumns: [ContributionWeek] { + ContributionCalendarLayout.recentWeeks(from: calendar?.days ?? [], count: 8) + } + + var isEmpty: Bool { + calendar?.isEmpty ?? false + } + + var isIndexedButEmpty: Bool { + displayState != .populated + && displayState != .unavailable + && ((stats?.totalEvents == 0) || (calendar?.isEmpty == true)) + } + + var displayState: DisplayState { + if hasActivity { + return .populated + } + + switch effectiveIndexingState { + case .pending: + return .indexing + case .error: + return .unavailable + case .indexed: + return .empty + case nil: + return .empty + } + } + + var emptyStateTitle: String { + switch displayState { + case .indexing: + "Indexing Activity" + case .empty: + "No Contribution Activity" + case .unavailable: + "Activity Unavailable" + case .populated: + "" + } + } + + var emptyStateMessage: String { + switch displayState { + case .indexing: + "This user’s SourceHut activity is being indexed. Check back soon." + case .empty: + "No activity was found for this time range." + case .unavailable: + "The contribution graph couldn’t be refreshed right now. Try again later." + case .populated: + "" + } + } + + var lastUpdatedText: String? { + guard let lastPolledAt = effectiveLastPolledAt else { + return nil + } + + return "Updated \(lastPolledAt.formatted(date: .abbreviated, time: .shortened))" + } + + var canAdvanceYear: Bool { + selectedEndDate < currentEndDate + } + + var displayedRangeText: String { + let range = trailingRange + return "\(range.lowerBound.formatted(date: .abbreviated, time: .omitted)) to \(range.upperBound.formatted(date: .abbreviated, time: .omitted))" + } + + func load() async { + isLoading = true + loadErrorMessage = nil + defer { isLoading = false } + debugLog("load start actor=\(actor) endDate=\(selectedEndDate.formatted(date: .abbreviated, time: .omitted))") + + let fetchedCalendar: ContributionCalendarResponse? + let fetchedStats: ContributionStatsResponse? + var errors: [any Error] = [] + + do { + fetchedCalendar = try await service.fetchContributionCalendar(actor: actor, endingOn: selectedEndDate) + } catch { + fetchedCalendar = nil + errors.append(error) + } + + do { + fetchedStats = try await service.fetchContributionStats(actor: actor, endingOn: selectedEndDate) + } catch { + fetchedStats = nil + errors.append(error) + } + + calendar = fetchedCalendar + stats = fetchedStats + + if displayState != .unavailable { + loadErrorMessage = nil + } else if fetchedCalendar == nil && fetchedStats == nil { + loadErrorMessage = errors.first?.userFacingMessage + } else { + loadErrorMessage = errors.first?.userFacingMessage + } + + debugLog( + "load complete actor=\(actor) endDate=\(selectedEndDate.formatted(date: .abbreviated, time: .omitted)) state=\(String(describing: displayState)) " + + "calendarDays=\(calendar?.days.count ?? 0) totalEvents=\(stats?.totalEvents ?? 0) " + + "indexingState=\(String(describing: effectiveIndexingState)) error=\(loadErrorMessage ?? "none")" + ) + } + + func selectPreviousYear() async { + guard let previousEndDate = Calendar.contributionCalendar.date(byAdding: .year, value: -1, to: selectedEndDate) else { + return + } + selectedEndDate = previousEndDate + await load() + } + + func selectNextYear() async { + guard canAdvanceYear else { return } + let advancedDate = Calendar.contributionCalendar.date(byAdding: .year, value: 1, to: selectedEndDate) ?? currentEndDate + selectedEndDate = min(advancedDate, currentEndDate) + await load() + } + + private var hasActivity: Bool { + if let stats, stats.totalEvents > 0 { + return true + } + + if let calendar, !calendar.isEmpty { + return true + } + + return false + } + + private var effectiveIndexingState: ContributionIndexingState? { + stats?.indexingState ?? calendar?.indexingState + } + + private var effectiveLastPolledAt: Date? { + stats?.lastPolledAt ?? calendar?.lastPolledAt + } + + private var trailingRange: ClosedRange<Date> { + let normalizedEndDate = Calendar.contributionCalendar.startOfDay(for: selectedEndDate) + let oneYearBack = Calendar.contributionCalendar.date(byAdding: .year, value: -1, to: normalizedEndDate) ?? normalizedEndDate + let normalizedStartDate = Calendar.contributionCalendar.date(byAdding: .day, value: 1, to: oneYearBack) ?? oneYearBack + return normalizedStartDate...normalizedEndDate + } + + private func debugLog(_ message: String) { +#if DEBUG + print("[ContributionCalendarViewModel] \(message)") +#endif + } +} diff --git a/Hutch/Views/Lookup/LookupView.swift b/Hutch/Views/Lookup/LookupView.swift index eb585fb..2753ea3 100644 --- a/Hutch/Views/Lookup/LookupView.swift +++ b/Hutch/Views/Lookup/LookupView.swift @@ -429,6 +429,8 @@ struct LookupView: View { MailingListListView() case .pastes: PasteListView() + case .profile: + ProfileView() case .settings: SettingsView() case .mailingList(let mailingList): diff --git a/Hutch/Views/Lookup/UserProfileView.swift b/Hutch/Views/Lookup/UserProfileView.swift index dc9197c..bc04b08 100644 --- a/Hutch/Views/Lookup/UserProfileView.swift +++ b/Hutch/Views/Lookup/UserProfileView.swift @@ -2,6 +2,7 @@ import SwiftUI struct UserProfileView: View { @Environment(AppState.self) private var appState + @AppStorage(AppStorageKeys.contributionGraphsEnabled) private var contributionGraphsEnabled = true let user: User @State private var profileViewModel: UserProfileViewModel? @@ -91,6 +92,21 @@ struct UserProfileView: View { } if let viewModel = profileViewModel { + if contributionGraphsEnabled { + Section { + ContributionProfileCard( + actor: viewModel.actor, + weeks: viewModel.contributionCalendar.map { + ContributionCalendarLayout.weekColumns(from: $0.days) + } ?? [], + stats: viewModel.contributionStats, + isLoading: viewModel.isLoadingContributions, + error: viewModel.contributionsError ?? viewModel.contributionStatusText, + isIndexedButEmpty: viewModel.isContributionActivityIndexedButEmpty + ) + } + } + Section { if viewModel.isLoadingRepositories && viewModel.repositories.isEmpty { ProgressView() @@ -143,15 +159,36 @@ struct UserProfileView: View { .listStyle(.insetGrouped) .navigationTitle(user.canonicalName) .navigationBarTitleDisplayMode(.inline) - .task { + .task(id: user.canonicalName) { let owner = user.canonicalName.hasPrefix("~") ? String(user.canonicalName.dropFirst()) : user.canonicalName - let vm = UserProfileViewModel(ownerUsername: owner, client: appState.client) - profileViewModel = vm + let actor = user.canonicalName.hasPrefix("~") ? user.canonicalName : "~\(user.canonicalName)" + + let vm: UserProfileViewModel + if let existingViewModel = profileViewModel, + existingViewModel.actor == actor, + existingViewModel.ownerUsername == owner { + vm = existingViewModel + } else { + let newViewModel = UserProfileViewModel( + ownerUsername: owner, + actor: actor, + client: appState.client, + statsService: HutchStatsService(configuration: appState.configuration) + ) + profileViewModel = newViewModel + vm = newViewModel + } + async let repos: () = vm.loadRepositories() async let trackers: () = vm.loadTrackers() - _ = await (repos, trackers) + if contributionGraphsEnabled { + async let contributions: () = vm.loadContributions() + _ = await (repos, trackers, contributions) + } else { + _ = await (repos, trackers) + } } } diff --git a/Hutch/Views/Lookup/UserProfileViewModel.swift b/Hutch/Views/Lookup/UserProfileViewModel.swift index bec79fb..143cb6f 100644 --- a/Hutch/Views/Lookup/UserProfileViewModel.swift +++ b/Hutch/Views/Lookup/UserProfileViewModel.swift @@ -5,17 +5,42 @@ import Foundation final class UserProfileViewModel { private(set) var repositories: [RepositorySummary] = [] private(set) var trackers: [TrackerSummary] = [] + private(set) var contributionCalendar: ContributionCalendarResponse? + private(set) var contributionStats: ContributionStatsResponse? private(set) var isLoadingRepositories = false private(set) var isLoadingTrackers = false + private(set) var isLoadingContributions = false var repositoriesError: String? var trackersError: String? + var contributionsError: String? private let client: SRHTClient + private let statsService: HutchStatsService let ownerUsername: String + let actor: String - init(ownerUsername: String, client: SRHTClient) { + var isContributionActivityIndexedButEmpty: Bool { + contributionDisplayState != .populated && contributionDisplayState != .unavailable + } + + var contributionStatusText: String? { + switch contributionDisplayState { + case .indexing: + return "Activity is being indexed." + case .empty: + return "No contribution activity found." + case .unavailable: + return "Contribution activity is unavailable." + case .populated: + return nil + } + } + + init(ownerUsername: String, actor: String, client: SRHTClient, statsService: HutchStatsService) { self.ownerUsername = ownerUsername + self.actor = actor self.client = client + self.statsService = statsService } func loadRepositories() async { @@ -54,6 +79,37 @@ final class UserProfileViewModel { } } + func loadContributions(endingOn endDate: Date? = nil) async { + isLoadingContributions = true + contributionsError = nil + defer { isLoadingContributions = false } + + let resolvedEndDate = Calendar.contributionCalendar.startOfDay(for: endDate ?? Date()) + debugLog("profile contributions start actor=\(actor) endDate=\(resolvedEndDate.formatted(date: .abbreviated, time: .omitted))") + + do { + async let contributionCalendar = statsService.fetchContributionCalendar(actor: actor, endingOn: resolvedEndDate) + async let contributionStats = statsService.fetchContributionStats(actor: actor, endingOn: resolvedEndDate) + + self.contributionCalendar = try await contributionCalendar + self.contributionStats = try await contributionStats + if contributionDisplayState != .unavailable { + contributionsError = nil + } + debugLog( + "profile contributions complete actor=\(actor) endDate=\(resolvedEndDate.formatted(date: .abbreviated, time: .omitted)) " + + "state=\(String(describing: contributionDisplayState)) days=\(self.contributionCalendar?.days.count ?? 0) " + + "totalEvents=\(self.contributionStats?.totalEvents ?? 0) error=\(contributionsError ?? "none")" + ) + } catch { + contributionsError = error.userFacingMessage + debugLog( + "profile contributions failed actor=\(actor) endDate=\(resolvedEndDate.formatted(date: .abbreviated, time: .omitted)) " + + "error=\(error.localizedDescription) userFacing=\(contributionsError ?? "none")" + ) + } + } + private static let repositoriesQuery = """ query userRepositories($owner: String!) { user(username: $owner) { @@ -148,4 +204,37 @@ final class UserProfileViewModel { let results: [TrackerSummary] let cursor: String? } + + private enum ContributionDisplayState { + case populated + case indexing + case empty + case unavailable + } + + private var contributionDisplayState: ContributionDisplayState { + if let contributionStats, contributionStats.totalEvents > 0 { + return .populated + } + + if let contributionCalendar, !contributionCalendar.isEmpty { + return .populated + } + + let indexingState = contributionStats?.indexingState ?? contributionCalendar?.indexingState + switch indexingState { + case .pending: + return .indexing + case .error: + return .unavailable + case .indexed, nil: + return .empty + } + } + + private func debugLog(_ message: String) { +#if DEBUG + print("[UserProfileViewModel] \(message)") +#endif + } } |
