krz/hutch

an ios client for sourcehut

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

remove-splash-highlighter: 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}