import { select } from 'd3-selection'; import { scaleQuantize } from 'd3-scale'; import { timeMonday, timeSunday, timeDay, timeMonth } from 'd3-time'; import { timeFormat } from 'd3-time-format'; import { max } from 'd3-array'; import type { DayEntry, HeatmapData, HeatmapConfig, ProjectOption } from './types'; const DEFAULT_CONFIG: HeatmapConfig = { cellSize: 13, cellGap: 2, marginTop: 20, marginLeft: 30, marginBottom: 4, }; const FALLBACK_COLORS = ['#9be9a8', '#40c463', '#30a14e', '#216e39']; const DAY_LABELS_MONDAY = ['Mon', '', 'Wed', '', 'Fri', '', '']; const DAY_LABELS_SUNDAY = ['Sun', '', 'Tue', '', 'Thu', '', 'Sat']; function getDayLabels(weekStart: string): string[] { return weekStart === 'sunday' ? DAY_LABELS_SUNDAY : DAY_LABELS_MONDAY; } const MONTH_FORMAT = timeFormat('%b'); const DATE_FORMAT = timeFormat('%Y-%m-%d'); const DISPLAY_FORMAT = timeFormat('%a, %b %-d, %Y'); interface DayCell { date: Date; dateStr: string; entry: DayEntry | null; week: number; day: number; isWeekend: boolean; } function resolveColors(container: HTMLElement): string[] { try { const style = getComputedStyle(container); const test = style.getPropertyValue('--tblr-bg-surface'); if (!test) return FALLBACK_COLORS; return FALLBACK_COLORS; // Use hardcoded greens — Tabler doesn't expose a green scale via CSS vars } catch { return FALLBACK_COLORS; } } function buildDateMap(days: DayEntry[]): Map { const map = new Map(); for (const d of days) { map.set(d.date, d); } return map; } function getWeekInterval(weekStart: string) { return weekStart === 'sunday' ? timeSunday : timeMonday; } function generateCells( begin: Date, end: Date, dateMap: Map, weekStart: string = 'monday', ): DayCell[] { const weekInterval = getWeekInterval(weekStart); const firstWeekDay = weekInterval.floor(begin); const cells: DayCell[] = []; let current = new Date(begin); while (current <= end) { const dateStr = DATE_FORMAT(current); const weeksSinceStart = weekInterval.count(firstWeekDay, current); const jsDay = current.getDay(); // 0=Sunday, 6=Saturday const dayOfWeek = weekStart === 'sunday' ? jsDay // Sunday=0 already first : (jsDay + 6) % 7; // Monday=0, Sunday=6 const isWeekend = jsDay === 0 || jsDay === 6; cells.push({ date: new Date(current), dateStr, entry: dateMap.get(dateStr) || null, week: weeksSinceStart, day: dayOfWeek, isWeekend, }); current = timeDay.offset(current, 1); } return cells; } function createTooltip(): HTMLDivElement { const tip = document.createElement('div'); tip.className = 'heatmap-tooltip'; tip.style.display = 'none'; return tip; } export function calculateStreak(days: DayEntry[]): number { if (days.length === 0) return 0; const tracked = new Set( days.filter((d) => d.hours > 0).map((d) => d.date), ); if (tracked.size === 0) return 0; const today = new Date(); today.setHours(0, 0, 0, 0); let current = new Date(today); // If today has no entry, start from yesterday if (!tracked.has(DATE_FORMAT(current))) { current = timeDay.offset(current, -1); } let streak = 0; while (tracked.has(DATE_FORMAT(current))) { streak++; current = timeDay.offset(current, -1); } return streak; } export interface HeatmapStats { totalHours: number; avgHours: number; busiestDay: { date: string; hours: number } | null; } export function calculateStats(days: DayEntry[]): HeatmapStats { const withEntries = days.filter((d) => d.hours > 0); if (withEntries.length === 0) { return { totalHours: 0, avgHours: 0, busiestDay: null }; } const totalHours = Math.round(withEntries.reduce((sum, d) => sum + d.hours, 0) * 10) / 10; const avgHours = Math.round((totalHours / withEntries.length) * 10) / 10; const busiest = withEntries.reduce((best, d) => (d.hours > best.hours ? d : best)); return { totalHours, avgHours, busiestDay: { date: busiest.date, hours: busiest.hours }, }; } function renderStats(container: HTMLElement, days: DayEntry[]): void { // Remove existing stats const existing = container.querySelector('.heatmap-stats'); if (existing) existing.remove(); const streak = calculateStreak(days); const stats = calculateStats(days); const statsDiv = document.createElement('div'); statsDiv.className = 'heatmap-stats'; const parts: string[] = []; parts.push(`\u{1F525} ${streak} days`); parts.push(`Total: ${stats.totalHours}h`); parts.push(`Avg: ${stats.avgHours}h/day`); if (stats.busiestDay) { const d = new Date(stats.busiestDay.date + 'T00:00:00'); const label = DISPLAY_FORMAT(d); parts.push(`Busiest: ${label} \u2014 ${stats.busiestDay.hours.toFixed(1)}h`); } statsDiv.innerHTML = parts.join(''); container.appendChild(statsDiv); } export function renderHeatmap( container: HTMLElement, data: HeatmapData, config: HeatmapConfig = DEFAULT_CONFIG, onCellClick?: (dateStr: string) => void, emptyMessage?: string, weekStart: string = 'monday', ): void { container.innerHTML = ''; if (!data.days || data.days.length === 0) { const msg = document.createElement('div'); msg.textContent = emptyMessage || 'No tracking data available'; msg.style.padding = '1rem'; msg.style.color = 'var(--tblr-secondary, #6c757d)'; container.appendChild(msg); return; } const dateMap = buildDateMap(data.days); const begin = new Date(data.range.begin); const end = new Date(data.range.end); const cells = generateCells(begin, end, dateMap, weekStart); const maxHours = max(data.days, (d) => d.hours) || 1; const colors = resolveColors(container); const colorScale = scaleQuantize() .domain([0, maxHours]) .range(colors); const { cellGap, marginTop, marginLeft, marginBottom } = config; const numWeeks = (max(cells, (c) => c.week) ?? 0) + 1; // Compute cell size to fill available width, capped at 16px const containerWidth = container.clientWidth || 800; const maxCellSize = 18; const cellSize = Math.min(maxCellSize, Math.max(2, Math.floor((containerWidth - marginLeft) / numWeeks) - cellGap)); const step = cellSize + cellGap; const svgWidth = marginLeft + numWeeks * step; const svgHeight = marginTop + 7 * step + marginBottom; const wrapper = document.createElement('div'); wrapper.style.maxWidth = `${svgWidth}px`; wrapper.style.margin = '0 auto'; container.appendChild(wrapper); const svg = select(wrapper) .append('svg') .attr('width', svgWidth) .attr('height', svgHeight) .attr('class', 'heatmap-svg'); // Month labels const weekInterval = getWeekInterval(weekStart); const months: { date: Date; week: number }[] = []; const firstWeekDay = weekInterval.floor(begin); timeMonth.range(timeMonth.ceil(begin), end).forEach((m) => { months.push({ date: m, week: weekInterval.count(firstWeekDay, m), }); }); svg .selectAll('.month-label') .data(months) .join('text') .attr('class', 'heatmap-label month-label') .attr('x', (d) => marginLeft + d.week * step) .attr('y', marginTop - 6) .text((d) => MONTH_FORMAT(d.date)); // Day labels svg .selectAll('.day-label') .data(getDayLabels(weekStart)) .join('text') .attr('class', 'heatmap-label day-label') .attr('x', marginLeft - 6) .attr('y', (_d, i) => marginTop + i * step + cellSize - 2) .attr('text-anchor', 'end') .text((d) => d); // Tooltip (fixed positioning to escape overflow clipping) // Remove any stale tooltip from previous renders document.querySelectorAll('.heatmap-tooltip').forEach(el => el.remove()); const tooltip = createTooltip(); tooltip.style.position = 'fixed'; document.body.appendChild(tooltip); // Cells svg .selectAll('.heatmap-cell') .data(cells) .join('rect') .attr('class', (d) => { let cls = 'heatmap-cell'; if (!d.entry) cls += ' heatmap-empty'; if (d.isWeekend) cls += ' heatmap-weekend'; return cls; }) .attr('x', (d) => marginLeft + d.week * step) .attr('y', (d) => marginTop + d.day * step) .attr('width', cellSize) .attr('height', cellSize) .attr('fill', (d) => (d.entry ? colorScale(d.entry.hours) : '')) .on('mouseenter', function (event: MouseEvent, d: DayCell) { const hours = d.entry ? d.entry.hours.toFixed(1) : '0.0'; const count = d.entry ? d.entry.count : 0; tooltip.innerHTML = `${DISPLAY_FORMAT(d.date)}
${hours}h (${count} entries)`; tooltip.style.display = 'block'; const rect = (event.target as SVGRectElement).getBoundingClientRect(); tooltip.style.left = `${rect.left + cellSize / 2}px`; tooltip.style.top = `${rect.top - tooltip.offsetHeight - 8}px`; }) .on('mouseleave', function () { tooltip.style.display = 'none'; }) .on('click', function (_event: MouseEvent, d: DayCell) { if (!onCellClick) return; onCellClick(d.dateStr); }); } export function init(container: HTMLElement): void { const baseUrl = container.getAttribute('data-url'); if (!baseUrl) { console.error('KimaiHeatmap: missing data-url attribute'); return; } const timesheetUrl = container.getAttribute('data-timesheet-url') || '/en/timesheet/'; const weekStart = container.getAttribute('data-week-start') || 'monday'; const projectsJson = container.getAttribute('data-projects'); const projects: ProjectOption[] = projectsJson ? JSON.parse(projectsJson) : []; let activeProjectId: number | null = null; const onCellClick = (dateStr: string): void => { const daterange = `${dateStr} - ${dateStr}`; let url = `${timesheetUrl}?daterange=${encodeURIComponent(daterange)}`; if (activeProjectId) { url += `&projects[]=${activeProjectId}`; } window.location.href = url; }; // Build wrapper layout container.innerHTML = ''; const wrapper = document.createElement('div'); wrapper.className = 'heatmap-wrapper'; const svgArea = document.createElement('div'); svgArea.className = 'heatmap-svg-area'; wrapper.appendChild(svgArea); // Build filter dropdown (only if projects exist) if (projects.length > 0) { const filterDiv = document.createElement('div'); filterDiv.className = 'heatmap-filter'; const select = document.createElement('select'); select.className = 'form-select form-select-sm'; select.setAttribute('aria-label', 'Filter by project'); const defaultOpt = document.createElement('option'); defaultOpt.value = ''; defaultOpt.textContent = 'All Projects'; select.appendChild(defaultOpt); for (const p of projects) { const opt = document.createElement('option'); opt.value = String(p.id); opt.textContent = p.name; select.appendChild(opt); } select.addEventListener('change', () => { const val = select.value; activeProjectId = val ? parseInt(val, 10) : null; const fetchUrl = val ? `${baseUrl}?project=${val}` : baseUrl; fetch(fetchUrl) .then(res => { if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json() as Promise; }) .then(data => { renderHeatmap(svgArea, data, DEFAULT_CONFIG, onCellClick, 'No tracking data for this project', weekStart); renderStats(wrapper, data.days); }) .catch(err => { console.error('KimaiHeatmap: failed to load filtered data', err); }); }); filterDiv.appendChild(select); wrapper.appendChild(filterDiv); } container.appendChild(wrapper); // Initial data fetch fetch(baseUrl) .then(res => { if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json() as Promise; }) .then(data => { renderHeatmap(svgArea, data, DEFAULT_CONFIG, onCellClick, undefined, weekStart); renderStats(wrapper, data.days); }) .catch(err => { console.error('KimaiHeatmap: failed to load data', err); svgArea.textContent = 'Failed to load heatmap data'; }); }