SPB Git

spb/spbgit Public MIT

SPB Git — the platform hosting itself

JavaScript 73.9% CSS 11.7% Nunjucks 11.6% Shell 2.7%
5.3 KB · 149 lines javascript
Raw Blame History
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