orange+green
This commit is contained in:
parent
80c352a5ab
commit
81308c0b6f
|
|
@ -44,7 +44,12 @@ type ActivityDatum = {
|
|||
};
|
||||
|
||||
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> = {
|
||||
"7d": "7 days",
|
||||
|
|
@ -55,7 +60,20 @@ const RANGE_SUMMARY: Record<RangeKey, string> = {
|
|||
"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"];
|
||||
const MONTHS = [
|
||||
"Jan",
|
||||
"Feb",
|
||||
"Mar",
|
||||
"Apr",
|
||||
"May",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Oct",
|
||||
"Nov",
|
||||
"Dec",
|
||||
];
|
||||
|
||||
// ── Colors ─────────────────────────────────────────────────────────────────
|
||||
const VIOLET = "rgba(139,92,246,1)";
|
||||
|
|
@ -76,7 +94,11 @@ const HMAP_FILL = [
|
|||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
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 {
|
||||
const [year, month, day] = value.split("-").map(Number);
|
||||
|
|
@ -90,8 +112,10 @@ function fmtLines(n: number): string {
|
|||
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`;
|
||||
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 {
|
||||
|
|
@ -109,7 +133,10 @@ function smoothPath(pts: {x:number;y:number}[]): string {
|
|||
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)];
|
||||
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;
|
||||
|
|
@ -135,7 +162,10 @@ function RangeSelect({
|
|||
}) {
|
||||
const isViolet = accent === "violet";
|
||||
return (
|
||||
<Select value={value} onValueChange={(next) => onValueChange(next as RangeKey)}>
|
||||
<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"
|
||||
|
|
@ -167,19 +197,27 @@ function RangeSelect({
|
|||
}
|
||||
|
||||
// ── Line chart sub-component ───────────────────────────────────────────────
|
||||
function LineChart({ days, range, onRangeChange, lastPush }: {
|
||||
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 { 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 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 d = new Date(today);
|
||||
d.setDate(d.getDate() - (numDays - 1 - i));
|
||||
const key = isoDate(d);
|
||||
return { date: key, count: lookup.get(key) ?? 0 };
|
||||
});
|
||||
|
|
@ -194,16 +232,18 @@ function LineChart({ days, range, onRangeChange, lastPush }: {
|
|||
label: `Week of ${fmtActivityDate(chunk[0].date)}`,
|
||||
};
|
||||
})
|
||||
: daily.map(d => ({
|
||||
: daily.map((d) => ({
|
||||
...d,
|
||||
label: fmtActivityDate(d.date),
|
||||
}));
|
||||
|
||||
const localMax = Math.max(...series.map(p => p.count), 1);
|
||||
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,
|
||||
date: p.date,
|
||||
count: p.count,
|
||||
label: p.label,
|
||||
}));
|
||||
const totalEvents = daily.reduce((s, d) => s + d.count, 0);
|
||||
|
||||
|
|
@ -216,7 +256,8 @@ function LineChart({ days, range, onRangeChange, lastPush }: {
|
|||
const d = dateFromIsoDate(p.date);
|
||||
return {
|
||||
x: LP_L + (idx / Math.max(series.length - 1, 1)) * LCW,
|
||||
label: useWeekly || numDays > 7
|
||||
label:
|
||||
useWeekly || numDays > 7
|
||||
? `${MONTHS[d.getMonth()]} ${d.getDate()}`
|
||||
: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][d.getDay()],
|
||||
};
|
||||
|
|
@ -224,16 +265,23 @@ function LineChart({ days, range, onRangeChange, lastPush }: {
|
|||
|
||||
// Y labels
|
||||
const yStep = localMax / 4;
|
||||
const yLabels = [1,2,3,4].map(i => {
|
||||
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) };
|
||||
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 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`;
|
||||
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]);
|
||||
|
|
@ -241,7 +289,7 @@ function LineChart({ days, range, onRangeChange, lastPush }: {
|
|||
const baseline = LP_T + LCH;
|
||||
const lastPt = points[points.length - 1];
|
||||
const activePoint = hoveredPoint
|
||||
? points.find((p) => p.date === hoveredPoint.date) ?? null
|
||||
? (points.find((p) => p.date === hoveredPoint.date) ?? null)
|
||||
: null;
|
||||
const displayedCount = hoveredPoint?.count ?? totalEvents;
|
||||
const displayedLabel = hoveredPoint?.label ?? RANGE_SUMMARY[range];
|
||||
|
|
@ -253,8 +301,10 @@ function LineChart({ days, range, onRangeChange, lastPush }: {
|
|||
<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}}>
|
||||
<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 }}>
|
||||
|
|
@ -270,40 +320,117 @@ function LineChart({ days, range, onRangeChange, lastPush }: {
|
|||
</div>
|
||||
|
||||
{/* SVG */}
|
||||
<svg viewBox={`0 0 ${LVW} ${LVH}`} width="100%" style={{ display:"block", height:LVH, overflow:"visible" }}
|
||||
aria-label={`Git activity last ${range}`}>
|
||||
<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>
|
||||
<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>
|
||||
<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)"/>
|
||||
<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}}/>
|
||||
<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}}/>
|
||||
<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>
|
||||
<text
|
||||
key={i}
|
||||
x={x}
|
||||
y={LVH - 6}
|
||||
fontSize={10}
|
||||
textAnchor="middle"
|
||||
style={{ fill: W70 }}
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
))}
|
||||
{points.map((p, i) => (
|
||||
<rect
|
||||
|
|
@ -313,8 +440,12 @@ function LineChart({ days, range, onRangeChange, lastPush }: {
|
|||
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 })}
|
||||
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" }}
|
||||
|
|
@ -327,18 +458,31 @@ function LineChart({ days, range, onRangeChange, lastPush }: {
|
|||
{/* 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}}>
|
||||
<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)"}}>
|
||||
<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)"}}>
|
||||
<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)}
|
||||
<span style={{ color: "rgba(139,92,246,0.90)" }}>
|
||||
{lastPush.branch}
|
||||
</span>
|
||||
{" · "}
|
||||
{lastPush.repo}
|
||||
{" · "}
|
||||
{fmtRelative(lastPush.pushed_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -348,15 +492,20 @@ function LineChart({ days, range, onRangeChange, lastPush }: {
|
|||
}
|
||||
|
||||
// ── Heatmap grid sub-component ─────────────────────────────────────────────
|
||||
function HeatmapGrid({ days, range, onRangeChange }: {
|
||||
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 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));
|
||||
|
|
@ -375,7 +524,7 @@ function HeatmapGrid({ days, range, onRangeChange }: {
|
|||
});
|
||||
|
||||
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) {
|
||||
const gap = numDays <= 7 ? 12 : 4;
|
||||
|
|
@ -383,7 +532,10 @@ function HeatmapGrid({ days, range, onRangeChange }: {
|
|||
const availableWidth = LVW - HLEFT - HRIGHT;
|
||||
const cell = Math.max(
|
||||
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 x = HLEFT + (availableWidth - width) / 2;
|
||||
|
|
@ -407,10 +559,17 @@ function HeatmapGrid({ days, range, onRangeChange }: {
|
|||
calendarEnd.setDate(calendarEnd.getDate() + (6 - calendarEnd.getDay()));
|
||||
const weekCount = Math.max(
|
||||
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 monthLabelList: { weekIdx: number; label: string }[] = [];
|
||||
let lastMonth = -1;
|
||||
|
|
@ -441,7 +600,10 @@ function HeatmapGrid({ days, range, onRangeChange }: {
|
|||
const maxCell = range === "90d" ? 14 : range === "6m" ? 12 : 8;
|
||||
const cell = Math.max(
|
||||
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 width = weekCount * cell + Math.max(0, weekCount - 1) * gap;
|
||||
|
|
@ -469,10 +631,17 @@ function HeatmapGrid({ days, range, onRangeChange }: {
|
|||
{/* 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}}>
|
||||
<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 }}>
|
||||
|
|
@ -488,7 +657,12 @@ function HeatmapGrid({ days, range, onRangeChange }: {
|
|||
</div>
|
||||
|
||||
{/* 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.daily.map((day, i) => (
|
||||
|
|
@ -506,16 +680,23 @@ function HeatmapGrid({ days, range, onRangeChange }: {
|
|||
onBlur={() => setHoveredDay(null)}
|
||||
style={{
|
||||
fill: heatFill(day.count, heatmap.maxCount),
|
||||
stroke: hoveredDay?.date === day.date ? GREEN : "transparent",
|
||||
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) && (
|
||||
{(range === "7d" ||
|
||||
i % 5 === 0 ||
|
||||
i === heatmap.daily.length - 1) && (
|
||||
<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}
|
||||
fontSize={10}
|
||||
textAnchor="middle"
|
||||
|
|
@ -530,50 +711,106 @@ function HeatmapGrid({ days, range, onRangeChange }: {
|
|||
) : (
|
||||
<>
|
||||
{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) => (
|
||||
<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) =>
|
||||
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}
|
||||
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 })}
|
||||
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",
|
||||
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) => (
|
||||
<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>
|
||||
|
||||
{/* Contributions summary */}
|
||||
<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()}
|
||||
</span>
|
||||
<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>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -593,13 +830,25 @@ export function ForgejoHeatmap({
|
|||
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">
|
||||
{[0, 1].map((i) => (
|
||||
<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="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
|
||||
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
|
||||
className="animate-pulse rounded"
|
||||
style={{ height: LVH, background: "rgba(255,255,255,0.05)" }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -608,29 +857,68 @@ export function ForgejoHeatmap({
|
|||
|
||||
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 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}
|
||||
/>
|
||||
</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 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}
|
||||
/>
|
||||
</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="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>
|
||||
<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
|
||||
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>
|
||||
<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>
|
||||
) : (
|
||||
|
|
@ -638,7 +926,6 @@ export function ForgejoHeatmap({
|
|||
Line stats syncing with Forgejo — will appear on next refresh
|
||||
</p>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -402,7 +402,7 @@ export function ForgejoIssueMetricCards({
|
|||
];
|
||||
|
||||
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="min-w-0">
|
||||
<h3 className="text-lg font-semibold text-strong">
|
||||
|
|
|
|||
Loading…
Reference in New Issue