"use client"; import { useMemo, useState } from "react"; import { Activity, LayoutGrid } from "lucide-react"; import type { ForgejoHeatmapDay, ForgejoLastPush } from "@/lib/api-forgejo"; interface ForgejoHeatmapProps { days: ForgejoHeatmapDay[]; maxCount: number; totalAdditions?: number; totalDeletions?: number; hasLineStats?: boolean; lastPush?: ForgejoLastPush | null; isLoading?: boolean; } // ── Line chart layout ────────────────────────────────────────────────────── const LVW = 580; const LVH = 160; const LP_L = 34; // Y-axis labels const LP_R = 8; const LP_T = 10; const LP_B = 24; const LCW = LVW - LP_L - LP_R; const LCH = LVH - LP_T - LP_B; // ── Heatmap layout ───────────────────────────────────────────────────────── const HCELL = 11; const HGAP = 3; const HSTRIDE = HCELL + HGAP; const HWEEKS = 52; const HLEFT = 30; const HTOP = 18; const HVW = HLEFT + HWEEKS * HSTRIDE; const HVH = HTOP + 7 * HSTRIDE - HGAP + 22; // +22 for legend // ── Types & data ─────────────────────────────────────────────────────────── type RangeKey = "7d" | "30d" | "3m" | "6m" | "1y"; const RANGE_DAYS: Record = { "7d": 7, "30d": 30, "3m": 91, "6m": 183, "1y": 365, }; const RANGE_LABELS: RangeKey[] = ["7d", "30d", "3m", "6m", "1y"]; const MONTHS = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]; // ── Colors ───────────────────────────────────────────────────────────────── const VIOLET = "rgba(139,92,246,1)"; const GREEN = "rgba(52,211,153,1)"; const RED = "rgba(248,113,113,1)"; const W70 = "rgba(255,255,255,0.70)"; const W40 = "rgba(255,255,255,0.40)"; const W12 = "rgba(255,255,255,0.12)"; // Green palette for heatmap cells const HMAP_FILL = [ "rgba(16,185,129,0.08)", // 0 — empty "rgba(16,185,129,0.28)", // 1 "rgba(16,185,129,0.55)", // 2 "rgba(16,185,129,0.78)", // 3 "rgba(16,185,129,1.00)", // 4 — full emerald ]; // ── Helpers ──────────────────────────────────────────────────────────────── function isoDate(d: Date): string { return [d.getFullYear(), String(d.getMonth()+1).padStart(2,"0"), String(d.getDate()).padStart(2,"0")].join("-"); } function fmtLines(n: number): string { if (n >= 1_000_000) return `${(n/1_000_000).toFixed(1)}M`; if (n >= 1_000) return `${(n/1_000).toFixed(1)}k`; return n.toLocaleString(); } function fmtRelative(iso: string): string { const s = Math.floor((Date.now() - new Date(iso).getTime()) / 1000); if (s < 60) return "just now"; const m = Math.floor(s/60); if (m < 60) return `${m}m ago`; const h = Math.floor(m/60); if (h < 24) return `${h}h ago`; return `${Math.floor(h/24)}d ago`; } // Catmull-Rom → cubic bezier smooth path function smoothPath(pts: {x:number;y:number}[]): string { if (pts.length < 2) return ""; const t = 1/6; let d = `M${pts[0].x.toFixed(1)},${pts[0].y.toFixed(1)}`; for (let i = 0; i < pts.length-1; i++) { const p0=pts[Math.max(0,i-1)], p1=pts[i], p2=pts[i+1], p3=pts[Math.min(pts.length-1,i+2)]; d += ` C${(p1.x+t*(p2.x-p0.x)).toFixed(1)},${(p1.y+t*(p2.y-p0.y)).toFixed(1)} ${(p2.x-t*(p3.x-p1.x)).toFixed(1)},${(p2.y-t*(p3.y-p1.y)).toFixed(1)} ${p2.x.toFixed(1)},${p2.y.toFixed(1)}`; } return d; } function toLevel(count: number): number { if (count === 0) return 0; if (count <= 2) return 1; if (count <= 5) return 2; if (count <= 9) return 3; return 4; } // ── Line chart sub-component ─────────────────────────────────────────────── function LineChart({ days, range, onRangeChange, lastPush }: { days: ForgejoHeatmapDay[]; range: RangeKey; onRangeChange: (r: RangeKey) => void; lastPush: ForgejoLastPush | null; }) { const { points, xLabels, yLabels, linePath, areaPath } = useMemo(() => { const lookup = new Map(days.map(d => [d.date, d.count])); const numDays = RANGE_DAYS[range]; const today = new Date(); today.setHours(0,0,0,0); const daily = Array.from({length: numDays}, (_, i) => { const d = new Date(today); d.setDate(d.getDate()-(numDays-1-i)); const key = isoDate(d); return { date: key, count: lookup.get(key) ?? 0 }; }); const useWeekly = numDays > 30; const series = useWeekly ? Array.from({length: Math.ceil(daily.length/7)}, (_, i) => { const chunk = daily.slice(i*7, i*7+7); return { date: chunk[0].date, count: chunk.reduce((s,d) => s+d.count, 0) }; }) : daily; const localMax = Math.max(...series.map(p => p.count), 1); const pts = series.map((p, i) => ({ x: LP_L + (i / Math.max(series.length-1,1)) * LCW, y: LP_T + LCH - (p.count / localMax) * LCH, date: p.date, count: p.count, })); // X labels const step = Math.max(1, Math.floor(series.length / 5)); const xLabels = series .filter((_, i) => i % step === 0 || i === series.length-1) .map((p) => { const idx = series.indexOf(p); const d = new Date(p.date); return { x: LP_L + (idx / Math.max(series.length-1,1)) * LCW, label: useWeekly || numDays > 7 ? `${MONTHS[d.getMonth()]} ${d.getDate()}` : ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"][d.getDay()], }; }); // Y labels const yStep = localMax / 4; const yLabels = [1,2,3,4].map(i => { const val = Math.round(yStep * i); return { y: LP_T + LCH - (val/localMax)*LCH, label: val >= 1000 ? `${(val/1000).toFixed(0)}k` : String(val) }; }); const linePath = smoothPath(pts); const last = pts[pts.length-1], first = pts[0]; const baseline = LP_T + LCH; const areaPath = pts.length < 2 ? "" : linePath + ` L${last.x.toFixed(1)},${baseline} L${first.x.toFixed(1)},${baseline} Z`; return { points: pts, xLabels, yLabels, linePath, areaPath }; }, [days, range]); const baseline = LP_T + LCH; const lastPt = points[points.length-1]; return (
{/* Header */}
Git Activity
{RANGE_LABELS.map(r => ( ))}
{/* SVG */} {yLabels.map(({y, label}) => ( {label} ))} {lastPt && ( <> )} {xLabels.map(({x, label}, i) => ( {label} ))} {points.map((p,i) => ( {p.date}{p.count > 0 ? `: ${p.count} event${p.count!==1?"s":""}` : ": no activity"} ))} {/* Last push — centered at bottom of card */} {lastPush && (
{lastPush.sha}

{lastPush.message}

{lastPush.author} pushed to{" "} {lastPush.branch} {" · "}{lastPush.repo}{" · "}{fmtRelative(lastPush.pushed_at)}

)}
); } // ── Heatmap grid sub-component ───────────────────────────────────────────── function HeatmapGrid({ days, range, onRangeChange }: { days: ForgejoHeatmapDay[]; range: RangeKey; onRangeChange: (r: RangeKey) => void; }) { const { weeks, monthLabels, totalEvents } = useMemo(() => { const lookup = new Map(days.map(d => [d.date, d.count])); const today = new Date(); today.setHours(0,0,0,0); const start = new Date(today); start.setDate(start.getDate() - start.getDay()); start.setDate(start.getDate() - (HWEEKS-1)*7); type Cell = { date:string; count:number; future:boolean }; const builtWeeks: Cell[][] = []; const monthLabelList: {weekIdx:number; label:string}[] = []; let lastMonth = -1; const cur = new Date(start); for (let w = 0; w < HWEEKS; w++) { const week: Cell[] = []; for (let d = 0; d < 7; d++) { const dateStr = isoDate(cur); const month = cur.getMonth(); if (d === 0 && month !== lastMonth) { monthLabelList.push({ weekIdx: w, label: MONTHS[month] }); lastMonth = month; } week.push({ date: dateStr, count: lookup.get(dateStr) ?? 0, future: cur > today }); cur.setDate(cur.getDate()+1); } builtWeeks.push(week); } // contributions count for selected range const numDays = RANGE_DAYS[range]; const cutoff = new Date(today); cutoff.setDate(cutoff.getDate() - numDays); const cutoffStr = isoDate(cutoff); const totalEvents = days.filter(d => d.date >= cutoffStr).reduce((s,d) => s+d.count, 0); return { weeks: builtWeeks, monthLabels: monthLabelList, totalEvents }; }, [days, range]); return (
{/* Header */}
Activity Heatmap
{RANGE_LABELS.map(r => ( ))}
{/* SVG */} {monthLabels.map(({weekIdx, label}) => ( {label} ))} {(["Mon","Wed","Fri"] as const).map((label, i) => ( {label} ))} {weeks.map((week, wi) => week.map((cell, di) => cell.future ? null : ( {cell.date}{cell.count>0?`: ${cell.count} event${cell.count!==1?"s":""}` : ": no activity"} )) )} Less {HMAP_FILL.map((fill, i) => ( ))} More {/* Contributions summary */}

{totalEvents.toLocaleString()} contributions across all tracked repositories in the last {range}

); } // ── Main export ──────────────────────────────────────────────────────────── export function ForgejoHeatmap({ days, totalAdditions = 0, totalDeletions = 0, hasLineStats = false, lastPush = null, isLoading = false, }: ForgejoHeatmapProps) { const [lineRange, setLineRange] = useState("7d"); const [heatRange, setHeatRange] = useState("7d"); if (isLoading) { return (
{[0,1].map(i => (
))}
); } return (
{/* ── Two cards ───────────────────────────────────────────── */}
{/* ── Line stats — boxed ──────────────────────────────────── */} {hasLineStats ? (
+{fmtLines(totalAdditions)} lines added
-{fmtLines(totalDeletions)} lines removed
) : (

Line stats syncing with Forgejo — will appear on next refresh

)}
); }