summaryrefslogtreecommitdiff
path: root/Hutch/Views/Lookup
diff options
context:
space:
mode:
Diffstat (limited to 'Hutch/Views/Lookup')
-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
5 files changed, 561 insertions, 5 deletions
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
+ }
}