Остання активність 1767784072

Версія dfb9873dff301acecb01ef6621f9552c1035ef79

GitHubActivityCalendar.tsx Неформатований
1---
2import {
3 differenceInCalendarDays,
4 eachDayOfInterval,
5 formatISO,
6 getDay,
7 nextDay,
8 parseISO,
9 subWeeks,
10 getYear,
11 getMonth,
12 isValid,
13} from 'date-fns'
14import type {
15 WeekdayIndex,
16 CodebergActivityApiResponse,
17 CodebergActivityDay,
18 CodebergActivityWeek,
19 CodebergActivityMonthLabel,
20} from '~/types'
21
22interface Props {
23 username: string
24 year?: number
25}
26
27function range(n: number) {
28 return [...Array(n).keys()]
29}
30
31async function fetchData(
32 username: string,
33 year: number | 'last',
34): Promise<CodebergActivityApiResponse> {
35 function validateActivities(activities: Array<CodebergActivityDay>) {
36 if (activities.length === 0) {
37 throw new Error('Activity data must not be empty.')
38 }
39 for (const { date, count } of activities) {
40 if (!isValid(parseISO(date))) {
41 throw new Error(`Activity date '${date}' is not a valid ISO 8601 date string.`)
42 }
43 if (count < 0) {
44 throw new RangeError(`Activity count must not be negative, found ${count}.`)
45 }
46 }
47 }
48 const apiUrl = 'https://github-contributions-api.jogruber.de/v4/'
49 const response = await fetch(`${apiUrl}${username}?y=${String(year)}`)
50 const data = (await response.json()) as GitHubActivityApiResponse
51 if (!response.ok) {
52 const message = data.error || 'Unknown error'
53 throw Error(`Fetching GitHub contribution data for "${username}" failed: ${message}`)
54 }
55
56 validateActivities(data.contributions)
57
58 return data
59}
60
61function calcColorScale([start, end]: [string, string], steps: number): Array<string> {
62 return range(steps).map((i) => {
63 switch (i) {
64 case 0:
65 return start
66 case steps - 1:
67 return end
68 default: {
69 const pos = (i / (steps - 1)) * 100
70 return `color-mix(in oklab, ${end} ${parseFloat(pos.toFixed(2))}%, ${start})`
71 }
72 }
73 })
74}
75
76function fillHoles(activities: Array<GitHubActivityDay>): Array<GitHubActivityDay> {
77 const calendar = new Map<string, GitHubActivityDay>(activities.map((a) => [a.date, a]))
78 const firstActivity = activities[0] as GitHubActivityDay
79 const lastActivity = activities[activities.length - 1] as GitHubActivityDay
80
81 return eachDayOfInterval({
82 start: parseISO(firstActivity.date),
83 end: parseISO(lastActivity.date),
84 }).map((day) => {
85 const date = formatISO(day, { representation: 'date' })
86 if (calendar.has(date)) {
87 return calendar.get(date) as GitHubActivityDay
88 }
89 return {
90 date,
91 count: 0,
92 level: 0,
93 }
94 })
95}
96
97function groupByWeeks(
98 activities: Array<GitHubActivityDay>,
99 weekStart: WeekdayIndex = 0, // 0 = Sunday
100): Array<GitHubActivityWeek> {
101 const normalizedActivities = fillHoles(activities)
102 // Determine the first date of the calendar. If the first date is not the
103 // passed weekday, the respective weekday one week earlier is used.
104 const firstActivity = normalizedActivities[0] as GitHubActivityDay
105 const firstDate = parseISO(firstActivity.date)
106 const firstCalendarDate =
107 getDay(firstDate) === weekStart
108 ? firstDate
109 : subWeeks(nextDay(firstDate, weekStart), 1)
110 // To correctly group activities by week, it is necessary to left-pad the list
111 // because the first date might not be set start weekday.
112 const paddedActivities = [
113 ...(Array(differenceInCalendarDays(firstDate, firstCalendarDat e)).fill(
114 undefined,
115 ) as Array<GitHubActivityDay>),
116 ...normalizedActivities,
117 ]
118 const numberOfWeeks = Math.ceil(paddedActivities.length / 7)
119
120 // Finally, group activities by week
121 return [...Array(numberOfWeeks).keys()].map((weekIndex) =>
122 paddedActivities.slice(weekIndex * 7, weekIndex * 7 + 7),
123 )
124}
125
126function getMonthLabels(
127 weeks: Array<GitHubActivityWeek>,
128 monthNames: Array<string>,
129): Array<GitHubActivityMonthLabel> {
130 return weeks
131 .reduce<Array<GitHubActivityMonthLabel>>((labels, week, weekIndex) => {
132 const firstActivity = week.find((activity) => activity !== undefined)
133 if (!firstActivity) {
134 throw new Error(`Unexpected error: Week ${weekIndex + 1} is empty.`)
135 }
136 const month = monthNames[getMonth(parseISO(firstActivity.date))]
137 if (!month) {
138 const monthName = new Date(firstActivity.date).toLocaleString('en-US', {
139 month: 'short',
140 })
141 throw new Error(`Unexpected error: undefined month label for ${monthName}.`)
142 }
143 const prevLabel = labels[labels.length - 1]
144 if (weekIndex === 0 || !prevLabel || prevLabel.label !== month) {
145 return [...labels, { weekIndex, label: month }]
146 }
147 return labels
148 }, [])
149 .filter(({ weekIndex }, index, labels) => {
150 const minWeeks = 3
151 // Skip the first month label if there is not enough space to the next one.
152 if (index === 0) {
153 return labels[1] && labels[1].weekIndex - weekIndex >= minWeeks
154 }
155 // Skip the last month label if there is not enough data in that month
156 if (index === labels.length - 1) {
157 return weeks.slice(weekIndex).length >= minWeeks
158 }
159 return true
160 })
161}
162
163const { username, year = 'last' } = Astro.props
164
165const data = await fetchData(username, year)
166
167const themeFromColorscheme: [string, string] = [
168 'var(--theme-background)',
169 'var(--theme-accent)',
170]
171
172const totalCount = year === 'last' ? data.total.lastYear : data.total[year]
173const maxLevel = 4
174const blockMargin = 4
175const labelMargin = 8
176const blockRadius = 2
177const blockSize = 12
178const fontSize = 14
179const hideColorLegend = false
180const hideMonthLabels = false
181const hideTotalCount = false
182const weekStart = 0 // 0 = Sunday, 1 = Monday, etc.
183
184const colorScale = calcColorScale(themeFromColorscheme, maxLevel + 1)
185const activities = data.contributions
186
187const firstActivity = activities[0]
188const activityYear = getYear(parseISO(firstActivity.date))
189const weeks = groupByWeeks(activities, weekStart)
190const labels = {
191 months: [
192 'Jan',
193 'Feb',
194 'Mar',
195 'Apr',
196 'May',
197 'Jun',
198 'Jul',
199 'Aug',
200 'Sep',
201 'Oct',
202 'Nov',
203 'Dec',
204 ],
205 totalCount: `{{count}} contributions in ${year === 'last' ? 'the last year' : '{{year}}'}`,
206 legend: {
207 less: 'Less',
208 more: 'More',
209 },
210}
211const labelHeight = hideMonthLabels ? 0 : fontSize + labelMargin
212const width = weeks.length * (blockSize + blockMargin) - blockMargin
213const height = labelHeight + (blockSize + blockMargin) * 7 - blockMargin
214---
215
216<article
217 id="github-activity-calendar"
218 class="w-max max-w-full flex flex-col gap-2 text-sm"
219>
220 <div
221 class="max-w-full overflow-x-auto pt-0.5"
222 style={{
223 // Don't cover the calendar with the scrollbar.
224 scrollbarGutter: 'stable',
225 }}
226 >
227 <svg
228 class="block visible"
229 width={width}
230 height={height}
231 viewBox={`0 0 ${width} ${height}`}
232 >
233 {
234 !hideMonthLabels && (
235 <g>
236 {getMonthLabels(weeks, labels.months).map(({ label, weekIndex }) => (
237 <text
238 x={(blockSize + blockMargin) * weekIndex}
239 y={0}
240 dominant-baseline="hanging"
241 fill="currentColor"
242 >
243 {label}
244 </text>
245 ))}
246 </g>
247 )
248 }
249 {
250 weeks.map((week, weekIndex) => (
251 <g transform={`translate(${(blockSize + blockMargin) * weekIndex}, 0)`}>
252 {week.map((activity, dayIndex) => {
253 if (!activity) return null
254 return (
255 <rect
256 class="stroke-foreground/10"
257 x={0}
258 y={labelHeight + (blockSize + blockMargin) * dayIndex}
259 width={blockSize}
260 height={blockSize}
261 rx={blockRadius}
262 ry={blockRadius}
263 fill={colorScale[activity.level]}
264 data-date={activity.date}
265 data-level={activity.level}
266 />
267 )
268 })}
269 </g>
270 ))
271 }
272 </svg>
273 </div>
274 {
275 !(hideTotalCount && hideColorLegend) && (
276 <footer class="flex flex-col sm:flex-row sm:justify-between gap-x-1 gap-y-2">
277 {!hideTotalCount && (
278 <div>
279 {labels.totalCount
280 ? labels.totalCount
281 .replace('{{count}}', String(totalCount))
282 .replace('{{year}}', String(activityYear))
283 : `${totalCount} activities in ${activityYear}`}
284 </div>
285 )}
286 {!hideColorLegend && (
287 <div class="flex items-center gap-[3px]">
288 <span class="mr-1.5">{labels.legend.less}</span>
289 {range(maxLevel + 1).map((level) => (
290 <svg width={blockSize} height={blockSize}>
291 <rect
292 class="stroke-foreground/10"
293 width={blockSize}
294 height={blockSize}
295 fill={colorScale[level]}
296 rx={blockRadius}
297 ry={blockRadius}
298 />
299 </svg>
300 ))}
301 <span class="ml-1.5">{labels.legend.more}</span>
302 </div>
303 )}
304 </footer>
305 )
306 }
307</article>
308