feat: consolidate punchcard heatmaps into single toggle component
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m1s
CI / docker (push) Successful in 54s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 37s

Replace two separate Transaction/Error punchcard cards with a single
card containing a Transactions/Errors toggle. Uses internal state to
switch between modes without remounting the chart.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-30 15:45:22 +02:00
parent 52c22f1eb9
commit 4a91ca0774
4 changed files with 120 additions and 79 deletions

View File

@@ -456,14 +456,9 @@ export default function DashboardL1() {
onItemClick={(id) => navigate(`/dashboard/${id}`)} onItemClick={(id) => navigate(`/dashboard/${id}`)}
/> />
</Card> </Card>
<div className={styles.punchcardStack}> <Card title="7-Day Pattern">
<Card title="Transactions (7-day pattern)"> <PunchcardHeatmap cells={punchcardData ?? []} />
<PunchcardHeatmap cells={punchcardData ?? []} mode="transactions" /> </Card>
</Card>
<Card title="Errors (7-day pattern)">
<PunchcardHeatmap cells={punchcardData ?? []} mode="errors" />
</Card>
</div>
</div> </div>
)} )}
</div> </div>

View File

@@ -437,14 +437,9 @@ export default function DashboardL2() {
onItemClick={(id) => navigate(`/dashboard/${appId}/${id}`)} onItemClick={(id) => navigate(`/dashboard/${appId}/${id}`)}
/> />
</Card> </Card>
<div className={styles.punchcardStack}> <Card title="7-Day Pattern">
<Card title="Transactions (7-day pattern)"> <PunchcardHeatmap cells={punchcardData ?? []} />
<PunchcardHeatmap cells={punchcardData ?? []} mode="transactions" /> </Card>
</Card>
<Card title="Errors (7-day pattern)">
<PunchcardHeatmap cells={punchcardData ?? []} mode="errors" />
</Card>
</div>
</div> </div>
)} )}
</div> </div>

View File

