diff options
Diffstat (limited to 'Hutch')
| -rw-r--r-- | Hutch/App/AppConfiguration.swift | 42 | ||||
| -rw-r--r-- | Hutch/App/AppState.swift | 7 | ||||
| -rw-r--r-- | Hutch/App/AppStorageKeys.swift | 2 | ||||
| -rw-r--r-- | Hutch/App/RootView.swift | 3 | ||||
| -rw-r--r-- | Hutch/Models/ContributionCalendar.swift | 340 | ||||
| -rw-r--r-- | Hutch/Networking/HutchStatsService.swift | 121 | ||||
| -rw-r--r-- | Hutch/Views/Lookup/ContributionCalendarView.swift | 224 | ||||
| -rw-r--r-- | Hutch/Views/Lookup/ContributionCalendarViewModel.swift | 204 | ||||
| -rw-r--r-- | Hutch/Views/Lookup/LookupView.swift | 2 | ||||
| -rw-r--r-- | Hutch/Views/Lookup/UserProfileView.swift | 45 | ||||
| -rw-r--r-- | Hutch/Views/Lookup/UserProfileViewModel.swift | 91 |
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 + } } |
