GitHubActivityCalendar.tsx
· 8.9 KiB · TypeScript
Ham
---
import {
differenceInCalendarDays,
eachDayOfInterval,
formatISO,
getDay,
nextDay,
parseISO,
subWeeks,
getYear,
getMonth,
isValid,
} from 'date-fns'
import type {
WeekdayIndex,
CodebergActivityApiResponse,
CodebergActivityDay,
CodebergActivityWeek,
CodebergActivityMonthLabel,
} from '~/types'
interface Props {
username: string
year?: number
}
function range(n: number) {
return [...Array(n).keys()]
}
async function fetchData(
username: string,
year: number | 'last',
): Promise<CodebergActivityApiResponse> {
function validateActivities(activities: Array<CodebergActivityDay>) {
if (activities.length === 0) {
throw new Error('Activity data must not be empty.')
}
for (const { date, count } of activities) {
if (!isValid(parseISO(date))) {
throw new Error(`Activity date '${date}' is not a valid ISO 8601 date string.`)
}
if (count < 0) {
throw new RangeError(`Activity count must not be negative, found ${count}.`)
}
}
}
const apiUrl = 'https://github-contributions-api.jogruber.de/v4/'
const response = await fetch(`${apiUrl}${username}?y=${String(year)}`)
const data = (await response.json()) as GitHubActivityApiResponse
if (!response.ok) {
const message = data.error || 'Unknown error'
throw Error(`Fetching GitHub contribution data for "${username}" failed: ${message}`)
}
validateActivities(data.contributions)
return data
}
function calcColorScale([start, end]: [string, string], steps: number): Array<string> {
return range(steps).map((i) => {
switch (i) {
case 0:
return start
case steps - 1:
return end
default: {
const pos = (i / (steps - 1)) * 100
return `color-mix(in oklab, ${end} ${parseFloat(pos.toFixed(2))}%, ${start})`
}
}
})
}
function fillHoles(activities: Array<GitHubActivityDay>): Array<GitHubActivityDay> {
const calendar = new Map<string, GitHubActivityDay>(activities.map((a) => [a.date, a]))
const firstActivity = activities[0] as GitHubActivityDay
const lastActivity = activities[activities.length - 1] as GitHubActivityDay
return eachDayOfInterval({
start: parseISO(firstActivity.date),
end: parseISO(lastActivity.date),
}).map((day) => {
const date = formatISO(day, { representation: 'date' })
if (calendar.has(date)) {
return calendar.get(date) as GitHubActivityDay
}
return {
date,
count: 0,
level: 0,
}
})
}
function groupByWeeks(
activities: Array<GitHubActivityDay>,
weekStart: WeekdayIndex = 0, // 0 = Sunday
): Array<GitHubActivityWeek> {
const normalizedActivities = fillHoles(activities)
// Determine the first date of the calendar. If the first date is not the
// passed weekday, the respective weekday one week earlier is used.
const firstActivity = normalizedActivities[0] as GitHubActivityDay
const firstDate = parseISO(firstActivity.date)
const firstCalendarDate =
getDay(firstDate) === weekStart
? firstDate
: subWeeks(nextDay(firstDate, weekStart), 1)
// To correctly group activities by week, it is necessary to left-pad the list
// because the first date might not be set start weekday.
const paddedActivities = [
...(Array(differenceInCalendarDays(firstDate, firstCalendarDat e)).fill(
undefined,
) as Array<GitHubActivityDay>),
...normalizedActivities,
]
const numberOfWeeks = Math.ceil(paddedActivities.length / 7)
// Finally, group activities by week
return [...Array(numberOfWeeks).keys()].map((weekIndex) =>
paddedActivities.slice(weekIndex * 7, weekIndex * 7 + 7),
)
}
function getMonthLabels(
weeks: Array<GitHubActivityWeek>,
monthNames: Array<string>,
): Array<GitHubActivityMonthLabel> {
return weeks
.reduce<Array<GitHubActivityMonthLabel>>((labels, week, weekIndex) => {
const firstActivity = week.find((activity) => activity !== undefined)
if (!firstActivity) {
throw new Error(`Unexpected error: Week ${weekIndex + 1} is empty.`)
}
const month = monthNames[getMonth(parseISO(firstActivity.date))]
if (!month) {
const monthName = new Date(firstActivity.date).toLocaleString('en-US', {
month: 'short',
})
throw new Error(`Unexpected error: undefined month label for ${monthName}.`)
}
const prevLabel = labels[labels.length - 1]
if (weekIndex === 0 || !prevLabel || prevLabel.label !== month) {
return [...labels, { weekIndex, label: month }]
}
return labels
}, [])
.filter(({ weekIndex }, index, labels) => {
const minWeeks = 3
// Skip the first month label if there is not enough space to the next one.
if (index === 0) {
return labels[1] && labels[1].weekIndex - weekIndex >= minWeeks
}
// Skip the last month label if there is not enough data in that month
if (index === labels.length - 1) {
return weeks.slice(weekIndex).length >= minWeeks
}
return true
})
}
const { username, year = 'last' } = Astro.props
const data = await fetchData(username, year)
const themeFromColorscheme: [string, string] = [
'var(--theme-background)',
'var(--theme-accent)',
]
const totalCount = year === 'last' ? data.total.lastYear : data.total[year]
const maxLevel = 4
const blockMargin = 4
const labelMargin = 8
const blockRadius = 2
const blockSize = 12
const fontSize = 14
const hideColorLegend = false
const hideMonthLabels = false
const hideTotalCount = false
const weekStart = 0 // 0 = Sunday, 1 = Monday, etc.
const colorScale = calcColorScale(themeFromColorscheme, maxLevel + 1)
const activities = data.contributions
const firstActivity = activities[0]
const activityYear = getYear(parseISO(firstActivity.date))
const weeks = groupByWeeks(activities, weekStart)
const labels = {
months: [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
],
totalCount: `{{count}} contributions in ${year === 'last' ? 'the last year' : '{{year}}'}`,
legend: {
less: 'Less',
more: 'More',
},
}
const labelHeight = hideMonthLabels ? 0 : fontSize + labelMargin
const width = weeks.length * (blockSize + blockMargin) - blockMargin
const height = labelHeight + (blockSize + blockMargin) * 7 - blockMargin
---
<article
id="github-activity-calendar"
class="w-max max-w-full flex flex-col gap-2 text-sm"
>
<div
class="max-w-full overflow-x-auto pt-0.5"
style={{
// Don't cover the calendar with the scrollbar.
scrollbarGutter: 'stable',
}}
>
<svg
class="block visible"
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
>
{
!hideMonthLabels && (
<g>
{getMonthLabels(weeks, labels.months).map(({ label, weekIndex }) => (
<text
x={(blockSize + blockMargin) * weekIndex}
y={0}
dominant-baseline="hanging"
fill="currentColor"
>
{label}
</text>
))}
</g>
)
}
{
weeks.map((week, weekIndex) => (
<g transform={`translate(${(blockSize + blockMargin) * weekIndex}, 0)`}>
{week.map((activity, dayIndex) => {
if (!activity) return null
return (
<rect
class="stroke-foreground/10"
x={0}
y={labelHeight + (blockSize + blockMargin) * dayIndex}
width={blockSize}
height={blockSize}
rx={blockRadius}
ry={blockRadius}
fill={colorScale[activity.level]}
data-date={activity.date}
data-level={activity.level}
/>
)
})}
</g>
))
}
</svg>
</div>
{
!(hideTotalCount && hideColorLegend) && (
<footer class="flex flex-col sm:flex-row sm:justify-between gap-x-1 gap-y-2">
{!hideTotalCount && (
<div>
{labels.totalCount
? labels.totalCount
.replace('{{count}}', String(totalCount))
.replace('{{year}}', String(activityYear))
: `${totalCount} activities in ${activityYear}`}
</div>
)}
{!hideColorLegend && (
<div class="flex items-center gap-[3px]">
<span class="mr-1.5">{labels.legend.less}</span>
{range(maxLevel + 1).map((level) => (
<svg width={blockSize} height={blockSize}>
<rect
class="stroke-foreground/10"
width={blockSize}
height={blockSize}
fill={colorScale[level]}
rx={blockRadius}
ry={blockRadius}
/>
</svg>
))}
<span class="ml-1.5">{labels.legend.more}</span>
</div>
)}
</footer>
)
}
</article>
| 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> |
| 308 |