summaryrefslogtreecommitdiff
path: root/Hutch/Views/Lookup/ContributionCalendarViewModel.swift
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-11 19:47:40 -0500
committerChristian Cleberg <[email protected]>2026-04-11 19:47:40 -0500
commit61164b6dd8343c8699d091dd5fccc55beb0b90b9 (patch)
tree0a2befeb986966d42062802e2a29d795be907fee /Hutch/Views/Lookup/ContributionCalendarViewModel.swift
parent27bfcbbed6acde158ec40c5bb46e45e76c81a319 (diff)
downloadhutch-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.swift204
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
+ }
+}