Pipeline/frontend/src/components/git/ForgejoHeatmap.tsx

413 lines
18 KiB
TypeScript

"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<RangeKey, number> = {
"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 (
<div>
{/* Header */}
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<Activity className="h-4 w-4" style={{ color: VIOLET }} />
<span className="text-sm font-semibold text-strong">Git Activity</span>
</div>
<div className="flex gap-1">
{RANGE_LABELS.map(r => (
<button
key={r} type="button"
onClick={() => onRangeChange(r)}
className="rounded px-2 py-0.5 text-xs font-medium transition-all"
style={r === range
? { background:"rgba(139,92,246,0.22)", color:VIOLET, border:"1px solid rgba(139,92,246,0.40)" }
: { background:"transparent", color:W40, border:"1px solid transparent" }}
>{r}</button>
))}
</div>
</div>
{/* SVG */}
<svg viewBox={`0 0 ${LVW} ${LVH}`} width="100%" style={{ display:"block", overflow:"visible" }}
aria-label={`Git activity last ${range}`}>
<defs>
<linearGradient id="la-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="rgba(139,92,246,0.50)" />
<stop offset="80%" stopColor="rgba(139,92,246,0.05)" />
<stop offset="100%" stopColor="rgba(139,92,246,0.00)" />
</linearGradient>
<clipPath id="la-clip"><rect x={LP_L} y={LP_T-2} width={LCW} height={LCH+4}/></clipPath>
</defs>
{yLabels.map(({y, label}) => (
<g key={label}>
<line x1={LP_L} y1={y} x2={LP_L+LCW} y2={y} strokeDasharray="4 5" style={{stroke:W12,strokeWidth:1}}/>
<text x={LP_L-5} y={y+3.5} fontSize={9} textAnchor="end" style={{fill:W40}}>{label}</text>
</g>
))}
<line x1={LP_L} y1={baseline} x2={LP_L+LCW} y2={baseline} style={{stroke:W12,strokeWidth:1}}/>
<path d={areaPath} style={{fill:"url(#la-fill)"}} clipPath="url(#la-clip)"/>
<path d={linePath} style={{fill:"none",stroke:VIOLET,strokeWidth:2,strokeLinejoin:"round",strokeLinecap:"round"}} clipPath="url(#la-clip)"/>
{lastPt && (
<>
<circle cx={lastPt.x} cy={lastPt.y} r={5.5} style={{fill:"rgba(10,10,30,0.9)"}}/>
<circle cx={lastPt.x} cy={lastPt.y} r={3.5} style={{fill:VIOLET}}/>
</>
)}
{xLabels.map(({x, label}, i) => (
<text key={i} x={x} y={LVH-6} fontSize={10} textAnchor="middle" style={{fill:W70}}>{label}</text>
))}
{points.map((p,i) => (
<rect key={i} x={p.x - LCW/Math.max(points.length,1)/2} y={LP_T} width={LCW/Math.max(points.length,1)} height={LCH} style={{fill:"transparent"}}>
<title>{p.date}{p.count > 0 ? `: ${p.count} event${p.count!==1?"s":""}` : ": no activity"}</title>
</rect>
))}
</svg>
{/* Last push — centered at bottom of card */}
{lastPush && (
<div className="mt-3 flex items-start justify-center gap-2.5">
<span className="mt-0.5 shrink-0 rounded px-1.5 py-0.5 font-mono text-[11px] font-semibold"
style={{background:"rgba(139,92,246,0.20)", color:VIOLET}}>
{lastPush.sha}
</span>
<div className="min-w-0">
<p className="truncate text-sm font-medium" style={{color:"rgba(255,255,255,0.90)"}}>
{lastPush.message}
</p>
<p className="mt-0.5 text-center text-xs" style={{color:"rgba(255,255,255,0.50)"}}>
{lastPush.author} pushed to{" "}
<span style={{color:"rgba(139,92,246,0.90)"}}>{lastPush.branch}</span>
{" · "}{lastPush.repo}{" · "}{fmtRelative(lastPush.pushed_at)}
</p>
</div>
</div>
)}
</div>
);
}
// ── 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 (
<div className="flex flex-col gap-3">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<LayoutGrid className="h-4 w-4" style={{ color: "rgba(16,185,129,1)" }} />
<span className="text-sm font-semibold text-strong">Activity Heatmap</span>
</div>
<div className="flex gap-1">
{RANGE_LABELS.map(r => (
<button key={r} type="button" onClick={() => onRangeChange(r)}
className="rounded px-2 py-0.5 text-xs font-medium transition-all"
style={r === range
? { background:"rgba(16,185,129,0.18)", color:"rgba(16,185,129,1)", border:"1px solid rgba(16,185,129,0.40)" }
: { background:"transparent", color:W40, border:"1px solid transparent" }}>
{r}
</button>
))}
</div>
</div>
{/* SVG */}
<svg viewBox={`0 0 ${HVW} ${HVH}`} width="100%" style={{ display:"block" }} aria-label="Activity heatmap">
{monthLabels.map(({weekIdx, label}) => (
<text key={`m-${weekIdx}`} x={HLEFT+weekIdx*HSTRIDE} y={11} fontSize={10} style={{fill:W70}}>{label}</text>
))}
{(["Mon","Wed","Fri"] as const).map((label, i) => (
<text key={label} x={0} y={HTOP+(i*2+1)*HSTRIDE+HCELL-2} fontSize={10} style={{fill:W70}}>{label}</text>
))}
{weeks.map((week, wi) =>
week.map((cell, di) => cell.future ? null : (
<rect key={cell.date}
x={HLEFT+wi*HSTRIDE} y={HTOP+di*HSTRIDE}
width={HCELL} height={HCELL} rx={2}
style={{fill: HMAP_FILL[toLevel(cell.count)]}}>
<title>{cell.date}{cell.count>0?`: ${cell.count} event${cell.count!==1?"s":""}` : ": no activity"}</title>
</rect>
))
)}
<text x={HLEFT} y={HVH-4} fontSize={9} style={{fill:W40}}>Less</text>
{HMAP_FILL.map((fill, i) => (
<rect key={i} x={HLEFT+32+i*(HCELL+3)} y={HVH-HCELL-4} width={HCELL} height={HCELL} rx={2} style={{fill}}/>
))}
<text x={HLEFT+32+HMAP_FILL.length*(HCELL+3)+2} y={HVH-4} fontSize={9} style={{fill:W40}}>More</text>
</svg>
{/* Contributions summary */}
<p className="text-center">
<span className="text-2xl font-bold tabular-nums" style={{color:VIOLET}}>
{totalEvents.toLocaleString()}
</span>
<span className="ml-1.5 text-sm" style={{color:W70}}>
contributions across all tracked repositories in the last {range}
</span>
</p>
</div>
);
}
// ── Main export ────────────────────────────────────────────────────────────
export function ForgejoHeatmap({
days,
totalAdditions = 0,
totalDeletions = 0,
hasLineStats = false,
lastPush = null,
isLoading = false,
}: ForgejoHeatmapProps) {
const [lineRange, setLineRange] = useState<RangeKey>("7d");
const [heatRange, setHeatRange] = useState<RangeKey>("7d");
if (isLoading) {
return (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{[0,1].map(i => (
<div key={i} className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4">
<div className="mb-3 flex justify-between">
<div className="h-4 w-28 animate-pulse rounded" style={{background:"rgba(255,255,255,0.08)"}}/>
<div className="h-4 w-20 animate-pulse rounded" style={{background:"rgba(255,255,255,0.08)"}}/>
</div>
<div className="animate-pulse rounded" style={{height:160,background:"rgba(255,255,255,0.05)"}}/>
</div>
))}
</div>
);
}
return (
<div className="space-y-4">
{/* ── Two cards ───────────────────────────────────────────── */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<section className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-lush">
<LineChart days={days} range={lineRange} onRangeChange={setLineRange} lastPush={lastPush} />
</section>
<section className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-lush">
<HeatmapGrid days={days} range={heatRange} onRangeChange={setHeatRange} />
</section>
</div>
{/* ── Line stats — boxed ──────────────────────────────────── */}
{hasLineStats ? (
<div className="mx-auto flex max-w-xs items-center justify-center gap-8 rounded-xl px-8 py-4"
style={{background:"rgba(139,92,246,0.07)", border:"1px solid rgba(139,92,246,0.20)"}}>
<div className="flex flex-col items-center gap-0.5">
<span className="text-2xl font-bold tabular-nums" style={{color:GREEN}}>+{fmtLines(totalAdditions)}</span>
<span className="text-xs font-medium" style={{color:"rgba(52,211,153,0.70)"}}>lines added</span>
</div>
<div style={{width:1,height:40,background:"rgba(139,92,246,0.25)"}}/>
<div className="flex flex-col items-center gap-0.5">
<span className="text-2xl font-bold tabular-nums" style={{color:RED}}>-{fmtLines(totalDeletions)}</span>
<span className="text-xs font-medium" style={{color:"rgba(248,113,113,0.70)"}}>lines removed</span>
</div>
</div>
) : (
<p className="text-center text-xs" style={{color:W70,opacity:0.6}}>
Line stats syncing with Forgejo will appear on next refresh
</p>
)}
</div>
);
}