summaryrefslogtreecommitdiff
path: root/Hutch
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-11 19:47:40 -0500
committerChristian Cleberg <[email protected]>2026-04-11 19:47:40 -0500
commit61164b6dd8343c8699d091dd5fccc55beb0b90b9 (patch)
tree0a2befeb986966d42062802e2a29d795be907fee /Hutch
parent27bfcbbed6acde158ec40c5bb46e45e76c81a319 (diff)
downloadhutch-61164b6dd8343c8699d091dd5fccc55beb0b90b9.tar.gz
hutch-61164b6dd8343c8699d091dd5fccc55beb0b90b9.tar.bz2
hutch-61164b6dd8343c8699d091dd5fccc55beb0b90b9.zip
feat: add contribution calendar to user profilesv2.11.0
Add a trailing 365-day contribution heatmap to user profile views, backed by HutchStatsService. Includes calendar model, view model, SwiftUI calendar view, app configuration, and unit tests.
Diffstat (limited to 'Hutch')
-rw-r--r--Hutch/App/AppConfiguration.swift42
-rw-r--r--Hutch/App/AppState.swift7
-rw-r--r--Hutch/App/AppStorageKeys.swift2
-rw-r--r--Hutch/App/RootView.swift3
-rw-r--r--Hutch/Models/ContributionCalendar.swift340
-rw-r--r--Hutch/Networking/HutchStatsService.swift121
-rw-r--r--Hutch/Views/Lookup/ContributionCalendarView.swift224
-rw-r--r--Hutch/Views/Lookup/ContributionCalendarViewModel.swift204
-rw-r--r--Hutch/Views/Lookup/LookupView.swift2
-rw-r--r--Hutch/Views/Lookup/UserProfileView.swift45
-rw-r--r--Hutch/Views/Lookup/UserProfileViewModel.swift91
11 files changed, 1076 insertions, 5 deletions
diff --git a/Hutch/App/AppConfiguration.swift b/Hutch/App/AppConfiguration.swift
new file mode 100644
index 0000000..7824371
--- /dev/null
+++ b/Hutch/App/AppConfiguration.swift
@@ -0,0 +1,42 @@
+import Foundation
+
+struct AppConfiguration: Sendable {
+ static let defaultHutchStatsBaseURL = URL(string: "https://hutch-stats.zerolabs.sh")!
+ static let hutchStatsBaseURLEnvironmentKey = "HUTCH_STATS_BASE_URL"
+
+ let hutchStatsBaseURL: URL
+
+ init(
+ userDefaults: UserDefaults = .standard,
+ processInfo: ProcessInfo = .processInfo,
+ environment: [String: String]? = nil
+ ) {
+ let resolvedEnvironment = environment ?? processInfo.environment
+
+ if
+ let rawValue = resolvedEnvironment[Self.hutchStatsBaseURLEnvironmentKey],
+ let url = Self.normalizedURL(from: rawValue)
+ {
+ self.hutchStatsBaseURL = url
+ } else if
+ let rawValue = userDefaults.string(forKey: AppStorageKeys.hutchStatsBaseURL),
+ let url = Self.normalizedURL(from: rawValue)
+ {
+ self.hutchStatsBaseURL = url
+ } else {
+ self.hutchStatsBaseURL = Self.defaultHutchStatsBaseURL
+ }
+ }
+
+ private static func normalizedURL(from rawValue: String) -> URL? {
+ guard var components = URLComponents(string: rawValue.trimmingCharacters(in: .whitespacesAndNewlines)) else {
+ return nil
+ }
+
+ if components.path.isEmpty {
+ components.path = "/"
+ }
+
+ return components.url
+ }
+}
diff --git a/Hutch/App/AppState.swift b/Hutch/App/AppState.swift
index 8613dc8..c519adc 100644
--- a/Hutch/App/AppState.swift
+++ b/Hutch/App/AppState.swift
@@ -56,6 +56,7 @@ final class AppState {
// MARK: - Networking
let client: SRHTClient
+ let configuration: AppConfiguration
// MARK: - Deep link pending navigation
@@ -67,6 +68,7 @@ final class AppState {
// MARK: - Init
init() {
+ self.configuration = AppConfiguration()
let token = KeychainHelper.loadToken()
self.client = SRHTClient(token: token)
}
@@ -107,11 +109,13 @@ final class AppState {
accounts = storedAccounts
activeAccountID = target.id
currentUser = user
+ ContributionWidgetContextStore.saveActor(user.canonicalName)
authPhase = .authenticated
await refreshNeedsAttentionSnapshot()
} catch {
client.setToken(nil)
currentUser = nil
+ ContributionWidgetContextStore.clear()
authPhase = .unauthenticated
NeedsAttentionSnapshotStore.clear()
}
@@ -131,6 +135,7 @@ final class AppState {
UserDefaults.standard.set(entry.id, forKey: AppStorageKeys.activeAccountID)
try KeychainHelper.saveAccounts(accounts)
currentUser = user
+ ContributionWidgetContextStore.saveActor(user.canonicalName)
authPhase = .authenticated
await refreshNeedsAttentionSnapshot()
} catch {
@@ -168,6 +173,7 @@ final class AppState {
let user = try await fetchMe()
currentUser = user
+ ContributionWidgetContextStore.saveActor(user.canonicalName)
authPhase = .authenticated
await refreshNeedsAttentionSnapshot()
}
@@ -355,6 +361,7 @@ final class AppState {
activeAccountID = ""
UserDefaults.standard.removeObject(forKey: AppStorageKeys.activeAccountID)
currentUser = nil
+ ContributionWidgetContextStore.clear()
pendingDeepLink = nil
pendingTabNavigation = nil
deepLinkError = nil
diff --git a/Hutch/App/AppStorageKeys.swift b/Hutch/App/AppStorageKeys.swift
index 6b8c804..dcf38ba 100644
--- a/Hutch/App/AppStorageKeys.swift
+++ b/Hutch/App/AppStorageKeys.swift
@@ -1,7 +1,9 @@
// Shared UserDefaults key constants
enum AppStorageKeys {
static let swipeActionsEnabled = "swipeActionsEnabled"
+ static let contributionGraphsEnabled = "contributionGraphsEnabled"
static let activeAccountID = "activeAccountID"
static let wrapRepositoryFileLines = "wrapRepositoryFileLines"
static let lookupHistory = "lookupHistory"
+ static let hutchStatsBaseURL = "hutchStatsBaseURL"
}
diff --git a/Hutch/App/RootView.swift b/Hutch/App/RootView.swift
index 96c17a8..f3e7159 100644
--- a/Hutch/App/RootView.swift
+++ b/Hutch/App/RootView.swift
@@ -266,6 +266,7 @@ enum MoreRoute: Hashable {
case lookup
case lists
case pastes
+ case profile
case settings
case mailingList(InboxMailingListReference)
case thread(InboxThreadSummary)
@@ -284,6 +285,8 @@ private struct MoreNavigationRoot: View {
MailingListListView()
case .pastes:
PasteListView()
+ case .profile:
+ ProfileView()
case .settings:
SettingsView()
case .mailingList(let mailingList):
diff --git a/Hutch/Models/ContributionCalendar.swift b/Hutch/Models/ContributionCalendar.swift
new file mode 100644
index 0000000..e2942dd
--- /dev/null
+++ b/Hutch/Models/ContributionCalendar.swift
@@ -0,0 +1,340 @@
+import Foundation
+
+struct ContributionCalendarResponse: Decodable, Sendable, Hashable {
+ let actor: String
+ let from: Date
+ let to: Date
+ let isIndexed: Bool
+ let lastPolledAt: Date?
+ let indexingState: ContributionIndexingState
+ let days: [ContributionDay]
+
+ enum CodingKeys: String, CodingKey {
+ case actor
+ case from
+ case to
+ case isIndexed = "is_indexed"
+ case lastPolledAt = "last_polled_at"
+ case indexingState = "indexing_state"
+ case days
+ }
+
+ init(
+ actor: String,
+ from: Date,
+ to: Date,
+ isIndexed: Bool,
+ lastPolledAt: Date?,
+ indexingState: ContributionIndexingState,
+ days: [ContributionDay]
+ ) {
+ self.actor = actor
+ self.from = from
+ self.to = to
+ self.isIndexed = isIndexed
+ self.lastPolledAt = lastPolledAt
+ self.indexingState = indexingState
+ self.days = days.sorted { $0.date < $1.date }
+ }
+
+ init(from decoder: any Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ actor = try container.decode(String.self, forKey: .actor)
+ from = try ContributionDateParser.decodeDateString(from: container, forKey: .from)
+ to = try ContributionDateParser.decodeDateString(from: container, forKey: .to)
+ isIndexed = try container.decodeIfPresent(Bool.self, forKey: .isIndexed) ?? false
+ lastPolledAt = try ContributionDateParser.decodeOptionalTimestamp(from: container, forKey: .lastPolledAt)
+ indexingState = try container.decodeIfPresent(ContributionIndexingState.self, forKey: .indexingState) ?? .indexed
+ days = try container.decode([ContributionDay].self, forKey: .days).sorted { $0.date < $1.date }
+ }
+
+ var totalCount: Int {
+ days.reduce(into: 0) { partialResult, day in
+ partialResult += day.count
+ }
+ }
+
+ var isEmpty: Bool {
+ totalCount == 0
+ }
+}
+
+struct ContributionDay: Decodable, Sendable, Hashable, Identifiable {
+ var id: Date { date }
+
+ let date: Date
+ let count: Int
+ let score: Double
+
+ enum CodingKeys: String, CodingKey {
+ case date
+ case count
+ case score
+ }
+
+ init(date: Date, count: Int, score: Double) {
+ self.date = date
+ self.count = count
+ self.score = score
+ }
+
+ init(from decoder: any Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ date = try ContributionDateParser.decodeDateString(from: container, forKey: .date)
+ count = try container.decode(Int.self, forKey: .count)
+ score = try container.decode(Double.self, forKey: .score)
+ }
+
+ var intensity: ContributionIntensity {
+ ContributionIntensity(count: count)
+ }
+}
+
+struct ContributionStatsResponse: Decodable, Sendable, Hashable {
+ let actor: String
+ let from: Date
+ let to: Date
+ let isIndexed: Bool
+ let lastPolledAt: Date?
+ let indexingState: ContributionIndexingState
+ let totalEvents: Int
+ let totalScore: Double
+ let activeDays: Int
+ let longestStreak: Int
+ let currentStreak: Int
+
+ enum CodingKeys: String, CodingKey {
+ case actor
+ case from
+ case to
+ case isIndexed = "is_indexed"
+ case lastPolledAt = "last_polled_at"
+ case indexingState = "indexing_state"
+ case totalEvents = "total_events"
+ case totalScore = "total_score"
+ case activeDays = "active_days"
+ case longestStreak = "longest_streak"
+ case currentStreak = "current_streak"
+ }
+
+ init(
+ actor: String,
+ from: Date,
+ to: Date,
+ isIndexed: Bool,
+ lastPolledAt: Date?,
+ indexingState: ContributionIndexingState,
+ totalEvents: Int,
+ totalScore: Double,
+ activeDays: Int,
+ longestStreak: Int,
+ currentStreak: Int
+ ) {
+ self.actor = actor
+ self.from = from
+ self.to = to
+ self.isIndexed = isIndexed
+ self.lastPolledAt = lastPolledAt
+ self.indexingState = indexingState
+ self.totalEvents = totalEvents
+ self.totalScore = totalScore
+ self.activeDays = activeDays
+ self.longestStreak = longestStreak
+ self.currentStreak = currentStreak
+ }
+
+ init(from decoder: any Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ actor = try container.decode(String.self, forKey: .actor)
+ from = try ContributionDateParser.decodeDateString(from: container, forKey: .from)
+ to = try ContributionDateParser.decodeDateString(from: container, forKey: .to)
+ isIndexed = try container.decodeIfPresent(Bool.self, forKey: .isIndexed) ?? false
+ lastPolledAt = try ContributionDateParser.decodeOptionalTimestamp(from: container, forKey: .lastPolledAt)
+ indexingState = try container.decodeIfPresent(ContributionIndexingState.self, forKey: .indexingState) ?? .indexed
+ totalEvents = try container.decode(Int.self, forKey: .totalEvents)
+ totalScore = try container.decode(Double.self, forKey: .totalScore)
+ activeDays = try container.decode(Int.self, forKey: .activeDays)
+ longestStreak = try container.decode(Int.self, forKey: .longestStreak)
+ currentStreak = try container.decode(Int.self, forKey: .currentStreak)
+ }
+}
+
+enum ContributionIndexingState: String, Codable, Sendable, Hashable {
+ case pending
+ case indexed
+ case error
+}
+
+enum ContributionIntensity: Int, Sendable, 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
+ }
+ }
+}
+
+struct ContributionWeek: Sendable, Hashable {
+ let startDate: Date
+ let days: [ContributionDay]
+}
+
+enum ContributionCalendarLayout {
+ static func weekColumns(
+ from days: [ContributionDay],
+ calendar: Calendar = .contributionCalendar
+ ) -> [ContributionWeek] {
+ let groupedDays = Dictionary(grouping: days) { day in
+ calendar.startOfWeek(for: day.date)
+ }
+
+ return groupedDays.keys.sorted().map { weekStart in
+ ContributionWeek(
+ startDate: weekStart,
+ days: groupedDays[weekStart, default: []].sorted { $0.date < $1.date }
+ )
+ }
+ }
+
+ static func recentWeeks(
+ from days: [ContributionDay],
+ count: Int,
+ calendar: Calendar = .contributionCalendar
+ ) -> [ContributionWeek] {
+ Array(weekColumns(from: days, calendar: calendar).suffix(count))
+ }
+}
+
+enum ContributionDateParser {
+ 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
+
+ guard let date = components.date else {
+ return nil
+ }
+
+ let resolvedComponents = Calendar.contributionCalendar.dateComponents([.year, .month, .day], from: date)
+ guard
+ resolvedComponents.year == year,
+ resolvedComponents.month == month,
+ resolvedComponents.day == day
+ else {
+ return nil
+ }
+
+ return 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
+ }
+
+ static func parseTimestamp(_ rawValue: String) -> Date? {
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ formatter.timeZone = TimeZone(secondsFromGMT: 0)
+
+ if let date = formatter.date(from: rawValue) {
+ return date
+ }
+
+ formatter.formatOptions = [.withInternetDateTime]
+ if let date = formatter.date(from: rawValue) {
+ return date
+ }
+
+ let fallbackFormats = [
+ "yyyy-MM-dd'T'HH:mm:ss.SSSSSS",
+ "yyyy-MM-dd'T'HH:mm:ss.SSS",
+ "yyyy-MM-dd'T'HH:mm:ss"
+ ]
+
+ let dateFormatter = DateFormatter()
+ dateFormatter.calendar = .contributionCalendar
+ dateFormatter.locale = Locale(identifier: "en_US_POSIX")
+ dateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
+
+ for format in fallbackFormats {
+ dateFormatter.dateFormat = format
+ if let date = dateFormatter.date(from: rawValue) {
+ return date
+ }
+ }
+
+ return nil
+ }
+
+ static func decodeOptionalTimestamp<Key: CodingKey>(
+ from container: KeyedDecodingContainer<Key>,
+ forKey key: Key
+ ) throws -> Date? {
+ guard let rawValue = try container.decodeIfPresent(String.self, forKey: key) else {
+ return nil
+ }
+
+ guard let date = parseTimestamp(rawValue) else {
+ throw DecodingError.dataCorruptedError(
+ forKey: key,
+ in: container,
+ debugDescription: "Invalid contribution timestamp: \(rawValue)"
+ )
+ }
+
+ return date
+ }
+}
+
+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/Hutch/Networking/HutchStatsService.swift b/Hutch/Networking/HutchStatsService.swift
new file mode 100644
index 0000000..cb08715
--- /dev/null
+++ b/Hutch/Networking/HutchStatsService.swift
@@ -0,0 +1,121 @@
+import Foundation
+
+protocol ContributionCalendarServing: Sendable {
+ func fetchContributionCalendar(actor: String, endingOn endDate: Date) async throws -> ContributionCalendarResponse
+ func fetchContributionStats(actor: String, endingOn endDate: Date) async throws -> ContributionStatsResponse
+}
+
+struct HutchStatsService: ContributionCalendarServing {
+ private let session: URLSession
+ private let decoder: JSONDecoder
+ private let baseURL: URL
+
+ init(
+ session: URLSession = .shared,
+ configuration: AppConfiguration
+ ) {
+ self.session = session
+ self.baseURL = configuration.hutchStatsBaseURL
+ self.decoder = JSONDecoder()
+ }
+
+ func fetchContributionCalendar(actor: String, endingOn endDate: Date) async throws -> ContributionCalendarResponse {
+ debugLog("calendar request actor=\(actor) endDate=\(Self.rangeFormatter.string(from: endDate))")
+ return try await fetch(
+ path: "api/contributions/\(actor)",
+ queryItems: trailingYearQueryItems(endingOn: endDate),
+ responseType: ContributionCalendarResponse.self
+ )
+ }
+
+ func fetchContributionStats(actor: String, endingOn endDate: Date) async throws -> ContributionStatsResponse {
+ debugLog("stats request actor=\(actor) endDate=\(Self.rangeFormatter.string(from: endDate))")
+ return try await fetch(
+ path: "api/contributions/\(actor)/stats",
+ queryItems: trailingYearQueryItems(endingOn: endDate),
+ responseType: ContributionStatsResponse.self
+ )
+ }
+
+ private func fetch<Response: Decodable>(
+ path: String,
+ queryItems: [URLQueryItem],
+ responseType: Response.Type
+ ) async throws -> Response {
+ guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else {
+ throw URLError(.badURL)
+ }
+
+ components.path = normalizedPath(basePath: components.path, appendedPath: path)
+ components.queryItems = queryItems
+
+ guard let url = components.url else {
+ throw URLError(.badURL)
+ }
+
+ debugLog("request \(url.absoluteString)")
+ let (data, response): (Data, URLResponse)
+ do {
+ (data, response) = try await session.data(from: url)
+ } catch {
+ debugLog("network failure \(url.absoluteString) error=\(error.localizedDescription)")
+ throw SRHTError.networkError(error)
+ }
+
+ if let httpResponse = response as? HTTPURLResponse {
+ debugLog("response \(url.absoluteString) status=\(httpResponse.statusCode) bytes=\(data.count)")
+ if !(200...299).contains(httpResponse.statusCode) {
+ throw SRHTError.httpError(httpResponse.statusCode)
+ }
+ }
+
+ do {
+ return try decoder.decode(responseType, from: data)
+ } catch {
+ let preview = String(decoding: data.prefix(300), as: UTF8.self)
+ debugLog("decode failure \(url.absoluteString) error=\(error.localizedDescription) body=\(preview)")
+ throw SRHTError.decodingError(error)
+ }
+ }
+
+ private func normalizedPath(basePath: String, appendedPath: String) -> String {
+ let trimmedBase = basePath.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
+ let trimmedAppendix = appendedPath.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
+
+ let pathComponents = [trimmedBase, trimmedAppendix].filter { !$0.isEmpty }
+ return "/" + pathComponents.joined(separator: "/")
+ }
+
+ 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 func trailingYearQueryItems(endingOn endDate: Date) -> [URLQueryItem] {
+ let range = trailingRange(endingOn: endDate)
+ let start = Self.rangeFormatter.string(from: range.lowerBound)
+ let end = Self.rangeFormatter.string(from: range.upperBound)
+
+ return [
+ URLQueryItem(name: "from", value: start),
+ URLQueryItem(name: "to", value: end)
+ ]
+ }
+
+ 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 func debugLog(_ message: String) {
+#if DEBUG
+ print("[HutchStatsService] \(message)")
+#endif
+ }
+}
diff --git a/Hutch/Views/Lookup/ContributionCalendarView.swift b/Hutch/Views/Lookup/ContributionCalendarView.swift
new file mode 100644
index 0000000..d52deab
--- /dev/null
+++ b/Hutch/Views/Lookup/ContributionCalendarView.swift
@@ -0,0 +1,224 @@
+import SwiftUI
+
+struct ContributionProfileCard: View {
+ let actor: String
+ let weeks: [ContributionWeek]
+ let stats: ContributionStatsResponse?
+ let isLoading: Bool
+ let error: String?
+ var isIndexedButEmpty = false
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ HStack {
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Contribution Activity")
+ .font(.headline)
+ Text(actor)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ Spacer()
+ }
+
+ if isLoading && weeks.isEmpty {
+ HStack(spacing: 10) {
+ ProgressView()
+ .controlSize(.small)
+ Text("Loading activity…")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ } else if let error, weeks.isEmpty {
+ Label(error, systemImage: "exclamationmark.triangle")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ } else if isIndexedButEmpty || weeks.isEmpty || stats?.totalEvents == 0 {
+ Text("Contribution activity may still be indexing.")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ } else {
+ ContributionScrollableHeatmap(
+ weeks: weeks,
+ squareSize: 10,
+ spacing: 3,
+ showsWeekdayLabels: false,
+ showsMonthLabels: false
+ )
+
+ ContributionLegendView()
+
+ if let stats {
+ HStack(spacing: 14) {
+ ContributionMetricChip(value: "\(stats.totalEvents)", title: "Events")
+ ContributionMetricChip(value: "\(stats.activeDays)", title: "Days")
+ ContributionMetricChip(value: "\(stats.longestStreak)", title: "Streak")
+ }
+ }
+ }
+ }
+ .padding(.vertical, 4)
+ }
+}
+
+private struct ContributionScrollableHeatmap: View {
+ let weeks: [ContributionWeek]
+ let squareSize: CGFloat
+ let spacing: CGFloat
+ let showsWeekdayLabels: Bool
+ let showsMonthLabels: Bool
+
+ @State private var didApplyInitialScroll = false
+
+ private let weekdayLabels = ["S", "M", "T", "W", "T", "F", "S"]
+
+ var body: some View {
+ ScrollViewReader { proxy in
+ ScrollView(.horizontal, showsIndicators: false) {
+ VStack(alignment: .leading, spacing: 6) {
+ if showsMonthLabels {
+ HStack(spacing: spacing) {
+ if showsWeekdayLabels {
+ Color.clear
+ .frame(width: 10)
+ }
+
+ ForEach(Array(weeks.enumerated()), id: \.element.startDate) { index, week in
+ Text(monthLabel(for: week, index: index))
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ .fixedSize(horizontal: true, vertical: false)
+ .frame(width: squareSize, alignment: .leading)
+ }
+ }
+ }
+
+ HStack(alignment: .top, spacing: 8) {
+ if showsWeekdayLabels {
+ VStack(alignment: .trailing, spacing: spacing) {
+ ForEach(Array(weekdayLabels.enumerated()), id: \.offset) { _, label in
+ Text(label)
+ .font(.caption2)
+ .foregroundStyle(.tertiary)
+ .frame(width: 10, height: squareSize, alignment: .center)
+ }
+ }
+ }
+
+ HStack(alignment: .top, spacing: spacing) {
+ ForEach(weeks, id: \.startDate) { week in
+ VStack(spacing: spacing) {
+ ForEach(week.days) { day in
+ ContributionDayCell(day: day, size: squareSize)
+ }
+ }
+ .id(week.startDate)
+ }
+ }
+ }
+ }
+ .padding(.vertical, 2)
+ }
+ .onAppear {
+ guard !didApplyInitialScroll, let lastWeek = weeks.last?.startDate else { return }
+ didApplyInitialScroll = true
+
+ DispatchQueue.main.async {
+ proxy.scrollTo(lastWeek, anchor: .trailing)
+ }
+ }
+ }
+ .accessibilityElement(children: .contain)
+ }
+
+ private func monthLabel(for week: ContributionWeek, index: Int) -> String {
+ let month = week.startDate.formatted(.dateTime.month(.abbreviated))
+ if index == 0 {
+ return month
+ }
+
+ let previousMonth = weeks[index - 1].startDate.formatted(.dateTime.month(.abbreviated))
+ return previousMonth == month ? "" : month
+ }
+}
+
+private struct ContributionDayCell: View {
+ let day: ContributionDay
+ let size: CGFloat
+
+ var body: some View {
+ RoundedRectangle(cornerRadius: 3, style: .continuous)
+ .fill(day.intensity.color)
+ .frame(width: size, height: size)
+ .overlay {
+ RoundedRectangle(cornerRadius: 3, style: .continuous)
+ .stroke(Color.primary.opacity(day.intensity == .empty ? 0.08 : 0), lineWidth: 0.5)
+ }
+ .accessibilityLabel(day.accessibilityLabel)
+ }
+}
+
+
+private struct ContributionMetricChip: View {
+ let value: String
+ let title: String
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(value)
+ .font(.headline)
+ Text(title)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(10)
+ .background(Color.secondary.opacity(0.08), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
+ }
+}
+
+private struct ContributionLegendView: View {
+ var body: some View {
+ HStack(spacing: 8) {
+ Text("Less")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+
+ ForEach(ContributionIntensity.allCases, id: \.rawValue) { intensity in
+ RoundedRectangle(cornerRadius: 3, style: .continuous)
+ .fill(intensity.color)
+ .frame(width: 12, height: 12)
+ }
+
+ Text("More")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+}
+
+private extension ContributionIntensity {
+ 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)
+ }
+ }
+}
+
+private extension ContributionDay {
+ var accessibilityLabel: String {
+ let contributionLabel = count == 1 ? "1 contribution" : "\(count) contributions"
+ return "\(date.formatted(date: .long, time: .omitted)): \(contributionLabel), score \(score.formatted(.number.precision(.fractionLength(0...2))))"
+ }
+}
diff --git a/Hutch/Views/Lookup/ContributionCalendarViewModel.swift b/Hutch/Views/Lookup/ContributionCalendarViewModel.swift
new file mode 100644
index 0000000..1ff789d
--- /dev/null
+++ b/Hutch/Views/Lookup/ContributionCalendarViewModel.swift
@@ -0,0 +1,204 @@
+import Foundation
+
+@Observable
+@MainActor
+final class ContributionCalendarViewModel {
+ enum DisplayState: Equatable {
+ case populated
+ case indexing
+ case empty
+ case unavailable
+ }
+
+ private(set) var calendar: ContributionCalendarResponse?
+ private(set) var stats: ContributionStatsResponse?
+ private(set) var isLoading = false
+ var loadErrorMessage: String?
+ var selectedEndDate: Date
+
+ let actor: String
+
+ private let service: any ContributionCalendarServing
+ private let currentEndDate: Date
+
+ init(
+ actor: String,
+ service: any ContributionCalendarServing,
+ selectedEndDate: Date? = nil
+ ) {
+ self.actor = actor
+ self.service = service
+ let today = Calendar.contributionCalendar.startOfDay(for: Date())
+ let resolvedEndDate = Calendar.contributionCalendar.startOfDay(for: selectedEndDate ?? today)
+ self.selectedEndDate = resolvedEndDate
+ self.currentEndDate = today
+ }
+
+ var weekColumns: [ContributionWeek] {
+ ContributionCalendarLayout.weekColumns(from: calendar?.days ?? [])
+ }
+
+ var recentWeekColumns: [ContributionWeek] {
+ ContributionCalendarLayout.recentWeeks(from: calendar?.days ?? [], count: 8)
+ }
+
+ var isEmpty: Bool {
+ calendar?.isEmpty ?? false
+ }
+
+ var isIndexedButEmpty: Bool {
+ displayState != .populated
+ && displayState != .unavailable
+ && ((stats?.totalEvents == 0) || (calendar?.isEmpty == true))
+ }
+
+ var displayState: DisplayState {
+ if hasActivity {
+ return .populated
+ }
+
+ switch effectiveIndexingState {
+ case .pending:
+ return .indexing
+ case .error:
+ return .unavailable
+ case .indexed:
+ return .empty
+ case nil:
+ return .empty
+ }
+ }
+
+ var emptyStateTitle: String {
+ switch displayState {
+ case .indexing:
+ "Indexing Activity"
+ case .empty:
+ "No Contribution Activity"
+ case .unavailable:
+ "Activity Unavailable"
+ case .populated:
+ ""
+ }
+ }
+
+ var emptyStateMessage: String {
+ switch displayState {
+ case .indexing:
+ "This user’s SourceHut activity is being indexed. Check back soon."
+ case .empty:
+ "No activity was found for this time range."
+ case .unavailable:
+ "The contribution graph couldn’t be refreshed right now. Try again later."
+ case .populated:
+ ""
+ }
+ }
+
+ var lastUpdatedText: String? {
+ guard let lastPolledAt = effectiveLastPolledAt else {
+ return nil
+ }
+
+ return "Updated \(lastPolledAt.formatted(date: .abbreviated, time: .shortened))"
+ }
+
+ var canAdvanceYear: Bool {
+ selectedEndDate < currentEndDate
+ }
+
+ var displayedRangeText: String {
+ let range = trailingRange
+ return "\(range.lowerBound.formatted(date: .abbreviated, time: .omitted)) to \(range.upperBound.formatted(date: .abbreviated, time: .omitted))"
+ }
+
+ func load() async {
+ isLoading = true
+ loadErrorMessage = nil
+ defer { isLoading = false }
+ debugLog("load start actor=\(actor) endDate=\(selectedEndDate.formatted(date: .abbreviated, time: .omitted))")
+
+ let fetchedCalendar: ContributionCalendarResponse?
+ let fetchedStats: ContributionStatsResponse?
+ var errors: [any Error] = []
+
+ do {
+ fetchedCalendar = try await service.fetchContributionCalendar(actor: actor, endingOn: selectedEndDate)
+ } catch {
+ fetchedCalendar = nil
+ errors.append(error)
+ }
+
+ do {
+ fetchedStats = try await service.fetchContributionStats(actor: actor, endingOn: selectedEndDate)
+ } catch {
+ fetchedStats = nil
+ errors.append(error)
+ }
+
+ calendar = fetchedCalendar
+ stats = fetchedStats
+
+ if displayState != .unavailable {
+ loadErrorMessage = nil
+ } else if fetchedCalendar == nil && fetchedStats == nil {
+ loadErrorMessage = errors.first?.userFacingMessage
+ } else {
+ loadErrorMessage = errors.first?.userFacingMessage
+ }
+
+ debugLog(
+ "load complete actor=\(actor) endDate=\(selectedEndDate.formatted(date: .abbreviated, time: .omitted)) state=\(String(describing: displayState)) " +
+ "calendarDays=\(calendar?.days.count ?? 0) totalEvents=\(stats?.totalEvents ?? 0) " +
+ "indexingState=\(String(describing: effectiveIndexingState)) error=\(loadErrorMessage ?? "none")"
+ )
+ }
+
+ func selectPreviousYear() async {
+ guard let previousEndDate = Calendar.contributionCalendar.date(byAdding: .year, value: -1, to: selectedEndDate) else {
+ return
+ }
+ selectedEndDate = previousEndDate
+ await load()
+ }
+
+ func selectNextYear() async {
+ guard canAdvanceYear else { return }
+ let advancedDate = Calendar.contributionCalendar.date(byAdding: .year, value: 1, to: selectedEndDate) ?? currentEndDate
+ selectedEndDate = min(advancedDate, currentEndDate)
+ await load()
+ }
+
+ private var hasActivity: Bool {
+ if let stats, stats.totalEvents > 0 {
+ return true
+ }
+
+ if let calendar, !calendar.isEmpty {
+ return true
+ }
+
+ return false
+ }
+
+ private var effectiveIndexingState: ContributionIndexingState? {
+ stats?.indexingState ?? calendar?.indexingState
+ }
+
+ private var effectiveLastPolledAt: Date? {
+ stats?.lastPolledAt ?? calendar?.lastPolledAt
+ }
+
+ private var trailingRange: ClosedRange<Date> {
+ let normalizedEndDate = Calendar.contributionCalendar.startOfDay(for: selectedEndDate)
+ 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 func debugLog(_ message: String) {
+#if DEBUG
+ print("[ContributionCalendarViewModel] \(message)")
+#endif
+ }
+}
diff --git a/Hutch/Views/Lookup/LookupView.swift b/Hutch/Views/Lookup/LookupView.swift
index eb585fb..2753ea3 100644
--- a/Hutch/Views/Lookup/LookupView.swift
+++ b/Hutch/Views/Lookup/LookupView.swift
@@ -429,6 +429,8 @@ struct LookupView: View {
MailingListListView()
case .pastes:
PasteListView()
+ case .profile:
+ ProfileView()
case .settings:
SettingsView()
case .mailingList(let mailingList):
diff --git a/Hutch/Views/Lookup/UserProfileView.swift b/Hutch/Views/Lookup/UserProfileView.swift
index dc9197c..bc04b08 100644
--- a/Hutch/Views/Lookup/UserProfileView.swift
+++ b/Hutch/Views/Lookup/UserProfileView.swift
@@ -2,6 +2,7 @@ import SwiftUI
struct UserProfileView: View {
@Environment(AppState.self) private var appState
+ @AppStorage(AppStorageKeys.contributionGraphsEnabled) private var contributionGraphsEnabled = true
let user: User
@State private var profileViewModel: UserProfileViewModel?
@@ -91,6 +92,21 @@ struct UserProfileView: View {
}
if let viewModel = profileViewModel {
+ if contributionGraphsEnabled {
+ Section {
+ ContributionProfileCard(
+ actor: viewModel.actor,
+ weeks: viewModel.contributionCalendar.map {
+ ContributionCalendarLayout.weekColumns(from: $0.days)
+ } ?? [],
+ stats: viewModel.contributionStats,
+ isLoading: viewModel.isLoadingContributions,
+ error: viewModel.contributionsError ?? viewModel.contributionStatusText,
+ isIndexedButEmpty: viewModel.isContributionActivityIndexedButEmpty
+ )
+ }
+ }
+
Section {
if viewModel.isLoadingRepositories && viewModel.repositories.isEmpty {
ProgressView()
@@ -143,15 +159,36 @@ struct UserProfileView: View {
.listStyle(.insetGrouped)
.navigationTitle(user.canonicalName)
.navigationBarTitleDisplayMode(.inline)
- .task {
+ .task(id: user.canonicalName) {
let owner = user.canonicalName.hasPrefix("~")
? String(user.canonicalName.dropFirst())
: user.canonicalName
- let vm = UserProfileViewModel(ownerUsername: owner, client: appState.client)
- profileViewModel = vm
+ let actor = user.canonicalName.hasPrefix("~") ? user.canonicalName : "~\(user.canonicalName)"
+
+ let vm: UserProfileViewModel
+ if let existingViewModel = profileViewModel,
+ existingViewModel.actor == actor,
+ existingViewModel.ownerUsername == owner {
+ vm = existingViewModel
+ } else {
+ let newViewModel = UserProfileViewModel(
+ ownerUsername: owner,
+ actor: actor,
+ client: appState.client,
+ statsService: HutchStatsService(configuration: appState.configuration)
+ )
+ profileViewModel = newViewModel
+ vm = newViewModel
+ }
+
async let repos: () = vm.loadRepositories()
async let trackers: () = vm.loadTrackers()
- _ = await (repos, trackers)
+ if contributionGraphsEnabled {
+ async let contributions: () = vm.loadContributions()
+ _ = await (repos, trackers, contributions)
+ } else {
+ _ = await (repos, trackers)
+ }
}
}
diff --git a/Hutch/Views/Lookup/UserProfileViewModel.swift b/Hutch/Views/Lookup/UserProfileViewModel.swift
index bec79fb..143cb6f 100644
--- a/Hutch/Views/Lookup/UserProfileViewModel.swift
+++ b/Hutch/Views/Lookup/UserProfileViewModel.swift
@@ -5,17 +5,42 @@ import Foundation
final class UserProfileViewModel {
private(set) var repositories: [RepositorySummary] = []
private(set) var trackers: [TrackerSummary] = []
+ private(set) var contributionCalendar: ContributionCalendarResponse?
+ private(set) var contributionStats: ContributionStatsResponse?
private(set) var isLoadingRepositories = false
private(set) var isLoadingTrackers = false
+ private(set) var isLoadingContributions = false
var repositoriesError: String?
var trackersError: String?
+ var contributionsError: String?
private let client: SRHTClient
+ private let statsService: HutchStatsService
let ownerUsername: String
+ let actor: String
- init(ownerUsername: String, client: SRHTClient) {
+ var isContributionActivityIndexedButEmpty: Bool {
+ contributionDisplayState != .populated && contributionDisplayState != .unavailable
+ }
+
+ var contributionStatusText: String? {
+ switch contributionDisplayState {
+ case .indexing:
+ return "Activity is being indexed."
+ case .empty:
+ return "No contribution activity found."
+ case .unavailable:
+ return "Contribution activity is unavailable."
+ case .populated:
+ return nil
+ }
+ }
+
+ init(ownerUsername: String, actor: String, client: SRHTClient, statsService: HutchStatsService) {
self.ownerUsername = ownerUsername
+ self.actor = actor
self.client = client
+ self.statsService = statsService
}
func loadRepositories() async {
@@ -54,6 +79,37 @@ final class UserProfileViewModel {
}
}
+ func loadContributions(endingOn endDate: Date? = nil) async {
+ isLoadingContributions = true
+ contributionsError = nil
+ defer { isLoadingContributions = false }
+
+ let resolvedEndDate = Calendar.contributionCalendar.startOfDay(for: endDate ?? Date())
+ debugLog("profile contributions start actor=\(actor) endDate=\(resolvedEndDate.formatted(date: .abbreviated, time: .omitted))")
+
+ do {
+ async let contributionCalendar = statsService.fetchContributionCalendar(actor: actor, endingOn: resolvedEndDate)
+ async let contributionStats = statsService.fetchContributionStats(actor: actor, endingOn: resolvedEndDate)
+
+ self.contributionCalendar = try await contributionCalendar
+ self.contributionStats = try await contributionStats
+ if contributionDisplayState != .unavailable {
+ contributionsError = nil
+ }
+ debugLog(
+ "profile contributions complete actor=\(actor) endDate=\(resolvedEndDate.formatted(date: .abbreviated, time: .omitted)) " +
+ "state=\(String(describing: contributionDisplayState)) days=\(self.contributionCalendar?.days.count ?? 0) " +
+ "totalEvents=\(self.contributionStats?.totalEvents ?? 0) error=\(contributionsError ?? "none")"
+ )
+ } catch {
+ contributionsError = error.userFacingMessage
+ debugLog(
+ "profile contributions failed actor=\(actor) endDate=\(resolvedEndDate.formatted(date: .abbreviated, time: .omitted)) " +
+ "error=\(error.localizedDescription) userFacing=\(contributionsError ?? "none")"
+ )
+ }
+ }
+
private static let repositoriesQuery = """
query userRepositories($owner: String!) {
user(username: $owner) {
@@ -148,4 +204,37 @@ final class UserProfileViewModel {
let results: [TrackerSummary]
let cursor: String?
}
+
+ private enum ContributionDisplayState {
+ case populated
+ case indexing
+ case empty
+ case unavailable
+ }
+
+ private var contributionDisplayState: ContributionDisplayState {
+ if let contributionStats, contributionStats.totalEvents > 0 {
+ return .populated
+ }
+
+ if let contributionCalendar, !contributionCalendar.isEmpty {
+ return .populated
+ }
+
+ let indexingState = contributionStats?.indexingState ?? contributionCalendar?.indexingState
+ switch indexingState {
+ case .pending:
+ return .indexing
+ case .error:
+ return .unavailable
+ case .indexed, nil:
+ return .empty
+ }
+ }
+
+ private func debugLog(_ message: String) {
+#if DEBUG
+ print("[UserProfileViewModel] \(message)")
+#endif
+ }
}