summaryrefslogtreecommitdiff
path: root/HutchWidgetExtension
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-11 19:47:47 -0500
committerChristian Cleberg <[email protected]>2026-04-11 19:47:47 -0500
commitba773aaa9da5798a458ee58f7ce37db070ada7d8 (patch)
tree1485cab8d853d853654b199a4ec8cab4233d06d5 /HutchWidgetExtension
parent61164b6dd8343c8699d091dd5fccc55beb0b90b9 (diff)
downloadhutch-ba773aaa9da5798a458ee58f7ce37db070ada7d8.tar.gz
hutch-ba773aaa9da5798a458ee58f7ce37db070ada7d8.tar.bz2
hutch-ba773aaa9da5798a458ee58f7ce37db070ada7d8.zip
feat: add contribution graph widget with adaptive layoutv2.12.0
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.
Diffstat (limited to 'HutchWidgetExtension')
-rw-r--r--HutchWidgetExtension/ContributionGraphWidget.swift415
-rw-r--r--HutchWidgetExtension/NeedsAttentionWidget.swift1
2 files changed, 416 insertions, 0 deletions
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()
}
}