diff --git a/frontend/src/components/git/ForgejoHeatmap.tsx b/frontend/src/components/git/ForgejoHeatmap.tsx index f96d0fe..d3515da 100644 --- a/frontend/src/components/git/ForgejoHeatmap.tsx +++ b/frontend/src/components/git/ForgejoHeatmap.tsx @@ -25,12 +25,8 @@ 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 HLEFT = 30; -const HTOP = 18; -const HMIN_VW = 420; +const HLEFT = 32; +const HRIGHT = 14; const HVH = LVH; // ── Types & data ─────────────────────────────────────────────────────────── @@ -69,6 +65,10 @@ const HMAP_FILL = [ 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`; @@ -140,7 +140,7 @@ function LineChart({ days, range, onRangeChange, lastPush }: { .filter((_, i) => i % step === 0 || i === series.length-1) .map((p) => { const idx = series.indexOf(p); - const d = new Date(p.date); + const d = dateFromIsoDate(p.date); return { x: LP_L + (idx / Math.max(series.length-1,1)) * LCW, label: useWeekly || numDays > 7 @@ -255,12 +255,51 @@ function HeatmapGrid({ days, range, onRangeChange }: { range: RangeKey; onRangeChange: (r: RangeKey) => void; }) { - const { weeks, monthLabels, totalEvents, viewWidth } = useMemo(() => { + 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); + + 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, + cell, + gap, + x, + y, + }; + } + const calendarStart = new Date(rangeStart); calendarStart.setDate(calendarStart.getDate() - calendarStart.getDay()); const calendarEnd = new Date(today); @@ -270,14 +309,14 @@ function HeatmapGrid({ days, range, onRangeChange }: { Math.ceil(((calendarEnd.getTime() - calendarStart.getTime()) / 86400000 + 1) / 7), ); - type Cell = { date:string; count:number; outsideRange:boolean }; - const builtWeeks: Cell[][] = []; + type CalendarCell = { date:string; count:number; 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: Cell[] = []; + const week: CalendarCell[] = []; for (let d = 0; d < 7; d++) { const dateStr = isoDate(cur); const month = cur.getMonth(); @@ -295,12 +334,29 @@ function HeatmapGrid({ days, range, onRangeChange }: { builtWeeks.push(week); } - // contributions count for selected range - const cutoffStr = isoDate(rangeStart); - const totalEvents = days.filter(d => d.date >= cutoffStr).reduce((s,d) => s+d.count, 0); - const viewWidth = Math.max(HMIN_VW, HLEFT + weekCount * HSTRIDE); + const gap = 3; + const availableWidth = LVW - HLEFT - HRIGHT; + const maxCell = range === "3m" ? 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 { weeks: builtWeeks, monthLabels: monthLabelList, totalEvents, viewWidth }; + return { + mode: "calendar" as const, + weeks: builtWeeks, + monthLabels: monthLabelList, + totalEvents, + cell, + gap, + stride, + x, + y, + }; }, [days, range]); return ( @@ -325,34 +381,66 @@ function HeatmapGrid({ days, range, onRangeChange }: { {/* SVG */} - - {monthLabels.map(({weekIdx, label}) => ( - {label} - ))} - {(["Mon","Wed","Fri"] as const).map((label, i) => ( - {label} - ))} - {weeks.map((week, wi) => - week.map((cell, di) => cell.outsideRange ? null : ( - - {cell.date}{cell.count>0?`: ${cell.count} event${cell.count!==1?"s":""}` : ": no activity"} - - )) + + {heatmap.mode === "strip" ? ( + <> + {heatmap.daily.map((day, i) => ( + + + {day.date}{day.count>0?`: ${day.count} event${day.count!==1?"s":""}` : ": no activity"} + + {(range === "7d" || i % 5 === 0 || i === heatmap.daily.length - 1) && ( + + {day.label} + + )} + + ))} + + ) : ( + <> + {heatmap.monthLabels.map(({weekIdx, label}) => ( + {label} + ))} + {(["Mon","Wed","Fri"] as const).map((label, i) => ( + {label} + ))} + {heatmap.weeks.map((week, wi) => + week.map((cell, di) => cell.outsideRange ? null : ( + + {cell.date}{cell.count>0?`: ${cell.count} event${cell.count!==1?"s":""}` : ": no activity"} + + )) + )} + )} - Less + Less {HMAP_FILL.map((fill, i) => ( - + ))} - More + More {/* Contributions summary */}

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