krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.13.1: 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 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}