krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.8.1: 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 let fetchedCalendar: ContributionCalendarResponse?
120 let fetchedStats: ContributionStatsResponse?
121 var errors: [any Error] = []
122
123 do {
124 fetchedCalendar = try await service.fetchContributionCalendar(actor: actor, endingOn: selectedEndDate)
125 } catch {
126 fetchedCalendar = nil
127 errors.append(error)
128 }
129
130 do {
131 fetchedStats = try await service.fetchContributionStats(actor: actor, endingOn: selectedEndDate)
132 } catch {
133 fetchedStats = nil
134 errors.append(error)
135 }
136
137 calendar = fetchedCalendar
138 stats = fetchedStats
139
140 if displayState != .unavailable {
141 loadErrorMessage = nil
142 } else if fetchedCalendar == nil && fetchedStats == nil {
143 loadErrorMessage = errors.first?.userFacingMessage
144 } else {
145 loadErrorMessage = errors.first?.userFacingMessage
146 }
147
148 }
149
150 func selectPreviousYear() async {
151 guard let previousEndDate = Calendar.contributionCalendar.date(byAdding: .year, value: -1, to: selectedEndDate) else {
152 return
153 }
154 selectedEndDate = previousEndDate
155 await load()
156 }
157
158 func selectNextYear() async {
159 guard canAdvanceYear else { return }
160 let advancedDate = Calendar.contributionCalendar.date(byAdding: .year, value: 1, to: selectedEndDate) ?? currentEndDate
161 selectedEndDate = min(advancedDate, currentEndDate)
162 await load()
163 }
164
165 private var hasActivity: Bool {
166 if let stats, stats.totalEvents > 0 {
167 return true
168 }
169
170 if let calendar, !calendar.isEmpty {
171 return true
172 }
173
174 return false
175 }
176
177 private var effectiveIndexingState: ContributionIndexingState? {
178 stats?.indexingState ?? calendar?.indexingState
179 }
180
181 private var effectiveLastPolledAt: Date? {
182 stats?.lastPolledAt ?? calendar?.lastPolledAt
183 }
184
185 private var trailingRange: ClosedRange<Date> {
186 let normalizedEndDate = Calendar.contributionCalendar.startOfDay(for: selectedEndDate)
187 let oneYearBack = Calendar.contributionCalendar.date(byAdding: .year, value: -1, to: normalizedEndDate) ?? normalizedEndDate
188 let normalizedStartDate = Calendar.contributionCalendar.date(byAdding: .day, value: 1, to: oneYearBack) ?? oneYearBack
189 return normalizedStartDate...normalizedEndDate
190 }
191
192}