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