import Foundation import Observation /// `profile show ` — the whole profile in one read: description, /// links, org membership (or an org's members), the repositories the /// account may see, and a year of activity. @Observable @MainActor final class ProfileViewModel { nonisolated struct Profile: Decodable, Sendable, Hashable { let name: String let kind: String // user | org let description: String? let website: String? /// Long-form profile text, rendered between the header and the /// activity graph the way the web renders it. let about: String? let aboutFormat: String? let links: [Link]? let orgs: [Member]? let members: [Member]? let repos: [Repo] let activity: [Day]? let activityTotal: Int enum CodingKeys: String, CodingKey { case name, kind, description, website, about, links, orgs, members, repos, activity case aboutFormat = "about_format" case activityTotal = "activity_total" } var isOrg: Bool { kind == "org" } /// The filename the about text is rendered under. The server has /// none to dispatch on either, so the stored format picks the /// extension and anything but org is markdown. var aboutFile: String { aboutFormat == "org" ? "about.org" : "about.md" } /// One of the free-form links a profile carries beside its /// website. The server caps the list at five. nonisolated struct Link: Decodable, Sendable, Hashable { let label: String? let url: String /// Labels are optional; a link without one shows its URL. var title: String { if let label, !label.isEmpty { return label } return url } } nonisolated struct Member: Decodable, Sendable, Hashable, Identifiable { let name: String let role: String? var id: String { name } } nonisolated struct Repo: Decodable, Sendable, Hashable, Identifiable { let path: String let visibility: String let description: String? let archived: Bool? let topics: [String]? let license: String? let defaultBranch: String? let updated: String? enum CodingKeys: String, CodingKey { case path, visibility, description, archived, topics, license, updated case defaultBranch = "default_branch" } var id: String { path } var isPrivate: Bool { visibility == "private" } var isArchived: Bool { archived ?? false } var name: String { String(path.split(separator: "/").last ?? "") } /// "main · MIT · updated 2026-08-30", the line the web's repo /// rows carry. Parts the server did not report are left out. var meta: String { var parts: [String] = [] if let defaultBranch, !defaultBranch.isEmpty { parts.append(defaultBranch) } if let license, !license.isEmpty { parts.append(license) } if let updated, !updated.isEmpty { parts.append("updated \(updated)") } return parts.joined(separator: " · ") } } /// One day's contributions. Days with none are omitted, so the /// calendar is filled in by the view. nonisolated struct Day: Decodable, Sendable, Hashable { let date: String let count: Int } } private(set) var state: LoadState = .loading private let client: GitbayClient let name: String init(client: GitbayClient, name: String) { self.client = client self.name = name } func load() async { do { state = .loaded(try await client.read(["profile", "show", name], as: Profile.self)) } catch { state = .from(error) } } }