krz/hutch

an ios client for sourcehut

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

ba773aaa9da5798a458ee58f7ce37db070ada7d8

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-12T00:47:47Z

feat: add contribution graph widget with adaptive layout

Add small and medium widget families showing a trailing contribution
heatmap. Cell size is derived from available height to fill 7 rows,
columns scale with width. Trailing empty weeks are trimmed.
 HutchWidgetExtension/ContributionGraphWidget.swift | 415 +++++++++++++++++++++
 HutchWidgetExtension/NeedsAttentionWidget.swift    |   1 +
 Shared/ContributionWidgetContext.swift             |  36 ++
 3 files changed, 452 insertions(+)

diff --git a/HutchWidgetExtension/ContributionGraphWidget.swift b/HutchWidgetExtension/ContributionGraphWidget.swift
new file mode 100644
index 0000000..c690f54
--- /dev/null
+++ b/HutchWidgetExtension/ContributionGraphWidget.swift
@@ -0,0 +1,415 @@
+import SwiftUI
+import WidgetKit
+
+struct ContributionGraphEntry: TimelineEntry {
+    let date: Date
+    let actor: String?
+    let state: State
+    let weeks: [ContributionGraphWeek]
+
+    enum State {
+        case placeholder
+        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 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 {
+        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)
+        }
+        .widgetURL(URL(string: "hutch://home"))
+        .containerBackground(for: .widget) {
+            Color(.systemBackground)
+        }
+    }
+
+    private func displayedWeeks(columnCount: Int) -> [ContributionGraphWeek] {
+        var baseWeeks = switch entry.state {
+        case .populated, .indexing, .empty:
+            entry.weeks
+        case .placeholder, .unavailable:
+            ContributionGraphSampleData.placeholderWeeks
+        }
+
+        // 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)
+                    }
+                }
+            }
+        }
+    }
+}
+
+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 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)
+    }
+}
diff --git a/HutchWidgetExtension/NeedsAttentionWidget.swift b/HutchWidgetExtension/NeedsAttentionWidget.swift
index 6191d20..e212af6 100644
--- a/HutchWidgetExtension/NeedsAttentionWidget.swift
+++ b/HutchWidgetExtension/NeedsAttentionWidget.swift
@@ -302,5 +302,6 @@ private struct NeedsAttentionWidgetView: View {
 struct HutchWidgets: WidgetBundle {
     var body: some Widget {
         NeedsAttentionWidget()
+        ContributionGraphWidget()
     }
 }
diff --git a/Shared/ContributionWidgetContext.swift b/Shared/ContributionWidgetContext.swift
new file mode 100644
index 0000000..3fadc8a
--- /dev/null
+++ b/Shared/ContributionWidgetContext.swift
@@ -0,0 +1,36 @@
+import Foundation
+#if canImport(WidgetKit)
+import WidgetKit
+#endif
+
+enum ContributionGraphWidgetConfiguration {
+    static let kind = "ContributionGraphWidget"
+}
+
+enum ContributionWidgetContextStore {
+    private static let actorKey = "contributionWidget.actor"
+
+    static func loadActor(defaults: UserDefaults? = sharedDefaults()) -> String? {
+        defaults?.string(forKey: actorKey)
+    }
+
+    static func saveActor(_ actor: String, defaults: UserDefaults? = sharedDefaults()) {
+        defaults?.set(actor, forKey: actorKey)
+        reloadWidgetTimelines()
+    }
+
+    static func clear(defaults: UserDefaults? = sharedDefaults()) {
+        defaults?.removeObject(forKey: actorKey)
+        reloadWidgetTimelines()
+    }
+
+    private static func sharedDefaults() -> UserDefaults? {
+        UserDefaults(suiteName: HutchAppGroup.identifier)
+    }
+
+    private static func reloadWidgetTimelines() {
+        #if canImport(WidgetKit)
+        WidgetCenter.shared.reloadTimelines(ofKind: ContributionGraphWidgetConfiguration.kind)
+        #endif
+    }
+}