aboutsummaryrefslogtreecommitdiff
path: root/HutchWidgetExtension/ContributionGraphWidget.swift
blob: 2fc07f23b8e8a59d8991661e3318c2fc5344ecc6 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
import SwiftUI
import WidgetKit

struct ContributionGraphEntry: TimelineEntry {
    let date: Date
    let actor: String?
    let state: State
    let weeks: [ContributionGraphWeek]

    enum State {
        case placeholder
        case disabled
        case unavailable
        case empty
        case indexing
        case populated
    }
}

struct ContributionGraphTimelineProvider: TimelineProvider {
    func placeholder(in _: Context) -> ContributionGraphEntry {
        ContributionGraphEntry(
            date: .now,
            actor: "~ccleberg",
            state: .placeholder,
            weeks: ContributionGraphSampleData.placeholderWeeks
        )
    }

    func getSnapshot(in _: Context, completion: @escaping (ContributionGraphEntry) -> Void) {
        Task {
            completion(await loadEntry())
        }
    }

    func getTimeline(in _: Context, completion: @escaping (Timeline<ContributionGraphEntry>) -> Void) {
        Task {
            let entry = await loadEntry()
            let refreshDate = Calendar.contributionCalendar.date(byAdding: .hour, value: 1, to: Date()) ?? Date().addingTimeInterval(3600)
            completion(Timeline(entries: [entry], policy: .after(refreshDate)))
        }
    }

    private func loadEntry() async -> ContributionGraphEntry {
        guard ContributionWidgetContextStore.isEnabled() else {
            return ContributionGraphEntry(date: .now, actor: nil, state: .disabled, weeks: [])
        }

        guard let actor = ContributionWidgetContextStore.loadActor(), !actor.isEmpty else {
            return ContributionGraphEntry(date: .now, actor: nil, state: .unavailable, weeks: [])
        }

        do {
            let response = try await ContributionGraphWidgetService().fetchCalendar(actor: actor)
            let state: ContributionGraphEntry.State
            if response.totalCount > 0 {
                state = .populated
            } else {
                switch response.indexingState {
                case .pending:
                    state = .indexing
                case .error:
                    state = .unavailable
                case .indexed:
                    state = .empty
                }
            }

            return ContributionGraphEntry(
                date: .now,
                actor: actor,
                state: state,
                weeks: ContributionGraphLayout.weekColumns(from: response.days)
            )
        } catch {
            return ContributionGraphEntry(date: .now, actor: actor, state: .unavailable, weeks: [])
        }
    }
}

struct ContributionGraphWidget: Widget {
    static let kind = ContributionGraphWidgetConfiguration.kind

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: Self.kind, provider: ContributionGraphTimelineProvider()) { entry in
            ContributionGraphWidgetView(entry: entry)
        }
        .configurationDisplayName("Contribution Graph")
        .description("A trailing 365-day SourceHut contribution heatmap for your current profile.")
        .supportedFamilies([.systemSmall, .systemMedium])
    }
}

private struct ContributionGraphWidgetView: View {
    let entry: ContributionGraphEntry
    @Environment(\.widgetFamily) private var family

    var body: some View {
        Group {
            switch entry.state {
            case .disabled:
                messageView(
                    title: "Contributions Disabled",
                    detail: "Enable contribution graphs in Hutch settings to use this widget."
                )
            case .unavailable:
                messageView(
                    title: "Open Hutch",
                    detail: "Sign in and open your profile to load contribution data."
                )
            default:
                graphView
            }
        }
        .widgetURL(HutchDeepLinkURL.home)
        .containerBackground(for: .widget) {
            Color(.systemBackground)
        }
    }

    private var graphView: some View {
        GeometryReader { geometry in
            let cellSize = ContributionGraphSizing.baseCellSize(availableHeight: geometry.size.height)
            let layout = ContributionGraphSizing.layout(availableSize: geometry.size, cellSize: cellSize)
            let weeks = displayedWeeks(columnCount: layout.columns)

            let actualSize = ContributionGraphSizing.contentSize(
                columns: weeks.count, cellSize: layout.cellSize, spacing: layout.spacing
            )

            ContributionGraphGridView(
                weeks: weeks,
                squareSize: layout.cellSize,
                spacing: layout.spacing
            )
            .frame(width: actualSize.width, height: actualSize.height)
            .frame(maxWidth: .infinity, maxHeight: .infinity)
        }
    }

    private func messageView(title: String, detail: String) -> some View {
        VStack(spacing: 6) {
            Text(title)
                .font(family == .systemSmall ? .headline : .title3.weight(.semibold))
                .multilineTextAlignment(.center)
            Text(detail)
                .font(.caption)
                .foregroundStyle(.secondary)
                .multilineTextAlignment(.center)
        }
        .padding()
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }

