From d8a21f0724e7356553a2010bc08c326be4b72e5e Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 30 Mar 2026 15:49:45 +0200 Subject: [PATCH] feat: GitHub-style contribution grid for punchcard heatmap Replace Recharts ScatterChart with compact SVG grid of small rounded squares (11x11px, 2px gap). 7 rows (Mon-Sun) x 24 columns (hours). Color intensity = value relative to max. Transactions = blue scale, Errors = red scale. Toggle switches between modes. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../pages/DashboardTab/PunchcardHeatmap.tsx | 191 ++++++++---------- 1 file changed, 85 insertions(+), 106 deletions(-) diff --git a/ui/src/pages/DashboardTab/PunchcardHeatmap.tsx b/ui/src/pages/DashboardTab/PunchcardHeatmap.tsx index 345440bb..0bfaebb6 100644 --- a/ui/src/pages/DashboardTab/PunchcardHeatmap.tsx +++ b/ui/src/pages/DashboardTab/PunchcardHeatmap.tsx @@ -1,9 +1,4 @@ import { useMemo, useState } from 'react'; -import { - ScatterChart, Scatter, XAxis, YAxis, Tooltip, - ResponsiveContainer, Rectangle, -} from 'recharts'; -import { rechartsTheme } from '@cameleer/design-system'; import styles from './DashboardTab.module.css'; export interface PunchcardCell { @@ -19,82 +14,54 @@ interface PunchcardHeatmapProps { type Mode = 'transactions' | 'errors'; -const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; +const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; +// Remap: backend DOW 0=Sun..6=Sat → display 0=Mon..6=Sun +function toDisplayDay(dow: number): number { + return dow === 0 ? 6 : dow - 1; +} function transactionColor(ratio: number): string { if (ratio === 0) return 'var(--bg-inset)'; - const lightness = 90 - ratio * 55; - return `hsl(220, 60%, ${Math.round(lightness)}%)`; + // Blue scale matching --running hue + const alpha = 0.15 + ratio * 0.75; + return `hsla(220, 65%, 50%, ${alpha.toFixed(2)})`; } function errorColor(ratio: number): string { if (ratio === 0) return 'var(--bg-inset)'; - const lightness = 90 - ratio * 55; - return `hsl(0, 65%, ${Math.round(lightness)}%)`; + const alpha = 0.15 + ratio * 0.75; + return `hsla(0, 65%, 50%, ${alpha.toFixed(2)})`; } -interface HeatmapPoint { - weekday: number; - hour: number; - value: number; - fill: string; -} - -function HeatmapCell(props: Record) { - const { cx, cy, payload } = props as { - cx: number; cy: number; payload: HeatmapPoint; - }; - if (!payload) return null; - const cellW = 32; - const cellH = 10; - return ( - - ); -} - -function formatDay(value: number): string { - return DAYS[value] ?? ''; -} - -function formatHour(value: number): string { - return String(value).padStart(2, '0'); -} - -function buildGrid(cells: PunchcardCell[], mode: Mode): HeatmapPoint[] { - const values = cells.map(c => mode === 'errors' ? c.failedCount : c.totalCount); - const maxVal = Math.max(...values, 1); - - const cellMap = new Map(); - for (const c of cells) cellMap.set(`${c.weekday}-${c.hour}`, c); - - const points: HeatmapPoint[] = []; - for (let d = 0; d < 7; d++) { - for (let h = 0; h < 24; h++) { - const cell = cellMap.get(`${d}-${h}`); - const val = cell ? (mode === 'errors' ? cell.failedCount : cell.totalCount) : 0; - const ratio = maxVal > 0 ? val / maxVal : 0; - points.push({ - weekday: d, - hour: h, - value: val, - fill: mode === 'errors' ? errorColor(ratio) : transactionColor(ratio), - }); - } - } - return points; -} +const CELL = 11; +const GAP = 2; +const LABEL_W = 28; +const LABEL_H = 14; export function PunchcardHeatmap({ cells }: PunchcardHeatmapProps) { const [mode, setMode] = useState('transactions'); - const data = useMemo(() => buildGrid(cells, mode), [cells, mode]); + const { grid, maxVal } = useMemo(() => { + const cellMap = new Map(); + for (const c of cells) cellMap.set(`${toDisplayDay(c.weekday)}-${c.hour}`, c); + + let max = 0; + const g: { day: number; hour: number; value: number }[] = []; + for (let d = 0; d < 7; d++) { + for (let h = 0; h < 24; h++) { + const cell = cellMap.get(`${d}-${h}`); + const val = cell ? (mode === 'errors' ? cell.failedCount : cell.totalCount) : 0; + if (val > max) max = val; + g.push({ day: d, hour: h, value: val }); + } + } + return { grid: g, maxVal: Math.max(max, 1) }; + }, [cells, mode]); + + const cols = 24; + const rows = 7; + const svgW = LABEL_W + cols * (CELL + GAP); + const svgH = LABEL_H + rows * (CELL + GAP); return (
@@ -112,44 +79,56 @@ export function PunchcardHeatmap({ cells }: PunchcardHeatmapProps) { Errors
- - - - - { - const p = entry.payload; - if (!p) return ''; - return [`${p.value.toLocaleString()} ${mode}`, `${DAYS[p.weekday]} ${formatHour(p.hour)}:00`]; - }} - labelFormatter={() => ''} - /> - )} />} - isAnimationActive={false} - /> - - + + {/* Hour labels (top, every 4 hours) */} + {[0, 4, 8, 12, 16, 20].map(h => ( + + {String(h).padStart(2, '0')} + + ))} + + {/* Day labels (left) */} + {DAYS.map((day, i) => ( + + {day} + + ))} + + {/* Cells */} + {grid.map(({ day, hour, value }) => { + const ratio = value / maxVal; + const fill = mode === 'errors' ? errorColor(ratio) : transactionColor(ratio); + return ( + + {`${DAYS[day]} ${String(hour).padStart(2, '0')}:00 — ${value.toLocaleString()} ${mode}`} + + ); + })} + ); }