2026-03-30 15:45:22 +02:00
|
|
|
import { useMemo, useState } from 'react';
|
|
|
|
|
import styles from './DashboardTab.module.css';
|
2026-03-30 10:26:26 +02:00
|
|
|
|
|
|
|
|
export interface PunchcardCell {
|
|
|
|
|
weekday: number;
|
|
|
|
|
hour: number;
|
|
|
|
|
totalCount: number;
|
|
|
|
|
failedCount: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface PunchcardHeatmapProps {
|
|
|
|
|
cells: PunchcardCell[];
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-30 15:45:22 +02:00
|
|
|
type Mode = 'transactions' | 'errors';
|
|
|
|
|
|
2026-03-30 15:49:45 +02:00
|
|
|
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;
|
|
|
|
|
}
|
2026-03-30 10:26:26 +02:00
|
|
|
|
|
|
|
|
function transactionColor(ratio: number): string {
|
2026-03-30 15:20:29 +02:00
|
|
|
if (ratio === 0) return 'var(--bg-inset)';
|
2026-03-30 15:49:45 +02:00
|
|
|
// Blue scale matching --running hue
|
|
|
|
|
const alpha = 0.15 + ratio * 0.75;
|
|
|
|
|
return `hsla(220, 65%, 50%, ${alpha.toFixed(2)})`;
|
2026-03-30 10:26:26 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function errorColor(ratio: number): string {
|
2026-03-30 15:20:29 +02:00
|
|
|
if (ratio === 0) return 'var(--bg-inset)';
|
2026-03-30 15:49:45 +02:00
|
|
|
const alpha = 0.15 + ratio * 0.75;
|
|
|
|
|
return `hsla(0, 65%, 50%, ${alpha.toFixed(2)})`;
|
2026-03-30 15:20:29 +02:00
|
|
|
}
|
2026-03-30 10:26:26 +02:00
|
|
|
|
2026-03-30 15:49:45 +02:00
|
|
|
const CELL = 11;
|
|
|
|
|
const GAP = 2;
|
|
|
|
|
const LABEL_W = 28;
|
|
|
|
|
const LABEL_H = 14;
|
2026-03-30 15:45:22 +02:00
|
|
|
|
|
|
|
|
export function PunchcardHeatmap({ cells }: PunchcardHeatmapProps) {
|
|
|
|
|
const [mode, setMode] = useState<Mode>('transactions');
|
|
|
|
|
|
2026-03-30 15:49:45 +02:00
|
|
|
const { grid, maxVal } = useMemo(() => {
|
|
|
|
|
const cellMap = new Map<string, PunchcardCell>();
|
|
|
|
|
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);
|
2026-03-30 10:26:26 +02:00
|
|
|
|
|
|
|
|
return (
|
2026-03-30 15:45:22 +02:00
|
|
|
<div>
|
|
|
|
|
<div className={styles.toggleRow}>
|
|
|
|
|
<button
|
|
|
|
|
className={`${styles.toggleBtn} ${mode === 'transactions' ? styles.toggleActive : ''}`}
|
|
|
|
|
onClick={() => setMode('transactions')}
|
|
|
|
|
>
|
|
|
|
|
Transactions
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
className={`${styles.toggleBtn} ${mode === 'errors' ? styles.toggleActive : ''}`}
|
|
|
|
|
onClick={() => setMode('errors')}
|
|
|
|
|
>
|
|
|
|
|
Errors
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
2026-03-30 15:49:45 +02:00
|
|
|
<svg viewBox={`0 0 ${svgW} ${svgH}`} style={{ width: '100%', height: 'auto', display: 'block' }}>
|
|
|
|
|
{/* Hour labels (top, every 4 hours) */}
|
|
|
|
|
{[0, 4, 8, 12, 16, 20].map(h => (
|
|
|
|
|
<text
|
|
|
|
|
key={h}
|
|
|
|
|
x={LABEL_W + h * (CELL + GAP) + CELL / 2}
|
|
|
|
|
y={10}
|
|
|
|
|
textAnchor="middle"
|
|
|
|
|
fill="var(--text-faint)"
|
|
|
|
|
fontSize={7}
|
|
|
|
|
fontFamily="var(--font-mono)"
|
|
|
|
|
>
|
|
|
|
|
{String(h).padStart(2, '0')}
|
|
|
|
|
</text>
|
|
|
|
|
))}
|
|
|
|
|
|
|
|
|
|
{/* Day labels (left) */}
|
|
|
|
|
{DAYS.map((day, i) => (
|
|
|
|
|
<text
|
|
|
|
|
key={day}
|
|
|
|
|
x={LABEL_W - 4}
|
|
|
|
|
y={LABEL_H + i * (CELL + GAP) + CELL / 2 + 3}
|
|
|
|
|
textAnchor="end"
|
|
|
|
|
fill="var(--text-faint)"
|
|
|
|
|
fontSize={7}
|
|
|
|
|
fontFamily="var(--font-mono)"
|
|
|
|
|
>
|
|
|
|
|
{day}
|
|
|
|
|
</text>
|
|
|
|
|
))}
|
|
|
|
|
|
|
|
|
|
{/* Cells */}
|
|
|
|
|
{grid.map(({ day, hour, value }) => {
|
|
|
|
|
const ratio = value / maxVal;
|
|
|
|
|
const fill = mode === 'errors' ? errorColor(ratio) : transactionColor(ratio);
|
|
|
|
|
return (
|
|
|
|
|
<rect
|
|
|
|
|
key={`${day}-${hour}`}
|
|
|
|
|
x={LABEL_W + hour * (CELL + GAP)}
|
|
|
|
|
y={LABEL_H + day * (CELL + GAP)}
|
|
|
|
|
width={CELL}
|
|
|
|
|
height={CELL}
|
|
|
|
|
rx={2}
|
|
|
|
|
fill={fill}
|
|
|
|
|
>
|
|
|
|
|
<title>{`${DAYS[day]} ${String(hour).padStart(2, '0')}:00 — ${value.toLocaleString()} ${mode}`}</title>
|
|
|
|
|
</rect>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</svg>
|
2026-03-30 15:45:22 +02:00
|
|
|
</div>
|
2026-03-30 10:26:26 +02:00
|
|
|
);
|
|
|
|
|
}
|