krz/hutch

an ios client for sourcehut

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

61164b6dd8343c8699d091dd5fccc55beb0b90b9

verified · cmc

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

feat: add contribution calendar to user profiles

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.
 Hutch/App/AppConfiguration.swift                   |  42 +++
 Hutch/App/AppState.swift                           |   7 +
 Hutch/App/AppStorageKeys.swift                     |   2 +
 Hutch/App/RootView.swift                           |   3 +
 Hutch/Models/ContributionCalendar.swift            | 340 ++++++++++++++++++
 Hutch/Networking/HutchStatsService.swift           | 121 +++++++
 Hutch/Views/Lookup/ContributionCalendarView.swift  | 224 ++++++++++++
 .../Lookup/ContributionCalendarViewModel.swift     | 204 +++++++++++
 Hutch/Views/Lookup/LookupView.swift                |   2 +
 Hutch/Views/Lookup/UserProfileView.swift           |  45 ++-
 Hutch/Views/Lookup/UserProfileViewModel.swift      |  91 ++++-
 HutchTests/ContributionCalendarTests.swift         | 388 +++++++++++++++++++++
 12 files changed, 1464 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
+    }
 }
diff --git a/HutchTests/ContributionCalendarTests.swift b/HutchTests/ContributionCalendarTests.swift
new file mode 100644
index 0000000..4ce2f1f
--- /dev/null
+++ b/HutchTests/ContributionCalendarTests.swift
@@ -0,0 +1,388 @@
+import Foundation
+import Testing
+@testable import Hutch
+
+struct ContributionCalendarTests {
+
+    @Test
+    @MainActor
+    func contributionCalendarDecodingParsesDatesAndCounts() throws {
+        let data = Data(
+            """
+            {
+              "actor": "~ccleberg",
+              "from": "2026-03-01",
+              "to": "2026-04-15",
+              "is_indexed": false,
+              "last_polled_at": null,
+              "indexing_state": "pending",
+              "days": [
+                { "date": "2026-03-19", "count": 24, "score": 24.0 },
+                { "date": "2026-04-02", "count": 20, "score": 18.5 }
+              ]
+            }
+            """.utf8
+        )
+
+        let decoded = try JSONDecoder().decode(ContributionCalendarResponse.self, from: data)
+
+        #expect(decoded.actor == "~ccleberg")
+        #expect(decoded.from == ContributionDateParser.parse("2026-03-01"))
+        #expect(decoded.to == ContributionDateParser.parse("2026-04-15"))
+        #expect(decoded.isIndexed == false)
+        #expect(decoded.lastPolledAt == nil)
+        #expect(decoded.indexingState == .pending)
+        #expect(decoded.days.count == 2)
+        #expect(decoded.days[0].date == ContributionDateParser.parse("2026-03-19"))
+        #expect(decoded.days[0].count == 24)
+        #expect(decoded.days[1].score == 18.5)
+    }
+
+    @Test
+    @MainActor
+    func contributionStatsDecodingParsesSnakeCasePayload() throws {
+        let data = Data(
+            """
+            {
+              "actor": "~ccleberg",
+              "from": "2026-03-01",
+              "to": "2026-04-15",
+              "is_indexed": true,
+              "last_polled_at": "2026-04-15T15:42:18Z",
+              "indexing_state": "indexed",
+              "total_events": 126,
+              "total_score": 116.75,
+              "active_days": 14,
+              "longest_streak": 5,
+              "current_streak": 0
+            }
+            """.utf8
+        )
+
+        let decoded = try JSONDecoder().decode(ContributionStatsResponse.self, from: data)
+
+        #expect(decoded.totalEvents == 126)
+        #expect(decoded.totalScore == 116.75)
+        #expect(decoded.activeDays == 14)
+        #expect(decoded.longestStreak == 5)
+        #expect(decoded.currentStreak == 0)
+        #expect(decoded.isIndexed)
+        #expect(decoded.indexingState == .indexed)
+        #expect(decoded.lastPolledAt == ContributionDateParser.parseTimestamp("2026-04-15T15:42:18Z"))
+    }
+
+    @Test
+    func contributionDateParserRejectsInvalidDate() {
+        #expect(ContributionDateParser.parse("2026-13-40") == nil)
+    }
+
+    @Test
+    func contributionTimestampParserHandlesFractionalSecondsWithoutTimezone() {
+        let parsed = ContributionDateParser.parseTimestamp("2026-04-11T19:26:45.538015")
+
+        #expect(parsed != nil)
+    }
+
+    @Test
+    func trailingRangeEndsTodayAndStartsOneYearEarlierPlusOneDay() {
+        let service = HutchStatsService(configuration: AppConfiguration())
+        let endDate = ContributionDateParser.parse("2026-04-11")!
+        let range = service.trailingRange(endingOn: endDate)
+
+        #expect(range.lowerBound == ContributionDateParser.parse("2025-04-12"))
+        #expect(range.upperBound == endDate)
+    }
+
+    @Test
+    func contributionIntensityBucketsMatchProductRules() {
+        #expect(ContributionIntensity(count: 0) == .empty)
+        #expect(ContributionIntensity(count: 1) == .level1)
+        #expect(ContributionIntensity(count: 2) == .level2)
+        #expect(ContributionIntensity(count: 3) == .level2)
+        #expect(ContributionIntensity(count: 4) == .level3)
+        #expect(ContributionIntensity(count: 6) == .level3)
+        #expect(ContributionIntensity(count: 7) == .level4)
+        #expect(ContributionIntensity(count: 12) == .level4)
+    }
+
+    @Test
+    func weekColumnsGroupConsecutiveDaysIntoSundayBasedWeeks() {
+        let days = [
+            ContributionDay(date: ContributionDateParser.parse("2026-03-29")!, count: 1, score: 1),
+            ContributionDay(date: ContributionDateParser.parse("2026-03-30")!, count: 2, score: 2),
+            ContributionDay(date: ContributionDateParser.parse("2026-04-04")!, count: 3, score: 3),
+            ContributionDay(date: ContributionDateParser.parse("2026-04-05")!, count: 4, score: 4)
+        ]
+
+        let weeks = ContributionCalendarLayout.weekColumns(from: days)
+
+        #expect(weeks.count == 2)
+        #expect(weeks[0].startDate == ContributionDateParser.parse("2026-03-29"))
+        #expect(weeks[0].days.map(\.count) == [1, 2, 3])
+        #expect(weeks[1].startDate == ContributionDateParser.parse("2026-04-05"))
+        #expect(weeks[1].days.map(\.count) == [4])
+    }
+
+    @Test
+    func recentWeeksReturnsTrailingWindow() {
+        let days = (0..<21).compactMap { offset -> ContributionDay? in
+            guard let date = Calendar.contributionCalendar.date(byAdding: .day, value: offset, to: ContributionDateParser.parse("2026-03-01")!) else {
+                return nil
+            }
+
+            return ContributionDay(date: date, count: offset, score: Double(offset))
+        }
+
+        let weeks = ContributionCalendarLayout.recentWeeks(from: days, count: 2)
+
+        #expect(weeks.count == 2)
+        #expect(weeks[0].startDate == ContributionDateParser.parse("2026-03-08"))
+        #expect(weeks[1].startDate == ContributionDateParser.parse("2026-03-15"))
+    }
+
+    @Test
+    @MainActor
+    func emptyCalendarResponseTreatsActivityAsNotIndexedYet() async {
+        let service = MockContributionCalendarService(
+            calendarResponses: [.pending(actor: "~alice", year: 2026)],
+            statsResponses: [.pending(actor: "~alice", year: 2026)]
+        )
+        let viewModel = ContributionCalendarViewModel(
+            actor: "~alice",
+            service: service,
+            selectedEndDate: ContributionDateParser.parse("2026-04-11")
+        )
+
+        await viewModel.load()
+
+        #expect(viewModel.displayState == .indexing)
+        #expect(viewModel.emptyStateTitle == "Indexing Activity")
+        #expect(viewModel.emptyStateMessage == "This user’s SourceHut activity is being indexed. Check back soon.")
+        #expect(viewModel.loadErrorMessage == nil)
+    }
+
+    @Test
+    @MainActor
+    func indexedEmptyCalendarShowsTrueEmptyState() async {
+        let service = MockContributionCalendarService(
+            calendarResponses: [.empty(actor: "~alice", year: 2026)],
+            statsResponses: [.empty(actor: "~alice", year: 2026)]
+        )
+        let viewModel = ContributionCalendarViewModel(
+            actor: "~alice",
+            service: service,
+            selectedEndDate: ContributionDateParser.parse("2026-04-11")
+        )
+
+        await viewModel.load()
+
+        #expect(viewModel.displayState == .empty)
+        #expect(viewModel.emptyStateTitle == "No Contribution Activity")
+        #expect(viewModel.emptyStateMessage == "No activity was found for this time range.")
+    }
+
+    @Test
+    @MainActor
+    func errorIndexingStateShowsUnavailableState() async {
+        let service = MockContributionCalendarService(
+            calendarResponses: [.error(actor: "~alice", year: 2026)],
+            statsResponses: [.error(actor: "~alice", year: 2026)]
+        )
+        let viewModel = ContributionCalendarViewModel(
+            actor: "~alice",
+            service: service,
+            selectedEndDate: ContributionDateParser.parse("2026-04-11")
+        )
+
+        await viewModel.load()
+
+        #expect(viewModel.displayState == .unavailable)
+        #expect(viewModel.emptyStateTitle == "Activity Unavailable")
+        #expect(viewModel.emptyStateMessage == "The contribution graph couldn’t be refreshed right now. Try again later.")
+    }
+
+    @Test
+    @MainActor
+    func populatedStatsExposeLastUpdatedText() async {
+        let service = MockContributionCalendarService(
+            calendarResponses: [.active(actor: "~alice", date: "2026-03-19", count: 4, score: 4)],
+            statsResponses: [.active(actor: "~alice", year: 2026, totalEvents: 4, activeDays: 1, longestStreak: 1)]
+        )
+        let viewModel = ContributionCalendarViewModel(
+            actor: "~alice",
+            service: service,
+            selectedEndDate: ContributionDateParser.parse("2026-04-11")
+        )
+
+        await viewModel.load()
+
+        #expect(viewModel.displayState == .populated)
+        #expect(viewModel.lastUpdatedText != nil)
+    }
+}
+
+private final class MockContributionCalendarService: ContributionCalendarServing, @unchecked Sendable {
+    var calendarResponses: [ContributionCalendarResponse]
+    var statsResponses: [ContributionStatsResponse]
+    private(set) var fetchCalendarCallCount = 0
+    private(set) var fetchStatsCallCount = 0
+
+    init(
+        calendarResponses: [ContributionCalendarResponse],
+        statsResponses: [ContributionStatsResponse]
+    ) {
+        self.calendarResponses = calendarResponses
+        self.statsResponses = statsResponses
+    }
+
+    func fetchContributionCalendar(actor: String, endingOn endDate: Date) async throws -> ContributionCalendarResponse {
+        fetchCalendarCallCount += 1
+        return calendarResponses[min(fetchCalendarCallCount - 1, calendarResponses.count - 1)]
+    }
+
+    func fetchContributionStats(actor: String, endingOn endDate: Date) async throws -> ContributionStatsResponse {
+        fetchStatsCallCount += 1
+        return statsResponses[min(fetchStatsCallCount - 1, statsResponses.count - 1)]
+    }
+}
+
+private extension ContributionCalendarResponse {
+    static func empty(actor: String, year: Int) -> Self {
+        let from = ContributionDateParser.parse("\(year)-01-01")!
+        let to = ContributionDateParser.parse("\(year)-01-07")!
+        return ContributionCalendarResponse(
+            actor: actor,
+            from: from,
+            to: to,
+            isIndexed: true,
+            lastPolledAt: ContributionDateParser.parseTimestamp("\(year)-01-07T12:00:00Z"),
+            indexingState: .indexed,
+            days: (1...7).map { day in
+                ContributionDay(
+                    date: ContributionDateParser.parse("\(year)-01-0\(day)")!,
+                    count: 0,
+                    score: 0
+                )
+            }
+        )
+    }
+
+    static func active(actor: String, date: String, count: Int, score: Double) -> Self {
+        let resolvedDate = ContributionDateParser.parse(date)!
+        return ContributionCalendarResponse(
+            actor: actor,
+            from: resolvedDate,
+            to: resolvedDate,
+            isIndexed: true,
+            lastPolledAt: ContributionDateParser.parseTimestamp("2026-03-19T12:00:00Z"),
+            indexingState: .indexed,
+            days: [ContributionDay(date: resolvedDate, count: count, score: score)]
+        )
+    }
+
+    static func pending(actor: String, year: Int) -> Self {
+        let from = ContributionDateParser.parse("\(year)-01-01")!
+        let to = ContributionDateParser.parse("\(year)-01-07")!
+        return ContributionCalendarResponse(
+            actor: actor,
+            from: from,
+            to: to,
+            isIndexed: false,
+            lastPolledAt: nil,
+            indexingState: .pending,
+            days: (1...7).map { day in
+                ContributionDay(
+                    date: ContributionDateParser.parse("\(year)-01-0\(day)")!,
+                    count: 0,
+                    score: 0
+                )
+            }
+        )
+    }
+
+    static func error(actor: String, year: Int) -> Self {
+        let from = ContributionDateParser.parse("\(year)-01-01")!
+        let to = ContributionDateParser.parse("\(year)-01-07")!
+        return ContributionCalendarResponse(
+            actor: actor,
+            from: from,
+            to: to,
+            isIndexed: false,
+            lastPolledAt: nil,
+            indexingState: .error,
+            days: (1...7).map { day in
+                ContributionDay(
+                    date: ContributionDateParser.parse("\(year)-01-0\(day)")!,
+                    count: 0,
+                    score: 0
+                )
+            }
+        )
+    }
+}
+
+private extension ContributionStatsResponse {
+    static func empty(actor: String, year: Int) -> Self {
+        ContributionStatsResponse(
+            actor: actor,
+            from: ContributionDateParser.parse("\(year)-01-01")!,
+            to: ContributionDateParser.parse("\(year)-01-07")!,
+            isIndexed: true,
+            lastPolledAt: ContributionDateParser.parseTimestamp("\(year)-01-07T12:00:00Z"),
+            indexingState: .indexed,
+            totalEvents: 0,
+            totalScore: 0,
+            activeDays: 0,
+            longestStreak: 0,
+            currentStreak: 0
+        )
+    }
+
+    static func active(actor: String, year: Int, totalEvents: Int, activeDays: Int, longestStreak: Int) -> Self {
+        ContributionStatsResponse(
+            actor: actor,
+            from: ContributionDateParser.parse("\(year)-01-01")!,
+            to: ContributionDateParser.parse("\(year)-01-07")!,
+            isIndexed: true,
+            lastPolledAt: ContributionDateParser.parseTimestamp("\(year)-01-07T12:00:00Z"),
+            indexingState: .indexed,
+            totalEvents: totalEvents,
+            totalScore: Double(totalEvents),
+            activeDays: activeDays,
+            longestStreak: longestStreak,
+            currentStreak: 0
+        )
+    }
+
+    static func pending(actor: String, year: Int) -> Self {
+        ContributionStatsResponse(
+            actor: actor,
+            from: ContributionDateParser.parse("\(year)-01-01")!,
+            to: ContributionDateParser.parse("\(year)-01-07")!,
+            isIndexed: false,
+            lastPolledAt: nil,
+            indexingState: .pending,
+            totalEvents: 0,
+            totalScore: 0,
+            activeDays: 0,
+            longestStreak: 0,
+            currentStreak: 0
+        )
+    }
+
+    static func error(actor: String, year: Int) -> Self {
+        ContributionStatsResponse(
+            actor: actor,
+            from: ContributionDateParser.parse("\(year)-01-01")!,
+            to: ContributionDateParser.parse("\(year)-01-07")!,
+            isIndexed: false,
+            lastPolledAt: nil,
+            indexingState: .error,
+            totalEvents: 0,
+            totalScore: 0,
+            activeDays: 0,
+            longestStreak: 0,
+            currentStreak: 0
+        )
+    }
+}