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/ContributionCalendarViewModel.swift | |
| parent | 27bfcbbed6acde158ec40c5bb46e45e76c81a319 (diff) | |
| download | hutch-61164b6dd8343c8699d091dd5fccc55beb0b90b9.tar.gz hutch-61164b6dd8343c8699d091dd5fccc55beb0b90b9.tar.bz2 hutch-61164b6dd8343c8699d091dd5fccc55beb0b90b9.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/ContributionCalendarViewModel.swift')
| -rw-r--r-- | Hutch/Views/Lookup/ContributionCalendarViewModel.swift | 204 |
1 files changed, 204 insertions, 0 deletions
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 + } +} |
