krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.5.0: Hutch/Models/ContributionCalendar.swift · raw
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 struct StatsWindow: Sendable, Hashable {
121 let actor: String
122 let from: Date
123 let to: Date
124 let isIndexed: Bool
125 let lastPolledAt: Date?
126 let indexingState: ContributionIndexingState
127 }
128
129 struct StatsTotals: Sendable, Hashable {
130 let totalEvents: Int
131 let totalScore: Double
132 let activeDays: Int
133 let longestStreak: Int
134 let currentStreak: Int
135 }
136
137 init(window: StatsWindow, totals: StatsTotals) {
138 actor = window.actor
139 from = window.from
140 to = window.to
141 isIndexed = window.isIndexed
142 lastPolledAt = window.lastPolledAt
143 indexingState = window.indexingState
144 totalEvents = totals.totalEvents
145 totalScore = totals.totalScore
146 activeDays = totals.activeDays
147 longestStreak = totals.longestStreak
148 currentStreak = totals.currentStreak
149 }
150
151 init(from decoder: any Decoder) throws {
152 let container = try decoder.container(keyedBy: CodingKeys.self)
153 let actor = try container.decode(String.self, forKey: .actor)
154 let from = try ContributionDateParser.decodeDateString(from: container, forKey: .from)
155 let to = try ContributionDateParser.decodeDateString(from: container, forKey: .to)
156 let isIndexed = try container.decodeIfPresent(Bool.self, forKey: .isIndexed) ?? false
157 let lastPolledAt = try ContributionDateParser.decodeOptionalTimestamp(from: container, forKey: .lastPolledAt)
158 let indexingState = try container.decodeIfPresent(ContributionIndexingState.self, forKey: .indexingState) ?? .indexed
159 let totalEvents = try container.decode(Int.self, forKey: .totalEvents)
160 let totalScore = try container.decode(Double.self, forKey: .totalScore)
161 let activeDays = try container.decode(Int.self, forKey: .activeDays)
162 let longestStreak = try container.decode(Int.self, forKey: .longestStreak)
163 let currentStreak = try container.decode(Int.self, forKey: .currentStreak)
164 self.init(
165 window: .init(
166 actor: actor,
167 from: from,
168 to: to,
169 isIndexed: isIndexed,
170 lastPolledAt: lastPolledAt,
171 indexingState: indexingState
172 ),
173 totals: .init(
174 totalEvents: totalEvents,
175 totalScore: totalScore,
176 activeDays: activeDays,
177 longestStreak: longestStreak,
178 currentStreak: currentStreak
179 )
180 )
181 }
182}
183
184enum ContributionIndexingState: String, Codable, Sendable, Hashable {
185 case pending
186 case indexed
187 case error
188}
189
190enum ContributionIntensity: Int, Sendable, CaseIterable {
191 case empty = 0
192 case level1 = 1
193 case level2 = 2
194 case level3 = 3
195 case level4 = 4
196
197 init(count: Int) {
198 switch count {
199 case ..<1:
200 self = .empty
201 case 1:
202 self = .level1
203 case 2...3:
204 self = .level2
205 case 4...6:
206 self = .level3
207 default:
208 self = .level4
209 }
210 }
211}
212
213struct ContributionWeek: Sendable, Hashable {
214 let startDate: Date
215 let days: [ContributionDay]
216}
217
218enum ContributionCalendarLayout {
219 static func weekColumns(
220 from days: [ContributionDay],
221 calendar: Calendar = .contributionCalendar
222 ) -> [ContributionWeek] {
223 let groupedDays = Dictionary(grouping: days) { day in
224 calendar.startOfWeek(for: day.date)
225 }
226
227 return groupedDays.keys.sorted().map { weekStart in
228 ContributionWeek(
229 startDate: weekStart,
230 days: groupedDays[weekStart, default: []].sorted { $0.date < $1.date }
231 )
232 }
233 }
234
235 static func recentWeeks(
236 from days: [ContributionDay],
237 count: Int,
238 calendar: Calendar = .contributionCalendar
239 ) -> [ContributionWeek] {
240 Array(weekColumns(from: days, calendar: calendar).suffix(count))
241 }
242}
243
244enum ContributionDateParser {
245 static func parse(_ rawValue: String) -> Date? {
246 let parts = rawValue.split(separator: "-", omittingEmptySubsequences: false)
247 guard
248 parts.count == 3,
249 let year = Int(parts[0]),
250 let month = Int(parts[1]),
251 let day = Int(parts[2])
252 else {
253 return nil
254 }
255
256 var components = DateComponents()
257 components.calendar = .contributionCalendar
258 components.timeZone = TimeZone(secondsFromGMT: 0)
259 components.year = year
260 components.month = month
261 components.day = day
262
263 guard let date = components.date else {
264 return nil
265 }
266
267 let resolvedComponents = Calendar.contributionCalendar.dateComponents([.year, .month, .day], from: date)
268 guard
269 resolvedComponents.year == year,
270 resolvedComponents.month == month,
271 resolvedComponents.day == day
272 else {
273 return nil
274 }
275
276 return date
277 }
278
279 static func decodeDateString<Key: CodingKey>(
280 from container: KeyedDecodingContainer<Key>,
281 forKey key: Key
282 ) throws -> Date {
283 let rawValue = try container.decode(String.self, forKey: key)
284
285 guard let date = parse(rawValue) else {
286 throw DecodingError.dataCorruptedError(
287 forKey: key,
288 in: container,
289 debugDescription: "Invalid contribution date: \(rawValue)"
290 )
291 }
292
293 return date
294 }
295
296 static func parseTimestamp(_ rawValue: String) -> Date? {
297 let formatter = ISO8601DateFormatter()
298 formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
299 formatter.timeZone = TimeZone(secondsFromGMT: 0)
300
301 if let date = formatter.date(from: rawValue) {
302 return date
303 }
304
305 formatter.formatOptions = [.withInternetDateTime]
306 if let date = formatter.date(from: rawValue) {
307 return date
308 }
309
310 let fallbackFormats = [
311 "yyyy-MM-dd'T'HH:mm:ss.SSSSSS",
312 "yyyy-MM-dd'T'HH:mm:ss.SSS",
313 "yyyy-MM-dd'T'HH:mm:ss"
314 ]
315
316 let dateFormatter = DateFormatter()
317 dateFormatter.calendar = .contributionCalendar
318 dateFormatter.locale = Locale(identifier: "en_US_POSIX")
319 dateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
320
321 for format in fallbackFormats {
322 dateFormatter.dateFormat = format
323 if let date = dateFormatter.date(from: rawValue) {
324 return date
325 }
326 }
327
328 return nil
329 }
330
331 static func decodeOptionalTimestamp<Key: CodingKey>(
332 from container: KeyedDecodingContainer<Key>,
333 forKey key: Key
334 ) throws -> Date? {
335 guard let rawValue = try container.decodeIfPresent(String.self, forKey: key) else {
336 return nil
337 }
338
339 guard let date = parseTimestamp(rawValue) else {
340 throw DecodingError.dataCorruptedError(
341 forKey: key,
342 in: container,
343 debugDescription: "Invalid contribution timestamp: \(rawValue)"
344 )
345 }
346
347 return date
348 }
349}
350
351extension Calendar {
352 static var contributionCalendar: Calendar {
353 var calendar = Calendar(identifier: .gregorian)
354 calendar.firstWeekday = 1
355 calendar.timeZone = TimeZone(secondsFromGMT: 0)!
356 return calendar
357 }
358
359 func startOfWeek(for date: Date) -> Date {
360 dateInterval(of: .weekOfYear, for: date)?.start ?? startOfDay(for: date)
361 }
362}