    private func displayedWeeks(columnCount: Int) -> [ContributionGraphWeek] {
        var baseWeeks = switch entry.state {
        case .populated, .indexing:
            entry.weeks
        case .empty:
            ContributionGraphSampleData.emptyWeeks
        case .placeholder, .disabled, .unavailable:
            ContributionGraphSampleData.placeholderWeeks
        }

        if entry.state != .empty {
            // Drop any trailing week where every day has zero contributions.
            while let last = baseWeeks.last, last.days.allSatisfy({ $0.count == 0 }) {
                baseWeeks.removeLast()
            }
        }

        return Array(baseWeeks.suffix(columnCount))
    }
}

private struct ContributionGraphGridView: View {
    let weeks: [ContributionGraphWeek]
    let squareSize: CGFloat
    let spacing: CGFloat

    var body: some View {
        HStack(alignment: .top, spacing: spacing) {
            ForEach(weeks, id: \.startDate) { week in
                VStack(spacing: spacing) {
                    ForEach(Array(week.slots.enumerated()), id: \.offset) { _, day in
                        RoundedRectangle(cornerRadius: squareSize * 0.2, style: .continuous)
                            .fill((day?.intensity ?? .empty).color)
                            .frame(width: squareSize, height: squareSize)
                            .overlay {
                                let intensity = day?.intensity ?? .empty
                                RoundedRectangle(cornerRadius: squareSize * 0.2, style: .continuous)
                                    .stroke(Color.primary.opacity(intensity == .empty ? 0.08 : 0), lineWidth: 0.5)
                            }
                    }
                }
            }
        }
    }
}

private enum ContributionGraphSizing {
    static let rowCount = 7
    static let cellSpacing: CGFloat = 3

    struct GridLayout {
        let rows: Int
        let columns: Int
        let cellSize: CGFloat
        let spacing: CGFloat

        var contentSize: CGSize {
            let w = CGFloat(columns) * cellSize + CGFloat(max(0, columns - 1)) * spacing
            let h = CGFloat(rows) * cellSize + CGFloat(max(0, rows - 1)) * spacing
            return CGSize(width: w, height: h)
        }
    }

    /// Cell size derived from available height so 7 rows fill it exactly.
    static func baseCellSize(availableHeight: CGFloat) -> CGFloat {
        floor((availableHeight - cellSpacing * CGFloat(rowCount - 1)) / CGFloat(rowCount))
    }

    /// Compute layout for any family: 7 rows, as many columns as fit at the given cell size.
    static func layout(availableSize: CGSize, cellSize: CGFloat) -> GridLayout {
        let columns = max(1, Int(floor((availableSize.width + cellSpacing) / (cellSize + cellSpacing))))
        return GridLayout(rows: rowCount, columns: columns, cellSize: cellSize, spacing: cellSpacing)
    }

    /// Content size for the actual number of displayed columns (may differ from layout max).
    static func contentSize(columns: Int, cellSize: CGFloat, spacing: CGFloat) -> CGSize {
        let w = CGFloat(columns) * cellSize + CGFloat(max(0, columns - 1)) * spacing
        let h = CGFloat(rowCount) * cellSize + CGFloat(max(0, rowCount - 1)) * spacing
        return CGSize(width: w, height: h)
    }
}

private struct ContributionGraphWidgetService {
    private let baseURL = HutchStatsAPI.defaultBaseURL

    func fetchCalendar(actor: String) async throws -> ContributionGraphResponse {
        guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else {
            throw URLError(.badURL)
        }

        components.path = "/api/contributions/\(actor)"
        let range = trailingRange(endingOn: Date())
        components.queryItems = [
            URLQueryItem(name: "from", value: Self.rangeFormatter.string(from: range.lowerBound)),
            URLQueryItem(name: "to", value: Self.rangeFormatter.string(from: range.upperBound))
        ]

        guard let url = components.url else {
            throw URLError(.badURL)
        }

        let (data, response) = try await URLSession.shared.data(from: url)
        if let httpResponse = response as? HTTPURLResponse, !(200...299).contains(httpResponse.statusCode) {
            throw URLError(.badServerResponse)
        }

        return try JSONDecoder().decode(ContributionGraphResponse.self, from: data)
    }

    private func trailingRange(endingOn endDate: Date) -> ClosedRange<Date> {
        let normalizedEndDate = Calendar.contributionCalendar.startOfDay(for: endDate)
        let oneYearBack = Calendar.contributionCalendar.date(byAdding: .year, value: -1, to: normalizedEndDate) ?? normalizedEndDate
        let normalizedStartDate = Calendar.contributionCalendar.date(byAdding: .day, value: 1, to: oneYearBack) ?? oneYearBack
        return normalizedStartDate...normalizedEndDate
    }

    private static let rangeFormatter: DateFormatter = {
        let formatter = DateFormatter()
        formatter.calendar = .contributionCalendar
        formatter.locale = Locale(identifier: "en_US_POSIX")
        formatter.timeZone = TimeZone(secondsFromGMT: 0)
        formatter.dateFormat = "yyyy-MM-dd"
        return formatter
    }()
}

private struct ContributionGraphResponse: Decodable {
    let actor: String
    let indexingState: ContributionGraphIndexingState
    let days: [ContributionGraphDay]

    enum CodingKeys: String, CodingKey {
        case actor
        case indexingState = "indexing_state"
        case days
    }

