--- 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 { function validateActivities(activities: Array) { 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 { 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): Array { const calendar = new Map(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, weekStart: WeekdayIndex = 0, // 0 = Sunday ): Array { 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), ...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, monthNames: Array, ): Array { return weeks .reduce>((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 ---
{ !hideMonthLabels && ( {getMonthLabels(weeks, labels.months).map(({ label, weekIndex }) => ( {label} ))} ) } { weeks.map((week, weekIndex) => ( {week.map((activity, dayIndex) => { if (!activity) return null return ( ) })} )) }
{ !(hideTotalCount && hideColorLegend) && ( ) }