158 lines
5 KiB
TypeScript
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';
|
|
});
|
|
}
|