2026-03-30 15:45:22 +02:00
|
|
|
import { useMemo, useState } from 'react';
|
2026-03-30 15:20:29 +02:00
|
|
|
import {
|
|
|
|
|
ScatterChart, Scatter, XAxis, YAxis, Tooltip,
|
|
|
|
|
ResponsiveContainer, Rectangle,
|
|
|
|
|
} from 'recharts';
|
|
|
|
|
import { rechartsTheme } from '@cameleer/design-system';
|
2026-03-30 15:45:22 +02:00
|
|
|
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 10:26:26 +02:00
|
|
|
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
|
|
|
|
|
|
|
|
function transactionColor(ratio: number): string {
|
2026-03-30 15:20:29 +02:00
|
|
|
if (ratio === 0) return 'var(--bg-inset)';
|
2026-03-30 10:26:26 +02:00
|
|
|
const lightness = 90 - ratio * 55;
|
|
|
|
|
return `hsl(220, 60%, ${Math.round(lightness)}%)`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function errorColor(ratio: number): string {
|
2026-03-30 15:20:29 +02:00
|
|
|
if (ratio === 0) return 'var(--bg-inset)';
|
2026-03-30 10:26:26 +02:00
|
|
|
const lightness = 90 - ratio * 55;
|
|
|
|
|
return `hsl(0, 65%, ${Math.round(lightness)}%)`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-30 15:20:29 +02:00
|
|
|
interface HeatmapPoint {
|
|
|
|
|
weekday: number;
|
|
|
|
|
hour: number;
|
|
|
|
|
value: number;
|
|
|
|
|
fill: string;
|
|
|
|
|
}
|
2026-03-30 10:26:26 +02:00
|
|
|
|
2026-03-30 15:20:29 +02:00
|
|
|
function HeatmapCell(props: Record<string, unknown>) {
|
|
|
|
|
const { cx, cy, payload } = props as {
|
|
|
|
|
cx: number; cy: number; payload: HeatmapPoint;
|
|
|
|
|
};
|
|
|
|
|
if (!payload) return null;
|
|
|
|
|
const cellW = 32;
|
|
|
|
|
const cellH = 10;
|
|
|
|
|
return (
|
|
|
|
|
<Rectangle
|
|
|
|
|
x={cx - cellW / 2}
|
|
|
|
|
y={cy - cellH / 2}
|
|
|
|
|
width={cellW}
|
|
|
|
|
height={cellH}
|
|
|
|
|
fill={payload.fill}
|
|
|
|
|
radius={2}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-03-30 10:26:26 +02:00
|
|
|
|
2026-03-30 15:20:29 +02:00
|
|
|
function formatDay(value: number): string {
|
|
|
|
|
return DAYS[value] ?? '';
|
|
|
|
|
}
|
2026-03-30 10:26:26 +02:00
|
|
|
|
2026-03-30 15:20:29 +02:00
|
|
|
function formatHour(value: number): string {
|
|
|
|
|
return String(value).padStart(2, '0');
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-30 15:45:22 +02:00
|
|
|
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<string, PunchcardCell>();
|
|
|
|
|
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),
|
|
|
|
|
});
|
2026-03-30 10:26:26 +02:00
|
|
|
}
|
2026-03-30 15:45:22 +02:00
|
|
|
}
|
|
|
|
|
return points;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function PunchcardHeatmap({ cells }: PunchcardHeatmapProps) {
|
|
|
|
|
const [mode, setMode] = useState<Mode>('transactions');
|
|
|
|
|
|
|
|
|
|
const data = useMemo(() => buildGrid(cells, mode), [cells, mode]);
|
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>
|
|
|
|
|
<ResponsiveContainer width="100%" height={280}>
|
|
|
|
|
<ScatterChart margin={{ top: 10, right: 10, bottom: 10, left: 10 }}>
|
|
|
|
|
<XAxis
|
|
|
|
|
type="number"
|
|
|
|
|
dataKey="weekday"
|
|
|
|
|
domain={[0, 6]}
|
|
|
|
|
ticks={[0, 1, 2, 3, 4, 5, 6]}
|
|
|
|
|
tickFormatter={formatDay}
|
|
|
|
|
{...rechartsTheme.xAxis}
|
|
|
|
|
/>
|
|
|
|
|
<YAxis
|
|
|
|
|
type="number"
|
|
|
|
|
dataKey="hour"
|
|
|
|
|
domain={[0, 23]}
|
|
|
|
|
ticks={[0, 4, 8, 12, 16, 20]}
|
|
|
|
|
tickFormatter={formatHour}
|
|
|
|
|
reversed
|
|
|
|
|
{...rechartsTheme.yAxis}
|
|
|
|
|
/>
|
|
|
|
|
<Tooltip
|
|
|
|
|
contentStyle={rechartsTheme.tooltip.contentStyle}
|
|
|
|
|
labelStyle={rechartsTheme.tooltip.labelStyle}
|
|
|
|
|
itemStyle={rechartsTheme.tooltip.itemStyle}
|
|
|
|
|
cursor={false}
|
|
|
|
|
formatter={(_val: unknown, _name: string, entry: { payload?: HeatmapPoint }) => {
|
|
|
|
|
const p = entry.payload;
|
|
|
|
|
if (!p) return '';
|
|
|
|
|
return [`${p.value.toLocaleString()} ${mode}`, `${DAYS[p.weekday]} ${formatHour(p.hour)}:00`];
|
|
|
|
|
}}
|
|
|
|
|
labelFormatter={() => ''}
|
|
|
|
|
/>
|
|
|
|
|
<Scatter
|
|
|
|
|
data={data}
|
|
|
|
|
shape={(props: unknown) => <HeatmapCell {...(props as Record<string, unknown>)} />}
|
|
|
|
|
isAnimationActive={false}
|
|
|
|
|
/>
|
|
|
|
|
</ScatterChart>
|
|
|
|
|
</ResponsiveContainer>
|
|
|
|
|
</div>
|
2026-03-30 10:26:26 +02:00
|
|
|
);
|
|
|
|
|
}
|