krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.13.0: Hutch/Views/Lookup/ContributionCalendarViewModel.swift · raw
1import Foundation
2
3@Observable
4@MainActor
5final class ContributionCalendarViewModel {
6 enum DisplayState: Equatable {
7 case populated
8 case indexing
9 case empty
10 case unavailable
11 }
12
13 private(set) var calendar: ContributionCalendarResponse?
14 private(set) var stats: ContributionStatsResponse?
15 private(set) var isLoading = false
16 var loadErrorMessage: String?
17 var selectedEndDate: Date
18
19 let actor: String
20
21 private let service: any ContributionCalendarServing
22 private let currentEndDate: Date
23
24 init(
25 actor: String,
26 service: any ContributionCalendarServing,
27 selectedEndDate: Date? = nil
28 ) {
29 self.actor = actor
30 self.service = service
31 let today = Calendar.contributionCalendar.startOfDay(for: Date())
32 let resolvedEndDate = Calendar.contributionCalendar.startOfDay(for: selectedEndDate ?? today)
33 self.selectedEndDate = resolvedEndDate
34 self.currentEndDate = today
35 }
36
37 var weekColumns: [ContributionWeek] {
38 ContributionCalendarLayout.weekColumns(from: calendar?.days ?? [])
39 }
40
41 var recentWeekColumns: [ContributionWeek] {
42 ContributionCalendarLayout.recentWeeks(from: calendar?.days ?? [], count: 8)
43 }
44
45 var isEmpty: Bool {
46 calendar?.isEmpty ?? false
47 }
48
49 var isIndexedButEmpty: Bool {
50 displayState != .populated
51 && displayState != .unavailable
52 && ((stats?.totalEvents == 0) || (calendar?.isEmpty == true))
53 }
54
55 var displayState: DisplayState {
56 if hasActivity {
57 return .populated
58 }
59
60 switch effectiveIndexingState {
61 case .pending:
62 return .indexing
63 case .error:
64 return .unavailable
65 case .indexed:
66 return .empty
67 case nil:
68 return .empty
69 }
70 }
71
72 var emptyStateTitle: String {
73 switch displayState {
74 case .indexing:
75 "Indexing Activity"
76 case .empty:
77 "No Contribution Activity"
78 case .unavailable:
79 "Activity Unavailable"
80 case .populated:
81 ""
82 }
83 }
84
85 var emptyStateMessage: String {
86 switch displayState {
87 case .indexing:
88 "This user’s SourceHut activity is being indexed. Check back soon."
89 case .empty:
90 "No activity was found for this time range."
91 case .unavailable:
92 "The contribution graph couldn’t be refreshed right now. Try again later."
93 case .populated:
94 ""
95 }
96 }
97
98 var lastUpdatedText: String? {
99 guard let lastPolledAt = effectiveLastPolledAt else {
100 return nil
101 }
102
103 return "Updated \(lastPolledAt.formatted(date: .abbreviated, time: .shortened))"
104 }
105
106 var canAdvanceYear: Bool {
107 selectedEndDate < currentEndDate
108 }
109
110 var displayedRangeText: String {
111 let range = trailingRange
112 return "\(range.lowerBound.formatted(date: .abbreviated, time: .omitted)) to \(range.upperBound.formatted(date: .abbreviated, time: .omitted))"
113 }
114
115 func load() async {
116 isLoading = true
117 loadErrorMessage = nil
118 defer { isLoading = false }
119 debugLog("load start actor=\(actor) endDate=\(selectedEndDate.formatted(date: .abbreviated, time: .omitted))")
120
121 let fetchedCalendar: ContributionCalendarResponse?
122 let fetchedStats: ContributionStatsResponse?
123 var errors: [any Error] = []
124
125 do {
126 fetchedCalendar = try await service.fetchContributionCalendar(actor: actor, endingOn: selectedEndDate)
127 } catch {
128 fetchedCalendar = nil
129 errors.append(error)
130 }
131
132 do {
133 fetchedStats = try await service.fetchContributionStats(actor: actor, endingOn: selectedEndDate)
134 } catch {
135 fetchedStats = nil
136 errors.append(error)
137 }
138
139 calendar = fetchedCalendar
140 stats = fetchedStats
141
142 if displayState != .unavailable {
143 loadErrorMessage = nil
144 } else if fetchedCalendar == nil && fetchedStats == nil {
145 loadErrorMessage = errors.first?.userFacingMessage
146 } else {
147 loadErrorMessage = errors.first?.userFacingMessage
148 }
149
150 debugLog(
151 "load complete actor=\(actor) endDate=\(selectedEndDate.formatted(date: .abbreviated, time: .omitted)) state=\(String(describing: displayState)) " +
152 "calendarDays=\(calendar?.days.count ?? 0) totalEvents=\(stats?.totalEvents ?? 0) " +
153 "indexingState=\(String(describing: effectiveIndexingState)) error=\(loadErrorMessage ?? "none")"
154 )
155 }
156
157 func selectPreviousYear() async {
158 guard let previousEndDate = Calendar.contributionCalendar.date(byAdding: .year, value: -1, to: selectedEndDate) else {
159 return
160 }
161 selectedEndDate = previousEndDate
162 await load()
163 }
164
165 func selectNextYear() async {
166 guard canAdvanceYear else { return }
167 let advancedDate = Calendar.contributionCalendar.date(byAdding: .year, value: 1, to: selectedEndDate) ?? currentEndDate
168 selectedEndDate = min(advancedDate, currentEndDate)
169 await load()
170 }
171
172 private var hasActivity: Bool {
173 if let stats, stats.totalEvents > 0 {
174 return true
175 }
176
177 if let calendar, !calendar.isEmpty {
178 return true
179 }
180
181 return false
182 }
183
184 private var effectiveIndexingState: ContributionIndexingState? {
185 stats?.indexingState ?? calendar?.indexingState
186 }
187
188 private var effectiveLastPolledAt: Date? {
189 stats?.lastPolledAt ?? calendar?.lastPolledAt
190 }
191
192 private var trailingRange: ClosedRange<Date> {
193 let normalizedEndDate = Calendar.contributionCalendar.startOfDay(for: selectedEndDate)
194 let oneYearBack = Calendar.contributionCalendar.date(byAdding: .year, value: -1, to: normalizedEndDate) ?? normalizedEndDate
195 let normalizedStartDate = Calendar.contributionCalendar.date(byAdding: .day, value: 1, to: oneYearBack) ?? oneYearBack
196 return normalizedStartDate...normalizedEndDate
197 }
198
199 private func debugLog(_ message: String) {
200#if DEBUG
201 print("[ContributionCalendarViewModel] \(message)")
202#endif
203 }
204}