From f0a5c430f10dab7df41242cca51c2b3d54bdd29a Mon Sep 17 00:00:00 2001 From: null Date: Fri, 22 May 2026 18:56:32 -0500 Subject: [PATCH] feat(heatmap): enhance heatmap functionality with improved activity date formatting and dynamic hover effects --- .../src/components/git/ForgejoHeatmap.tsx | 123 +++++++++++++++--- 1 file changed, 104 insertions(+), 19 deletions(-) diff --git a/frontend/src/components/git/ForgejoHeatmap.tsx b/frontend/src/components/git/ForgejoHeatmap.tsx index d3515da..ece91fc 100644 --- a/frontend/src/components/git/ForgejoHeatmap.tsx +++ b/frontend/src/components/git/ForgejoHeatmap.tsx @@ -16,7 +16,7 @@ interface ForgejoHeatmapProps { // ── Line chart layout ────────────────────────────────────────────────────── const LVW = 580; -const LVH = 160; +const LVH = 188; const LP_L = 34; // Y-axis labels const LP_R = 8; const LP_T = 10; @@ -31,6 +31,12 @@ const HVH = LVH; // ── Types & data ─────────────────────────────────────────────────────────── type RangeKey = "7d" | "30d" | "3m" | "6m" | "1y"; +type ActivityDatum = { + date: string; + count: number; + label: string; +}; + const RANGE_DAYS: Record = { "7d": 7, "30d": 30, "3m": 91, "6m": 183, "1y": 365, }; @@ -81,6 +87,10 @@ function fmtRelative(iso: string): string { 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()}`; +} // Catmull-Rom → cubic bezier smooth path function smoothPath(pts: {x:number;y:number}[]): string { @@ -94,12 +104,11 @@ function smoothPath(pts: {x:number;y:number}[]): string { 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; +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)})`; } // ── Line chart sub-component ─────────────────────────────────────────────── @@ -109,7 +118,8 @@ function LineChart({ days, range, onRangeChange, lastPush }: { onRangeChange: (r: RangeKey) => void; lastPush: ForgejoLastPush | null; }) { - const { points, xLabels, yLabels, linePath, areaPath } = useMemo(() => { + const [hoveredPoint, setHoveredPoint] = useState(null); + const { points, xLabels, yLabels, linePath, areaPath, totalEvents } = useMemo(() => { const lookup = new Map(days.map(d => [d.date, d.count])); const numDays = RANGE_DAYS[range]; const today = new Date(); today.setHours(0,0,0,0); @@ -123,16 +133,24 @@ function LineChart({ days, range, onRangeChange, lastPush }: { 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) }; + return { + date: chunk[0].date, + count: chunk.reduce((s,d) => s+d.count, 0), + label: `Week of ${fmtActivityDate(chunk[0].date)}`, + }; }) - : daily; + : 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, + 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)); @@ -162,11 +180,16 @@ function LineChart({ days, range, onRangeChange, lastPush }: { 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 }; + 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 (
@@ -175,6 +198,13 @@ function LineChart({ days, range, onRangeChange, lastPush }: {
Git Activity + + {displayedCount.toLocaleString()} + + + {hoveredPoint ? `on ${displayedLabel}` : `in ${displayedLabel}`} +
{RANGE_LABELS.map(r => ( @@ -210,6 +240,13 @@ function LineChart({ days, range, onRangeChange, lastPush }: { + {activePoint && ( + <> + + + + + )} {lastPt && ( <> @@ -220,7 +257,19 @@ function LineChart({ days, range, onRangeChange, lastPush }: { {label} ))} {points.map((p,i) => ( - + setHoveredPoint({ date: p.date, count: p.count, label: p.label })} + onFocus={() => setHoveredPoint({ date: p.date, count: p.count, label: p.label })} + onMouseLeave={() => setHoveredPoint(null)} + onBlur={() => setHoveredPoint(null)} + style={{fill:"transparent", outline:"none"}} + > {p.date}{p.count > 0 ? `: ${p.count} event${p.count!==1?"s":""}` : ": no activity"} ))} @@ -255,6 +304,7 @@ function HeatmapGrid({ days, range, onRangeChange }: { range: RangeKey; onRangeChange: (r: RangeKey) => void; }) { + const [hoveredDay, setHoveredDay] = useState(null); const heatmap = useMemo(() => { const lookup = new Map(days.map(d => [d.date, d.count])); const today = new Date(); today.setHours(0,0,0,0); @@ -276,6 +326,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); if (numDays <= 30) { const gap = numDays <= 7 ? 12 : 4; @@ -293,6 +344,7 @@ function HeatmapGrid({ days, range, onRangeChange }: { mode: "strip" as const, daily, totalEvents, + maxCount, cell, gap, x, @@ -309,7 +361,7 @@ function HeatmapGrid({ days, range, onRangeChange }: { Math.ceil(((calendarEnd.getTime() - calendarStart.getTime()) / 86400000 + 1) / 7), ); - type CalendarCell = { date:string; count:number; outsideRange:boolean }; + type CalendarCell = { date:string; count:number; label:string; outsideRange:boolean }; const builtWeeks: CalendarCell[][] = []; const monthLabelList: {weekIdx:number; label:string}[] = []; let lastMonth = -1; @@ -327,6 +379,7 @@ function HeatmapGrid({ days, range, onRangeChange }: { week.push({ date: dateStr, count: lookup.get(dateStr) ?? 0, + label: fmtActivityDate(dateStr), outsideRange: cur < rangeStart || cur > today, }); cur.setDate(cur.getDate()+1); @@ -351,6 +404,7 @@ function HeatmapGrid({ days, range, onRangeChange }: { weeks: builtWeeks, monthLabels: monthLabelList, totalEvents, + maxCount, cell, gap, stride, @@ -358,6 +412,8 @@ function HeatmapGrid({ days, range, onRangeChange }: { y, }; }, [days, range]); + const displayedCount = hoveredDay?.count ?? heatmap.totalEvents; + const displayedLabel = hoveredDay?.label ?? RANGE_SUMMARY[range]; return (
@@ -366,6 +422,13 @@ function HeatmapGrid({ days, range, onRangeChange }: {
Activity Heatmap + + {displayedCount.toLocaleString()} + + + {hoveredDay ? `on ${displayedLabel}` : `in ${displayedLabel}`} +
{RANGE_LABELS.map(r => ( @@ -392,7 +455,17 @@ function HeatmapGrid({ days, range, onRangeChange }: { width={heatmap.cell} height={heatmap.cell} rx={4} - style={{fill: HMAP_FILL[toLevel(day.count)]}} + 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", + }} > {day.date}{day.count>0?`: ${day.count} event${day.count!==1?"s":""}` : ": no activity"} @@ -423,7 +496,17 @@ function HeatmapGrid({ days, range, onRangeChange }: { + 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", + }}> {cell.date}{cell.count>0?`: ${cell.count} event${cell.count!==1?"s":""}` : ": no activity"} )) @@ -440,10 +523,12 @@ function HeatmapGrid({ days, range, onRangeChange }: { {/* Contributions summary */}

- {heatmap.totalEvents.toLocaleString()} + {displayedCount.toLocaleString()} - contributions across all tracked repositories in the last {RANGE_SUMMARY[range]} + {hoveredDay + ? `contributions on ${displayedLabel}` + : `contributions across all tracked repositories in the last ${RANGE_SUMMARY[range]}`}

@@ -472,7 +557,7 @@ export function ForgejoHeatmap({
-
+
))}