"use client"; import { useMemo, useState } from "react"; import { Activity, LayoutGrid } from "lucide-react"; import type { ForgejoHeatmapDay, ForgejoLastPush } from "@/lib/api-forgejo"; import { Select, SelectContent, SelectItem, SelectTrigger, } from "@/components/ui/select"; 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 = 188; 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 HLEFT = 32; const HRIGHT = 14; const HVH = LVH; // ── Types & data ─────────────────────────────────────────────────────────── type RangeKey = "7d" | "14d" | "30d" | "90d" | "6m" | "1y"; type ActivityDatum = { date: string; count: number; label: string; }; const RANGE_DAYS: Record = { "7d": 7, "14d": 14, "30d": 30, "90d": 90, "6m": 183, "1y": 365, }; const RANGE_SUMMARY: Record = { "7d": "7 days", "14d": "14 days", "30d": "30 days", "90d": "90 days", "6m": "6 months", "1y": "1 year", }; const RANGE_LABELS: RangeKey[] = ["7d", "14d", "30d", "90d", "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 dateFromIsoDate(value: string): Date { const [year, month, day] = value.split("-").map(Number); return new Date(year, month - 1, day); } 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`; } function fmtActivityDate(date: string): string { const d = dateFromIsoDate(date); return `${MONTHS[d.getMonth()]} ${d.getDate()}`; } function fmtCommitTitle(date: string, count: number): string { if (count <= 0) return `${date}: no commits`; return `${date}: ${count} commit${count === 1 ? "" : "s"}`; } // 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 heatFill(count: number, maxCount: number): string { if (count <= 0 || maxCount <= 0) return HMAP_FILL[0]; const intensity = Math.max(0.18, Math.min(1, count / maxCount)); const alpha = 0.18 + intensity * 0.82; return `rgba(16,185,129,${alpha.toFixed(2)})`; } function RangeSelect({ value, onValueChange, accent, ariaLabel, }: { value: RangeKey; onValueChange: (value: RangeKey) => void; accent: "violet" | "green"; ariaLabel: string; }) { const isViolet = accent === "violet"; return ( ); } // ── Line chart sub-component ─────────────────────────────────────────────── function LineChart({ days, range, onRangeChange, lastPush, }: { days: ForgejoHeatmapDay[]; range: RangeKey; onRangeChange: (r: RangeKey) => void; lastPush: ForgejoLastPush | null; }) { const [hoveredPoint, setHoveredPoint] = useState(null); const { points, xLabels, yLabels, linePath, areaPath, totalEvents } = 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), label: `Week of ${fmtActivityDate(chunk[0].date)}`, }; }) : daily.map((d) => ({ ...d, label: fmtActivityDate(d.date), })); 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, label: p.label, })); const totalEvents = daily.reduce((s, d) => s + d.count, 0); // 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 = dateFromIsoDate(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, totalEvents }; }, [days, range]); const baseline = LP_T + LCH; const lastPt = points[points.length - 1]; const activePoint = hoveredPoint ? (points.find((p) => p.date === hoveredPoint.date) ?? null) : null; const displayedCount = hoveredPoint?.count ?? totalEvents; const displayedLabel = hoveredPoint?.label ?? RANGE_SUMMARY[range]; return (
{/* Header */}
Git Commits {displayedCount.toLocaleString()} {hoveredPoint ? `on ${displayedLabel}` : `in ${displayedLabel}`}
{/* SVG */} {yLabels.map(({ y, label }) => ( {label} ))} {activePoint && ( <> )} {lastPt && ( <> )} {xLabels.map(({ x, label }, i) => ( {label} ))} {points.map((p, i) => ( setHoveredPoint({ date: p.date, count: p.count, label: p.label }) } onFocus={() => setHoveredPoint({ date: p.date, count: p.count, label: p.label }) } onMouseLeave={() => setHoveredPoint(null)} onBlur={() => setHoveredPoint(null)} style={{ fill: "transparent", outline: "none" }} > {fmtCommitTitle(p.date, p.count)} ))} {/* 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 [hoveredDay, setHoveredDay] = useState(null); const heatmap = useMemo(() => { const lookup = new Map(days.map((d) => [d.date, d.count])); const today = new Date(); today.setHours(0, 0, 0, 0); const numDays = RANGE_DAYS[range]; const rangeStart = new Date(today); rangeStart.setDate(rangeStart.getDate() - (numDays - 1)); const daily = Array.from({ length: numDays }, (_, i) => { const d = new Date(rangeStart); d.setDate(d.getDate() + i); const date = isoDate(d); return { date, count: lookup.get(date) ?? 0, label: numDays <= 7 ? ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][d.getDay()] : `${MONTHS[d.getMonth()]} ${d.getDate()}`, }; }); const totalEvents = daily.reduce((s, d) => s + d.count, 0); const maxCount = Math.max(...daily.map((d) => d.count), 0); if (numDays <= 30) { const gap = numDays <= 7 ? 12 : 4; const maxCell = numDays <= 7 ? 42 : 15; const availableWidth = LVW - HLEFT - HRIGHT; const cell = Math.max( 8, Math.min( maxCell, Math.floor((availableWidth - gap * (numDays - 1)) / numDays), ), ); const width = daily.length * cell + Math.max(0, daily.length - 1) * gap; const x = HLEFT + (availableWidth - width) / 2; const y = numDays <= 7 ? 48 : 54; return { mode: "strip" as const, daily, totalEvents, maxCount, cell, gap, x, y, }; } const calendarStart = new Date(rangeStart); calendarStart.setDate(calendarStart.getDate() - calendarStart.getDay()); const calendarEnd = new Date(today); calendarEnd.setDate(calendarEnd.getDate() + (6 - calendarEnd.getDay())); const weekCount = Math.max( 1, Math.ceil( ((calendarEnd.getTime() - calendarStart.getTime()) / 86400000 + 1) / 7, ), ); type CalendarCell = { date: string; count: number; label: string; outsideRange: boolean; }; const builtWeeks: CalendarCell[][] = []; const monthLabelList: { weekIdx: number; label: string }[] = []; let lastMonth = -1; const cur = new Date(calendarStart); for (let w = 0; w < weekCount; w++) { const week: CalendarCell[] = []; for (let d = 0; d < 7; d++) { const dateStr = isoDate(cur); const month = cur.getMonth(); if (cur >= rangeStart && cur <= today && month !== lastMonth) { monthLabelList.push({ weekIdx: w, label: MONTHS[month] }); lastMonth = month; } week.push({ date: dateStr, count: lookup.get(dateStr) ?? 0, label: fmtActivityDate(dateStr), outsideRange: cur < rangeStart || cur > today, }); cur.setDate(cur.getDate() + 1); } builtWeeks.push(week); } const gap = 3; const availableWidth = LVW - HLEFT - HRIGHT; const maxCell = range === "90d" ? 14 : range === "6m" ? 12 : 8; const cell = Math.max( 6, Math.min( maxCell, Math.floor((availableWidth - gap * (weekCount - 1)) / weekCount), ), ); const stride = cell + gap; const width = weekCount * cell + Math.max(0, weekCount - 1) * gap; const x = HLEFT + (availableWidth - width) / 2; const y = 28; return { mode: "calendar" as const, weeks: builtWeeks, monthLabels: monthLabelList, totalEvents, maxCount, cell, gap, stride, x, y, }; }, [days, range]); const displayedCount = hoveredDay?.count ?? heatmap.totalEvents; const displayedLabel = hoveredDay?.label ?? RANGE_SUMMARY[range]; return (
{/* Header */}
Commit Heatmap {displayedCount.toLocaleString()} {hoveredDay ? `on ${displayedLabel}` : `in ${displayedLabel}`}
{/* SVG */} {heatmap.mode === "strip" ? ( <> {heatmap.daily.map((day, i) => ( setHoveredDay(day)} onFocus={() => setHoveredDay(day)} onMouseLeave={() => setHoveredDay(null)} onBlur={() => setHoveredDay(null)} style={{ fill: heatFill(day.count, heatmap.maxCount), stroke: hoveredDay?.date === day.date ? GREEN : "transparent", strokeWidth: 2, outline: "none", }} > {fmtCommitTitle(day.date, day.count)} {(range === "7d" || i % 5 === 0 || i === heatmap.daily.length - 1) && ( {day.label} )} ))} ) : ( <> {heatmap.monthLabels.map(({ weekIdx, label }) => ( {label} ))} {(["Mon", "Wed", "Fri"] as const).map((label, i) => ( {label} ))} {heatmap.weeks.map((week, wi) => week.map((cell, di) => cell.outsideRange ? null : ( setHoveredDay({ date: cell.date, count: cell.count, label: cell.label, }) } onFocus={() => setHoveredDay({ date: cell.date, count: cell.count, label: cell.label, }) } onMouseLeave={() => setHoveredDay(null)} onBlur={() => setHoveredDay(null)} style={{ fill: heatFill(cell.count, heatmap.maxCount), stroke: hoveredDay?.date === cell.date ? GREEN : "transparent", strokeWidth: 1.5, outline: "none", }} > {fmtCommitTitle(cell.date, cell.count)} ), ), )} )} Less {HMAP_FILL.map((fill, i) => ( ))} More {/* Contributions summary */}

{heatmap.totalEvents.toLocaleString()} commits across all tracked repositories in the last{" "} {RANGE_SUMMARY[range]}

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

Line stats syncing with Forgejo — will appear on next refresh

)}
); }