Files
cameleer-server/ui/src/pages/DashboardTab/PunchcardHeatmap.tsx
hsiegeln 4bc38453fe
Some checks failed
CI / cleanup-branch (push) Has been skipped
CI / build (push) Failing after 40s
CI / docker (push) Has been skipped
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Has been skipped
CI / cleanup-branch (pull_request) Has been skipped
CI / build (pull_request) Failing after 35s
CI / docker (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / deploy-feature (pull_request) Has been skipped
fix: nice-to-have polish — breadcrumbs, close button, status badges
- 7.1: Add deployment status badge (StatusDot + Badge) to AppsTab app
  list, sourced from catalog.deployment.status via slug lookup
- 7.3: Add X close button to top-right of exchange detail right panel
  in ExchangesPage (position:absolute, triggers handleClearSelection)
- 7.5: PunchcardHeatmap shows "Requires at least 2 days of data"
  when timeRangeMs < 2 days; DashboardL1 passes the range down
- 7.6: Command palette exchange results truncate IDs to ...{last8}
  matching the exchanges table display

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 18:51:49 +02:00

147 lines
4.3 KiB
TypeScript

import { useMemo, useState } from 'react';
import styles from './DashboardTab.module.css';
export interface PunchcardCell {
weekday: number;
hour: number;
totalCount: number;
failedCount: number;
}
interface PunchcardHeatmapProps {
cells: PunchcardCell[];
timeRangeMs?: number;
}
type Mode = 'transactions' | 'errors';
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)';
// 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 alpha = 0.15 + ratio * 0.75;
return `hsla(0, 65%, 50%, ${alpha.toFixed(2)})`;
}
const CELL = 11;
const GAP = 2;
const LABEL_W = 28;
const LABEL_H = 14;
const TWO_DAYS_MS = 2 * 24 * 60 * 60 * 1000;
export function PunchcardHeatmap({ cells, timeRangeMs }: PunchcardHeatmapProps) {
const [mode, setMode] = useState<Mode>('transactions');
const insufficientData = timeRangeMs !== undefined && timeRangeMs < TWO_DAYS_MS;
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);
if (insufficientData) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '2rem 1rem', color: 'var(--text-muted)', fontSize: '0.8125rem', fontStyle: 'italic' }}>
Requires at least 2 days of data
</div>
);
}
return (
<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>
<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>
</div>
);
}