spb/spbgit Public MIT
SPB Git — the platform hosting itself
JavaScript 73.9%
CSS 11.7%
Nunjucks 11.6%
Shell 2.7%
1/**2 * ─────────────────────────────────────────────3 * SPB Git — Personal Git Platform4 * ─────────────────────────────────────────────5 * Author : Simon-Pierre Boucher6 * Contact : contact@spboucher.ai7 * File : src/stats/activity.mjs8 * Purpose : Contribution heatmap (52 weeks) + commit calendar data9 * License : MIT © Simon-Pierre Boucher10 * ─────────────────────────────────────────────11 */1213const DAY_MS = 86400000;14const WEEKS = 52;15const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];1617/**18 * Bucket unix commit timestamps (seconds) into per-day counts.19 * @param {number[]} timestamps20 * @returns {Map<string, number>} ISO date (YYYY-MM-DD) → count21 */22export function bucketByDay(timestamps) {23 const days = new Map();24 for (const ts of timestamps) {25 const iso = new Date(ts * 1000).toISOString().slice(0, 10);26 days.set(iso, (days.get(iso) ?? 0) + 1);27 }28 return days;29}3031/**32 * Build the GitHub-style contribution calendar grid for the last 52 weeks.33 * @param {Map<string, number>} dayCounts34 * @param {Date} [today] injection point for tests35 * @returns {{weeks: Array<Array<{date: string, count: number, level: 0|1|2|3|4}|null>>, months: Array<{index: number, label: string}>, total: number, max: number}}36 */37export function buildCalendar(dayCounts, today = new Date()) {38 const end = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()));39 // Grid ends on the current day; columns are weeks starting Sunday.40 const endDow = end.getUTCDay();41 const start = new Date(end.getTime() - ((WEEKS - 1) * 7 + endDow) * DAY_MS);4243 let max = 0;44 let total = 0;45 const weeks = [];46 const months = [];47 let lastMonth = -1;48 for (let w = 0; w < WEEKS; w += 1) {49 const week = [];50 for (let d = 0; d < 7; d += 1) {51 const date = new Date(start.getTime() + (w * 7 + d) * DAY_MS);52 if (date.getTime() > end.getTime()) {53 week.push(null);54 continue;55 }56 const iso = date.toISOString().slice(0, 10);57 const count = dayCounts.get(iso) ?? 0;58 total += count;59 if (count > max) max = count;60 week.push({ date: iso, count });61 if (d === 0) {62 const month = date.getUTCMonth();63 if (month !== lastMonth) {64 months.push({ index: w, label: MONTHS[month] });65 lastMonth = month;66 }67 }68 }69 weeks.push(week);70 }71 // Quantize to 5 levels the way GitHub does (quartiles of the max).72 for (const week of weeks) {73 for (const day of week) {74 if (!day) continue;75 day.level = day.count === 0 ? 0 : Math.min(4, Math.ceil((day.count / Math.max(1, max)) * 4));76 }77 }78 // Drop a leading month label crammed against the second one.79 if (months.length >= 2 && months[1].index - months[0].index < 3) months.shift();80 return { weeks, months, total, max };81}8283/**84 * Render the contribution calendar as an accessible SVG.85 * @param {ReturnType<typeof buildCalendar>} calendar86 * @returns {string} SVG markup87 */88export function calendarSvg(calendar) {89 const cell = 11;90 const gap = 3;91 const left = 30;92 const top = 20;93 const width = left + WEEKS * (cell + gap) + 4;94 const height = top + 7 * (cell + gap) + 4;95 const parts = [];96 parts.push(97 `<svg xmlns="http://www.w3.org/2000/svg" class="heatmap-svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" role="img" aria-label="Commit activity, last 52 weeks">`,98 );99 for (const month of calendar.months) {100 parts.push(101 `<text x="${left + month.index * (cell + gap)}" y="12" class="heatmap-label">${month.label}</text>`,102 );103 }104 const dayLabels = [105 [1, 'Mon'],106 [3, 'Wed'],107 [5, 'Fri'],108 ];109 for (const [row, label] of dayLabels) {110 parts.push(111 `<text x="0" y="${top + row * (cell + gap) + cell - 2}" class="heatmap-label">${label}</text>`,112 );113 }114 calendar.weeks.forEach((week, w) => {115 week.forEach((day, d) => {116 if (!day) return;117 const x = left + w * (cell + gap);118 const y = top + d * (cell + gap);119 const plural = day.count === 1 ? 'commit' : 'commits';120 parts.push(121 `<rect x="${x}" y="${y}" width="${cell}" height="${cell}" rx="2" class="heatmap-cell" data-level="${day.level}"><title>${day.count} ${plural} on ${day.date}</title></rect>`,122 );123 });124 });125 parts.push('</svg>');126 return parts.join('');127}128129/**130 * Aggregate commit timestamps across every repo, then build calendar + SVG.131 * Cached globally, refreshed on push.132 * @param {{repos: object, cache: object}} ctx133 * @returns {Promise<{svg: string, total: number}>}134 */135export async function contributionCalendar(ctx) {136 const cachePath = ctx.cache.path('global', 'heatmap.json');137 const cached = ctx.cache.getJSON(cachePath);138 if (cached) return cached;139 const all = [];140 for (const name of ctx.repos.list()) {141 const stamps = await ctx.repos.commitTimestamps(name);142 all.push(...stamps);143 }144 const calendar = buildCalendar(bucketByDay(all));145 const result = { svg: calendarSvg(calendar), total: calendar.total };146 ctx.cache.setJSON(cachePath, result);147 return result;148}149