orange+green

This commit is contained in:
null 2026-05-26 16:43:11 -05:00
parent 80c352a5ab
commit 81308c0b6f
2 changed files with 484 additions and 197 deletions

View File

@ -21,19 +21,19 @@ interface ForgejoHeatmapProps {
} }
// ── Line chart layout ────────────────────────────────────────────────────── // ── Line chart layout ──────────────────────────────────────────────────────
const LVW = 580; const LVW = 580;
const LVH = 188; const LVH = 188;
const LP_L = 34; // Y-axis labels const LP_L = 34; // Y-axis labels
const LP_R = 8; const LP_R = 8;
const LP_T = 10; const LP_T = 10;
const LP_B = 24; const LP_B = 24;
const LCW = LVW - LP_L - LP_R; const LCW = LVW - LP_L - LP_R;
const LCH = LVH - LP_T - LP_B; const LCH = LVH - LP_T - LP_B;
// ── Heatmap layout ───────────────────────────────────────────────────────── // ── Heatmap layout ─────────────────────────────────────────────────────────
const HLEFT = 32; const HLEFT = 32;
const HRIGHT = 14; const HRIGHT = 14;
const HVH = LVH; const HVH = LVH;
// ── Types & data ─────────────────────────────────────────────────────────── // ── Types & data ───────────────────────────────────────────────────────────
type RangeKey = "7d" | "14d" | "30d" | "90d" | "6m" | "1y"; type RangeKey = "7d" | "14d" | "30d" | "90d" | "6m" | "1y";
@ -44,7 +44,12 @@ type ActivityDatum = {
}; };
const RANGE_DAYS: Record<RangeKey, number> = { const RANGE_DAYS: Record<RangeKey, number> = {
"7d": 7, "14d": 14, "30d": 30, "90d": 90, "6m": 183, "1y": 365, "7d": 7,
"14d": 14,
"30d": 30,
"90d": 90,
"6m": 183,
"1y": 365,
}; };
const RANGE_SUMMARY: Record<RangeKey, string> = { const RANGE_SUMMARY: Record<RangeKey, string> = {
"7d": "7 days", "7d": "7 days",
@ -55,44 +60,63 @@ const RANGE_SUMMARY: Record<RangeKey, string> = {
"1y": "1 year", "1y": "1 year",
}; };
const RANGE_LABELS: RangeKey[] = ["7d", "14d", "30d", "90d", "6m", "1y"]; const RANGE_LABELS: RangeKey[] = ["7d", "14d", "30d", "90d", "6m", "1y"];
const MONTHS = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]; const MONTHS = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
// ── Colors ───────────────────────────────────────────────────────────────── // ── Colors ─────────────────────────────────────────────────────────────────
const VIOLET = "rgba(139,92,246,1)"; const VIOLET = "rgba(139,92,246,1)";
const GREEN = "rgba(52,211,153,1)"; const GREEN = "rgba(52,211,153,1)";
const RED = "rgba(248,113,113,1)"; const RED = "rgba(248,113,113,1)";
const W70 = "rgba(255,255,255,0.70)"; const W70 = "rgba(255,255,255,0.70)";
const W40 = "rgba(255,255,255,0.40)"; const W40 = "rgba(255,255,255,0.40)";
const W12 = "rgba(255,255,255,0.12)"; const W12 = "rgba(255,255,255,0.12)";
// Green palette for heatmap cells // Green palette for heatmap cells
const HMAP_FILL = [ const HMAP_FILL = [
"rgba(16,185,129,0.08)", // 0 — empty "rgba(16,185,129,0.08)", // 0 — empty
"rgba(16,185,129,0.28)", // 1 "rgba(16,185,129,0.28)", // 1
"rgba(16,185,129,0.55)", // 2 "rgba(16,185,129,0.55)", // 2
"rgba(16,185,129,0.78)", // 3 "rgba(16,185,129,0.78)", // 3
"rgba(16,185,129,1.00)", // 4 — full emerald "rgba(16,185,129,1.00)", // 4 — full emerald
]; ];
// ── Helpers ──────────────────────────────────────────────────────────────── // ── Helpers ────────────────────────────────────────────────────────────────
function isoDate(d: Date): string { function isoDate(d: Date): string {
return [d.getFullYear(), String(d.getMonth()+1).padStart(2,"0"), String(d.getDate()).padStart(2,"0")].join("-"); return [
d.getFullYear(),
String(d.getMonth() + 1).padStart(2, "0"),
String(d.getDate()).padStart(2, "0"),
].join("-");
} }
function dateFromIsoDate(value: string): Date { function dateFromIsoDate(value: string): Date {
const [year, month, day] = value.split("-").map(Number); const [year, month, day] = value.split("-").map(Number);
return new Date(year, month - 1, day); return new Date(year, month - 1, day);
} }
function fmtLines(n: number): string { function fmtLines(n: number): string {
if (n >= 1_000_000) return `${(n/1_000_000).toFixed(1)}M`; if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n/1_000).toFixed(1)}k`; if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
return n.toLocaleString(); return n.toLocaleString();
} }
function fmtRelative(iso: string): string { function fmtRelative(iso: string): string {
const s = Math.floor((Date.now() - new Date(iso).getTime()) / 1000); const s = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
if (s < 60) return "just now"; if (s < 60) return "just now";
const m = Math.floor(s/60); if (m < 60) return `${m}m ago`; const m = Math.floor(s / 60);
const h = Math.floor(m/60); if (h < 24) return `${h}h ago`; if (m < 60) return `${m}m ago`;
return `${Math.floor(h/24)}d 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 { function fmtActivityDate(date: string): string {
const d = dateFromIsoDate(date); const d = dateFromIsoDate(date);
@ -104,13 +128,16 @@ function fmtCommitTitle(date: string, count: number): string {
} }
// Catmull-Rom → cubic bezier smooth path // Catmull-Rom → cubic bezier smooth path
function smoothPath(pts: {x:number;y:number}[]): string { function smoothPath(pts: { x: number; y: number }[]): string {
if (pts.length < 2) return ""; if (pts.length < 2) return "";
const t = 1/6; const t = 1 / 6;
let d = `M${pts[0].x.toFixed(1)},${pts[0].y.toFixed(1)}`; let d = `M${pts[0].x.toFixed(1)},${pts[0].y.toFixed(1)}`;
for (let i = 0; i < pts.length-1; i++) { 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)]; const p0 = pts[Math.max(0, i - 1)],
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)}`; 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; return d;
} }
@ -135,7 +162,10 @@ function RangeSelect({
}) { }) {
const isViolet = accent === "violet"; const isViolet = accent === "violet";
return ( return (
<Select value={value} onValueChange={(next) => onValueChange(next as RangeKey)}> <Select
value={value}
onValueChange={(next) => onValueChange(next as RangeKey)}
>
<SelectTrigger <SelectTrigger
aria-label={ariaLabel} aria-label={ariaLabel}
className="h-8 w-[86px] rounded-lg px-2.5 text-xs font-semibold shadow-none focus:ring-offset-0" className="h-8 w-[86px] rounded-lg px-2.5 text-xs font-semibold shadow-none focus:ring-offset-0"
@ -167,81 +197,99 @@ function RangeSelect({
} }
// ── Line chart sub-component ─────────────────────────────────────────────── // ── Line chart sub-component ───────────────────────────────────────────────
function LineChart({ days, range, onRangeChange, lastPush }: { function LineChart({
days,
range,
onRangeChange,
lastPush,
}: {
days: ForgejoHeatmapDay[]; days: ForgejoHeatmapDay[];
range: RangeKey; range: RangeKey;
onRangeChange: (r: RangeKey) => void; onRangeChange: (r: RangeKey) => void;
lastPush: ForgejoLastPush | null; lastPush: ForgejoLastPush | null;
}) { }) {
const [hoveredPoint, setHoveredPoint] = useState<ActivityDatum | null>(null); const [hoveredPoint, setHoveredPoint] = useState<ActivityDatum | null>(null);
const { points, xLabels, yLabels, linePath, areaPath, totalEvents } = useMemo(() => { const { points, xLabels, yLabels, linePath, areaPath, totalEvents } =
const lookup = new Map(days.map(d => [d.date, d.count])); useMemo(() => {
const numDays = RANGE_DAYS[range]; 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 daily = Array.from({length: numDays}, (_, i) => { const today = new Date();
const d = new Date(today); d.setDate(d.getDate()-(numDays-1-i)); today.setHours(0, 0, 0, 0);
const key = isoDate(d); const daily = Array.from({ length: numDays }, (_, i) => {
return { date: key, count: lookup.get(key) ?? 0 }; 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 useWeekly = numDays > 30;
const series = useWeekly const series = useWeekly
? Array.from({length: Math.ceil(daily.length/7)}, (_, i) => { ? Array.from({ length: Math.ceil(daily.length / 7) }, (_, i) => {
const chunk = daily.slice(i*7, i*7+7); 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 { return {
date: chunk[0].date, x: LP_L + (idx / Math.max(series.length - 1, 1)) * LCW,
count: chunk.reduce((s,d) => s+d.count, 0), label:
label: `Week of ${fmtActivityDate(chunk[0].date)}`, useWeekly || numDays > 7
? `${MONTHS[d.getMonth()]} ${d.getDate()}`
: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][d.getDay()],
}; };
}) });
: daily.map(d => ({
...d,
label: fmtActivityDate(d.date),
}));
const localMax = Math.max(...series.map(p => p.count), 1); // Y labels
const pts = series.map((p, i) => ({ const yStep = localMax / 4;
x: LP_L + (i / Math.max(series.length-1,1)) * LCW, const yLabels = [1, 2, 3, 4].map((i) => {
y: LP_T + LCH - (p.count / localMax) * LCH, const val = Math.round(yStep * i);
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 { return {
x: LP_L + (idx / Math.max(series.length-1,1)) * LCW, y: LP_T + LCH - (val / localMax) * LCH,
label: useWeekly || numDays > 7 label: val >= 1000 ? `${(val / 1000).toFixed(0)}k` : String(val),
? `${MONTHS[d.getMonth()]} ${d.getDate()}`
: ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"][d.getDay()],
}; };
}); });
// Y labels const linePath = smoothPath(pts);
const yStep = localMax / 4; const last = pts[pts.length - 1],
const yLabels = [1,2,3,4].map(i => { first = pts[0];
const val = Math.round(yStep * i); const baseline = LP_T + LCH;
return { y: LP_T + LCH - (val/localMax)*LCH, label: val >= 1000 ? `${(val/1000).toFixed(0)}k` : String(val) }; const areaPath =
}); pts.length < 2
? ""
: linePath +
` L${last.x.toFixed(1)},${baseline} L${first.x.toFixed(1)},${baseline} Z`;
const linePath = smoothPath(pts); return { points: pts, xLabels, yLabels, linePath, areaPath, totalEvents };
const last = pts[pts.length-1], first = pts[0]; }, [days, range]);
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 baseline = LP_T + LCH;
const lastPt = points[points.length-1]; const lastPt = points[points.length - 1];
const activePoint = hoveredPoint const activePoint = hoveredPoint
? points.find((p) => p.date === hoveredPoint.date) ?? null ? (points.find((p) => p.date === hoveredPoint.date) ?? null)
: null; : null;
const displayedCount = hoveredPoint?.count ?? totalEvents; const displayedCount = hoveredPoint?.count ?? totalEvents;
const displayedLabel = hoveredPoint?.label ?? RANGE_SUMMARY[range]; const displayedLabel = hoveredPoint?.label ?? RANGE_SUMMARY[range];
@ -253,11 +301,13 @@ function LineChart({ days, range, onRangeChange, lastPush }: {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Activity className="h-4 w-4" style={{ color: VIOLET }} /> <Activity className="h-4 w-4" style={{ color: VIOLET }} />
<span className="text-sm font-semibold text-strong">Git Commits</span> <span className="text-sm font-semibold text-strong">Git Commits</span>
<span className="ml-1 rounded px-1.5 py-0.5 text-[11px] font-semibold tabular-nums" <span
style={{background:"rgba(139,92,246,0.14)", color:VIOLET}}> className="ml-1 rounded px-1.5 py-0.5 text-[11px] font-semibold tabular-nums"
style={{ background: "rgba(139,92,246,0.14)", color: VIOLET }}
>
{displayedCount.toLocaleString()} {displayedCount.toLocaleString()}
</span> </span>
<span className="hidden text-xs sm:inline" style={{color:W40}}> <span className="hidden text-xs sm:inline" style={{ color: W40 }}>
{hoveredPoint ? `on ${displayedLabel}` : `in ${displayedLabel}`} {hoveredPoint ? `on ${displayedLabel}` : `in ${displayedLabel}`}
</span> </span>
</div> </div>
@ -270,54 +320,135 @@ function LineChart({ days, range, onRangeChange, lastPush }: {
</div> </div>
{/* SVG */} {/* SVG */}
<svg viewBox={`0 0 ${LVW} ${LVH}`} width="100%" style={{ display:"block", height:LVH, overflow:"visible" }} <svg
aria-label={`Git activity last ${range}`}> viewBox={`0 0 ${LVW} ${LVH}`}
width="100%"
style={{ display: "block", height: LVH, overflow: "visible" }}
aria-label={`Git activity last ${range}`}
>
<defs> <defs>
<linearGradient id="la-fill" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="la-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="rgba(139,92,246,0.50)" /> <stop offset="0%" stopColor="rgba(139,92,246,0.50)" />
<stop offset="80%" stopColor="rgba(139,92,246,0.05)" /> <stop offset="80%" stopColor="rgba(139,92,246,0.05)" />
<stop offset="100%" stopColor="rgba(139,92,246,0.00)" /> <stop offset="100%" stopColor="rgba(139,92,246,0.00)" />
</linearGradient> </linearGradient>
<clipPath id="la-clip"><rect x={LP_L} y={LP_T-2} width={LCW} height={LCH+4}/></clipPath> <clipPath id="la-clip">
<rect x={LP_L} y={LP_T - 2} width={LCW} height={LCH + 4} />
</clipPath>
</defs> </defs>
{yLabels.map(({y, label}) => ( {yLabels.map(({ y, label }) => (
<g key={label}> <g key={label}>
<line x1={LP_L} y1={y} x2={LP_L+LCW} y2={y} strokeDasharray="4 5" style={{stroke:W12,strokeWidth:1}}/> <line
<text x={LP_L-5} y={y+3.5} fontSize={9} textAnchor="end" style={{fill:W40}}>{label}</text> 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> </g>
))} ))}
<line x1={LP_L} y1={baseline} x2={LP_L+LCW} y2={baseline} style={{stroke:W12,strokeWidth:1}}/> <line
<path d={areaPath} style={{fill:"url(#la-fill)"}} clipPath="url(#la-clip)"/> x1={LP_L}
<path d={linePath} style={{fill:"none",stroke:VIOLET,strokeWidth:2,strokeLinejoin:"round",strokeLinecap:"round"}} clipPath="url(#la-clip)"/> 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)"
/>
{activePoint && ( {activePoint && (
<> <>
<line x1={activePoint.x} y1={LP_T} x2={activePoint.x} y2={baseline} strokeDasharray="3 4" style={{stroke:"rgba(139,92,246,0.55)",strokeWidth:1}}/> <line
<circle cx={activePoint.x} cy={activePoint.y} r={7} style={{fill:"rgba(139,92,246,0.18)"}}/> x1={activePoint.x}
<circle cx={activePoint.x} cy={activePoint.y} r={4} style={{fill:VIOLET}}/> y1={LP_T}
x2={activePoint.x}
y2={baseline}
strokeDasharray="3 4"
style={{ stroke: "rgba(139,92,246,0.55)", strokeWidth: 1 }}
/>
<circle
cx={activePoint.x}
cy={activePoint.y}
r={7}
style={{ fill: "rgba(139,92,246,0.18)" }}
/>
<circle
cx={activePoint.x}
cy={activePoint.y}
r={4}
style={{ fill: VIOLET }}
/>
</> </>
)} )}
{lastPt && ( {lastPt && (
<> <>
<circle cx={lastPt.x} cy={lastPt.y} r={5.5} style={{fill:"rgba(10,10,30,0.9)"}}/> <circle
<circle cx={lastPt.x} cy={lastPt.y} r={3.5} style={{fill:VIOLET}}/> 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) => ( {xLabels.map(({ x, label }, i) => (
<text key={i} x={x} y={LVH-6} fontSize={10} textAnchor="middle" style={{fill:W70}}>{label}</text> <text
key={i}
x={x}
y={LVH - 6}
fontSize={10}
textAnchor="middle"
style={{ fill: W70 }}
>
{label}
</text>
))} ))}
{points.map((p,i) => ( {points.map((p, i) => (
<rect <rect
key={i} key={i}
x={p.x - LCW/Math.max(points.length,1)/2} x={p.x - LCW / Math.max(points.length, 1) / 2}
y={LP_T} y={LP_T}
width={LCW/Math.max(points.length,1)} width={LCW / Math.max(points.length, 1)}
height={LCH} height={LCH}
tabIndex={0} tabIndex={0}
onMouseEnter={() => setHoveredPoint({ date: p.date, count: p.count, label: p.label })} onMouseEnter={() =>
onFocus={() => setHoveredPoint({ date: p.date, count: p.count, label: p.label })} setHoveredPoint({ date: p.date, count: p.count, label: p.label })
}
onFocus={() =>
setHoveredPoint({ date: p.date, count: p.count, label: p.label })
}
onMouseLeave={() => setHoveredPoint(null)} onMouseLeave={() => setHoveredPoint(null)}
onBlur={() => setHoveredPoint(null)} onBlur={() => setHoveredPoint(null)}
style={{fill:"transparent", outline:"none"}} style={{ fill: "transparent", outline: "none" }}
> >
<title>{fmtCommitTitle(p.date, p.count)}</title> <title>{fmtCommitTitle(p.date, p.count)}</title>
</rect> </rect>
@ -327,18 +458,31 @@ function LineChart({ days, range, onRangeChange, lastPush }: {
{/* Last push — centered at bottom of card */} {/* Last push — centered at bottom of card */}
{lastPush && ( {lastPush && (
<div className="mt-3 flex items-start justify-center gap-2.5"> <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" <span
style={{background:"rgba(139,92,246,0.20)", color:VIOLET}}> 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} {lastPush.sha}
</span> </span>
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate text-sm font-medium" style={{color:"rgba(255,255,255,0.90)"}}> <p
className="truncate text-sm font-medium"
style={{ color: "rgba(255,255,255,0.90)" }}
>
{lastPush.message} {lastPush.message}
</p> </p>
<p className="mt-0.5 text-center text-xs" style={{color:"rgba(255,255,255,0.50)"}}> <p
className="mt-0.5 text-center text-xs"
style={{ color: "rgba(255,255,255,0.50)" }}
>
{lastPush.author} pushed to{" "} {lastPush.author} pushed to{" "}
<span style={{color:"rgba(139,92,246,0.90)"}}>{lastPush.branch}</span> <span style={{ color: "rgba(139,92,246,0.90)" }}>
{" · "}{lastPush.repo}{" · "}{fmtRelative(lastPush.pushed_at)} {lastPush.branch}
</span>
{" · "}
{lastPush.repo}
{" · "}
{fmtRelative(lastPush.pushed_at)}
</p> </p>
</div> </div>
</div> </div>
@ -348,15 +492,20 @@ function LineChart({ days, range, onRangeChange, lastPush }: {
} }
// ── Heatmap grid sub-component ───────────────────────────────────────────── // ── Heatmap grid sub-component ─────────────────────────────────────────────
function HeatmapGrid({ days, range, onRangeChange }: { function HeatmapGrid({
days,
range,
onRangeChange,
}: {
days: ForgejoHeatmapDay[]; days: ForgejoHeatmapDay[];
range: RangeKey; range: RangeKey;
onRangeChange: (r: RangeKey) => void; onRangeChange: (r: RangeKey) => void;
}) { }) {
const [hoveredDay, setHoveredDay] = useState<ActivityDatum | null>(null); const [hoveredDay, setHoveredDay] = useState<ActivityDatum | null>(null);
const heatmap = useMemo(() => { const heatmap = useMemo(() => {
const lookup = new Map(days.map(d => [d.date, d.count])); const lookup = new Map(days.map((d) => [d.date, d.count]));
const today = new Date(); today.setHours(0,0,0,0); const today = new Date();
today.setHours(0, 0, 0, 0);
const numDays = RANGE_DAYS[range]; const numDays = RANGE_DAYS[range];
const rangeStart = new Date(today); const rangeStart = new Date(today);
rangeStart.setDate(rangeStart.getDate() - (numDays - 1)); rangeStart.setDate(rangeStart.getDate() - (numDays - 1));
@ -369,13 +518,13 @@ function HeatmapGrid({ days, range, onRangeChange }: {
count: lookup.get(date) ?? 0, count: lookup.get(date) ?? 0,
label: label:
numDays <= 7 numDays <= 7
? ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"][d.getDay()] ? ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][d.getDay()]
: `${MONTHS[d.getMonth()]} ${d.getDate()}`, : `${MONTHS[d.getMonth()]} ${d.getDate()}`,
}; };
}); });
const totalEvents = daily.reduce((s, d) => s + d.count, 0); const totalEvents = daily.reduce((s, d) => s + d.count, 0);
const maxCount = Math.max(...daily.map(d => d.count), 0); const maxCount = Math.max(...daily.map((d) => d.count), 0);
if (numDays <= 30) { if (numDays <= 30) {
const gap = numDays <= 7 ? 12 : 4; const gap = numDays <= 7 ? 12 : 4;
@ -383,7 +532,10 @@ function HeatmapGrid({ days, range, onRangeChange }: {
const availableWidth = LVW - HLEFT - HRIGHT; const availableWidth = LVW - HLEFT - HRIGHT;
const cell = Math.max( const cell = Math.max(
8, 8,
Math.min(maxCell, Math.floor((availableWidth - gap * (numDays - 1)) / numDays)), Math.min(
maxCell,
Math.floor((availableWidth - gap * (numDays - 1)) / numDays),
),
); );
const width = daily.length * cell + Math.max(0, daily.length - 1) * gap; const width = daily.length * cell + Math.max(0, daily.length - 1) * gap;
const x = HLEFT + (availableWidth - width) / 2; const x = HLEFT + (availableWidth - width) / 2;
@ -407,12 +559,19 @@ function HeatmapGrid({ days, range, onRangeChange }: {
calendarEnd.setDate(calendarEnd.getDate() + (6 - calendarEnd.getDay())); calendarEnd.setDate(calendarEnd.getDate() + (6 - calendarEnd.getDay()));
const weekCount = Math.max( const weekCount = Math.max(
1, 1,
Math.ceil(((calendarEnd.getTime() - calendarStart.getTime()) / 86400000 + 1) / 7), Math.ceil(
((calendarEnd.getTime() - calendarStart.getTime()) / 86400000 + 1) / 7,
),
); );
type CalendarCell = { date:string; count:number; label:string; outsideRange:boolean }; type CalendarCell = {
date: string;
count: number;
label: string;
outsideRange: boolean;
};
const builtWeeks: CalendarCell[][] = []; const builtWeeks: CalendarCell[][] = [];
const monthLabelList: {weekIdx:number; label:string}[] = []; const monthLabelList: { weekIdx: number; label: string }[] = [];
let lastMonth = -1; let lastMonth = -1;
const cur = new Date(calendarStart); const cur = new Date(calendarStart);
@ -431,7 +590,7 @@ function HeatmapGrid({ days, range, onRangeChange }: {
label: fmtActivityDate(dateStr), label: fmtActivityDate(dateStr),
outsideRange: cur < rangeStart || cur > today, outsideRange: cur < rangeStart || cur > today,
}); });
cur.setDate(cur.getDate()+1); cur.setDate(cur.getDate() + 1);
} }
builtWeeks.push(week); builtWeeks.push(week);
} }
@ -441,7 +600,10 @@ function HeatmapGrid({ days, range, onRangeChange }: {
const maxCell = range === "90d" ? 14 : range === "6m" ? 12 : 8; const maxCell = range === "90d" ? 14 : range === "6m" ? 12 : 8;
const cell = Math.max( const cell = Math.max(
6, 6,
Math.min(maxCell, Math.floor((availableWidth - gap * (weekCount - 1)) / weekCount)), Math.min(
maxCell,
Math.floor((availableWidth - gap * (weekCount - 1)) / weekCount),
),
); );
const stride = cell + gap; const stride = cell + gap;
const width = weekCount * cell + Math.max(0, weekCount - 1) * gap; const width = weekCount * cell + Math.max(0, weekCount - 1) * gap;
@ -469,13 +631,20 @@ function HeatmapGrid({ days, range, onRangeChange }: {
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<LayoutGrid className="h-4 w-4" style={{ color: "rgba(16,185,129,1)" }} /> <LayoutGrid
<span className="text-sm font-semibold text-strong">Commit Heatmap</span> className="h-4 w-4"
<span className="ml-1 rounded px-1.5 py-0.5 text-[11px] font-semibold tabular-nums" style={{ color: "rgba(16,185,129,1)" }}
style={{background:"rgba(16,185,129,0.14)", color:GREEN}}> />
<span className="text-sm font-semibold text-strong">
Commit Heatmap
</span>
<span
className="ml-1 rounded px-1.5 py-0.5 text-[11px] font-semibold tabular-nums"
style={{ background: "rgba(16,185,129,0.14)", color: GREEN }}
>
{displayedCount.toLocaleString()} {displayedCount.toLocaleString()}
</span> </span>
<span className="hidden text-xs sm:inline" style={{color:W40}}> <span className="hidden text-xs sm:inline" style={{ color: W40 }}>
{hoveredDay ? `on ${displayedLabel}` : `in ${displayedLabel}`} {hoveredDay ? `on ${displayedLabel}` : `in ${displayedLabel}`}
</span> </span>
</div> </div>
@ -488,7 +657,12 @@ function HeatmapGrid({ days, range, onRangeChange }: {
</div> </div>
{/* SVG */} {/* SVG */}
<svg viewBox={`0 0 ${LVW} ${HVH}`} width="100%" style={{ display:"block", height:LVH }} aria-label={`Commit heatmap last ${range}`}> <svg
viewBox={`0 0 ${LVW} ${HVH}`}
width="100%"
style={{ display: "block", height: LVH }}
aria-label={`Commit heatmap last ${range}`}
>
{heatmap.mode === "strip" ? ( {heatmap.mode === "strip" ? (
<> <>
{heatmap.daily.map((day, i) => ( {heatmap.daily.map((day, i) => (
@ -506,20 +680,27 @@ function HeatmapGrid({ days, range, onRangeChange }: {
onBlur={() => setHoveredDay(null)} onBlur={() => setHoveredDay(null)}
style={{ style={{
fill: heatFill(day.count, heatmap.maxCount), fill: heatFill(day.count, heatmap.maxCount),
stroke: hoveredDay?.date === day.date ? GREEN : "transparent", stroke:
hoveredDay?.date === day.date ? GREEN : "transparent",
strokeWidth: 2, strokeWidth: 2,
outline: "none", outline: "none",
}} }}
> >
<title>{fmtCommitTitle(day.date, day.count)}</title> <title>{fmtCommitTitle(day.date, day.count)}</title>
</rect> </rect>
{(range === "7d" || i % 5 === 0 || i === heatmap.daily.length - 1) && ( {(range === "7d" ||
i % 5 === 0 ||
i === heatmap.daily.length - 1) && (
<text <text
x={heatmap.x + i * (heatmap.cell + heatmap.gap) + heatmap.cell / 2} x={
heatmap.x +
i * (heatmap.cell + heatmap.gap) +
heatmap.cell / 2
}
y={heatmap.y + heatmap.cell + 17} y={heatmap.y + heatmap.cell + 17}
fontSize={10} fontSize={10}
textAnchor="middle" textAnchor="middle"
style={{fill:W70}} style={{ fill: W70 }}
> >
{day.label} {day.label}
</text> </text>
@ -529,51 +710,107 @@ function HeatmapGrid({ days, range, onRangeChange }: {
</> </>
) : ( ) : (
<> <>
{heatmap.monthLabels.map(({weekIdx, label}) => ( {heatmap.monthLabels.map(({ weekIdx, label }) => (
<text key={`m-${weekIdx}`} x={heatmap.x+weekIdx*heatmap.stride} y={15} fontSize={10} style={{fill:W70}}>{label}</text> <text
key={`m-${weekIdx}`}
x={heatmap.x + weekIdx * heatmap.stride}
y={15}
fontSize={10}
style={{ fill: W70 }}
>
{label}
</text>
))} ))}
{(["Mon","Wed","Fri"] as const).map((label, i) => ( {(["Mon", "Wed", "Fri"] as const).map((label, i) => (
<text key={label} x={0} y={heatmap.y+(i*2+1)*heatmap.stride+heatmap.cell-2} fontSize={10} style={{fill:W70}}>{label}</text> <text
key={label}
x={0}
y={heatmap.y + (i * 2 + 1) * heatmap.stride + heatmap.cell - 2}
fontSize={10}
style={{ fill: W70 }}
>
{label}
</text>
))} ))}
{heatmap.weeks.map((week, wi) => {heatmap.weeks.map((week, wi) =>
week.map((cell, di) => cell.outsideRange ? null : ( week.map((cell, di) =>
<rect key={cell.date} cell.outsideRange ? null : (
x={heatmap.x+wi*heatmap.stride} y={heatmap.y+di*heatmap.stride} <rect
width={heatmap.cell} height={heatmap.cell} rx={2} key={cell.date}
tabIndex={0} x={heatmap.x + wi * heatmap.stride}
onMouseEnter={() => setHoveredDay({ date: cell.date, count: cell.count, label: cell.label })} y={heatmap.y + di * heatmap.stride}
onFocus={() => setHoveredDay({ date: cell.date, count: cell.count, label: cell.label })} width={heatmap.cell}
onMouseLeave={() => setHoveredDay(null)} height={heatmap.cell}
onBlur={() => setHoveredDay(null)} rx={2}
style={{ tabIndex={0}
fill: heatFill(cell.count, heatmap.maxCount), onMouseEnter={() =>
stroke: hoveredDay?.date === cell.date ? GREEN : "transparent", setHoveredDay({
strokeWidth: 1.5, date: cell.date,
outline: "none", count: cell.count,
}}> label: cell.label,
<title>{fmtCommitTitle(cell.date, cell.count)}</title> })
</rect> }
)) 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",
}}
>
<title>{fmtCommitTitle(cell.date, cell.count)}</title>
</rect>
),
),
)} )}
</> </>
)} )}
<text x={HLEFT} y={HVH-9} fontSize={9} style={{fill:W40}}>Less</text> <text x={HLEFT} y={HVH - 9} fontSize={9} style={{ fill: W40 }}>
Less
</text>
{HMAP_FILL.map((fill, i) => ( {HMAP_FILL.map((fill, i) => (
<rect key={i} x={HLEFT+32+i*14} y={HVH-20} width={11} height={11} rx={2} style={{fill}}/> <rect
key={i}
x={HLEFT + 32 + i * 14}
y={HVH - 20}
width={11}
height={11}
rx={2}
style={{ fill }}
/>
))} ))}
<text x={HLEFT+32+HMAP_FILL.length*14+2} y={HVH-9} fontSize={9} style={{fill:W40}}>More</text> <text
x={HLEFT + 32 + HMAP_FILL.length * 14 + 2}
y={HVH - 9}
fontSize={9}
style={{ fill: W40 }}
>
More
</text>
</svg> </svg>
{/* Contributions summary */} {/* Contributions summary */}
<p className="text-center"> <p className="text-center">
<span className="text-2xl font-bold tabular-nums" style={{color:GREEN}}> <span
className="text-2xl font-bold tabular-nums"
style={{ color: GREEN }}
>
{heatmap.totalEvents.toLocaleString()} {heatmap.totalEvents.toLocaleString()}
</span> </span>
<span className="ml-1.5 text-sm" style={{color:W70}}> <span className="ml-1.5 text-sm" style={{ color: W70 }}>
commits across all tracked repositories in the last {RANGE_SUMMARY[range]} commits across all tracked repositories in the last{" "}
{RANGE_SUMMARY[range]}
</span> </span>
</p> </p>
</div> </div>
); );
} }
@ -583,9 +820,9 @@ export function ForgejoHeatmap({
days, days,
totalAdditions = 0, totalAdditions = 0,
totalDeletions = 0, totalDeletions = 0,
hasLineStats = false, hasLineStats = false,
lastPush = null, lastPush = null,
isLoading = false, isLoading = false,
}: ForgejoHeatmapProps) { }: ForgejoHeatmapProps) {
const [lineRange, setLineRange] = useState<RangeKey>("14d"); const [lineRange, setLineRange] = useState<RangeKey>("14d");
const [heatRange, setHeatRange] = useState<RangeKey>("14d"); const [heatRange, setHeatRange] = useState<RangeKey>("14d");
@ -593,13 +830,25 @@ export function ForgejoHeatmap({
if (isLoading) { if (isLoading) {
return ( return (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2"> <div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{[0,1].map(i => ( {[0, 1].map((i) => (
<div key={i} className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4"> <div
key={i}
className="rounded-xl border border-[color:var(--border)] bg-[linear-gradient(145deg,rgba(52,211,153,0.14),var(--surface)_44%)] p-4"
>
<div className="mb-3 flex justify-between"> <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
<div className="h-4 w-20 animate-pulse rounded" style={{background:"rgba(255,255,255,0.08)"}}/> 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>
<div className="animate-pulse rounded" style={{height:LVH,background:"rgba(255,255,255,0.05)"}}/> <div
className="animate-pulse rounded"
style={{ height: LVH, background: "rgba(255,255,255,0.05)" }}
/>
</div> </div>
))} ))}
</div> </div>
@ -608,37 +857,75 @@ export function ForgejoHeatmap({
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* ── Two cards ───────────────────────────────────────────── */} {/* ── Two cards ───────────────────────────────────────────── */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2"> <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"> <section className="rounded-xl border border-[color:var(--border)] bg-[linear-gradient(145deg,rgba(52,211,153,0.14),var(--surface)_44%)] p-4 shadow-lush">
<LineChart days={days} range={lineRange} onRangeChange={setLineRange} lastPush={lastPush} /> <LineChart
days={days}
range={lineRange}
onRangeChange={setLineRange}
lastPush={lastPush}
/>
</section> </section>
<section className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-lush"> <section className="rounded-xl border border-[color:var(--border)] bg-[linear-gradient(145deg,rgba(52,211,153,0.14),var(--surface)_44%)] p-4 shadow-lush">
<HeatmapGrid days={days} range={heatRange} onRangeChange={setHeatRange} /> <HeatmapGrid
days={days}
range={heatRange}
onRangeChange={setHeatRange}
/>
</section> </section>
</div> </div>
{/* ── Line stats — boxed ──────────────────────────────────── */} {/* ── Line stats — boxed ──────────────────────────────────── */}
{hasLineStats ? ( {hasLineStats ? (
<div className="mx-auto flex max-w-xs items-center justify-center gap-8 rounded-xl px-8 py-4" <div
style={{background:"rgba(139,92,246,0.07)", border:"1px solid rgba(139,92,246,0.20)"}}> 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"> <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
<span className="text-xs font-medium" style={{color:"rgba(52,211,153,0.70)"}}>lines of code added</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 of code added
</span>
</div> </div>
<div style={{width:1,height:40,background:"rgba(139,92,246,0.25)"}}/> <div
style={{
width: 1,
height: 40,
background: "rgba(139,92,246,0.25)",
}}
/>
<div className="flex flex-col items-center gap-0.5"> <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
<span className="text-xs font-medium" style={{color:"rgba(248,113,113,0.70)"}}>lines removed</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>
</div> </div>
) : ( ) : (
<p className="text-center text-xs" style={{color:W70,opacity:0.6}}> <p className="text-center text-xs" style={{ color: W70, opacity: 0.6 }}>
Line stats syncing with Forgejo will appear on next refresh Line stats syncing with Forgejo will appear on next refresh
</p> </p>
)} )}
</div> </div>
); );
} }

View File

@ -402,7 +402,7 @@ export function ForgejoIssueMetricCards({
]; ];
return ( return (
<section className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-lush md:p-6"> <section className="rounded-xl border border-[color:var(--border)] bg-[linear-gradient(145deg,rgba(251,191,36,0.13),var(--surface)_44%)] p-4 shadow-lush md:p-6">
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"> <div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0"> <div className="min-w-0">
<h3 className="text-lg font-semibold text-strong"> <h3 className="text-lg font-semibold text-strong">