feat(frontend): 12-month heatmap component

This commit is contained in:
2026-05-21 07:03:50 +02:00
parent ab382a2c62
commit 5df6b240d9

View File

@@ -0,0 +1,91 @@
import { useMemo } from 'react';
interface HeatmapPoint { day: string; sessions: number; attempts: number; }
export function Heatmap({ points }: { points: HeatmapPoint[] }) {
const grid = useMemo(() => {
const map = new Map<string, HeatmapPoint>();
for (const p of points) map.set(p.day, p);
const today = new Date();
today.setUTCHours(0, 0, 0, 0);
const lastSat = new Date(today);
while (lastSat.getUTCDay() !== 6) lastSat.setUTCDate(lastSat.getUTCDate() + 1);
const weeks: { date: Date; data?: HeatmapPoint }[][] = [];
const cursor = new Date(lastSat);
cursor.setUTCDate(cursor.getUTCDate() - 53 * 7 + 1);
for (let w = 0; w < 53; w++) {
const col: { date: Date; data?: HeatmapPoint }[] = [];
for (let d = 0; d < 7; d++) {
const key = `${cursor.getUTCFullYear()}-${cursor.getUTCMonth()}-${cursor.getUTCDate()}`;
col.push({ date: new Date(cursor), data: map.get(key) });
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
weeks.push(col);
}
return weeks;
}, [points]);
function colorFor(attempts: number): string {
if (attempts === 0) return 'bg-slate-100 dark:bg-slate-800';
if (attempts < 5) return 'bg-success-200 dark:bg-success-400/30';
if (attempts < 15) return 'bg-success-400 dark:bg-success-400/60';
if (attempts < 50) return 'bg-success-500 dark:bg-success-500/80';
return 'bg-success-700 dark:bg-success-500';
}
const monthLabels = useMemo(() => {
const labels: { col: number; text: string }[] = [];
let lastMonth = -1;
grid.forEach((col, i) => {
const m = col[0]!.date.getUTCMonth();
if (m !== lastMonth) {
labels.push({ col: i, text: ['jan','feb','mrt','apr','mei','jun','jul','aug','sep','okt','nov','dec'][m]! });
lastMonth = m;
}
});
return labels;
}, [grid]);
const todayKey = (() => {
const t = new Date();
return `${t.getUTCFullYear()}-${t.getUTCMonth()}-${t.getUTCDate()}`;
})();
return (
<div className="overflow-x-auto">
<div className="inline-block">
<div className="relative ml-8 mb-1 h-4 text-xs text-slate-500">
{monthLabels.map((m, i) => (
<span key={i} className="absolute" style={{ left: m.col * 16 }}>{m.text}</span>
))}
</div>
<div className="flex gap-1">
<div className="flex flex-col gap-1 pt-0">
{['Ma', '', 'Wo', '', 'Vr', '', ''].map((label, i) => (
<span key={i} className="h-3 w-6 text-[10px] leading-3 text-slate-500">{label}</span>
))}
</div>
<div className="flex gap-1">
{grid.map((col, i) => (
<div key={i} className="flex flex-col gap-1">
{col.map((cell, j) => {
const key = `${cell.date.getUTCFullYear()}-${cell.date.getUTCMonth()}-${cell.date.getUTCDate()}`;
const isToday = key === todayKey;
const a = cell.data?.attempts ?? 0;
return (
<div
key={j}
title={`${cell.date.toISOString().slice(0, 10)}${a} pogingen, ${cell.data?.sessions ?? 0} sessies`}
className={`h-3 w-3 rounded-sm ${colorFor(a)} ${isToday ? 'ring-1 ring-brand-600' : ''}`}
/>
);
})}
</div>
))}
</div>
</div>
</div>
</div>
);
}