import React, { useRef, useEffect, useState } from "react"; import dayjs from "dayjs"; interface Props { weekNames?: string[]; monthNames?: string[]; panelColors?: string[]; values: { [date: string]: number }; until: string; dateFormat?: string; } const DEFAULT_WEEK_NAMES = ["L", "M", "M", "J", "V", "S", "D"]; // TODO i18n const DEFAULT_MONTH_NAMES = ["Jan", "Fév", "Mar", "Avr", "Mai", "Juin", "Juil", "Août", "Sep", "Oct", "Nov", "Déc"]; // TODO i18n const DEFAULT_PANEL_COLORS = [ "var(--color-base-300)", // 0: empty "var(--color-success)", // 1: low activity "var(--color-success-content)", // 2: medium activity "var(--color-success-content)", // 3: high activity "var(--color-success-content)", // 4: max activity ]; const DEFAULT_DATE_FORMAT = "YYYY-MM-DD"; const PANEL_SIZE = 18; const PANEL_MARGIN = 2; const WEEK_LABEL_WIDTH = 18; const MONTH_LABEL_HEIGHT = 18; const MIN_COLUMNS = 10; const MAX_COLUMNS = 53; export const WorkoutSessionHeatmap: React.FC = ({ weekNames = DEFAULT_WEEK_NAMES, monthNames = DEFAULT_MONTH_NAMES, panelColors = DEFAULT_PANEL_COLORS, values, until, dateFormat = DEFAULT_DATE_FORMAT, }) => { const containerRef = useRef(null); const [columns, setColumns] = useState(MAX_COLUMNS); const [hovered, setHovered] = useState(null); // responsive: adapt the number of columns to the width useEffect(() => { function updateColumns() { if (!containerRef.current) return; const width = containerRef.current.offsetWidth; const available = Math.floor((width - WEEK_LABEL_WIDTH) / (PANEL_SIZE + PANEL_MARGIN)); setColumns(Math.max(MIN_COLUMNS, Math.min(MAX_COLUMNS, available))); } updateColumns(); const observer = new window.ResizeObserver(updateColumns); if (containerRef.current) observer.observe(containerRef.current); return () => observer.disconnect(); }, []); // matrix of contributions function makeCalendarData(history: { [k: string]: number }, lastDay: string, columns: number) { const d = dayjs(lastDay, dateFormat); const lastWeekend = d.endOf("week"); const endDate = d.endOf("day"); const result: ({ value: number; month: number } | null)[][] = []; for (let i = 0; i < columns; i++) { result[i] = []; for (let j = 0; j < 7; j++) { const date = lastWeekend.subtract((columns - i - 1) * 7 + (6 - j), "day"); if (date <= endDate) { result[i][j] = { value: history[date.format(dateFormat)] || 0, month: date.month(), }; } else { result[i][j] = null; } } } return result; } const contributions = makeCalendarData(values, until, columns); const innerDom: React.ReactElement[] = []; for (let i = 0; i < columns; i++) { for (let j = 0; j < 7; j++) { const contribution = contributions[i][j]; if (contribution === null) continue; const x = WEEK_LABEL_WIDTH + (PANEL_SIZE + PANEL_MARGIN) * i; const y = MONTH_LABEL_HEIGHT + (PANEL_SIZE + PANEL_MARGIN) * j; const numOfColors = panelColors.length; const color = contribution.value >= numOfColors ? panelColors[numOfColors - 1] : panelColors[contribution.value]; // TODO i18n const d = dayjs(until, dateFormat) .endOf("week") .subtract((columns - i - 1) * 7 + (6 - j), "day"); const dateStr = d.format(dateFormat); const tooltip = contribution.value > 0 ? (
{dateStr} :
{contribution.value} workout{contribution.value > 1 ? "s" : ""}
) : (
{dateStr} :
No workout
); innerDom.push( setHovered({ i, j, tooltip, mouseX: e.clientX, mouseY: e.clientY })} onMouseLeave={() => setHovered(null)} onMouseMove={(e) => setHovered((prev) => prev && { ...prev, mouseX: e.clientX, mouseY: e.clientY })} rx={3} style={{ cursor: "pointer", stroke: hovered && hovered.i === i && hovered.j === j ? "#059669" : "transparent", strokeWidth: hovered && hovered.i === i && hovered.j === j ? 2 : 0, opacity: hovered && hovered.i === i && hovered.j === j ? 0.85 : 1, transition: "stroke 0.1s, opacity 0.1s", }} width={PANEL_SIZE} x={x} y={y} />, ); } } for (let i = 0; i < weekNames.length; i++) { const x = WEEK_LABEL_WIDTH / 2; const y = MONTH_LABEL_HEIGHT + (PANEL_SIZE + PANEL_MARGIN) * i + PANEL_SIZE / 2; innerDom.push( {weekNames[i]} , ); } let prevMonth = -1; for (let i = 0; i < columns; i++) { const c = contributions[i][0]; if (c === null) continue; if (columns > 1 && i === 0 && c.month !== contributions[i + 1][0]?.month) { continue; } if (c.month !== prevMonth) { const x = WEEK_LABEL_WIDTH + (PANEL_SIZE + PANEL_MARGIN) * i + PANEL_SIZE / 2; const y = MONTH_LABEL_HEIGHT / 1.5; innerDom.push( {monthNames[c.month]} , ); } prevMonth = c.month; } const tooltipNode = hovered ? (
{hovered.tooltip}
) : null; return (
{innerDom} {tooltipNode}
); };