krz/hutch

an ios client for sourcehut

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

v2.13.1: Hutch/Views/Lookup/UserProfileViewModel.swift · raw

  1import Foundation
  2
  3@Observable
  4@MainActor
  5final class UserProfileViewModel {
  6    private(set) var repositories: [RepositorySummary] = []
  7    private(set) var trackers: [TrackerSummary] = []
  8    private(set) var contributionCalendar: ContributionCalendarResponse?
  9    private(set) var contributionStats: ContributionStatsResponse?
 10    private(set) var isLoadingRepositories = false
 11    private(set) var isLoadingTrackers = false
 12    private(set) var isLoadingContributions = false
 13    var repositoriesError: String?
 14    var trackersError: String?
 15    var contributionsError: String?
 16
 17    private let client: SRHTClient
 18    private let statsService: HutchStatsService
 19    let ownerUsername: String
 20    let actor: String
 21
 22    var isContributionActivityIndexedButEmpty: Bool {
 23        contributionDisplayState != .populated && contributionDisplayState != .unavailable
 24    }
 25
 26    var contributionStatusText: String? {
 27        switch contributionDisplayState {
 28        case .indexing:
 29            return "Activity is being indexed."
 30        case .empty:
 31            return "No contribution activity found."
 32        case .unavailable:
 33            return "Contribution activity is unavailable."
 34        case .populated:
 35            return nil
 36        }
 37    }
 38
 39    init(ownerUsername: String, actor: String, client: SRHTClient, statsService: HutchStatsService) {
 40        self.ownerUsername = ownerUsername
 41        self.actor = actor
 42        self.client = client
 43        self.statsService = statsService
 44    }
 45
 46    func loadRepositories() async {
 47        isLoadingRepositories = true
 48        repositoriesError = nil
 49        defer { isLoadingRepositories = false }
 50
 51        do {
 52            let result = try await client.execute(
 53                service: .git,
 54                query: Self.repositoriesQuery,
 55                variables: ["owner": ownerUsername],
 56                responseType: UserRepositoriesResponse.self
 57            )
 58            repositories = result.user.repositories.results.map { $0.repositorySummary(service: .git) }
 59        } catch {
 60            repositoriesError = error.userFacingMessage
 61        }
 62    }
 63
 64    func loadTrackers() async {
 65        isLoadingTrackers = true
 66        trackersError = nil
 67        defer { isLoadingTrackers = false }
 68
 69        do {
 70            let result = try await client.execute(
 71                service: .todo,
 72                query: Self.trackersQuery,
 73                variables: ["owner": ownerUsername],
 74                responseType: UserTrackersResponse.self
 75            )
 76            trackers = result.user.trackers.results
 77        } catch {
 78            trackersError = error.userFacingMessage
 79        }
 80    }
 81
 82    func loadContributions(endingOn endDate: Date? = nil) async {
 83        isLoadingContributions = true
 84        contributionsError = nil
 85        defer { isLoadingContributions = false }
 86
 87        let resolvedEndDate = Calendar.contributionCalendar.startOfDay(for: endDate ?? Date())
 88        debugLog("profile contributions start actor=\(actor) endDate=\(resolvedEndDate.formatted(date: .abbreviated, time: .omitted))")
 89
 90        do {
 91            async let contributionCalendar = statsService.fetchContributionCalendar(actor: actor, endingOn: resolvedEndDate)
 92            async let contributionStats = statsService.fetchContributionStats(actor: actor, endingOn: resolvedEndDate)
 93
 94            self.contributionCalendar = try await contributionCalendar
 95            self.contributionStats = try await contributionStats
 96            if contributionDisplayState != .unavailable {
 97                contributionsError = nil
 98            }
 99            debugLog(
100                "profile contributions complete actor=\(actor) endDate=\(resolvedEndDate.formatted(date: .abbreviated, time: .omitted)) " +
101                "state=\(String(describing: contributionDisplayState)) days=\(self.contributionCalendar?.days.count ?? 0) " +
102                "totalEvents=\(self.contributionStats?.totalEvents ?? 0) error=\(contributionsError ?? "none")"
103            )
104        } catch {
105            contributionsError = error.userFacingMessage
106            debugLog(
107                "profile contributions failed actor=\(actor) endDate=\(resolvedEndDate.formatted(date: .abbreviated, time: .omitted)) " +
108                "error=\(error.localizedDescription) userFacing=\(contributionsError ?? "none")"
109            )
110        }
111    }
112
113    private static let repositoriesQuery = """
114    query userRepositories($owner: String!) {
115        user(username: $owner) {
116            repositories {
117                results {
118                    id
119                    rid
120                    name
121                    description
122                    visibility
123                    updated
124                    owner { canonicalName }
125                    HEAD { name target }
126                }
127                cursor
128            }
129        }
130    }
131    """
132
133    private static let trackersQuery = """
134    query userTrackers($owner: String!) {
135        user(username: $owner) {
136            trackers {
137                results {
138                    id
139                    rid
140                    name
141                    description
142                    visibility
143                    updated
144                    owner { canonicalName }
145                }
146                cursor
147            }
148        }
149    }
150    """
151
152    private struct UserRepositoriesResponse: Decodable, Sendable {
153        let user: UserRepositoriesContainer
154    }
155
156    private struct UserRepositoriesContainer: Decodable, Sendable {
157        let repositories: RepositoriesPage
158    }
159
160    private struct RepositoriesPage: Decodable, Sendable {
161        let results: [RepositoryPayload]
162        let cursor: String?
163    }
164
165    private struct RepositoryPayload: Decodable, Sendable {
166        let id: Int
167        let rid: String
168        let name: String
169        let description: String?
170        let visibility: Visibility
171        let updated: Date
172        let owner: Entity
173        let head: Reference?
174
175        enum CodingKeys: String, CodingKey {
176            case id, rid, name, description, visibility, updated, owner
177            case head = "HEAD"
178        }
179
180        func repositorySummary(service: SRHTService) -> RepositorySummary {
181            RepositorySummary(
182                id: id,
183                rid: rid,
184                service: service,
185                name: name,
186                description: description,
187                visibility: visibility,
188                updated: updated,
189                owner: owner,
190                head: head
191            )
192        }
193    }
194
195    private struct UserTrackersResponse: Decodable, Sendable {
196        let user: UserTrackersContainer
197    }
198
199    private struct UserTrackersContainer: Decodable, Sendable {
200        let trackers: TrackersPage
201    }
202
203    private struct TrackersPage: Decodable, Sendable {
204        let results: [TrackerSummary]
205        let cursor: String?
206    }
207
208    private enum ContributionDisplayState {
209        case populated
210        case indexing
211        case empty
212        case unavailable
213    }
214
215    private var contributionDisplayState: ContributionDisplayState {
216        if let contributionStats, contributionStats.totalEvents > 0 {
217            return .populated
218        }
219
220        if let contributionCalendar, !contributionCalendar.isEmpty {
221            return .populated
222        }
223
224        let indexingState = contributionStats?.indexingState ?? contributionCalendar?.indexingState
225        switch indexingState {
226        case .pending:
227            return .indexing
228        case .error:
229            return .unavailable
230        case .indexed, nil:
231            return .empty
232        }
233    }
234
235    private func debugLog(_ message: String) {
236#if DEBUG
237        print("[UserProfileViewModel] \(message)")
238#endif
239    }
240}