Commit 94939d8252

94939d82525c6538fbf7bcac2e8eda462475f601

parent: 0af36f5b04

Unsigned

cmc <hello@cleberg.net> · 2026-04-12 00:47 UTC

feat: add contribution calendar to user profiles

Add a trailing 365-day contribution heatmap to user profile views,
backed by HutchStatsService. Includes calendar model, view model,
SwiftUI calendar view, app configuration, and unit tests.

Layout: unified · split

Hutch/App/AppConfiguration.swift added +42
@@ -0,0 +1,42 @@
1import Foundation
2
3struct 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 {
5656 // MARK: - Networking
5757
5858 let client: SRHTClient
59 let configuration: AppConfiguration
5960
6061 // MARK: - Deep link pending navigation
6162
@@ -67,6 +68,7 @@ final class AppState {
6768 // MARK: - Init
6869
6970 init() {
71 self.configuration = AppConfiguration()
7072 let token = KeychainHelper.loadToken()
7173 self.client = SRHTClient(token: token)
7274 }
@@ -107,11 +109,13 @@ final class AppState {
107109 accounts = storedAccounts
108110 activeAccountID = target.id
109111 currentUser = user
112 ContributionWidgetContextStore.saveActor(user.canonicalName)
110113 authPhase = .authenticated
111114 await refreshNeedsAttentionSnapshot()
112115 } catch {
113116 client.setToken(nil)
114117 currentUser = nil
118 ContributionWidgetContextStore.clear()
115119 authPhase = .unauthenticated
116120 NeedsAttentionSnapshotStore.clear()
117121 }
@@ -131,6 +135,7 @@ final class AppState {
131135 UserDefaults.standard.set(entry.id, forKey: AppStorageKeys.activeAccountID)
132136 try KeychainHelper.saveAccounts(accounts)
133137 currentUser = user
138 ContributionWidgetContextStore.saveActor(user.canonicalName)
134139 authPhase = .authenticated
135140 await refreshNeedsAttentionSnapshot()
136141 } catch {
@@ -168,6 +173,7 @@ final class AppState {
168173
169174 let user = try await fetchMe()
170175 currentUser = user
176 ContributionWidgetContextStore.saveActor(user.canonicalName)
171177 authPhase = .authenticated
172178 await refreshNeedsAttentionSnapshot()
173179 }
@@ -355,6 +361,7 @@ final class AppState {
355361 activeAccountID = ""
356362 UserDefaults.standard.removeObject(forKey: AppStorageKeys.activeAccountID)
357363 currentUser = nil
364 ContributionWidgetContextStore.clear()
358365 pendingDeepLink = nil
359366 pendingTabNavigation = nil
360367 deepLinkError = nil
Hutch/App/AppStorageKeys.swift +2
@@ -1,7 +1,9 @@
11// Shared UserDefaults key constants
22enum AppStorageKeys {
33 static let swipeActionsEnabled = "swipeActionsEnabled"
4 static let contributionGraphsEnabled = "contributionGraphsEnabled"
45 static let activeAccountID = "activeAccountID"
56 static let wrapRepositoryFileLines = "wrapRepositoryFileLines"
67 static let lookupHistory = "lookupHistory"
8 static let hutchStatsBaseURL = "hutchStatsBaseURL"
79}
Hutch/App/RootView.swift +3
@@ -266,6 +266,7 @@ enum MoreRoute: Hashable {
266266 case lookup
267267 case lists
268268 case pastes
269 case profile
269270 case settings
270271 case mailingList(InboxMailingListReference)
271272 case thread(InboxThreadSummary)
@@ -284,6 +285,8 @@ private struct MoreNavigationRoot: View {
284285 MailingListListView()
285286 case .pastes:
286287 PasteListView()
288 case .profile:
289 ProfileView()
287290 case .settings:
288291 SettingsView()
289292 case .mailingList(let mailingList):
Hutch/Models/ContributionCalendar.swift added +340
@@ -0,0 +1,340 @@
1import Foundation
2
3struct 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
62struct 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
93struct 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
162enum ContributionIndexingState: String, Codable, Sendable, Hashable {
163 case pending
164 case indexed
165 case error
166}
167
168enum 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
191struct ContributionWeek: Sendable, Hashable {
192 let startDate: Date
193 let days: [ContributionDay]
194}
195
196enum 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
222enum 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
329extension 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 @@
1import Foundation
2
3protocol 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
8struct 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 @@
1import SwiftUI
2
3struct 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
65private 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
147private 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
164private 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
182private 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
202private 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
219private 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 @@
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}
Hutch/Views/Lookup/LookupView.swift +2
@@ -429,6 +429,8 @@ struct LookupView: View {
429429 MailingListListView()
430430 case .pastes:
431431 PasteListView()
432 case .profile:
433 ProfileView()
432434 case .settings:
433435 SettingsView()
434436 case .mailingList(let mailingList):
Hutch/Views/Lookup/UserProfileView.swift +41 −4
@@ -2,6 +2,7 @@ import SwiftUI
22
33struct UserProfileView: View {
44 @Environment(AppState.self) private var appState
5 @AppStorage(AppStorageKeys.contributionGraphsEnabled) private var contributionGraphsEnabled = true
56
67 let user: User
78 @State private var profileViewModel: UserProfileViewModel?
@@ -91,6 +92,21 @@ struct UserProfileView: View {
9192 }
9293
9394 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
94110 Section {
95111 if viewModel.isLoadingRepositories && viewModel.repositories.isEmpty {
96112 ProgressView()
@@ -143,15 +159,36 @@ struct UserProfileView: View {
143159 .listStyle(.insetGrouped)
144160 .navigationTitle(user.canonicalName)
145161 .navigationBarTitleDisplayMode(.inline)
146 .task {
162 .task(id: user.canonicalName) {
147163 let owner = user.canonicalName.hasPrefix("~")
148164 ? String(user.canonicalName.dropFirst())
149165 : user.canonicalName
150 let vm = UserProfileViewModel(ownerUsername: owner, client: appState.client)
151 profileViewModel = vm
166 let actor = user.canonicalName.hasPrefix("~") ? user.canonicalName : "~\(user.canonicalName)"
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
152184 async let repos: () = vm.loadRepositories()
153185 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 }
155192 }
156193 }
157194
Hutch/Views/Lookup/UserProfileViewModel.swift +90 −1
@@ -5,17 +5,42 @@ import Foundation
55final class UserProfileViewModel {
66 private(set) var repositories: [RepositorySummary] = []
77 private(set) var trackers: [TrackerSummary] = []
8 private(set) var contributionCalendar: ContributionCalendarResponse?
9 private(set) var contributionStats: ContributionStatsResponse?
810 private(set) var isLoadingRepositories = false
911 private(set) var isLoadingTrackers = false
12 private(set) var isLoadingContributions = false
1013 var repositoriesError: String?
1114 var trackersError: String?
15 var contributionsError: String?
1216
1317 private let client: SRHTClient
18 private let statsService: HutchStatsService
1419 let ownerUsername: String
20 let actor: String
1521
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) {
1740 self.ownerUsername = ownerUsername
41 self.actor = actor
1842 self.client = client
43 self.statsService = statsService
1944 }
2045
2146 func loadRepositories() async {
@@ -54,6 +79,37 @@ final class UserProfileViewModel {
5479 }
5580 }
5681
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
57113 private static let repositoriesQuery = """
58114 query userRepositories($owner: String!) {
59115 user(username: $owner) {
@@ -148,4 +204,37 @@ final class UserProfileViewModel {
148204 let results: [TrackerSummary]
149205 let cursor: String?
150206 }
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 }
151240}
HutchTests/ContributionCalendarTests.swift added +388
@@ -0,0 +1,388 @@
1import Foundation
2import Testing
3@testable import Hutch
4
5struct 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
224private 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
249private 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
324private 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}