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(URL(string: "hutch://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 = URL(string: "https://hutch-stats.zerolabs.sh")!
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)
}
}
|