/** * ───────────────────────────────────────────── * SPB Git — Personal Git Platform * ───────────────────────────────────────────── * Author : Simon-Pierre Boucher * Contact : contact@spboucher.ai * File : src/stats/activity.mjs * Purpose : Contribution heatmap (52 weeks) + commit calendar data * License : MIT © Simon-Pierre Boucher * ───────────────────────────────────────────── */ const DAY_MS = 86400000; const WEEKS = 52; const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; /** * Bucket unix commit timestamps (seconds) into per-day counts. * @param {number[]} timestamps * @returns {Map} ISO date (YYYY-MM-DD) → count */ export function bucketByDay(timestamps) { const days = new Map(); for (const ts of timestamps) { const iso = new Date(ts * 1000).toISOString().slice(0, 10); days.set(iso, (days.get(iso) ?? 0) + 1); } return days; } /** * Build the GitHub-style contribution calendar grid for the last 52 weeks. * @param {Map} dayCounts * @param {Date} [today] injection point for tests * @returns {{weeks: Array>, months: Array<{index: number, label: string}>, total: number, max: number}} */ export function buildCalendar(dayCounts, today = new Date()) { const end = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate())); // Grid ends on the current day; columns are weeks starting Sunday. const endDow = end.getUTCDay(); const start = new Date(end.getTime() - ((WEEKS - 1) * 7 + endDow) * DAY_MS); let max = 0; let total = 0; const weeks = []; const months = []; let lastMonth = -1; for (let w = 0; w < WEEKS; w += 1) { const week = []; for (let d = 0; d < 7; d += 1) { const date = new Date(start.getTime() + (w * 7 + d) * DAY_MS); if (date.getTime() > end.getTime()) { week.push(null); continue; } const iso = date.toISOString().slice(0, 10); const count = dayCounts.get(iso) ?? 0; total += count; if (count > max) max = count; week.push({ date: iso, count }); if (d === 0) { const month = date.getUTCMonth(); if (month !== lastMonth) { months.push({ index: w, label: MONTHS[month] }); lastMonth = month; } } } weeks.push(week); } // Quantize to 5 levels the way GitHub does (quartiles of the max). for (const week of weeks) { for (const day of week) { if (!day) continue; day.level = day.count === 0 ? 0 : Math.min(4, Math.ceil((day.count / Math.max(1, max)) * 4)); } } // Drop a leading month label crammed against the second one. if (months.length >= 2 && months[1].index - months[0].index < 3) months.shift(); return { weeks, months, total, max }; } /** * Render the contribution calendar as an accessible SVG. * @param {ReturnType} calendar * @returns {string} SVG markup */ export function calendarSvg(calendar) { const cell = 11; const gap = 3; const left = 30; const top = 20; const width = left + WEEKS * (cell + gap) + 4; const height = top + 7 * (cell + gap) + 4; const parts = []; parts.push( ``, ); for (const month of calendar.months) { parts.push( `${month.label}`, ); } const dayLabels = [ [1, 'Mon'], [3, 'Wed'], [5, 'Fri'], ]; for (const [row, label] of dayLabels) { parts.push( `${label}`, ); } calendar.weeks.forEach((week, w) => { week.forEach((day, d) => { if (!day) return; const x = left + w * (cell + gap); const y = top + d * (cell + gap); const plural = day.count === 1 ? 'commit' : 'commits'; parts.push( `${day.count} ${plural} on ${day.date}`, ); }); }); parts.push(''); return parts.join(''); } /** * Aggregate commit timestamps across every repo, then build calendar + SVG. * Cached globally, refreshed on push. * @param {{repos: object, cache: object}} ctx * @returns {Promise<{svg: string, total: number}>} */ export async function contributionCalendar(ctx) { const cachePath = ctx.cache.path('global', 'heatmap.json'); const cached = ctx.cache.getJSON(cachePath); if (cached) return cached; const all = []; for (const name of ctx.repos.list()) { const stamps = await ctx.repos.commitTimestamps(name); all.push(...stamps); } const calendar = buildCalendar(bucketByDay(all)); const result = { svg: calendarSvg(calendar), total: calendar.total }; ctx.cache.setJSON(cachePath, result); return result; }