diff --git a/frontend/src/components/git/ForgejoHeatmap.tsx b/frontend/src/components/git/ForgejoHeatmap.tsx index 7d24fb7..2acd029 100644 --- a/frontend/src/components/git/ForgejoHeatmap.tsx +++ b/frontend/src/components/git/ForgejoHeatmap.tsx @@ -21,19 +21,19 @@ interface ForgejoHeatmapProps { } // ── Line chart layout ────────────────────────────────────────────────────── -const LVW = 580; -const LVH = 188; -const LP_L = 34; // Y-axis labels +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; +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; +const HLEFT = 32; +const HRIGHT = 14; +const HVH = LVH; // ── Types & data ─────────────────────────────────────────────────────────── type RangeKey = "7d" | "14d" | "30d" | "90d" | "6m" | "1y"; @@ -44,7 +44,12 @@ type ActivityDatum = { }; const RANGE_DAYS: Record = { - "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 = { "7d": "7 days", @@ -55,44 +60,63 @@ const RANGE_SUMMARY: Record = { "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)"; -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)"; +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 + "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("-"); + 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`; + 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`; + 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); @@ -104,13 +128,16 @@ function fmtCommitTitle(date: string, count: number): string { } // 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 ""; - const t = 1/6; + 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)}`; + 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; } @@ -135,7 +162,10 @@ function RangeSelect({ }) { const isViolet = accent === "violet"; return ( - onValueChange(next as RangeKey)} + > void; lastPush: ForgejoLastPush | null; }) { 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); - 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 { 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); + 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 { - date: chunk[0].date, - count: chunk.reduce((s,d) => s+d.count, 0), - label: `Week of ${fmtActivityDate(chunk[0].date)}`, + 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()], }; - }) - : 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); + // Y labels + const yStep = localMax / 4; + const yLabels = [1, 2, 3, 4].map((i) => { + const val = Math.round(yStep * i); 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: LP_T + LCH - (val / localMax) * LCH, + label: val >= 1000 ? `${(val / 1000).toFixed(0)}k` : String(val), }; }); - // 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`; - 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]); + return { points: pts, xLabels, yLabels, linePath, areaPath, totalEvents }; + }, [days, range]); const baseline = LP_T + LCH; - const lastPt = points[points.length-1]; + 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,11 +301,13 @@ function LineChart({ days, range, onRangeChange, lastPush }: {
Git Commits - + {displayedCount.toLocaleString()} - + {hoveredPoint ? `on ${displayedLabel}` : `in ${displayedLabel}`}
@@ -270,54 +320,135 @@ function LineChart({ days, range, onRangeChange, lastPush }: { {/* SVG */} - + - - + + - + + + - {yLabels.map(({y, label}) => ( + {yLabels.map(({ y, label }) => ( - - {label} + + + {label} + ))} - - - + + + {activePoint && ( <> - - - + + + )} {lastPt && ( <> - - + + )} - {xLabels.map(({x, label}, i) => ( - {label} + {xLabels.map(({ x, label }, i) => ( + + {label} + ))} - {points.map((p,i) => ( + {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 })} + 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"}} + style={{ fill: "transparent", outline: "none" }} > {fmtCommitTitle(p.date, p.count)} @@ -327,18 +458,31 @@ function LineChart({ days, range, onRangeChange, lastPush }: { {/* Last push — centered at bottom of card */} {lastPush && (
- + {lastPush.sha}
-

+

{lastPush.message}

-

+

{lastPush.author} pushed to{" "} - {lastPush.branch} - {" · "}{lastPush.repo}{" · "}{fmtRelative(lastPush.pushed_at)} + + {lastPush.branch} + + {" · "} + {lastPush.repo} + {" · "} + {fmtRelative(lastPush.pushed_at)}

@@ -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(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)); @@ -369,13 +518,13 @@ function HeatmapGrid({ days, range, onRangeChange }: { count: lookup.get(date) ?? 0, label: 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()}`, }; }); 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,12 +559,19 @@ 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}[] = []; + const monthLabelList: { weekIdx: number; label: string }[] = []; let lastMonth = -1; const cur = new Date(calendarStart); @@ -431,7 +590,7 @@ function HeatmapGrid({ days, range, onRangeChange }: { label: fmtActivityDate(dateStr), outsideRange: cur < rangeStart || cur > today, }); - cur.setDate(cur.getDate()+1); + cur.setDate(cur.getDate() + 1); } builtWeeks.push(week); } @@ -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,13 +631,20 @@ function HeatmapGrid({ days, range, onRangeChange }: { {/* Header */}
- - Commit Heatmap - + + + Commit Heatmap + + {displayedCount.toLocaleString()} - + {hoveredDay ? `on ${displayedLabel}` : `in ${displayedLabel}`}
@@ -488,7 +657,12 @@ function HeatmapGrid({ days, range, onRangeChange }: {
{/* SVG */} - + {heatmap.mode === "strip" ? ( <> {heatmap.daily.map((day, i) => ( @@ -506,20 +680,27 @@ 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", }} > {fmtCommitTitle(day.date, day.count)} - {(range === "7d" || i % 5 === 0 || i === heatmap.daily.length - 1) && ( + {(range === "7d" || + i % 5 === 0 || + i === heatmap.daily.length - 1) && ( {day.label} @@ -529,51 +710,107 @@ function HeatmapGrid({ days, range, onRangeChange }: { ) : ( <> - {heatmap.monthLabels.map(({weekIdx, label}) => ( - {label} + {heatmap.monthLabels.map(({ weekIdx, label }) => ( + + {label} + ))} - {(["Mon","Wed","Fri"] as const).map((label, i) => ( - {label} + {(["Mon", "Wed", "Fri"] as const).map((label, i) => ( + + {label} + ))} {heatmap.weeks.map((week, wi) => - week.map((cell, di) => cell.outsideRange ? null : ( - 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", - }}> - {fmtCommitTitle(cell.date, cell.count)} - - )) + week.map((cell, di) => + cell.outsideRange ? null : ( + + 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", + }} + > + {fmtCommitTitle(cell.date, cell.count)} + + ), + ), )} )} - Less + + Less + {HMAP_FILL.map((fill, i) => ( - + ))} - More + + More + {/* Contributions summary */}

- + {heatmap.totalEvents.toLocaleString()} - - commits across all tracked repositories in the last {RANGE_SUMMARY[range]} + + commits across all tracked repositories in the last{" "} + {RANGE_SUMMARY[range]}

- ); } @@ -583,9 +820,9 @@ export function ForgejoHeatmap({ days, totalAdditions = 0, totalDeletions = 0, - hasLineStats = false, - lastPush = null, - isLoading = false, + hasLineStats = false, + lastPush = null, + isLoading = false, }: ForgejoHeatmapProps) { const [lineRange, setLineRange] = useState("14d"); const [heatRange, setHeatRange] = useState("14d"); @@ -593,13 +830,25 @@ export function ForgejoHeatmap({ if (isLoading) { return (
- {[0,1].map(i => ( -
+ {[0, 1].map((i) => ( +
-
-
+
+
-
+
))}
@@ -608,37 +857,75 @@ export function ForgejoHeatmap({ return (
- {/* ── Two cards ───────────────────────────────────────────── */}
-
- +
+
-
- +
+
{/* ── Line stats — boxed ──────────────────────────────────── */} {hasLineStats ? ( -
+
- +{fmtLines(totalAdditions)} - lines of code added + + +{fmtLines(totalAdditions)} + + + lines of code added +
-
+
- -{fmtLines(totalDeletions)} - lines removed + + -{fmtLines(totalDeletions)} + + + lines removed +
) : ( -

+

Line stats syncing with Forgejo — will appear on next refresh

)} -
); } diff --git a/frontend/src/components/git/ForgejoIssueMetricCards.tsx b/frontend/src/components/git/ForgejoIssueMetricCards.tsx index fbc8e4c..4dd0b9f 100644 --- a/frontend/src/components/git/ForgejoIssueMetricCards.tsx +++ b/frontend/src/components/git/ForgejoIssueMetricCards.tsx @@ -402,7 +402,7 @@ export function ForgejoIssueMetricCards({ ]; return ( -
+