feat(frontend): 12-month heatmap component
This commit is contained in:
91
packages/frontend/src/components/Heatmap.tsx
Normal file
91
packages/frontend/src/components/Heatmap.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user