krz/hutch

an ios client for sourcehut

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

v2.12.0: HutchWidgetExtension/ContributionGraphWidget.swift · raw

  1import SwiftUI
  2import WidgetKit
  3
  4struct ContributionGraphEntry: TimelineEntry {
  5    let date: Date
  6    let actor: String?
  7    let state: State
  8    let weeks: [ContributionGraphWeek]
  9
 10    enum State {
 11        case placeholder
 12        case unavailable
 13        case empty
 14        case indexing
 15        case populated
 16    }
 17}
 18
 19struct ContributionGraphTimelineProvider: TimelineProvider {
 20    func placeholder(in _: Context) -> ContributionGraphEntry {
 21        ContributionGraphEntry(
 22            date: .now,
 23            actor: "~ccleberg",
 24            state: .placeholder,
 25            weeks: ContributionGraphSampleData.placeholderWeeks
 26        )
 27    }
 28
 29    func getSnapshot(in _: Context, completion: @escaping (ContributionGraphEntry) -> Void) {
 30        Task {
 31            completion(await loadEntry())
 32        }
 33    }
 34
 35    func getTimeline(in _: Context, completion: @escaping (Timeline<ContributionGraphEntry>) -> Void) {
 36        Task {
 37            let entry = await loadEntry()
 38            let refreshDate = Calendar.contributionCalendar.date(byAdding: .hour, value: 1, to: Date()) ?? Date().addingTimeInterval(3600)
 39            completion(Timeline(entries: [entry], policy: .after(refreshDate)))
 40        }
 41    }
 42
 43    private func loadEntry() async -> ContributionGraphEntry {
 44        guard let actor = ContributionWidgetContextStore.loadActor(), !actor.isEmpty else {
 45            return ContributionGraphEntry(date: .now, actor: nil, state: .unavailable, weeks: [])
 46        }
 47
 48        do {
 49            let response = try await ContributionGraphWidgetService().fetchCalendar(actor: actor)
 50            let state: ContributionGraphEntry.State
 51            if response.totalCount > 0 {
 52                state = .populated
 53            } else {
 54                switch response.indexingState {
 55                case .pending:
 56                    state = .indexing
 57                case .error:
 58                    state = .unavailable
 59                case .indexed:
 60                    state = .empty
 61                }
 62            }
 63
 64            return ContributionGraphEntry(
 65                date: .now,
 66                actor: actor,
 67                state: state,
 68                weeks: ContributionGraphLayout.weekColumns(from: response.days)
 69            )
 70        } catch {
 71            return ContributionGraphEntry(date: .now, actor: actor, state: .unavailable, weeks: [])
 72        }
 73    }
 74}
 75
 76struct ContributionGraphWidget: Widget {
 77    static let kind = ContributionGraphWidgetConfiguration.kind
 78
 79    var body: some WidgetConfiguration {
 80        StaticConfiguration(kind: Self.kind, provider: ContributionGraphTimelineProvider()) { entry in
 81            ContributionGraphWidgetView(entry: entry)
 82        }
 83        .configurationDisplayName("Contribution Graph")
 84        .description("A trailing 365-day SourceHut contribution heatmap for your current profile.")
 85        .supportedFamilies([.systemSmall, .systemMedium])
 86    }
 87}
 88
 89private struct ContributionGraphWidgetView: View {
 90    let entry: ContributionGraphEntry
 91    @Environment(\.widgetFamily) private var family
 92
 93    var body: some View {
 94        GeometryReader { geometry in
 95            let cellSize = ContributionGraphSizing.baseCellSize(availableHeight: geometry.size.height)
 96            let layout = ContributionGraphSizing.layout(availableSize: geometry.size, cellSize: cellSize)
 97            let weeks = displayedWeeks(columnCount: layout.columns)
 98
 99            let actualSize = ContributionGraphSizing.contentSize(
100                columns: weeks.count, cellSize: layout.cellSize, spacing: layout.spacing
101            )
102
103            ContributionGraphGridView(
104                weeks: weeks,
105                squareSize: layout.cellSize,
106                spacing: layout.spacing
107            )
108            .frame(width: actualSize.width, height: actualSize.height)
109            .frame(maxWidth: .infinity, maxHeight: .infinity)
110        }
111        .widgetURL(URL(string: "hutch://home"))
112        .containerBackground(for: .widget) {
113            Color(.systemBackground)
114        }
115    }
116
117    private func displayedWeeks(columnCount: Int) -> [ContributionGraphWeek] {
118        var baseWeeks = switch entry.state {
119        case .populated, .indexing, .empty:
120            entry.weeks
121        case .placeholder, .unavailable:
122            ContributionGraphSampleData.placeholderWeeks
123        }
124
125        // Drop any trailing week where every day has zero contributions
126        while let last = baseWeeks.last, last.days.allSatisfy({ $0.count == 0 }) {
127            baseWeeks.removeLast()
128        }
129
130        return Array(baseWeeks.suffix(columnCount))
131    }
132}
133
134private struct ContributionGraphGridView: View {
135    let weeks: [ContributionGraphWeek]
136    let squareSize: CGFloat
137    let spacing: CGFloat
138
139    var body: some View {
140        HStack(alignment: .top, spacing: spacing) {
141            ForEach(weeks, id: \.startDate) { week in
142                VStack(spacing: spacing) {
143                    ForEach(Array(week.slots.enumerated()), id: \.offset) { _, day in
144                        RoundedRectangle(cornerRadius: squareSize * 0.2, style: .continuous)
145                            .fill((day?.intensity ?? .empty).color)
146                            .frame(width: squareSize, height: squareSize)
147                    }
148                }
149            }
150        }
151    }
152}
153
154private enum ContributionGraphSizing {
155    static let rowCount = 7
156    static let cellSpacing: CGFloat = 3
157
158    struct GridLayout {
159        let rows: Int
160        let columns: Int
161        let cellSize: CGFloat
162        let spacing: CGFloat
163
164        var contentSize: CGSize {
165            let w = CGFloat(columns) * cellSize + CGFloat(max(0, columns - 1)) * spacing
166            let h = CGFloat(rows) * cellSize + CGFloat(max(0, rows - 1)) * spacing
167            return CGSize(width: w, height: h)
168        }
169    }
170
171    /// Cell size derived from available height so 7 rows fill it exactly.
172    static func baseCellSize(availableHeight: CGFloat) -> CGFloat {
173        floor((availableHeight - cellSpacing * CGFloat(rowCount - 1)) / CGFloat(rowCount))
174    }
175
176    /// Compute layout for any family: 7 rows, as many columns as fit at the given cell size.
177    static func layout(availableSize: CGSize, cellSize: CGFloat) -> GridLayout {
178        let columns = max(1, Int(floor((availableSize.width + cellSpacing) / (cellSize + cellSpacing))))
179        return GridLayout(rows: rowCount, columns: columns, cellSize: cellSize, spacing: cellSpacing)
180    }
181
182    /// Content size for the actual number of displayed columns (may differ from layout max).
183    static func contentSize(columns: Int, cellSize: CGFloat, spacing: CGFloat) -> CGSize {
184        let w = CGFloat(columns) * cellSize + CGFloat(max(0, columns - 1)) * spacing
185        let h = CGFloat(rowCount) * cellSize + CGFloat(max(0, rowCount - 1)) * spacing
186        return CGSize(width: w, height: h)
187    }
188}
189
190private struct ContributionGraphWidgetService {
191    private let baseURL = URL(string: "https://hutch-stats.zerolabs.sh")!
192
193    func fetchCalendar(actor: String) async throws -> ContributionGraphResponse {
194        guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else {
195            throw URLError(.badURL)
196        }
197
198        components.path = "/api/contributions/\(actor)"
199        let range = trailingRange(endingOn: Date())
200        components.queryItems = [
201            URLQueryItem(name: "from", value: Self.rangeFormatter.string(from: range.lowerBound)),
202            URLQueryItem(name: "to", value: Self.rangeFormatter.string(from: range.upperBound))
203        ]
204
205        guard let url = components.url else {
206            throw URLError(.badURL)
207        }
208
209        let (data, response) = try await URLSession.shared.data(from: url)
210        if let httpResponse = response as? HTTPURLResponse, !(200...299).contains(httpResponse.statusCode) {
211            throw URLError(.badServerResponse)
212        }
213
214        return try JSONDecoder().decode(ContributionGraphResponse.self, from: data)
215    }
216
217    private func trailingRange(endingOn endDate: Date) -> ClosedRange<Date> {
218        let normalizedEndDate = Calendar.contributionCalendar.startOfDay(for: endDate)
219        let oneYearBack = Calendar.contributionCalendar.date(byAdding: .year, value: -1, to: normalizedEndDate) ?? normalizedEndDate
220        let normalizedStartDate = Calendar.contributionCalendar.date(byAdding: .day, value: 1, to: oneYearBack) ?? oneYearBack
221        return normalizedStartDate...normalizedEndDate
222    }
223
224    private static let rangeFormatter: DateFormatter = {
225        let formatter = DateFormatter()
226        formatter.calendar = .contributionCalendar
227        formatter.locale = Locale(identifier: "en_US_POSIX")
228        formatter.timeZone = TimeZone(secondsFromGMT: 0)
229        formatter.dateFormat = "yyyy-MM-dd"
230        return formatter
231    }()
232}
233
234private struct ContributionGraphResponse: Decodable {
235    let actor: String
236    let indexingState: ContributionGraphIndexingState
237    let days: [ContributionGraphDay]
238
239    enum CodingKeys: String, CodingKey {
240        case actor
241        case indexingState = "indexing_state"
242        case days
243    }
244
245    var totalCount: Int {
246        days.reduce(0) { $0 + $1.count }
247    }
248}
249
250struct ContributionGraphDay: Decodable, Identifiable {
251    var id: Date { date }
252
253    let date: Date
254    let count: Int
255
256    enum CodingKeys: String, CodingKey {
257        case date
258        case count
259    }
260
261    var intensity: ContributionGraphIntensity {
262        ContributionGraphIntensity(count: count)
263    }
264
265    init(from decoder: Decoder) throws {
266        let container = try decoder.container(keyedBy: CodingKeys.self)
267        date = try ContributionGraphDateParser.decodeDateString(from: container, forKey: .date)
268        count = try container.decode(Int.self, forKey: .count)
269    }
270}
271
272private enum ContributionGraphIndexingState: String, Decodable {
273    case pending
274    case indexed
275    case error
276}
277
278enum ContributionGraphIntensity: Int, CaseIterable {
279    case empty = 0
280    case level1 = 1
281    case level2 = 2
282    case level3 = 3
283    case level4 = 4
284
285    init(count: Int) {
286        switch count {
287        case ..<1:
288            self = .empty
289        case 1:
290            self = .level1
291        case 2...3:
292            self = .level2
293        case 4...6:
294            self = .level3
295        default:
296            self = .level4
297        }
298    }
299
300    var color: Color {
301        switch self {
302        case .empty:
303            Color(uiColor: .secondarySystemFill)
304        case .level1:
305            Color(red: 0.82, green: 0.92, blue: 0.83)
306        case .level2:
307            Color(red: 0.58, green: 0.83, blue: 0.61)
308        case .level3:
309            Color(red: 0.25, green: 0.69, blue: 0.36)
310        case .level4:
311            Color(red: 0.12, green: 0.47, blue: 0.21)
312        }
313    }
314}
315
316struct ContributionGraphWeek {
317    let startDate: Date
318    let days: [ContributionGraphDay]
319
320    var slots: [ContributionGraphDay?] {
321        let calendar = Calendar.contributionCalendar
322        let indexedDays = Dictionary(uniqueKeysWithValues: days.map { day in
323            (calendar.component(.weekday, from: day.date), day)
324        })
325
326        return (1...7).map { weekday in
327            indexedDays[weekday]
328        }
329    }
330}
331
332private enum ContributionGraphLayout {
333    static func weekColumns(from days: [ContributionGraphDay]) -> [ContributionGraphWeek] {
334        let groupedDays = Dictionary(grouping: days) { day in
335            Calendar.contributionCalendar.startOfWeek(for: day.date)
336        }
337
338        return groupedDays.keys.sorted().map { weekStart in
339            ContributionGraphWeek(
340                startDate: weekStart,
341                days: groupedDays[weekStart, default: []].sorted { $0.date < $1.date }
342            )
343        }
344    }
345}
346
347private enum ContributionGraphDateParser {
348    static func parse(_ rawValue: String) -> Date? {
349        let parts = rawValue.split(separator: "-", omittingEmptySubsequences: false)
350        guard
351            parts.count == 3,
352            let year = Int(parts[0]),
353            let month = Int(parts[1]),
354            let day = Int(parts[2])
355        else {
356            return nil
357        }
358
359        var components = DateComponents()
360        components.calendar = .contributionCalendar
361        components.timeZone = TimeZone(secondsFromGMT: 0)
362        components.year = year
363        components.month = month
364        components.day = day
365        return components.date
366    }
367
368    static func decodeDateString<Key: CodingKey>(
369        from container: KeyedDecodingContainer<Key>,
370        forKey key: Key
371    ) throws -> Date {
372        let rawValue = try container.decode(String.self, forKey: key)
373        guard let date = parse(rawValue) else {
374            throw DecodingError.dataCorruptedError(
375                forKey: key,
376                in: container,
377                debugDescription: "Invalid contribution date: \(rawValue)"
378            )
379        }
380        return date
381    }
382}
383
384private enum ContributionGraphSampleData {
385    static let placeholderWeeks: [ContributionGraphWeek] = {
386        let startDate = Calendar.contributionCalendar.startOfDay(for: .now)
387        let days = (0..<150).compactMap { offset -> ContributionGraphDay? in
388            guard let date = Calendar.contributionCalendar.date(byAdding: .day, value: -offset, to: startDate) else {
389                return nil
390            }
391            return ContributionGraphDay(date: date, count: (offset % 8), score: 0)
392        }
393        return ContributionGraphLayout.weekColumns(from: days)
394    }()
395}
396
397private extension ContributionGraphDay {
398    init(date: Date, count: Int, score _: Double) {
399        self.date = date
400        self.count = count
401    }
402}
403
404private extension Calendar {
405    static var contributionCalendar: Calendar {
406        var calendar = Calendar(identifier: .gregorian)
407        calendar.firstWeekday = 1
408        calendar.timeZone = TimeZone(secondsFromGMT: 0)!
409        return calendar
410    }
411
412    func startOfWeek(for date: Date) -> Date {
413        dateInterval(of: .weekOfYear, for: date)?.start ?? startOfDay(for: date)
414    }
415}