krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v2.12.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}