krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v3.0.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    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}