kimai-plugin-heatmap/assets/src/heatmap.ts

158 lines
5 KiB
TypeScript

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<HeatmapData>;
})
.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<typeof setTimeout>;
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<HeatmapData>;
})
.then(data => {
state.data = data;
doRender();
})
.catch(err => {
console.error('KimaiHeatmap: failed to load data', err);
svgArea.textContent = 'Failed to load heatmap data';
});
}