summaryrefslogtreecommitdiff
path: root/Hutch/Models
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-11 19:47:40 -0500
committerChristian Cleberg <[email protected]>2026-04-11 19:47:40 -0500
commit61164b6dd8343c8699d091dd5fccc55beb0b90b9 (patch)
tree0a2befeb986966d42062802e2a29d795be907fee /Hutch/Models
parent27bfcbbed6acde158ec40c5bb46e45e76c81a319 (diff)
downloadhutch-61164b6dd8343c8699d091dd5fccc55beb0b90b9.tar.gz
hutch-61164b6dd8343c8699d091dd5fccc55beb0b90b9.tar.bz2
hutch-61164b6dd8343c8699d091dd5fccc55beb0b90b9.zip
feat: add contribution calendar to user profilesv2.11.0
Add a trailing 365-day contribution heatmap to user profile views, backed by HutchStatsService. Includes calendar model, view model, SwiftUI calendar view, app configuration, and unit tests.
Diffstat (limited to 'Hutch/Models')
-rw-r--r--Hutch/Models/ContributionCalendar.swift340
1 files changed, 340 insertions, 0 deletions
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)
+ }
+}