932 lines
29 KiB
TypeScript
932 lines
29 KiB
TypeScript
"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<RangeKey, number> = {
|
|
"7d": 7,
|
|
"14d": 14,
|
|
"30d": 30,
|
|
"90d": 90,
|
|
"6m": 183,
|
|
"1y": 365,
|
|
};
|
|
const RANGE_SUMMARY: Record<RangeKey, string> = {
|
|
"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 (
|
|
<Select
|
|
value={value}
|
|
onValueChange={(next) => onValueChange(next as RangeKey)}
|
|
>
|
|
<SelectTrigger
|
|
aria-label={ariaLabel}
|
|
className="h-8 w-[86px] rounded-lg px-2.5 text-xs font-semibold shadow-none focus:ring-offset-0"
|
|
style={
|
|
isViolet
|
|
? {
|
|
background: "rgba(139,92,246,0.12)",
|
|
borderColor: "rgba(139,92,246,0.34)",
|
|
color: VIOLET,
|
|
}
|
|
: {
|
|
background: "rgba(16,185,129,0.12)",
|
|
borderColor: "rgba(16,185,129,0.34)",
|
|
color: GREEN,
|
|
}
|
|
}
|
|
>
|
|
<span>{value}</span>
|
|
</SelectTrigger>
|
|
<SelectContent align="end" className="min-w-[8rem]">
|
|
{RANGE_LABELS.map((range) => (
|
|
<SelectItem key={range} value={range}>
|
|
{RANGE_SUMMARY[range]}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
);
|
|
}
|
|
|
|
// ── 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<ActivityDatum | null>(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 (
|
|
<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 Commits</span>
|
|
<span
|
|
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()}
|
|
</span>
|
|
<span className="hidden text-xs sm:inline" style={{ color: W40 }}>
|
|
{hoveredPoint ? `on ${displayedLabel}` : `in ${displayedLabel}`}
|
|
</span>
|
|
</div>
|
|
<RangeSelect
|
|
value={range}
|
|
onValueChange={onRangeChange}
|
|
accent="violet"
|
|
ariaLabel="Select Git Commits range"
|
|
/>
|
|
</div>
|
|
|
|
{/* SVG */}
|
|
<svg
|
|
viewBox={`0 0 ${LVW} ${LVH}`}
|
|
width="100%"
|
|
style={{ display: "block", height: LVH, 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)"
|
|
/>
|
|
{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 }}
|
|
/>
|
|
<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 && (
|
|
<>
|
|
<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}
|
|
tabIndex={0}
|
|
onMouseEnter={() =>
|
|
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" }}
|
|
>
|
|
<title>{fmtCommitTitle(p.date, p.count)}</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 [hoveredDay, setHoveredDay] = useState<ActivityDatum | null>(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 (
|
|
<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">
|
|
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()}
|
|
</span>
|
|
<span className="hidden text-xs sm:inline" style={{ color: W40 }}>
|
|
{hoveredDay ? `on ${displayedLabel}` : `in ${displayedLabel}`}
|
|
</span>
|
|
</div>
|
|
<RangeSelect
|
|
value={range}
|
|
onValueChange={onRangeChange}
|
|
accent="green"
|
|
ariaLabel="Select Commit Heatmap range"
|
|
/>
|
|
</div>
|
|
|
|
{/* SVG */}
|
|
<svg
|
|
viewBox={`0 0 ${LVW} ${HVH}`}
|
|
width="100%"
|
|
style={{ display: "block", height: LVH }}
|
|
aria-label={`Commit heatmap last ${range}`}
|
|
>
|
|
{heatmap.mode === "strip" ? (
|
|
<>
|
|
{heatmap.daily.map((day, i) => (
|
|
<g key={day.date}>
|
|
<rect
|
|
x={heatmap.x + i * (heatmap.cell + heatmap.gap)}
|
|
y={heatmap.y}
|
|
width={heatmap.cell}
|
|
height={heatmap.cell}
|
|
rx={4}
|
|
tabIndex={0}
|
|
onMouseEnter={() => 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",
|
|
}}
|
|
>
|
|
<title>{fmtCommitTitle(day.date, day.count)}</title>
|
|
</rect>
|
|
{(range === "7d" ||
|
|
i % 5 === 0 ||
|
|
i === heatmap.daily.length - 1) && (
|
|
<text
|
|
x={
|
|
heatmap.x +
|
|
i * (heatmap.cell + heatmap.gap) +
|
|
heatmap.cell / 2
|
|
}
|
|
y={heatmap.y + heatmap.cell + 17}
|
|
fontSize={10}
|
|
textAnchor="middle"
|
|
style={{ fill: W70 }}
|
|
>
|
|
{day.label}
|
|
</text>
|
|
)}
|
|
</g>
|
|
))}
|
|
</>
|
|
) : (
|
|
<>
|
|
{heatmap.monthLabels.map(({ weekIdx, label }) => (
|
|
<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) => (
|
|
<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) =>
|
|
week.map((cell, di) =>
|
|
cell.outsideRange ? null : (
|
|
<rect
|
|
key={cell.date}
|
|
x={heatmap.x + wi * heatmap.stride}
|
|
y={heatmap.y + di * heatmap.stride}
|
|
width={heatmap.cell}
|
|
height={heatmap.cell}
|
|
rx={2}
|
|
tabIndex={0}
|
|
onMouseEnter={() =>
|
|
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",
|
|
}}
|
|
>
|
|
<title>{fmtCommitTitle(cell.date, cell.count)}</title>
|
|
</rect>
|
|
),
|
|
),
|
|
)}
|
|
</>
|
|
)}
|
|
<text x={HLEFT} y={HVH - 9} fontSize={9} style={{ fill: W40 }}>
|
|
Less
|
|
</text>
|
|
{HMAP_FILL.map((fill, i) => (
|
|
<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>
|
|
</svg>
|
|
|
|
{/* Contributions summary */}
|
|
<p className="text-center">
|
|
<span
|
|
className="text-2xl font-bold tabular-nums"
|
|
style={{ color: GREEN }}
|
|
>
|
|
{heatmap.totalEvents.toLocaleString()}
|
|
</span>
|
|
<span className="ml-1.5 text-sm" style={{ color: W70 }}>
|
|
commits across all tracked repositories in the last{" "}
|
|
{RANGE_SUMMARY[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>("14d");
|
|
const [heatRange, setHeatRange] = useState<RangeKey>("14d");
|
|
|
|
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: LVH, 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 of code 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>
|
|
);
|
|
}
|