@@ -140,6 +140,37 @@
gap: 16px; gap: 16px;
} }
/* Toggle button row */
.toggleRow {
display: flex;
gap: 2px;
padding: 0 12px 4px;
}
.toggleBtn {
padding: 3px 10px;
font-size: 11px;
font-family: var(--font-mono);
color: var(--text-muted);
background: transparent;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
cursor: pointer;
transition: all 0.15s;
}
.toggleBtn:hover {
color: var(--text-primary);
border-color: var(--border);
}
.toggleActive {
color: var(--text-primary);
background: var(--bg-inset);
border-color: var(--border);
font-weight: 600;
}
/* Errors section */ /* Errors section */
.errorsSection { .errorsSection {
background: var(--bg-surface); background: var(--bg-surface);

View File

@@ -1,9 +1,10 @@
import { useMemo } from 'react'; import { useMemo, useState } from 'react';
import { import {
ScatterChart, Scatter, XAxis, YAxis, Tooltip, ScatterChart, Scatter, XAxis, YAxis, Tooltip,
ResponsiveContainer, Rectangle, ResponsiveContainer, Rectangle,
} from 'recharts'; } from 'recharts';
import { rechartsTheme } from '@cameleer/design-system'; import { rechartsTheme } from '@cameleer/design-system';
import styles from './DashboardTab.module.css';
export interface PunchcardCell { export interface PunchcardCell {
weekday: number; weekday: number;
@@ -14,9 +15,10 @@ export interface PunchcardCell {
interface PunchcardHeatmapProps { interface PunchcardHeatmapProps {
cells: PunchcardCell[]; cells: PunchcardCell[];
mode: 'transactions' | 'errors';
} }
type Mode = 'transactions' | 'errors';
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
function transactionColor(ratio: number): string { function transactionColor(ratio: number): string {
@@ -43,7 +45,6 @@ function HeatmapCell(props: Record<string, unknown>) {
cx: number; cy: number; payload: HeatmapPoint; cx: number; cy: number; payload: HeatmapPoint;
}; };
if (!payload) return null; if (!payload) return null;
// Cell size: chart area / grid divisions. Approximate from scatter positioning.
const cellW = 32; const cellW = 32;
const cellH = 10; const cellH = 10;
return ( return (
@@ -66,70 +67,89 @@ function formatHour(value: number): string {
return String(value).padStart(2, '0'); return String(value).padStart(2, '0');
} }
export function PunchcardHeatmap({ cells, mode }: PunchcardHeatmapProps) { function buildGrid(cells: PunchcardCell[], mode: Mode): HeatmapPoint[] {
const data: HeatmapPoint[] = useMemo(() => { const values = cells.map(c => mode === 'errors' ? c.failedCount : c.totalCount);
const values = cells.map(c => mode === 'errors' ? c.failedCount : c.totalCount); const maxVal = Math.max(...values, 1);
const maxVal = Math.max(...values, 1);
// Build full 7x24 grid const cellMap = new Map<string, PunchcardCell>();
const points: HeatmapPoint[] = []; for (const c of cells) cellMap.set(`${c.weekday}-${c.hour}`, c);
const cellMap = new Map<string, PunchcardCell>();
for (const c of cells) cellMap.set(`${c.weekday}-${c.hour}`, c);
for (let d = 0; d < 7; d++) { const points: HeatmapPoint[] = [];
for (let h = 0; h < 24; h++) { for (let d = 0; d < 7; d++) {
const cell = cellMap.get(`${d}-${h}`); for (let h = 0; h < 24; h++) {
const val = cell ? (mode === 'errors' ? cell.failedCount : cell.totalCount) : 0; const cell = cellMap.get(`${d}-${h}`);
const ratio = maxVal > 0 ? val / maxVal : 0; const val = cell ? (mode === 'errors' ? cell.failedCount : cell.totalCount) : 0;
points.push({ const ratio = maxVal > 0 ? val / maxVal : 0;
weekday: d, points.push({
hour: h, weekday: d,
value: val, hour: h,
fill: mode === 'errors' ? errorColor(ratio) : transactionColor(ratio), value: val,
}); fill: mode === 'errors' ? errorColor(ratio) : transactionColor(ratio),
} });
} }
return points; }
}, [cells, mode]); return points;
}
export function PunchcardHeatmap({ cells }: PunchcardHeatmapProps) {
const [mode, setMode] = useState<Mode>('transactions');
const data = useMemo(() => buildGrid(cells, mode), [cells, mode]);
return ( return (
<ResponsiveContainer width="100%" height={300}> <div>
<ScatterChart margin={{ top: 10, right: 10, bottom: 10, left: 10 }}> <div className={styles.toggleRow}>
<XAxis <button
type="number" className={`${styles.toggleBtn} ${mode === 'transactions' ? styles.toggleActive : ''}`}
dataKey="weekday" onClick={() => setMode('transactions')}
domain={[0, 6]} >
ticks={[0, 1, 2, 3, 4, 5, 6]} Transactions
tickFormatter={formatDay} </button>
{...rechartsTheme.xAxis} <button
/> className={`${styles.toggleBtn} ${mode === 'errors' ? styles.toggleActive : ''}`}
<YAxis onClick={() => setMode('errors')}
type="number" >
dataKey="hour" Errors
domain={[0, 23]} </button>
ticks={[0, 4, 8, 12, 16, 20]} </div>
tickFormatter={formatHour} <ResponsiveContainer width="100%" height={280}>
reversed <ScatterChart margin={{ top: 10, right: 10, bottom: 10, left: 10 }}>
{...rechartsTheme.yAxis} <XAxis
/> type="number"
<Tooltip dataKey="weekday"
contentStyle={rechartsTheme.tooltip.contentStyle} domain={[0, 6]}
labelStyle={rechartsTheme.tooltip.labelStyle} ticks={[0, 1, 2, 3, 4, 5, 6]}
itemStyle={rechartsTheme.tooltip.itemStyle} tickFormatter={formatDay}
cursor={false} {...rechartsTheme.xAxis}
formatter={(_val: unknown, _name: string, entry: { payload?: HeatmapPoint }) => { />
const p = entry.payload; <YAxis
if (!p) return ''; type="number"
return [`${p.value.toLocaleString()} ${mode}`, `${DAYS[p.weekday]} ${formatHour(p.hour)}:00`]; dataKey="hour"
}} domain={[0, 23]}
labelFormatter={() => ''} ticks={[0, 4, 8, 12, 16, 20]}
/> tickFormatter={formatHour}
<Scatter reversed
data={data} {...rechartsTheme.yAxis}
shape={(props: unknown) => <HeatmapCell {...(props as Record<string, unknown>)} />} />
isAnimationActive={false} <Tooltip
/> contentStyle={rechartsTheme.tooltip.contentStyle}
</ScatterChart> labelStyle={rechartsTheme.tooltip.labelStyle}
</ResponsiveContainer> 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>
); );
} }