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 {
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
2enum AppStorageKeys { 2enum 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 @@
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 {
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
3struct UserProfileView: View { 3struct 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
5final class UserProfileViewModel { 5final 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 @@
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}