Dernière activité 1767784072

tiffmin a révisé ce gist 1767784072. Aller à la révision

1 file changed, 0 insertions, 0 deletions

GitHubActivityCalendar.astro renommé en GitHubActivityCalendar.tsx

Fichier renommé sans modifications

tiffmin a révisé ce gist 1767784021. Aller à la révision

1 file changed, 307 insertions

GitHubActivityCalendar.astro(fichier créé)

@@ -0,0 +1,307 @@
1 + ---
2 + import {
3 + differenceInCalendarDays,
4 + eachDayOfInterval,
5 + formatISO,
6 + getDay,
7 + nextDay,
8 + parseISO,
9 + subWeeks,
10 + getYear,
11 + getMonth,
12 + isValid,
13 + } from 'date-fns'
14 + import type {
15 + WeekdayIndex,
16 + CodebergActivityApiResponse,
17 + CodebergActivityDay,
18 + CodebergActivityWeek,
19 + CodebergActivityMonthLabel,
20 + } from '~/types'
21 +
22 + interface Props {
23 + username: string
24 + year?: number
25 + }
26 +
27 + function range(n: number) {
28 + return [...Array(n).keys()]
29 + }
30 +
31 + async 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 +
61 + function 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 +
76 + function 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 +
97 + function 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 +
126 + function 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 +
163 + const { username, year = 'last' } = Astro.props
164 +
165 + const data = await fetchData(username, year)
166 +
167 + const themeFromColorscheme: [string, string] = [
168 + 'var(--theme-background)',
169 + 'var(--theme-accent)',
170 + ]
171 +
172 + const totalCount = year === 'last' ? data.total.lastYear : data.total[year]
173 + const maxLevel = 4
174 + const blockMargin = 4
175 + const labelMargin = 8
176 + const blockRadius = 2
177 + const blockSize = 12
178 + const fontSize = 14
179 + const hideColorLegend = false
180 + const hideMonthLabels = false
181 + const hideTotalCount = false
182 + const weekStart = 0 // 0 = Sunday, 1 = Monday, etc.
183 +
184 + const colorScale = calcColorScale(themeFromColorscheme, maxLevel + 1)
185 + const activities = data.contributions
186 +
187 + const firstActivity = activities[0]
188 + const activityYear = getYear(parseISO(firstActivity.date))
189 + const weeks = groupByWeeks(activities, weekStart)
190 + const 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 + }
211 + const labelHeight = hideMonthLabels ? 0 : fontSize + labelMargin
212 + const width = weeks.length * (blockSize + blockMargin) - blockMargin
213 + const 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>
Plus récent Plus ancien