gitbay/Views/Discovery/ActivityGraph.swift
101 lines · 3661 bytes
1import SwiftUI
2
3/// The contribution calendar: 53 weeks of columns, Sunday at the top,
4/// ending on the current week — the same grid the web draws.
5struct ActivityGraph: View {
6
7 let days: [ProfileViewModel.Profile.Day]
8
9 private static let calendar: Calendar = {
10 var calendar = Calendar(identifier: .gregorian)
11 calendar.timeZone = TimeZone(identifier: "UTC")!
12 return calendar
13 }()
14
15 private static let formatter: DateFormatter = {
16 let formatter = DateFormatter()
17 formatter.calendar = calendar
18 formatter.timeZone = calendar.timeZone
19 formatter.dateFormat = "yyyy-MM-dd"
20 return formatter
21 }()
22
23 /// Weeks of 7 days; nil is a day outside the window (before the start
24 /// or after today), drawn as empty space rather than a zero cell.
25 private var weeks: [[Cell?]] {
26 let counts = Dictionary(days.map { ($0.date, $0.count) }, uniquingKeysWith: +)
27 let today = Self.calendar.startOfDay(for: Date())
28 // End on the Saturday of the current week, matching the server's
29 // window so both surfaces show the same year.
30 let weekday = Self.calendar.component(.weekday, from: today) // 1 = Sunday
31 guard let end = Self.calendar.date(byAdding: .day, value: 7 - weekday, to: today),
32 let start = Self.calendar.date(byAdding: .day, value: -53 * 7 + 1, to: end) else {
33 return []
34 }
35
36 var result: [[Cell?]] = []
37 var cursor = start
38 while cursor <= end {
39 var week: [Cell?] = []
40 for _ in 0..<7 {
41 if cursor > today {
42 week.append(nil)
43 } else {
44 let key = Self.formatter.string(from: cursor)
45 week.append(Cell(date: key, count: counts[key] ?? 0))
46 }
47 cursor = Self.calendar.date(byAdding: .day, value: 1, to: cursor) ?? cursor
48 }
49 result.append(week)
50 }
51 return result
52 }
53
54 private struct Cell: Hashable {
55 let date: String
56 let count: Int
57
58 /// The same five buckets activityLevel uses server-side.
59 var level: Int {
60 switch count {
61 case 0: 0
62 case 1...2: 1
63 case 3...5: 2
64 case 6...9: 3
65 default: 4
66 }
67 }
68 }
69
70 var body: some View {
71 ScrollView(.horizontal, showsIndicators: false) {
72 HStack(alignment: .top, spacing: 3) {
73 ForEach(Array(weeks.enumerated()), id: \.offset) { _, week in
74 VStack(spacing: 3) {
75 ForEach(Array(week.enumerated()), id: \.offset) { _, cell in
76 RoundedRectangle(cornerRadius: 2)
77 .fill(color(for: cell))
78 .frame(width: 11, height: 11)
79 }
80 }
81 }
82 }
83 .padding(.vertical, 4)
84 }
85 .defaultScrollAnchor(.trailing)
86 }
87
88 /// The stylesheet's ramp: an empty day is --faint, and the rest mix
89 /// --accent toward --bg at 25/50/75/100 percent. A day outside the
90 /// window draws nothing at all.
91 private func color(for cell: Cell?) -> Color {
92 guard let cell else { return .clear }
93 return switch cell.level {
94 case 0: Color.gbFaint
95 case 1: Color.gbAccent.mix(with: .gbBackground, by: 0.75)
96 case 2: Color.gbAccent.mix(with: .gbBackground, by: 0.50)
97 case 3: Color.gbAccent.mix(with: .gbBackground, by: 0.25)
98 default: Color.gbAccent
99 }
100 }
101}