import type { HeatmapData, HeatmapMode, DisplayMetric, ProjectOption } from './types'; import { createInitialState } from './state'; import type { HeatmapState } from './state'; import { getRenderer, registerRenderer } from './renderers/registry'; import { YearModeRenderer } from './renderers/year'; import { WeekModeRenderer } from './renderers/week'; import { renderStats } from './shared/stats'; import { createModeControl, createMetricControl } from './ui/controls'; // Register built-in renderers registerRenderer(new YearModeRenderer()); registerRenderer(new WeekModeRenderer()); 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) : []; const state: HeatmapState = createInitialState(weekStart); const onCellClick = (dateStr: string): void => { const daterange = `${dateStr} - ${dateStr}`; let url = `${timesheetUrl}?daterange=${encodeURIComponent(daterange)}`; if (state.filters.projectId) { url += `&projects[]=${state.filters.projectId}`; } 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); // Wire mode and metric controls into header const controlsContainer = document.getElementById('heatmap-controls'); if (controlsContainer) { const modeControl = createModeControl(state.mode, [ { key: 'year', label: 'Year' }, { key: 'week', label: 'Week' }, ], (mode) => { state.mode = mode as HeatmapMode; doRender(); }); const metricControl = createMetricControl(state.metric, (metric) => { state.metric = metric as DisplayMetric; doRender(); }); controlsContainer.appendChild(modeControl); controlsContainer.appendChild(metricControl); } const doRender = () => { if (!state.data) return; const renderer = getRenderer(state.mode); renderer.destroy?.(); renderer.render({ container: svgArea, data: state.data, state, config: { cellSize: 13, cellGap: 2, marginTop: 20, marginLeft: 30, marginBottom: 4 }, onCellClick, emptyMessage: state.filters.projectId ? 'No tracking data for this project' : undefined, }); if (state.mode === 'year') { renderStats(container, state.data.days); } else { const existingStats = container.querySelector('.heatmap-stats'); if (existingStats) existingStats.remove(); } if (state.mode === 'year') { svgArea.scrollLeft = svgArea.scrollWidth; } }; // Build filter dropdown (only if projects exist) if (projects.length > 0) { const filterDiv = document.createElement('div'); filterDiv.className = 'heatmap-filter'; const selectEl = document.createElement('select'); selectEl.className = 'form-select form-select-sm'; selectEl.setAttribute('aria-label', 'Filter by project'); const defaultOpt = document.createElement('option'); defaultOpt.value = ''; defaultOpt.textContent = 'All Projects'; selectEl.appendChild(defaultOpt); for (const p of projects) { const opt = document.createElement('option'); opt.value = String(p.id); opt.textContent = p.name; selectEl.appendChild(opt); } selectEl.addEventListener('change', () => { const val = selectEl.value; state.filters.projectId = 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 => { state.data = data; doRender(); }) .catch(err => { console.error('KimaiHeatmap: failed to load filtered data', err); }); }); filterDiv.appendChild(selectEl); wrapper.appendChild(filterDiv); } container.appendChild(wrapper); // Re-render on window resize (debounced) let resizeTimer: ReturnType; window.addEventListener('resize', () => { clearTimeout(resizeTimer); resizeTimer = setTimeout(() => { if (state.data) doRender(); }, 200); }); // Initial data fetch fetch(baseUrl) .then(res => { if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json() as Promise; }) .then(data => { state.data = data; doRender(); }) .catch(err => { console.error('KimaiHeatmap: failed to load data', err); svgArea.textContent = 'Failed to load heatmap data'; }); }