import SwiftUI /// The contribution calendar: 53 weeks of columns, Sunday at the top, /// ending on the current week — the same grid the web draws. struct ActivityGraph: View { let days: [ProfileViewModel.Profile.Day] private static let calendar: Calendar = { var calendar = Calendar(identifier: .gregorian) calendar.timeZone = TimeZone(identifier: "UTC")! return calendar }() private static let formatter: DateFormatter = { let formatter = DateFormatter() formatter.calendar = calendar formatter.timeZone = calendar.timeZone formatter.dateFormat = "yyyy-MM-dd" return formatter }() /// Weeks of 7 days; nil is a day outside the window (before the start /// or after today), drawn as empty space rather than a zero cell. private var weeks: [[Cell?]] { let counts = Dictionary(days.map { ($0.date, $0.count) }, uniquingKeysWith: +) let today = Self.calendar.startOfDay(for: Date()) // End on the Saturday of the current week, matching the server's // window so both surfaces show the same year. let weekday = Self.calendar.component(.weekday, from: today) // 1 = Sunday guard let end = Self.calendar.date(byAdding: .day, value: 7 - weekday, to: today), let start = Self.calendar.date(byAdding: .day, value: -53 * 7 + 1, to: end) else { return [] } var result: [[Cell?]] = [] var cursor = start while cursor <= end { var week: [Cell?] = [] for _ in 0..<7 { if cursor > today { week.append(nil) } else { let key = Self.formatter.string(from: cursor) week.append(Cell(date: key, count: counts[key] ?? 0)) } cursor = Self.calendar.date(byAdding: .day, value: 1, to: cursor) ?? cursor } result.append(week) } return result } private struct Cell: Hashable { let date: String let count: Int /// The same five buckets activityLevel uses server-side. var level: Int { switch count { case 0: 0 case 1...2: 1 case 3...5: 2 case 6...9: 3 default: 4 } } } var body: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(alignment: .top, spacing: 3) { ForEach(Array(weeks.enumerated()), id: \.offset) { _, week in VStack(spacing: 3) { ForEach(Array(week.enumerated()), id: \.offset) { _, cell in RoundedRectangle(cornerRadius: 2) .fill(color(for: cell)) .frame(width: 11, height: 11) } } } } .padding(.vertical, 4) } .defaultScrollAnchor(.trailing) } /// The stylesheet's ramp: an empty day is --faint, and the rest mix /// --accent toward --bg at 25/50/75/100 percent. A day outside the /// window draws nothing at all. private func color(for cell: Cell?) -> Color { guard let cell else { return .clear } return switch cell.level { case 0: Color.gbFaint case 1: Color.gbAccent.mix(with: .gbBackground, by: 0.75) case 2: Color.gbAccent.mix(with: .gbBackground, by: 0.50) case 3: Color.gbAccent.mix(with: .gbBackground, by: 0.25) default: Color.gbAccent } } }