diff --git a/backend/app/api/forgejo_metrics.py b/backend/app/api/forgejo_metrics.py index d4e413e..e13194d 100644 --- a/backend/app/api/forgejo_metrics.py +++ b/backend/app/api/forgejo_metrics.py @@ -382,7 +382,7 @@ async def get_forgejo_heatmap( if organization_id and organization_id != ctx.organization.id: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) - since = utcnow() - timedelta(days=183) + since = utcnow() - timedelta(days=365) # Fetch repos with their connections in one query repos_with_conns = ( diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index c17ed56..3478279 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -1243,22 +1243,15 @@ export default function DashboardPage() {
-
-
-

- Git Activity -

-
- -
+
diff --git a/frontend/src/components/git/ForgejoHeatmap.tsx b/frontend/src/components/git/ForgejoHeatmap.tsx index 093b8d9..910500e 100644 --- a/frontend/src/components/git/ForgejoHeatmap.tsx +++ b/frontend/src/components/git/ForgejoHeatmap.tsx @@ -1,6 +1,7 @@ "use client"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; +import { Activity, LayoutGrid } from "lucide-react"; import type { ForgejoHeatmapDay, ForgejoLastPush } from "@/lib/api-forgejo"; interface ForgejoHeatmapProps { @@ -13,126 +14,323 @@ interface ForgejoHeatmapProps { isLoading?: boolean; } -// Chart layout -const VW = 700; // viewBox width -const VH = 160; // viewBox height -const PAD_L = 8; -const PAD_R = 8; -const PAD_T = 12; -const PAD_B = 28; // room for month labels -const CW = VW - PAD_L - PAD_R; // chart width -const CH = VH - PAD_T - PAD_B; // chart height -const DAYS = 183; +// ── Line chart layout ────────────────────────────────────────────────────── +const LVW = 580; +const LVH = 160; +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 HCELL = 11; +const HGAP = 3; +const HSTRIDE = HCELL + HGAP; +const HWEEKS = 52; +const HLEFT = 30; +const HTOP = 18; +const HVW = HLEFT + HWEEKS * HSTRIDE; +const HVH = HTOP + 7 * HSTRIDE - HGAP + 22; // +22 for legend + +// ── Types & data ─────────────────────────────────────────────────────────── +type RangeKey = "7d" | "30d" | "3m" | "6m" | "1y"; +const RANGE_DAYS: Record = { + "7d": 7, "30d": 30, "3m": 91, "6m": 183, "1y": 365, +}; +const RANGE_LABELS: RangeKey[] = ["7d", "30d", "3m", "6m", "1y"]; const MONTHS = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]; -const VIOLET = "rgba(139,92,246,1)"; -const GREEN = "rgba(52,211,153,1)"; -const RED = "rgba(248,113,113,1)"; -const WHITE70 = "rgba(255,255,255,0.70)"; -const WHITE50 = "rgba(255,255,255,0.50)"; -const WHITE15 = "rgba(255,255,255,0.15)"; +// ── 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("-"); + return [d.getFullYear(), String(d.getMonth()+1).padStart(2,"0"), String(d.getDate()).padStart(2,"0")].join("-"); } - 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`; + 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`; } +// 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 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; +} + +// ── Line chart sub-component ─────────────────────────────────────────────── +function LineChart({ days, range, onRangeChange }: { + days: ForgejoHeatmapDay[]; + range: RangeKey; + onRangeChange: (r: RangeKey) => void; +}) { + const { points, xLabels, yLabels, linePath, areaPath } = 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) }; + }) + : daily; + + 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, + })); + + // 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 = new Date(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 }; + }, [days, range]); + + const baseline = LP_T + LCH; + const lastPt = points[points.length-1]; + + return ( +
+ {/* Header */} +
+
+ + Git Activity +
+
+ {RANGE_LABELS.map(r => ( + + ))} +
+
+ + {/* SVG */} + + + + + + + + + + {yLabels.map(({y, label}) => ( + + + {label} + + ))} + + + + {lastPt && ( + <> + + + + )} + {xLabels.map(({x, label}, i) => ( + {label} + ))} + {points.map((p,i) => ( + + {p.date}{p.count > 0 ? `: ${p.count} event${p.count!==1?"s":""}` : ": no activity"} + + ))} + +
+ ); +} + +// ── Heatmap grid sub-component ───────────────────────────────────────────── +function HeatmapGrid({ days }: { days: ForgejoHeatmapDay[] }) { + const { weeks, monthLabels } = useMemo(() => { + const lookup = new Map(days.map(d => [d.date, d.count])); + const today = new Date(); today.setHours(0,0,0,0); + const start = new Date(today); + start.setDate(start.getDate() - start.getDay()); + start.setDate(start.getDate() - (HWEEKS-1)*7); + + type Cell = { date:string; count:number; future:boolean }; + const builtWeeks: Cell[][] = []; + const monthLabelList: {weekIdx:number; label:string}[] = []; + let lastMonth = -1; + const cur = new Date(start); + + for (let w = 0; w < HWEEKS; w++) { + const week: Cell[] = []; + for (let d = 0; d < 7; d++) { + const dateStr = isoDate(cur); + const month = cur.getMonth(); + if (d === 0 && month !== lastMonth) { + monthLabelList.push({ weekIdx: w, label: MONTHS[month] }); + lastMonth = month; + } + week.push({ date: dateStr, count: lookup.get(dateStr) ?? 0, future: cur > today }); + cur.setDate(cur.getDate()+1); + } + builtWeeks.push(week); + } + return { weeks: builtWeeks, monthLabels: monthLabelList }; + }, [days]); + + return ( +
+ {/* Header */} +
+
+ + Activity Heatmap +
+ + Last 12 months + +
+ + {/* SVG */} + + {/* Month labels */} + {monthLabels.map(({weekIdx, label}) => ( + {label} + ))} + {/* Day labels */} + {(["Mon","Wed","Fri"] as const).map((label, i) => ( + {label} + ))} + {/* Cells */} + {weeks.map((week, wi) => + week.map((cell, di) => cell.future ? null : ( + + {cell.date}{cell.count>0?`: ${cell.count} event${cell.count!==1?"s":""}`+"" : ": no activity"} + + )) + )} + {/* Legend */} + Less + {HMAP_FILL.map((fill, i) => ( + + ))} + More + +
+ ); +} + +// ── Main export ──────────────────────────────────────────────────────────── export function ForgejoHeatmap({ days, - maxCount, totalAdditions = 0, totalDeletions = 0, hasLineStats = false, lastPush = null, isLoading = false, }: ForgejoHeatmapProps) { - const { points, monthLabels, gridLines, peak } = useMemo(() => { - const lookup = new Map(days.map((d) => [d.date, d.count])); + const [lineRange, setLineRange] = useState("30d"); - // Build a dense 183-day array ending today - const today = new Date(); - today.setHours(0, 0, 0, 0); - const series: { date: string; count: number }[] = []; - for (let i = DAYS - 1; i >= 0; i--) { - const d = new Date(today); - d.setDate(d.getDate() - i); - const key = isoDate(d); - series.push({ date: key, count: lookup.get(key) ?? 0 }); - } - - const localMax = Math.max(...series.map((p) => p.count), 1); - - // Map to SVG coords - const pts = series.map((p, i) => ({ - x: PAD_L + (i / (DAYS - 1)) * CW, - y: PAD_T + CH - (p.count / localMax) * CH, - date: p.date, - count: p.count, - })); - - // Month label positions - const labels: { x: number; label: string }[] = []; - let lastMonth = -1; - series.forEach((p, i) => { - const m = new Date(p.date).getMonth(); - if (m !== lastMonth) { - labels.push({ x: PAD_L + (i / (DAYS - 1)) * CW, label: MONTHS[m] }); - lastMonth = m; - } - }); - - // Horizontal grid lines at 25 / 50 / 75 % of chart height - const grid = [0.25, 0.5, 0.75].map((f) => PAD_T + CH * (1 - f)); - - // Peak point (highest count) - const peak = pts.reduce((best, p) => (p.count > best.count ? p : best), pts[0]); - - return { points: pts, monthLabels: labels, gridLines: grid, peak }; - }, [days, maxCount]); - - // Build SVG path strings - const lineD = useMemo(() => { - if (!points.length) return ""; - return points.map((p, i) => `${i === 0 ? "M" : "L"}${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" "); - }, [points]); - - const areaD = useMemo(() => { - if (!points.length) return ""; - const bottom = PAD_T + CH; - return ( - `M${PAD_L},${bottom} ` + - points.map((p) => `L${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" ") + - ` L${(PAD_L + CW).toFixed(1)},${bottom} Z` - ); - }, [points]); - - const totalEvents = days.reduce((sum, d) => sum + d.count, 0); + const totalEvents = useMemo(() => { + const numDays = RANGE_DAYS[lineRange]; + const today = new Date(); today.setHours(0,0,0,0); + const cutoff = new Date(today); cutoff.setDate(cutoff.getDate() - numDays); + const cutoffStr = isoDate(cutoff); + return days.filter(d => d.date >= cutoffStr).reduce((s,d) => s+d.count, 0); + }, [days, lineRange]); if (isLoading) { return ( -
-
-
+
+ {[0,1].map(i => ( +
+
+
+
+
+
+
+ ))}
); } @@ -140,162 +338,62 @@ export function ForgejoHeatmap({ return (
- {/* ── Area chart ──────────────────────────────────────────── */} - - - - - - - - - - - - {/* Grid lines */} - {gridLines.map((y, i) => ( - - ))} - - {/* Filled area */} - - - {/* Line */} - - - {/* Peak dot + label */} - {peak && peak.count > 0 && ( - <> - - - - {peak.count} - - - )} - - {/* Today dot */} - {points.length > 0 && ( - - )} - - {/* X-axis baseline */} - - - {/* Month labels */} - {monthLabels.map(({ x, label }, i) => ( - - {label} - - ))} - - {/* Invisible hit targets for per-day tooltip */} - {points.map((p) => ( - - {p.date}{p.count > 0 ? `: ${p.count} event${p.count !== 1 ? "s" : ""}` : ": no activity"} - - ))} - + {/* ── Two cards ───────────────────────────────────────────── */} +
+
+ +
+
+ +
+
{/* ── Contributions summary ────────────────────────────────── */}

- + {totalEvents.toLocaleString()} - - contributions across all tracked repositories in the last 6 months + + contributions across all tracked repositories in the last {lineRange}

- {/* ── Last push ───────────────────────────────────────────── */} + {/* ── Last push — no box, plain inline ────────────────────── */} {lastPush && ( -
- +
+ {lastPush.sha}
-

+

{lastPush.message}

-

+

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

)} - {/* ── Line stats ──────────────────────────────────────────── */} + {/* ── Line stats — boxed ──────────────────────────────────── */} {hasLineStats ? ( -
+
- - +{fmtLines(totalAdditions)} - - lines added + +{fmtLines(totalAdditions)} + lines 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/organisms/DashboardSidebar.tsx b/frontend/src/components/organisms/DashboardSidebar.tsx index 922d965..ee139e2 100644 --- a/frontend/src/components/organisms/DashboardSidebar.tsx +++ b/frontend/src/components/organisms/DashboardSidebar.tsx @@ -134,10 +134,7 @@ export function DashboardSidebar() { return (