krz/hutch

an ios client for sourcehut

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

v3.1.5: 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}