    var totalCount: Int {
        days.reduce(0) { $0 + $1.count }
    }
}

struct ContributionGraphDay: Decodable, Identifiable {
    var id: Date { date }

    let date: Date
    let count: Int

    enum CodingKeys: String, CodingKey {
        case date
        case count
    }

    var intensity: ContributionGraphIntensity {
        ContributionGraphIntensity(count: count)
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        date = try ContributionGraphDateParser.decodeDateString(from: container, forKey: .date)
        count = try container.decode(Int.self, forKey: .count)
    }
}

private enum ContributionGraphIndexingState: String, Decodable {
    case pending
    case indexed
    case error
}

enum ContributionGraphIntensity: Int, CaseIterable {
    case empty = 0
    case level1 = 1
    case level2 = 2
    case level3 = 3
    case level4 = 4

    init(count: Int) {
        switch count {
        case ..<1:
            self = .empty
        case 1:
            self = .level1
        case 2...3:
            self = .level2
        case 4...6:
            self = .level3
        default:
            self = .level4
        }
    }

    var color: Color {
        switch self {
        case .empty:
            Color(uiColor: .secondarySystemFill)
        case .level1:
            Color(red: 0.82, green: 0.92, blue: 0.83)
        case .level2:
            Color(red: 0.58, green: 0.83, blue: 0.61)
        case .level3:
            Color(red: 0.25, green: 0.69, blue: 0.36)
        case .level4:
            Color(red: 0.12, green: 0.47, blue: 0.21)
        }
    }
}

struct ContributionGraphWeek {
    let startDate: Date
    let days: [ContributionGraphDay]

    var slots: [ContributionGraphDay?] {
        let calendar = Calendar.contributionCalendar
        let indexedDays = Dictionary(uniqueKeysWithValues: days.map { day in
            (calendar.component(.weekday, from: day.date), day)
        })

        return (1...7).map { weekday in
            indexedDays[weekday]
        }
    }
}

private enum ContributionGraphLayout {
    static func weekColumns(from days: [ContributionGraphDay]) -> [ContributionGraphWeek] {
        let groupedDays = Dictionary(grouping: days) { day in
            Calendar.contributionCalendar.startOfWeek(for: day.date)
        }

        return groupedDays.keys.sorted().map { weekStart in
            ContributionGraphWeek(
                startDate: weekStart,
                days: groupedDays[weekStart, default: []].sorted { $0.date < $1.date }
            )
        }
    }
}

private enum ContributionGraphDateParser {
    static func parse(_ rawValue: String) -> Date? {
        let parts = rawValue.split(separator: "-", omittingEmptySubsequences: false)
        guard
            parts.count == 3,
            let year = Int(parts[0]),
            let month = Int(parts[1]),
            let day = Int(parts[2])
        else {
            return nil
        }

        var components = DateComponents()
        components.calendar = .contributionCalendar
        components.timeZone = TimeZone(secondsFromGMT: 0)
        components.year = year
        components.month = month
        components.day = day
        return components.date
    }

    static func decodeDateString<Key: CodingKey>(
        from container: KeyedDecodingContainer<Key>,
        forKey key: Key
    ) throws -> Date {
        let rawValue = try container.decode(String.self, forKey: key)
        guard let date = parse(rawValue) else {
            throw DecodingError.dataCorruptedError(
                forKey: key,
                in: container,
                debugDescription: "Invalid contribution date: \(rawValue)"
            )
        }
        return date
    }
}

private enum ContributionGraphSampleData {
    static let emptyWeeks: [ContributionGraphWeek] = {
        let startDate = Calendar.contributionCalendar.startOfDay(for: .now)
        let days = (0..<371).compactMap { offset -> ContributionGraphDay? in
            guard let date = Calendar.contributionCalendar.date(byAdding: .day, value: -offset, to: startDate) else {
                return nil
            }
            return ContributionGraphDay(date: date, count: 0, score: 0)
        }
        return ContributionGraphLayout.weekColumns(from: days)
    }()

    static let placeholderWeeks: [ContributionGraphWeek] = {
        let startDate = Calendar.contributionCalendar.startOfDay(for: .now)
        let days = (0..<150).compactMap { offset -> ContributionGraphDay? in
            guard let date = Calendar.contributionCalendar.date(byAdding: .day, value: -offset, to: startDate) else {
                return nil
            }
            return ContributionGraphDay(date: date, count: (offset % 8), score: 0)
        }
        return ContributionGraphLayout.weekColumns(from: days)
    }()
}

private extension ContributionGraphDay {
    init(date: Date, count: Int, score _: Double) {
        self.date = date
        self.count = count
    }
}

private extension Calendar {
    static var contributionCalendar: Calendar {
        var calendar = Calendar(identifier: .gregorian)
        calendar.firstWeekday = 1
        calendar.timeZone = TimeZone(secondsFromGMT: 0)!
        return calendar
    }

    func startOfWeek(for date: Date) -> Date {
        dateInterval(of: .weekOfYear, for: date)?.start ?? startOfDay(for: date)
    }
}