Commit 94939d8252
Unsigned
Layout: unified · split
Hutch/App/AppConfiguration.swift added +42
| @@ -0,0 +1,42 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | struct AppConfiguration: Sendable { | ||
| 4 | static let defaultHutchStatsBaseURL = URL(string: "https://hutch-stats.zerolabs.sh")! | ||
| 5 | static let hutchStatsBaseURLEnvironmentKey = "HUTCH_STATS_BASE_URL" | ||
| 6 | |||
| 7 | let hutchStatsBaseURL: URL | ||
| 8 | |||
| 9 | init( | ||
| 10 | userDefaults: UserDefaults = .standard, | ||
| 11 | processInfo: ProcessInfo = .processInfo, | ||
| 12 | environment: [String: String]? = nil | ||
| 13 | ) { | ||
| 14 | let resolvedEnvironment = environment ?? processInfo.environment | ||
| 15 | |||
| 16 | if | ||
| 17 | let rawValue = resolvedEnvironment[Self.hutchStatsBaseURLEnvironmentKey], | ||
| 18 | let url = Self.normalizedURL(from: rawValue) | ||
| 19 | { | ||
| 20 | self.hutchStatsBaseURL = url | ||
| 21 | } else if | ||
| 22 | let rawValue = userDefaults.string(forKey: AppStorageKeys.hutchStatsBaseURL), | ||
| 23 | let url = Self.normalizedURL(from: rawValue) | ||
| 24 | { | ||
| 25 | self.hutchStatsBaseURL = url | ||
| 26 | } else { | ||
| 27 | self.hutchStatsBaseURL = Self.defaultHutchStatsBaseURL | ||
| 28 | } | ||
| 29 | } | ||
| 30 | |||
| 31 | private static func normalizedURL(from rawValue: String) -> URL? { | ||
| 32 | guard var components = URLComponents(string: rawValue.trimmingCharacters(in: .whitespacesAndNewlines)) else { | ||
| 33 | return nil | ||
| 34 | } | ||
| 35 | |||
| 36 | if components.path.isEmpty { | ||
| 37 | components.path = "/" | ||
| 38 | } | ||
| 39 | |||
| 40 | return components.url | ||
| 41 | } | ||
| 42 | } | ||
Hutch/App/AppState.swift +7
| @@ -56,6 +56,7 @@ final class AppState { | |||
| 56 | // MARK: - Networking | 56 | // MARK: - Networking |
| 57 | 57 | ||
| 58 | let client: SRHTClient | 58 | let client: SRHTClient |
| 59 | let configuration: AppConfiguration | ||
| 59 | 60 | ||
| 60 | // MARK: - Deep link pending navigation | 61 | // MARK: - Deep link pending navigation |
| 61 | 62 | ||
| @@ -67,6 +68,7 @@ final class AppState { | |||
| 67 | // MARK: - Init | 68 | // MARK: - Init |
| 68 | 69 | ||
| 69 | init() { | 70 | init() { |
| 71 | self.configuration = AppConfiguration() | ||
| 70 | let token = KeychainHelper.loadToken() | 72 | let token = KeychainHelper.loadToken() |
| 71 | self.client = SRHTClient(token: token) | 73 | self.client = SRHTClient(token: token) |
| 72 | } | 74 | } |
| @@ -107,11 +109,13 @@ final class AppState { | |||
| 107 | accounts = storedAccounts | 109 | accounts = storedAccounts |
| 108 | activeAccountID = target.id | 110 | activeAccountID = target.id |
| 109 | currentUser = user | 111 | currentUser = user |
| 112 | ContributionWidgetContextStore.saveActor(user.canonicalName) | ||
| 110 | authPhase = .authenticated | 113 | authPhase = .authenticated |
| 111 | await refreshNeedsAttentionSnapshot() | 114 | await refreshNeedsAttentionSnapshot() |
| 112 | } catch { | 115 | } catch { |
| 113 | client.setToken(nil) | 116 | client.setToken(nil) |
| 114 | currentUser = nil | 117 | currentUser = nil |
| 118 | ContributionWidgetContextStore.clear() | ||
| 115 | authPhase = .unauthenticated | 119 | authPhase = .unauthenticated |
| 116 | NeedsAttentionSnapshotStore.clear() | 120 | NeedsAttentionSnapshotStore.clear() |
| 117 | } | 121 | } |
| @@ -131,6 +135,7 @@ final class AppState { | |||
| 131 | UserDefaults.standard.set(entry.id, forKey: AppStorageKeys.activeAccountID) | 135 | UserDefaults.standard.set(entry.id, forKey: AppStorageKeys.activeAccountID) |
| 132 | try KeychainHelper.saveAccounts(accounts) | 136 | try KeychainHelper.saveAccounts(accounts) |
| 133 | currentUser = user | 137 | currentUser = user |
| 138 | ContributionWidgetContextStore.saveActor(user.canonicalName) | ||
| 134 | authPhase = .authenticated | 139 | authPhase = .authenticated |
| 135 | await refreshNeedsAttentionSnapshot() | 140 | await refreshNeedsAttentionSnapshot() |
| 136 | } catch { | 141 | } catch { |
| @@ -168,6 +173,7 @@ final class AppState { | |||
| 168 | 173 | ||
| 169 | let user = try await fetchMe() | 174 | let user = try await fetchMe() |
| 170 | currentUser = user | 175 | currentUser = user |
| 176 | ContributionWidgetContextStore.saveActor(user.canonicalName) | ||
| 171 | authPhase = .authenticated | 177 | authPhase = .authenticated |
| 172 | await refreshNeedsAttentionSnapshot() | 178 | await refreshNeedsAttentionSnapshot() |
| 173 | } | 179 | } |
| @@ -355,6 +361,7 @@ final class AppState { | |||
| 355 | activeAccountID = "" | 361 | activeAccountID = "" |
| 356 | UserDefaults.standard.removeObject(forKey: AppStorageKeys.activeAccountID) | 362 | UserDefaults.standard.removeObject(forKey: AppStorageKeys.activeAccountID) |
| 357 | currentUser = nil | 363 | currentUser = nil |
| 364 | ContributionWidgetContextStore.clear() | ||
| 358 | pendingDeepLink = nil | 365 | pendingDeepLink = nil |
| 359 | pendingTabNavigation = nil | 366 | pendingTabNavigation = nil |
| 360 | deepLinkError = nil | 367 | deepLinkError = nil |
Hutch/App/AppStorageKeys.swift +2
| @@ -1,7 +1,9 @@ | |||
| 1 | // Shared UserDefaults key constants | 1 | // Shared UserDefaults key constants |
| 2 | enum AppStorageKeys { | 2 | enum AppStorageKeys { |
| 3 | static let swipeActionsEnabled = "swipeActionsEnabled" | 3 | static let swipeActionsEnabled = "swipeActionsEnabled" |
| 4 | static let contributionGraphsEnabled = "contributionGraphsEnabled" | ||
| 4 | static let activeAccountID = "activeAccountID" | 5 | static let activeAccountID = "activeAccountID" |
| 5 | static let wrapRepositoryFileLines = "wrapRepositoryFileLines" | 6 | static let wrapRepositoryFileLines = "wrapRepositoryFileLines" |
| 6 | static let lookupHistory = "lookupHistory" | 7 | static let lookupHistory = "lookupHistory" |
| 8 | static let hutchStatsBaseURL = "hutchStatsBaseURL" | ||
| 7 | } | 9 | } |
Hutch/App/RootView.swift +3
| @@ -266,6 +266,7 @@ enum MoreRoute: Hashable { | |||
| 266 | case lookup | 266 | case lookup |
| 267 | case lists | 267 | case lists |
| 268 | case pastes | 268 | case pastes |
| 269 | case profile | ||
| 269 | case settings | 270 | case settings |
| 270 | case mailingList(InboxMailingListReference) | 271 | case mailingList(InboxMailingListReference) |
| 271 | case thread(InboxThreadSummary) | 272 | case thread(InboxThreadSummary) |
| @@ -284,6 +285,8 @@ private struct MoreNavigationRoot: View { | |||
| 284 | MailingListListView() | 285 | MailingListListView() |
| 285 | case .pastes: | 286 | case .pastes: |
| 286 | PasteListView() | 287 | PasteListView() |
| 288 | case .profile: | ||
| 289 | ProfileView() | ||
| 287 | case .settings: | 290 | case .settings: |
| 288 | SettingsView() | 291 | SettingsView() |
| 289 | case .mailingList(let mailingList): | 292 | case .mailingList(let mailingList): |
Hutch/Models/ContributionCalendar.swift added +340
| @@ -0,0 +1,340 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | struct ContributionCalendarResponse: Decodable, Sendable, Hashable { | ||
| 4 | let actor: String | ||
| 5 | let from: Date | ||
| 6 | let to: Date | ||
| 7 | let isIndexed: Bool | ||
| 8 | let lastPolledAt: Date? | ||
| 9 | let indexingState: ContributionIndexingState | ||
| 10 | let days: [ContributionDay] | ||
| 11 | |||
| 12 | enum CodingKeys: String, CodingKey { | ||
| 13 | case actor | ||
| 14 | case from | ||
| 15 | case to | ||
| 16 | case isIndexed = "is_indexed" | ||
| 17 | case lastPolledAt = "last_polled_at" | ||
| 18 | case indexingState = "indexing_state" | ||
| 19 | case days | ||
| 20 | } | ||
| 21 | |||
| 22 | init( | ||
| 23 | actor: String, | ||
| 24 | from: Date, | ||
| 25 | to: Date, | ||
| 26 | isIndexed: Bool, | ||
| 27 | lastPolledAt: Date?, | ||
| 28 | indexingState: ContributionIndexingState, | ||
| 29 | days: [ContributionDay] | ||
| 30 | ) { | ||
| 31 | self.actor = actor | ||
| 32 | self.from = from | ||
| 33 | self.to = to | ||
| 34 | self.isIndexed = isIndexed | ||
| 35 | self.lastPolledAt = lastPolledAt | ||
| 36 | self.indexingState = indexingState | ||
| 37 | self.days = days.sorted { $0.date < $1.date } | ||
| 38 | } | ||
| 39 | |||
| 40 | init(from decoder: any Decoder) throws { | ||
| 41 | let container = try decoder.container(keyedBy: CodingKeys.self) | ||
| 42 | actor = try container.decode(String.self, forKey: .actor) | ||
| 43 | from = try ContributionDateParser.decodeDateString(from: container, forKey: .from) | ||
| 44 | to = try ContributionDateParser.decodeDateString(from: container, forKey: .to) | ||
| 45 | isIndexed = try container.decodeIfPresent(Bool.self, forKey: .isIndexed) ?? false | ||
| 46 | lastPolledAt = try ContributionDateParser.decodeOptionalTimestamp(from: container, forKey: .lastPolledAt) | ||
| 47 | indexingState = try container.decodeIfPresent(ContributionIndexingState.self, forKey: .indexingState) ?? .indexed | ||
| 48 | days = try container.decode([ContributionDay].self, forKey: .days).sorted { $0.date < $1.date } | ||
| 49 | } | ||
| 50 | |||
| 51 | var totalCount: Int { | ||
| 52 | days.reduce(into: 0) { partialResult, day in | ||
| 53 | partialResult += day.count | ||
| 54 | } | ||
| 55 | } | ||
| 56 | |||
| 57 | var isEmpty: Bool { | ||
| 58 | totalCount == 0 | ||
| 59 | } | ||
| 60 | } | ||
| 61 | |||
| 62 | struct ContributionDay: Decodable, Sendable, Hashable, Identifiable { | ||
| 63 | var id: Date { date } | ||
| 64 | |||
| 65 | let date: Date | ||
| 66 | let count: Int | ||
| 67 | let score: Double | ||
| 68 | |||
| 69 | enum CodingKeys: String, CodingKey { | ||
| 70 | case date | ||
| 71 | case count | ||
| 72 | case score | ||
| 73 | } | ||
| 74 | |||
| 75 | init(date: Date, count: Int, score: Double) { | ||
| 76 | self.date = date | ||
| 77 | self.count = count | ||
| 78 | self.score = score | ||
| 79 | } | ||
| 80 | |||
| 81 | init(from decoder: any Decoder) throws { | ||
| 82 | let container = try decoder.container(keyedBy: CodingKeys.self) | ||
| 83 | date = try ContributionDateParser.decodeDateString(from: container, forKey: .date) | ||
| 84 | count = try container.decode(Int.self, forKey: .count) | ||
| 85 | score = try container.decode(Double.self, forKey: .score) | ||
| 86 | } | ||
| 87 | |||
| 88 | var intensity: ContributionIntensity { | ||
| 89 | ContributionIntensity(count: count) | ||
| 90 | } | ||
| 91 | } | ||
| 92 | |||
| 93 | struct ContributionStatsResponse: Decodable, Sendable, Hashable { | ||
| 94 | let actor: String | ||
| 95 | let from: Date | ||
| 96 | let to: Date | ||
| 97 | let isIndexed: Bool | ||
| 98 | let lastPolledAt: Date? | ||
| 99 | let indexingState: ContributionIndexingState | ||
| 100 | let totalEvents: Int | ||
| 101 | let totalScore: Double | ||
| 102 | let activeDays: Int | ||
| 103 | let longestStreak: Int | ||
| 104 | let currentStreak: Int | ||
| 105 | |||
| 106 | enum CodingKeys: String, CodingKey { | ||
| 107 | case actor | ||
| 108 | case from | ||
| 109 | case to | ||
| 110 | case isIndexed = "is_indexed" | ||
| 111 | case lastPolledAt = "last_polled_at" | ||
| 112 | case indexingState = "indexing_state" | ||
| 113 | case totalEvents = "total_events" | ||
| 114 | case totalScore = "total_score" | ||
| 115 | case activeDays = "active_days" | ||
| 116 | case longestStreak = "longest_streak" | ||
| 117 | case currentStreak = "current_streak" | ||
| 118 | } | ||
| 119 | |||
| 120 | init( | ||
| 121 | actor: String, | ||
| 122 | from: Date, | ||
| 123 | to: Date, | ||
| 124 | isIndexed: Bool, | ||
| 125 | lastPolledAt: Date?, | ||
| 126 | indexingState: ContributionIndexingState, | ||
| 127 | totalEvents: Int, | ||
| 128 | totalScore: Double, | ||
| 129 | activeDays: Int, | ||
| 130 | longestStreak: Int, | ||
| 131 | currentStreak: Int | ||
| 132 | ) { | ||
| 133 | self.actor = actor | ||
| 134 | self.from = from | ||
| 135 | self.to = to | ||
| 136 | self.isIndexed = isIndexed | ||
| 137 | self.lastPolledAt = lastPolledAt | ||
| 138 | self.indexingState = indexingState | ||
| 139 | self.totalEvents = totalEvents | ||
| 140 | self.totalScore = totalScore | ||
| 141 | self.activeDays = activeDays | ||
| 142 | self.longestStreak = longestStreak | ||
| 143 | self.currentStreak = currentStreak | ||
| 144 | } | ||
| 145 | |||
| 146 | init(from decoder: any Decoder) throws { | ||
| 147 | let container = try decoder.container(keyedBy: CodingKeys.self) | ||
| 148 | actor = try container.decode(String.self, forKey: .actor) | ||
| 149 | from = try ContributionDateParser.decodeDateString(from: container, forKey: .from) | ||
| 150 | to = try ContributionDateParser.decodeDateString(from: container, forKey: .to) | ||
| 151 | isIndexed = try container.decodeIfPresent(Bool.self, forKey: .isIndexed) ?? false | ||
| 152 | lastPolledAt = try ContributionDateParser.decodeOptionalTimestamp(from: container, forKey: .lastPolledAt) | ||
| 153 | indexingState = try container.decodeIfPresent(ContributionIndexingState.self, forKey: .indexingState) ?? .indexed | ||
| 154 | totalEvents = try container.decode(Int.self, forKey: .totalEvents) | ||
| 155 | totalScore = try container.decode(Double.self, forKey: .totalScore) | ||
| 156 | activeDays = try container.decode(Int.self, forKey: .activeDays) | ||
| 157 | longestStreak = try container.decode(Int.self, forKey: .longestStreak) | ||
| 158 | currentStreak = try container.decode(Int.self, forKey: .currentStreak) | ||
| 159 | } | ||
| 160 | } | ||
| 161 | |||
| 162 | enum ContributionIndexingState: String, Codable, Sendable, Hashable { | ||
| 163 | case pending | ||
| 164 | case indexed | ||
| 165 | case error | ||
| 166 | } | ||
| 167 | |||
| 168 | enum ContributionIntensity: Int, Sendable, CaseIterable { | ||
| 169 | case empty = 0 | ||
| 170 | case level1 = 1 | ||
| 171 | case level2 = 2 | ||
| 172 | case level3 = 3 | ||
| 173 | case level4 = 4 | ||
| 174 | |||
| 175 | init(count: Int) { | ||
| 176 | switch count { | ||
| 177 | case ..<1: | ||
| 178 | self = .empty | ||
| 179 | case 1: | ||
| 180 | self = .level1 | ||
| 181 | case 2...3: | ||
| 182 | self = .level2 | ||
| 183 | case 4...6: | ||
| 184 | self = .level3 | ||
| 185 | default: | ||
| 186 | self = .level4 | ||
| 187 | } | ||
| 188 | } | ||
| 189 | } | ||
| 190 | |||
| 191 | struct ContributionWeek: Sendable, Hashable { | ||
| 192 | let startDate: Date | ||
| 193 | let days: [ContributionDay] | ||
| 194 | } | ||
| 195 | |||
| 196 | enum ContributionCalendarLayout { | ||
| 197 | static func weekColumns( | ||
| 198 | from days: [ContributionDay], | ||
| 199 | calendar: Calendar = .contributionCalendar | ||
| 200 | ) -> [ContributionWeek] { | ||
| 201 | let groupedDays = Dictionary(grouping: days) { day in | ||
| 202 | calendar.startOfWeek(for: day.date) | ||
| 203 | } | ||
| 204 | |||
| 205 | return groupedDays.keys.sorted().map { weekStart in | ||
| 206 | ContributionWeek( | ||
| 207 | startDate: weekStart, | ||
| 208 | days: groupedDays[weekStart, default: []].sorted { $0.date < $1.date } | ||
| 209 | ) | ||
| 210 | } | ||
| 211 | } | ||
| 212 | |||
| 213 | static func recentWeeks( | ||
| 214 | from days: [ContributionDay], | ||
| 215 | count: Int, | ||
| 216 | calendar: Calendar = .contributionCalendar | ||
| 217 | ) -> [ContributionWeek] { | ||
| 218 | Array(weekColumns(from: days, calendar: calendar).suffix(count)) | ||
| 219 | } | ||
| 220 | } | ||
| 221 | |||
| 222 | enum ContributionDateParser { | ||
| 223 | static func parse(_ rawValue: String) -> Date? { | ||
| 224 | let parts = rawValue.split(separator: "-", omittingEmptySubsequences: false) | ||
| 225 | guard | ||
| 226 | parts.count == 3, | ||
| 227 | let year = Int(parts[0]), | ||
| 228 | let month = Int(parts[1]), | ||
| 229 | let day = Int(parts[2]) | ||
| 230 | else { | ||
| 231 | return nil | ||
| 232 | } | ||
| 233 | |||
| 234 | var components = DateComponents() | ||
| 235 | components.calendar = .contributionCalendar | ||
| 236 | components.timeZone = TimeZone(secondsFromGMT: 0) | ||
| 237 | components.year = year | ||
| 238 | components.month = month | ||
| 239 | components.day = day | ||
| 240 | |||
| 241 | guard let date = components.date else { | ||
| 242 | return nil | ||
| 243 | } | ||
| 244 | |||
| 245 | let resolvedComponents = Calendar.contributionCalendar.dateComponents([.year, .month, .day], from: date) | ||
| 246 | guard | ||
| 247 | resolvedComponents.year == year, | ||
| 248 | resolvedComponents.month == month, | ||
| 249 | resolvedComponents.day == day | ||
| 250 | else { | ||
| 251 | return nil | ||
| 252 | } | ||
| 253 | |||
| 254 | return date | ||
| 255 | } | ||
| 256 | |||
| 257 | static func decodeDateString<Key: CodingKey>( | ||
| 258 | from container: KeyedDecodingContainer<Key>, | ||
| 259 | forKey key: Key | ||
| 260 | ) throws -> Date { | ||
| 261 | let rawValue = try container.decode(String.self, forKey: key) | ||
| 262 | |||
| 263 | guard let date = parse(rawValue) else { | ||
| 264 | throw DecodingError.dataCorruptedError( | ||
| 265 | forKey: key, | ||
| 266 | in: container, | ||
| 267 | debugDescription: "Invalid contribution date: \(rawValue)" | ||
| 268 | ) | ||
| 269 | } | ||
| 270 | |||
| 271 | return date | ||
| 272 | } | ||
| 273 | |||
| 274 | static func parseTimestamp(_ rawValue: String) -> Date? { | ||
| 275 | let formatter = ISO8601DateFormatter() | ||
| 276 | formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] | ||
| 277 | formatter.timeZone = TimeZone(secondsFromGMT: 0) | ||
| 278 | |||
| 279 | if let date = formatter.date(from: rawValue) { | ||
| 280 | return date | ||
| 281 | } | ||
| 282 | |||
| 283 | formatter.formatOptions = [.withInternetDateTime] | ||
| 284 | if let date = formatter.date(from: rawValue) { | ||
| 285 | return date | ||
| 286 | } | ||
| 287 | |||
| 288 | let fallbackFormats = [ | ||
| 289 | "yyyy-MM-dd'T'HH:mm:ss.SSSSSS", | ||
| 290 | "yyyy-MM-dd'T'HH:mm:ss.SSS", | ||
| 291 | "yyyy-MM-dd'T'HH:mm:ss" | ||
| 292 | ] | ||
| 293 | |||
| 294 | let dateFormatter = DateFormatter() | ||
| 295 | dateFormatter.calendar = .contributionCalendar | ||
| 296 | dateFormatter.locale = Locale(identifier: "en_US_POSIX") | ||
| 297 | dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) | ||
| 298 | |||
| 299 | for format in fallbackFormats { | ||
| 300 | dateFormatter.dateFormat = format | ||
| 301 | if let date = dateFormatter.date(from: rawValue) { | ||
| 302 | return date | ||
| 303 | } | ||
| 304 | } | ||
| 305 | |||
| 306 | return nil | ||
| 307 | } | ||
| 308 | |||
| 309 | static func decodeOptionalTimestamp<Key: CodingKey>( | ||
| 310 | from container: KeyedDecodingContainer<Key>, | ||
| 311 | forKey key: Key | ||
| 312 | ) throws -> Date? { | ||
| 313 | guard let rawValue = try container.decodeIfPresent(String.self, forKey: key) else { | ||
| 314 | return nil | ||
| 315 | } | ||
| 316 | |||
| 317 | guard let date = parseTimestamp(rawValue) else { | ||
| 318 | throw DecodingError.dataCorruptedError( | ||
| 319 | forKey: key, | ||
| 320 | in: container, | ||
| 321 | debugDescription: "Invalid contribution timestamp: \(rawValue)" | ||
| 322 | ) | ||
| 323 | } | ||
| 324 | |||
| 325 | return date | ||
| 326 | } | ||
| 327 | } | ||
| 328 | |||
| 329 | extension Calendar { | ||
| 330 | static var contributionCalendar: Calendar { | ||
| 331 | var calendar = Calendar(identifier: .gregorian) | ||
| 332 | calendar.firstWeekday = 1 | ||
| 333 | calendar.timeZone = TimeZone(secondsFromGMT: 0)! | ||
| 334 | return calendar | ||
| 335 | } | ||
| 336 | |||
| 337 | func startOfWeek(for date: Date) -> Date { | ||
| 338 | dateInterval(of: .weekOfYear, for: date)?.start ?? startOfDay(for: date) | ||
| 339 | } | ||
| 340 | } | ||
Hutch/Networking/HutchStatsService.swift added +121
| @@ -0,0 +1,121 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | protocol ContributionCalendarServing: Sendable { | ||
| 4 | func fetchContributionCalendar(actor: String, endingOn endDate: Date) async throws -> ContributionCalendarResponse | ||
| 5 | func fetchContributionStats(actor: String, endingOn endDate: Date) async throws -> ContributionStatsResponse | ||
| 6 | } | ||
| 7 | |||
| 8 | struct HutchStatsService: ContributionCalendarServing { | ||
| 9 | private let session: URLSession | ||
| 10 | private let decoder: JSONDecoder | ||
| 11 | private let baseURL: URL | ||
| 12 | |||
| 13 | init( | ||
| 14 | session: URLSession = .shared, | ||
| 15 | configuration: AppConfiguration | ||
| 16 | ) { | ||
| 17 | self.session = session | ||
| 18 | self.baseURL = configuration.hutchStatsBaseURL | ||
| 19 | self.decoder = JSONDecoder() | ||
| 20 | } | ||
| 21 | |||
| 22 | func fetchContributionCalendar(actor: String, endingOn endDate: Date) async throws -> ContributionCalendarResponse { | ||
| 23 | debugLog("calendar request actor=\(actor) endDate=\(Self.rangeFormatter.string(from: endDate))") | ||
| 24 | return try await fetch( | ||
| 25 | path: "api/contributions/\(actor)", | ||
| 26 | queryItems: trailingYearQueryItems(endingOn: endDate), | ||
| 27 | responseType: ContributionCalendarResponse.self | ||
| 28 | ) | ||
| 29 | } | ||
| 30 | |||
| 31 | func fetchContributionStats(actor: String, endingOn endDate: Date) async throws -> ContributionStatsResponse { | ||
| 32 | debugLog("stats request actor=\(actor) endDate=\(Self.rangeFormatter.string(from: endDate))") | ||
| 33 | return try await fetch( | ||
| 34 | path: "api/contributions/\(actor)/stats", | ||
| 35 | queryItems: trailingYearQueryItems(endingOn: endDate), | ||
| 36 | responseType: ContributionStatsResponse.self | ||
| 37 | ) | ||
| 38 | } | ||
| 39 | |||
| 40 | private func fetch<Response: Decodable>( | ||
| 41 | path: String, | ||
| 42 | queryItems: [URLQueryItem], | ||
| 43 | responseType: Response.Type | ||
| 44 | ) async throws -> Response { | ||
| 45 | guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else { | ||
| 46 | throw URLError(.badURL) | ||
| 47 | } | ||
| 48 | |||
| 49 | components.path = normalizedPath(basePath: components.path, appendedPath: path) | ||
| 50 | components.queryItems = queryItems | ||
| 51 | |||
| 52 | guard let url = components.url else { | ||
| 53 | throw URLError(.badURL) | ||
| 54 | } | ||
| 55 | |||
| 56 | debugLog("request \(url.absoluteString)") | ||
| 57 | let (data, response): (Data, URLResponse) | ||
| 58 | do { | ||
| 59 | (data, response) = try await session.data(from: url) | ||
| 60 | } catch { | ||
| 61 | debugLog("network failure \(url.absoluteString) error=\(error.localizedDescription)") | ||
| 62 | throw SRHTError.networkError(error) | ||
| 63 | } | ||
| 64 | |||
| 65 | if let httpResponse = response as? HTTPURLResponse { | ||
| 66 | debugLog("response \(url.absoluteString) status=\(httpResponse.statusCode) bytes=\(data.count)") | ||
| 67 | if !(200...299).contains(httpResponse.statusCode) { | ||
| 68 | throw SRHTError.httpError(httpResponse.statusCode) | ||
| 69 | } | ||
| 70 | } | ||
| 71 | |||
| 72 | do { | ||
| 73 | return try decoder.decode(responseType, from: data) | ||
| 74 | } catch { | ||
| 75 | let preview = String(decoding: data.prefix(300), as: UTF8.self) | ||
| 76 | debugLog("decode failure \(url.absoluteString) error=\(error.localizedDescription) body=\(preview)") | ||
| 77 | throw SRHTError.decodingError(error) | ||
| 78 | } | ||
| 79 | } | ||
| 80 | |||
| 81 | private func normalizedPath(basePath: String, appendedPath: String) -> String { | ||
| 82 | let trimmedBase = basePath.trimmingCharacters(in: CharacterSet(charactersIn: "/")) | ||
| 83 | let trimmedAppendix = appendedPath.trimmingCharacters(in: CharacterSet(charactersIn: "/")) | ||
| 84 | |||
| 85 | let pathComponents = [trimmedBase, trimmedAppendix].filter { !$0.isEmpty } | ||
| 86 | return "/" + pathComponents.joined(separator: "/") | ||
| 87 | } | ||
| 88 | |||
| 89 | func trailingRange(endingOn endDate: Date) -> ClosedRange<Date> { | ||
| 90 | let normalizedEndDate = Calendar.contributionCalendar.startOfDay(for: endDate) | ||
| 91 | let oneYearBack = Calendar.contributionCalendar.date(byAdding: .year, value: -1, to: normalizedEndDate) ?? normalizedEndDate | ||
| 92 | let normalizedStartDate = Calendar.contributionCalendar.date(byAdding: .day, value: 1, to: oneYearBack) ?? oneYearBack | ||
| 93 | return normalizedStartDate...normalizedEndDate | ||
| 94 | } | ||
| 95 | |||
| 96 | private func trailingYearQueryItems(endingOn endDate: Date) -> [URLQueryItem] { | ||
| 97 | let range = trailingRange(endingOn: endDate) | ||
| 98 | let start = Self.rangeFormatter.string(from: range.lowerBound) | ||
| 99 | let end = Self.rangeFormatter.string(from: range.upperBound) | ||
| 100 | |||
| 101 | return [ | ||
| 102 | URLQueryItem(name: "from", value: start), | ||
| 103 | URLQueryItem(name: "to", value: end) | ||
| 104 | ] | ||
| 105 | } | ||
| 106 | |||
| 107 | private static let rangeFormatter: DateFormatter = { | ||
| 108 | let formatter = DateFormatter() | ||
| 109 | formatter.calendar = .contributionCalendar | ||
| 110 | formatter.locale = Locale(identifier: "en_US_POSIX") | ||
| 111 | formatter.timeZone = TimeZone(secondsFromGMT: 0) | ||
| 112 | formatter.dateFormat = "yyyy-MM-dd" | ||
| 113 | return formatter | ||
| 114 | }() | ||
| 115 | |||
| 116 | private func debugLog(_ message: String) { | ||
| 117 | #if DEBUG | ||
| 118 | print("[HutchStatsService] \(message)") | ||
| 119 | #endif | ||
| 120 | } | ||
| 121 | } | ||
Hutch/Views/Lookup/ContributionCalendarView.swift added +224
| @@ -0,0 +1,224 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | struct ContributionProfileCard: View { | ||
| 4 | let actor: String | ||
| 5 | let weeks: [ContributionWeek] | ||
| 6 | let stats: ContributionStatsResponse? | ||
| 7 | let isLoading: Bool | ||
| 8 | let error: String? | ||
| 9 | var isIndexedButEmpty = false | ||
| 10 | |||
| 11 | var body: some View { | ||
| 12 | VStack(alignment: .leading, spacing: 12) { | ||
| 13 | HStack { | ||
| 14 | VStack(alignment: .leading, spacing: 2) { | ||
| 15 | Text("Contribution Activity") | ||
| 16 | .font(.headline) | ||
| 17 | Text(actor) | ||
| 18 | .font(.caption) | ||
| 19 | .foregroundStyle(.secondary) | ||
| 20 | } | ||
| 21 | |||
| 22 | Spacer() | ||
| 23 | } | ||
| 24 | |||
| 25 | if isLoading && weeks.isEmpty { | ||
| 26 | HStack(spacing: 10) { | ||
| 27 | ProgressView() | ||
| 28 | .controlSize(.small) | ||
| 29 | Text("Loading activity…") | ||
| 30 | .font(.subheadline) | ||
| 31 | .foregroundStyle(.secondary) | ||
| 32 | } | ||
| 33 | } else if let error, weeks.isEmpty { | ||
| 34 | Label(error, systemImage: "exclamationmark.triangle") | ||
| 35 | .font(.subheadline) | ||
| 36 | .foregroundStyle(.secondary) | ||
| 37 | } else if isIndexedButEmpty || weeks.isEmpty || stats?.totalEvents == 0 { | ||
| 38 | Text("Contribution activity may still be indexing.") | ||
| 39 | .font(.subheadline) | ||
| 40 | .foregroundStyle(.secondary) | ||
| 41 | } else { | ||
| 42 | ContributionScrollableHeatmap( | ||
| 43 | weeks: weeks, | ||
| 44 | squareSize: 10, | ||
| 45 | spacing: 3, | ||
| 46 | showsWeekdayLabels: false, | ||
| 47 | showsMonthLabels: false | ||
| 48 | ) | ||
| 49 | |||
| 50 | ContributionLegendView() | ||
| 51 | |||
| 52 | if let stats { | ||
| 53 | HStack(spacing: 14) { | ||
| 54 | ContributionMetricChip(value: "\(stats.totalEvents)", title: "Events") | ||
| 55 | ContributionMetricChip(value: "\(stats.activeDays)", title: "Days") | ||
| 56 | ContributionMetricChip(value: "\(stats.longestStreak)", title: "Streak") | ||
| 57 | } | ||
| 58 | } | ||
| 59 | } | ||
| 60 | } | ||
| 61 | .padding(.vertical, 4) | ||
| 62 | } | ||
| 63 | } | ||
| 64 | |||
| 65 | private struct ContributionScrollableHeatmap: View { | ||
| 66 | let weeks: [ContributionWeek] | ||
| 67 | let squareSize: CGFloat | ||
| 68 | let spacing: CGFloat | ||
| 69 | let showsWeekdayLabels: Bool | ||
| 70 | let showsMonthLabels: Bool | ||
| 71 | |||
| 72 | @State private var didApplyInitialScroll = false | ||
| 73 | |||
| 74 | private let weekdayLabels = ["S", "M", "T", "W", "T", "F", "S"] | ||
| 75 | |||
| 76 | var body: some View { | ||
| 77 | ScrollViewReader { proxy in | ||
| 78 | ScrollView(.horizontal, showsIndicators: false) { | ||
| 79 | VStack(alignment: .leading, spacing: 6) { | ||
| 80 | if showsMonthLabels { | ||
| 81 | HStack(spacing: spacing) { | ||
| 82 | if showsWeekdayLabels { | ||
| 83 | Color.clear | ||
| 84 | .frame(width: 10) | ||
| 85 | } | ||
| 86 | |||
| 87 | ForEach(Array(weeks.enumerated()), id: \.element.startDate) { index, week in | ||
| 88 | Text(monthLabel(for: week, index: index)) | ||
| 89 | .font(.caption2) | ||
| 90 | .foregroundStyle(.secondary) | ||
| 91 | .lineLimit(1) | ||
| 92 | .fixedSize(horizontal: true, vertical: false) | ||
| 93 | .frame(width: squareSize, alignment: .leading) | ||
| 94 | } | ||
| 95 | } | ||
| 96 | } | ||
| 97 | |||
| 98 | HStack(alignment: .top, spacing: 8) { | ||
| 99 | if showsWeekdayLabels { | ||
| 100 | VStack(alignment: .trailing, spacing: spacing) { | ||
| 101 | ForEach(Array(weekdayLabels.enumerated()), id: \.offset) { _, label in | ||
| 102 | Text(label) | ||
| 103 | .font(.caption2) | ||
| 104 | .foregroundStyle(.tertiary) | ||
| 105 | .frame(width: 10, height: squareSize, alignment: .center) | ||
| 106 | } | ||
| 107 | } | ||
| 108 | } | ||
| 109 | |||
| 110 | HStack(alignment: .top, spacing: spacing) { | ||
| 111 | ForEach(weeks, id: \.startDate) { week in | ||
| 112 | VStack(spacing: spacing) { | ||
| 113 | ForEach(week.days) { day in | ||
| 114 | ContributionDayCell(day: day, size: squareSize) | ||
| 115 | } | ||
| 116 | } | ||
| 117 | .id(week.startDate) | ||
| 118 | } | ||
| 119 | } | ||
| 120 | } | ||
| 121 | } | ||
| 122 | .padding(.vertical, 2) | ||
| 123 | } | ||
| 124 | .onAppear { | ||
| 125 | guard !didApplyInitialScroll, let lastWeek = weeks.last?.startDate else { return } | ||
| 126 | didApplyInitialScroll = true | ||
| 127 | |||
| 128 | DispatchQueue.main.async { | ||
| 129 | proxy.scrollTo(lastWeek, anchor: .trailing) | ||
| 130 | } | ||
| 131 | } | ||
| 132 | } | ||
| 133 | .accessibilityElement(children: .contain) | ||
| 134 | } | ||
| 135 | |||
| 136 | private func monthLabel(for week: ContributionWeek, index: Int) -> String { | ||
| 137 | let month = week.startDate.formatted(.dateTime.month(.abbreviated)) | ||
| 138 | if index == 0 { | ||
| 139 | return month | ||
| 140 | } | ||
| 141 | |||
| 142 | let previousMonth = weeks[index - 1].startDate.formatted(.dateTime.month(.abbreviated)) | ||
| 143 | return previousMonth == month ? "" : month | ||
| 144 | } | ||
| 145 | } | ||
| 146 | |||
| 147 | private struct ContributionDayCell: View { | ||
| 148 | let day: ContributionDay | ||
| 149 | let size: CGFloat | ||
| 150 | |||
| 151 | var body: some View { | ||
| 152 | RoundedRectangle(cornerRadius: 3, style: .continuous) | ||
| 153 | .fill(day.intensity.color) | ||
| 154 | .frame(width: size, height: size) | ||
| 155 | .overlay { | ||
| 156 | RoundedRectangle(cornerRadius: 3, style: .continuous) | ||
| 157 | .stroke(Color.primary.opacity(day.intensity == .empty ? 0.08 : 0), lineWidth: 0.5) | ||
| 158 | } | ||
| 159 | .accessibilityLabel(day.accessibilityLabel) | ||
| 160 | } | ||
| 161 | } | ||
| 162 | |||
| 163 | |||
| 164 | private struct ContributionMetricChip: View { | ||
| 165 | let value: String | ||
| 166 | let title: String | ||
| 167 | |||
| 168 | var body: some View { | ||
| 169 | VStack(alignment: .leading, spacing: 2) { | ||
| 170 | Text(value) | ||
| 171 | .font(.headline) | ||
| 172 | Text(title) | ||
| 173 | .font(.caption) | ||
| 174 | .foregroundStyle(.secondary) | ||
| 175 | } | ||
| 176 | .frame(maxWidth: .infinity, alignment: .leading) | ||
| 177 | .padding(10) | ||
| 178 | .background(Color.secondary.opacity(0.08), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) | ||
| 179 | } | ||
| 180 | } | ||
| 181 | |||
| 182 | private struct ContributionLegendView: View { | ||
| 183 | var body: some View { | ||
| 184 | HStack(spacing: 8) { | ||
| 185 | Text("Less") | ||
| 186 | .font(.caption) | ||
| 187 | .foregroundStyle(.secondary) | ||
| 188 | |||
| 189 | ForEach(ContributionIntensity.allCases, id: \.rawValue) { intensity in | ||
| 190 | RoundedRectangle(cornerRadius: 3, style: .continuous) | ||
| 191 | .fill(intensity.color) | ||
| 192 | .frame(width: 12, height: 12) | ||
| 193 | } | ||
| 194 | |||
| 195 | Text("More") | ||
| 196 | .font(.caption) | ||
| 197 | .foregroundStyle(.secondary) | ||
| 198 | } | ||
| 199 | } | ||
| 200 | } | ||
| 201 | |||
| 202 | private extension ContributionIntensity { | ||
| 203 | var color: Color { | ||
| 204 | switch self { | ||
| 205 | case .empty: | ||
| 206 | Color(uiColor: .secondarySystemFill) | ||
| 207 | case .level1: | ||
| 208 | Color(red: 0.82, green: 0.92, blue: 0.83) | ||
| 209 | case .level2: | ||
| 210 | Color(red: 0.58, green: 0.83, blue: 0.61) | ||
| 211 | case .level3: | ||
| 212 | Color(red: 0.25, green: 0.69, blue: 0.36) | ||
| 213 | case .level4: | ||
| 214 | Color(red: 0.12, green: 0.47, blue: 0.21) | ||
| 215 | } | ||
| 216 | } | ||
| 217 | } | ||
| 218 | |||
| 219 | private extension ContributionDay { | ||
| 220 | var accessibilityLabel: String { | ||
| 221 | let contributionLabel = count == 1 ? "1 contribution" : "\(count) contributions" | ||
| 222 | return "\(date.formatted(date: .long, time: .omitted)): \(contributionLabel), score \(score.formatted(.number.precision(.fractionLength(0...2))))" | ||
| 223 | } | ||
| 224 | } | ||
Hutch/Views/Lookup/ContributionCalendarViewModel.swift added +204
| @@ -0,0 +1,204 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | @Observable | ||
| 4 | @MainActor | ||
| 5 | final 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 | } | ||
Hutch/Views/Lookup/LookupView.swift +2
| @@ -429,6 +429,8 @@ struct LookupView: View { | |||
| 429 | MailingListListView() | 429 | MailingListListView() |
| 430 | case .pastes: | 430 | case .pastes: |
| 431 | PasteListView() | 431 | PasteListView() |
| 432 | case .profile: | ||
| 433 | ProfileView() | ||
| 432 | case .settings: | 434 | case .settings: |
| 433 | SettingsView() | 435 | SettingsView() |
| 434 | case .mailingList(let mailingList): | 436 | case .mailingList(let mailingList): |
Hutch/Views/Lookup/UserProfileView.swift +41 −4
| @@ -2,6 +2,7 @@ import SwiftUI | |||
| 2 | 2 | ||
| 3 | struct UserProfileView: View { | 3 | struct UserProfileView: View { |
| 4 | @Environment(AppState.self) private var appState | 4 | @Environment(AppState.self) private var appState |
| 5 | @AppStorage(AppStorageKeys.contributionGraphsEnabled) private var contributionGraphsEnabled = true | ||
| 5 | 6 | ||
| 6 | let user: User | 7 | let user: User |
| 7 | @State private var profileViewModel: UserProfileViewModel? | 8 | @State private var profileViewModel: UserProfileViewModel? |
| @@ -91,6 +92,21 @@ struct UserProfileView: View { | |||
| 91 | } | 92 | } |
| 92 | 93 | ||
| 93 | if let viewModel = profileViewModel { | 94 | if let viewModel = profileViewModel { |
| 95 | if contributionGraphsEnabled { | ||
| 96 | Section { | ||
| 97 | ContributionProfileCard( | ||
| 98 | actor: viewModel.actor, | ||
| 99 | weeks: viewModel.contributionCalendar.map { | ||
| 100 | ContributionCalendarLayout.weekColumns(from: $0.days) | ||
| 101 | } ?? [], | ||
| 102 | stats: viewModel.contributionStats, | ||
| 103 | isLoading: viewModel.isLoadingContributions, | ||
| 104 | error: viewModel.contributionsError ?? viewModel.contributionStatusText, | ||
| 105 | isIndexedButEmpty: viewModel.isContributionActivityIndexedButEmpty | ||
| 106 | ) | ||
| 107 | } | ||
| 108 | } | ||
| 109 | |||
| 94 | Section { | 110 | Section { |
| 95 | if viewModel.isLoadingRepositories && viewModel.repositories.isEmpty { | 111 | if viewModel.isLoadingRepositories && viewModel.repositories.isEmpty { |
| 96 | ProgressView() | 112 | ProgressView() |
| @@ -143,15 +159,36 @@ struct UserProfileView: View { | |||
| 143 | .listStyle(.insetGrouped) | 159 | .listStyle(.insetGrouped) |
| 144 | .navigationTitle(user.canonicalName) | 160 | .navigationTitle(user.canonicalName) |
| 145 | .navigationBarTitleDisplayMode(.inline) | 161 | .navigationBarTitleDisplayMode(.inline) |
| 146 | .task { | 162 | .task(id: user.canonicalName) { |
| 147 | let owner = user.canonicalName.hasPrefix("~") | 163 | let owner = user.canonicalName.hasPrefix("~") |
| 148 | ? String(user.canonicalName.dropFirst()) | 164 | ? String(user.canonicalName.dropFirst()) |
| 149 | : user.canonicalName | 165 | : user.canonicalName |
| 150 | let vm = UserProfileViewModel(ownerUsername: owner, client: appState.client) | 166 | let actor = user.canonicalName.hasPrefix("~") ? user.canonicalName : "~\(user.canonicalName)" |
| 151 | profileViewModel = vm | 167 | |
| 168 | let vm: UserProfileViewModel | ||
| 169 | if let existingViewModel = profileViewModel, | ||
| 170 | existingViewModel.actor == actor, | ||
| 171 | existingViewModel.ownerUsername == owner { | ||
| 172 | vm = existingViewModel | ||
| 173 | } else { | ||
| 174 | let newViewModel = UserProfileViewModel( | ||
| 175 | ownerUsername: owner, | ||
| 176 | actor: actor, | ||
| 177 | client: appState.client, | ||
| 178 | statsService: HutchStatsService(configuration: appState.configuration) | ||
| 179 | ) | ||
| 180 | profileViewModel = newViewModel | ||
| 181 | vm = newViewModel | ||
| 182 | } | ||
| 183 | |||
| 152 | async let repos: () = vm.loadRepositories() | 184 | async let repos: () = vm.loadRepositories() |
| 153 | async let trackers: () = vm.loadTrackers() | 185 | async let trackers: () = vm.loadTrackers() |
| 154 | _ = await (repos, trackers) | 186 | if contributionGraphsEnabled { |
| 187 | async let contributions: () = vm.loadContributions() | ||
| 188 | _ = await (repos, trackers, contributions) | ||
| 189 | } else { | ||
| 190 | _ = await (repos, trackers) | ||
| 191 | } | ||
| 155 | } | 192 | } |
| 156 | } | 193 | } |
| 157 | 194 | ||
Hutch/Views/Lookup/UserProfileViewModel.swift +90 −1
| @@ -5,17 +5,42 @@ import Foundation | |||
| 5 | final class UserProfileViewModel { | 5 | final class UserProfileViewModel { |
| 6 | private(set) var repositories: [RepositorySummary] = [] | 6 | private(set) var repositories: [RepositorySummary] = [] |
| 7 | private(set) var trackers: [TrackerSummary] = [] | 7 | private(set) var trackers: [TrackerSummary] = [] |
| 8 | private(set) var contributionCalendar: ContributionCalendarResponse? | ||
| 9 | private(set) var contributionStats: ContributionStatsResponse? | ||
| 8 | private(set) var isLoadingRepositories = false | 10 | private(set) var isLoadingRepositories = false |
| 9 | private(set) var isLoadingTrackers = false | 11 | private(set) var isLoadingTrackers = false |
| 12 | private(set) var isLoadingContributions = false | ||
| 10 | var repositoriesError: String? | 13 | var repositoriesError: String? |
| 11 | var trackersError: String? | 14 | var trackersError: String? |
| 15 | var contributionsError: String? | ||
| 12 | 16 | ||
| 13 | private let client: SRHTClient | 17 | private let client: SRHTClient |
| 18 | private let statsService: HutchStatsService | ||
| 14 | let ownerUsername: String | 19 | let ownerUsername: String |
| 20 | let actor: String | ||
| 15 | 21 | ||
| 16 | init(ownerUsername: String, client: SRHTClient) { | 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) { | ||
| 17 | self.ownerUsername = ownerUsername | 40 | self.ownerUsername = ownerUsername |
| 41 | self.actor = actor | ||
| 18 | self.client = client | 42 | self.client = client |
| 43 | self.statsService = statsService | ||
| 19 | } | 44 | } |
| 20 | 45 | ||
| 21 | func loadRepositories() async { | 46 | func loadRepositories() async { |
| @@ -54,6 +79,37 @@ final class UserProfileViewModel { | |||
| 54 | } | 79 | } |
| 55 | } | 80 | } |
| 56 | 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 | |||
| 57 | private static let repositoriesQuery = """ | 113 | private static let repositoriesQuery = """ |
| 58 | query userRepositories($owner: String!) { | 114 | query userRepositories($owner: String!) { |
| 59 | user(username: $owner) { | 115 | user(username: $owner) { |
| @@ -148,4 +204,37 @@ final class UserProfileViewModel { | |||
| 148 | let results: [TrackerSummary] | 204 | let results: [TrackerSummary] |
| 149 | let cursor: String? | 205 | let cursor: String? |
| 150 | } | 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 | } | ||
| 151 | } | 240 | } |
HutchTests/ContributionCalendarTests.swift added +388
| @@ -0,0 +1,388 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import Hutch | ||
| 4 | |||
| 5 | struct ContributionCalendarTests { | ||
| 6 | |||
| 7 | @Test | ||
| 8 | @MainActor | ||
| 9 | func contributionCalendarDecodingParsesDatesAndCounts() throws { | ||
| 10 | let data = Data( | ||
| 11 | """ | ||
| 12 | { | ||
| 13 | "actor": "~ccleberg", | ||
| 14 | "from": "2026-03-01", | ||
| 15 | "to": "2026-04-15", | ||
| 16 | "is_indexed": false, | ||
| 17 | "last_polled_at": null, | ||
| 18 | "indexing_state": "pending", | ||
| 19 | "days": [ | ||
| 20 | { "date": "2026-03-19", "count": 24, "score": 24.0 }, | ||
| 21 | { "date": "2026-04-02", "count": 20, "score": 18.5 } | ||
| 22 | ] | ||
| 23 | } | ||
| 24 | """.utf8 | ||
| 25 | ) | ||
| 26 | |||
| 27 | let decoded = try JSONDecoder().decode(ContributionCalendarResponse.self, from: data) | ||
| 28 | |||
| 29 | #expect(decoded.actor == "~ccleberg") | ||
| 30 | #expect(decoded.from == ContributionDateParser.parse("2026-03-01")) | ||
| 31 | #expect(decoded.to == ContributionDateParser.parse("2026-04-15")) | ||
| 32 | #expect(decoded.isIndexed == false) | ||
| 33 | #expect(decoded.lastPolledAt == nil) | ||
| 34 | #expect(decoded.indexingState == .pending) | ||
| 35 | #expect(decoded.days.count == 2) | ||
| 36 | #expect(decoded.days[0].date == ContributionDateParser.parse("2026-03-19")) | ||
| 37 | #expect(decoded.days[0].count == 24) | ||
| 38 | #expect(decoded.days[1].score == 18.5) | ||
| 39 | } | ||
| 40 | |||
| 41 | @Test | ||
| 42 | @MainActor | ||
| 43 | func contributionStatsDecodingParsesSnakeCasePayload() throws { | ||
| 44 | let data = Data( | ||
| 45 | """ | ||
| 46 | { | ||
| 47 | "actor": "~ccleberg", | ||
| 48 | "from": "2026-03-01", | ||
| 49 | "to": "2026-04-15", | ||
| 50 | "is_indexed": true, | ||
| 51 | "last_polled_at": "2026-04-15T15:42:18Z", | ||
| 52 | "indexing_state": "indexed", | ||
| 53 | "total_events": 126, | ||
| 54 | "total_score": 116.75, | ||
| 55 | "active_days": 14, | ||
| 56 | "longest_streak": 5, | ||
| 57 | "current_streak": 0 | ||
| 58 | } | ||
| 59 | """.utf8 | ||
| 60 | ) | ||
| 61 | |||
| 62 | let decoded = try JSONDecoder().decode(ContributionStatsResponse.self, from: data) | ||
| 63 | |||
| 64 | #expect(decoded.totalEvents == 126) | ||
| 65 | #expect(decoded.totalScore == 116.75) | ||
| 66 | #expect(decoded.activeDays == 14) | ||
| 67 | #expect(decoded.longestStreak == 5) | ||
| 68 | #expect(decoded.currentStreak == 0) | ||
| 69 | #expect(decoded.isIndexed) | ||
| 70 | #expect(decoded.indexingState == .indexed) | ||
| 71 | #expect(decoded.lastPolledAt == ContributionDateParser.parseTimestamp("2026-04-15T15:42:18Z")) | ||
| 72 | } | ||
| 73 | |||
| 74 | @Test | ||
| 75 | func contributionDateParserRejectsInvalidDate() { | ||
| 76 | #expect(ContributionDateParser.parse("2026-13-40") == nil) | ||
| 77 | } | ||
| 78 | |||
| 79 | @Test | ||
| 80 | func contributionTimestampParserHandlesFractionalSecondsWithoutTimezone() { | ||
| 81 | let parsed = ContributionDateParser.parseTimestamp("2026-04-11T19:26:45.538015") | ||
| 82 | |||
| 83 | #expect(parsed != nil) | ||
| 84 | } | ||
| 85 | |||
| 86 | @Test | ||
| 87 | func trailingRangeEndsTodayAndStartsOneYearEarlierPlusOneDay() { | ||
| 88 | let service = HutchStatsService(configuration: AppConfiguration()) | ||
| 89 | let endDate = ContributionDateParser.parse("2026-04-11")! | ||
| 90 | let range = service.trailingRange(endingOn: endDate) | ||
| 91 | |||
| 92 | #expect(range.lowerBound == ContributionDateParser.parse("2025-04-12")) | ||
| 93 | #expect(range.upperBound == endDate) | ||
| 94 | } | ||
| 95 | |||
| 96 | @Test | ||
| 97 | func contributionIntensityBucketsMatchProductRules() { | ||
| 98 | #expect(ContributionIntensity(count: 0) == .empty) | ||
| 99 | #expect(ContributionIntensity(count: 1) == .level1) | ||
| 100 | #expect(ContributionIntensity(count: 2) == .level2) | ||
| 101 | #expect(ContributionIntensity(count: 3) == .level2) | ||
| 102 | #expect(ContributionIntensity(count: 4) == .level3) | ||
| 103 | #expect(ContributionIntensity(count: 6) == .level3) | ||
| 104 | #expect(ContributionIntensity(count: 7) == .level4) | ||
| 105 | #expect(ContributionIntensity(count: 12) == .level4) | ||
| 106 | } | ||
| 107 | |||
| 108 | @Test | ||
| 109 | func weekColumnsGroupConsecutiveDaysIntoSundayBasedWeeks() { | ||
| 110 | let days = [ | ||
| 111 | ContributionDay(date: ContributionDateParser.parse("2026-03-29")!, count: 1, score: 1), | ||
| 112 | ContributionDay(date: ContributionDateParser.parse("2026-03-30")!, count: 2, score: 2), | ||
| 113 | ContributionDay(date: ContributionDateParser.parse("2026-04-04")!, count: 3, score: 3), | ||
| 114 | ContributionDay(date: ContributionDateParser.parse("2026-04-05")!, count: 4, score: 4) | ||
| 115 | ] | ||
| 116 | |||
| 117 | let weeks = ContributionCalendarLayout.weekColumns(from: days) | ||
| 118 | |||
| 119 | #expect(weeks.count == 2) | ||
| 120 | #expect(weeks[0].startDate == ContributionDateParser.parse("2026-03-29")) | ||
| 121 | #expect(weeks[0].days.map(\.count) == [1, 2, 3]) | ||
| 122 | #expect(weeks[1].startDate == ContributionDateParser.parse("2026-04-05")) | ||
| 123 | #expect(weeks[1].days.map(\.count) == [4]) | ||
| 124 | } | ||
| 125 | |||
| 126 | @Test | ||
| 127 | func recentWeeksReturnsTrailingWindow() { | ||
| 128 | let days = (0..<21).compactMap { offset -> ContributionDay? in | ||
| 129 | guard let date = Calendar.contributionCalendar.date(byAdding: .day, value: offset, to: ContributionDateParser.parse("2026-03-01")!) else { | ||
| 130 | return nil | ||
| 131 | } | ||
| 132 | |||
| 133 | return ContributionDay(date: date, count: offset, score: Double(offset)) | ||
| 134 | } | ||
| 135 | |||
| 136 | let weeks = ContributionCalendarLayout.recentWeeks(from: days, count: 2) | ||
| 137 | |||
| 138 | #expect(weeks.count == 2) | ||
| 139 | #expect(weeks[0].startDate == ContributionDateParser.parse("2026-03-08")) | ||
| 140 | #expect(weeks[1].startDate == ContributionDateParser.parse("2026-03-15")) | ||
| 141 | } | ||
| 142 | |||
| 143 | @Test | ||
| 144 | @MainActor | ||
| 145 | func emptyCalendarResponseTreatsActivityAsNotIndexedYet() async { | ||
| 146 | let service = MockContributionCalendarService( | ||
| 147 | calendarResponses: [.pending(actor: "~alice", year: 2026)], | ||
| 148 | statsResponses: [.pending(actor: "~alice", year: 2026)] | ||
| 149 | ) | ||
| 150 | let viewModel = ContributionCalendarViewModel( | ||
| 151 | actor: "~alice", | ||
| 152 | service: service, | ||
| 153 | selectedEndDate: ContributionDateParser.parse("2026-04-11") | ||
| 154 | ) | ||
| 155 | |||
| 156 | await viewModel.load() | ||
| 157 | |||
| 158 | #expect(viewModel.displayState == .indexing) | ||
| 159 | #expect(viewModel.emptyStateTitle == "Indexing Activity") | ||
| 160 | #expect(viewModel.emptyStateMessage == "This user’s SourceHut activity is being indexed. Check back soon.") | ||
| 161 | #expect(viewModel.loadErrorMessage == nil) | ||
| 162 | } | ||
| 163 | |||
| 164 | @Test | ||
| 165 | @MainActor | ||
| 166 | func indexedEmptyCalendarShowsTrueEmptyState() async { | ||
| 167 | let service = MockContributionCalendarService( | ||
| 168 | calendarResponses: [.empty(actor: "~alice", year: 2026)], | ||
| 169 | statsResponses: [.empty(actor: "~alice", year: 2026)] | ||
| 170 | ) | ||
| 171 | let viewModel = ContributionCalendarViewModel( | ||
| 172 | actor: "~alice", | ||
| 173 | service: service, | ||
| 174 | selectedEndDate: ContributionDateParser.parse("2026-04-11") | ||
| 175 | ) | ||
| 176 | |||
| 177 | await viewModel.load() | ||
| 178 | |||
| 179 | #expect(viewModel.displayState == .empty) | ||
| 180 | #expect(viewModel.emptyStateTitle == "No Contribution Activity") | ||
| 181 | #expect(viewModel.emptyStateMessage == "No activity was found for this time range.") | ||
| 182 | } | ||
| 183 | |||
| 184 | @Test | ||
| 185 | @MainActor | ||
| 186 | func errorIndexingStateShowsUnavailableState() async { | ||
| 187 | let service = MockContributionCalendarService( | ||
| 188 | calendarResponses: [.error(actor: "~alice", year: 2026)], | ||
| 189 | statsResponses: [.error(actor: "~alice", year: 2026)] | ||
| 190 | ) | ||
| 191 | let viewModel = ContributionCalendarViewModel( | ||
| 192 | actor: "~alice", | ||
| 193 | service: service, | ||
| 194 | selectedEndDate: ContributionDateParser.parse("2026-04-11") | ||
| 195 | ) | ||
| 196 | |||
| 197 | await viewModel.load() | ||
| 198 | |||
| 199 | #expect(viewModel.displayState == .unavailable) | ||
| 200 | #expect(viewModel.emptyStateTitle == "Activity Unavailable") | ||
| 201 | #expect(viewModel.emptyStateMessage == "The contribution graph couldn’t be refreshed right now. Try again later.") | ||
| 202 | } | ||
| 203 | |||
| 204 | @Test | ||
| 205 | @MainActor | ||
| 206 | func populatedStatsExposeLastUpdatedText() async { | ||
| 207 | let service = MockContributionCalendarService( | ||
| 208 | calendarResponses: [.active(actor: "~alice", date: "2026-03-19", count: 4, score: 4)], | ||
| 209 | statsResponses: [.active(actor: "~alice", year: 2026, totalEvents: 4, activeDays: 1, longestStreak: 1)] | ||
| 210 | ) | ||
| 211 | let viewModel = ContributionCalendarViewModel( | ||
| 212 | actor: "~alice", | ||
| 213 | service: service, | ||
| 214 | selectedEndDate: ContributionDateParser.parse("2026-04-11") | ||
| 215 | ) | ||
| 216 | |||
| 217 | await viewModel.load() | ||
| 218 | |||
| 219 | #expect(viewModel.displayState == .populated) | ||
| 220 | #expect(viewModel.lastUpdatedText != nil) | ||
| 221 | } | ||
| 222 | } | ||
| 223 | |||
| 224 | private final class MockContributionCalendarService: ContributionCalendarServing, @unchecked Sendable { | ||
| 225 | var calendarResponses: [ContributionCalendarResponse] | ||
| 226 | var statsResponses: [ContributionStatsResponse] | ||
| 227 | private(set) var fetchCalendarCallCount = 0 | ||
| 228 | private(set) var fetchStatsCallCount = 0 | ||
| 229 | |||
| 230 | init( | ||
| 231 | calendarResponses: [ContributionCalendarResponse], | ||
| 232 | statsResponses: [ContributionStatsResponse] | ||
| 233 | ) { | ||
| 234 | self.calendarResponses = calendarResponses | ||
| 235 | self.statsResponses = statsResponses | ||
| 236 | } | ||
| 237 | |||
| 238 | func fetchContributionCalendar(actor: String, endingOn endDate: Date) async throws -> ContributionCalendarResponse { | ||
| 239 | fetchCalendarCallCount += 1 | ||
| 240 | return calendarResponses[min(fetchCalendarCallCount - 1, calendarResponses.count - 1)] | ||
| 241 | } | ||
| 242 | |||
| 243 | func fetchContributionStats(actor: String, endingOn endDate: Date) async throws -> ContributionStatsResponse { | ||
| 244 | fetchStatsCallCount += 1 | ||
| 245 | return statsResponses[min(fetchStatsCallCount - 1, statsResponses.count - 1)] | ||
| 246 | } | ||
| 247 | } | ||
| 248 | |||
| 249 | private extension ContributionCalendarResponse { | ||
| 250 | static func empty(actor: String, year: Int) -> Self { | ||
| 251 | let from = ContributionDateParser.parse("\(year)-01-01")! | ||
| 252 | let to = ContributionDateParser.parse("\(year)-01-07")! | ||
| 253 | return ContributionCalendarResponse( | ||
| 254 | actor: actor, | ||
| 255 | from: from, | ||
| 256 | to: to, | ||
| 257 | isIndexed: true, | ||
| 258 | lastPolledAt: ContributionDateParser.parseTimestamp("\(year)-01-07T12:00:00Z"), | ||
| 259 | indexingState: .indexed, | ||
| 260 | days: (1...7).map { day in | ||
| 261 | ContributionDay( | ||
| 262 | date: ContributionDateParser.parse("\(year)-01-0\(day)")!, | ||
| 263 | count: 0, | ||
| 264 | score: 0 | ||
| 265 | ) | ||
| 266 | } | ||
| 267 | ) | ||
| 268 | } | ||
| 269 | |||
| 270 | static func active(actor: String, date: String, count: Int, score: Double) -> Self { | ||
| 271 | let resolvedDate = ContributionDateParser.parse(date)! | ||
| 272 | return ContributionCalendarResponse( | ||
| 273 | actor: actor, | ||
| 274 | from: resolvedDate, | ||
| 275 | to: resolvedDate, | ||
| 276 | isIndexed: true, | ||
| 277 | lastPolledAt: ContributionDateParser.parseTimestamp("2026-03-19T12:00:00Z"), | ||
| 278 | indexingState: .indexed, | ||
| 279 | days: [ContributionDay(date: resolvedDate, count: count, score: score)] | ||
| 280 | ) | ||
| 281 | } | ||
| 282 | |||
| 283 | static func pending(actor: String, year: Int) -> Self { | ||
| 284 | let from = ContributionDateParser.parse("\(year)-01-01")! | ||
| 285 | let to = ContributionDateParser.parse("\(year)-01-07")! | ||
| 286 | return ContributionCalendarResponse( | ||
| 287 | actor: actor, | ||
| 288 | from: from, | ||
| 289 | to: to, | ||
| 290 | isIndexed: false, | ||
| 291 | lastPolledAt: nil, | ||
| 292 | indexingState: .pending, | ||
| 293 | days: (1...7).map { day in | ||
| 294 | ContributionDay( | ||
| 295 | date: ContributionDateParser.parse("\(year)-01-0\(day)")!, | ||
| 296 | count: 0, | ||
| 297 | score: 0 | ||
| 298 | ) | ||
| 299 | } | ||
| 300 | ) | ||
| 301 | } | ||
| 302 | |||
| 303 | static func error(actor: String, year: Int) -> Self { | ||
| 304 | let from = ContributionDateParser.parse("\(year)-01-01")! | ||
| 305 | let to = ContributionDateParser.parse("\(year)-01-07")! | ||
| 306 | return ContributionCalendarResponse( | ||
| 307 | actor: actor, | ||
| 308 | from: from, | ||
| 309 | to: to, | ||
| 310 | isIndexed: false, | ||
| 311 | lastPolledAt: nil, | ||
| 312 | indexingState: .error, | ||
| 313 | days: (1...7).map { day in | ||
| 314 | ContributionDay( | ||
| 315 | date: ContributionDateParser.parse("\(year)-01-0\(day)")!, | ||
| 316 | count: 0, | ||
| 317 | score: 0 | ||
| 318 | ) | ||
| 319 | } | ||
| 320 | ) | ||
| 321 | } | ||
| 322 | } | ||
| 323 | |||
| 324 | private extension ContributionStatsResponse { | ||
| 325 | static func empty(actor: String, year: Int) -> Self { | ||
| 326 | ContributionStatsResponse( | ||
| 327 | actor: actor, | ||
| 328 | from: ContributionDateParser.parse("\(year)-01-01")!, | ||
| 329 | to: ContributionDateParser.parse("\(year)-01-07")!, | ||
| 330 | isIndexed: true, | ||
| 331 | lastPolledAt: ContributionDateParser.parseTimestamp("\(year)-01-07T12:00:00Z"), | ||
| 332 | indexingState: .indexed, | ||
| 333 | totalEvents: 0, | ||
| 334 | totalScore: 0, | ||
| 335 | activeDays: 0, | ||
| 336 | longestStreak: 0, | ||
| 337 | currentStreak: 0 | ||
| 338 | ) | ||
| 339 | } | ||
| 340 | |||
| 341 | static func active(actor: String, year: Int, totalEvents: Int, activeDays: Int, longestStreak: Int) -> Self { | ||
| 342 | ContributionStatsResponse( | ||
| 343 | actor: actor, | ||
| 344 | from: ContributionDateParser.parse("\(year)-01-01")!, | ||
| 345 | to: ContributionDateParser.parse("\(year)-01-07")!, | ||
| 346 | isIndexed: true, | ||
| 347 | lastPolledAt: ContributionDateParser.parseTimestamp("\(year)-01-07T12:00:00Z"), | ||
| 348 | indexingState: .indexed, | ||
| 349 | totalEvents: totalEvents, | ||
| 350 | totalScore: Double(totalEvents), | ||
| 351 | activeDays: activeDays, | ||
| 352 | longestStreak: longestStreak, | ||
| 353 | currentStreak: 0 | ||
| 354 | ) | ||
| 355 | } | ||
| 356 | |||
| 357 | static func pending(actor: String, year: Int) -> Self { | ||
| 358 | ContributionStatsResponse( | ||
| 359 | actor: actor, | ||
| 360 | from: ContributionDateParser.parse("\(year)-01-01")!, | ||
| 361 | to: ContributionDateParser.parse("\(year)-01-07")!, | ||
| 362 | isIndexed: false, | ||
| 363 | lastPolledAt: nil, | ||
| 364 | indexingState: .pending, | ||
| 365 | totalEvents: 0, | ||
| 366 | totalScore: 0, | ||
| 367 | activeDays: 0, | ||
| 368 | longestStreak: 0, | ||
| 369 | currentStreak: 0 | ||
| 370 | ) | ||
| 371 | } | ||
| 372 | |||
| 373 | static func error(actor: String, year: Int) -> Self { | ||
| 374 | ContributionStatsResponse( | ||
| 375 | actor: actor, | ||
| 376 | from: ContributionDateParser.parse("\(year)-01-01")!, | ||
| 377 | to: ContributionDateParser.parse("\(year)-01-07")!, | ||
| 378 | isIndexed: false, | ||
| 379 | lastPolledAt: nil, | ||
| 380 | indexingState: .error, | ||
| 381 | totalEvents: 0, | ||
| 382 | totalScore: 0, | ||
| 383 | activeDays: 0, | ||
| 384 | longestStreak: 0, | ||
| 385 | currentStreak: 0 | ||
| 386 | ) | ||
| 387 | } | ||
| 388 | } | ||