feat(04-01): add click navigation, project filter dropdown, and hover affordance

This commit is contained in:
Christopher Mühl 2026-04-08 15:31:03 +02:00
parent 3df754ea62
commit b57eb93097
No known key found for this signature in database
GPG key ID: 925AC7D69955293F
4 changed files with 2402 additions and 11 deletions

View file

@ -15,6 +15,13 @@
.heatmap-cell {
rx: 2;
ry: 2;
cursor: pointer;
transition: opacity 0.1s ease;
pointer-events: fill;
}
.heatmap-cell:hover {
opacity: 0.75;
}
.heatmap-empty {
@ -26,3 +33,26 @@
font-size: 10px;
font-family: var(--tblr-font-sans-serif);
}
/* Layout: heatmap + filter side by side */
.heatmap-wrapper {
display: flex;
align-items: flex-start;
gap: 16px;
}
.heatmap-wrapper .heatmap-svg-area {
flex: 1;
min-width: 0;
overflow-x: auto;
}
.heatmap-wrapper .heatmap-filter {
flex-shrink: 0;
padding-top: 20px;
}
.heatmap-filter select {
min-width: 140px;
max-width: 200px;
}

File diff suppressed because one or more lines are too long

View file

@ -3,7 +3,7 @@ import { scaleQuantize } from 'd3-scale';
import { timeMonday, timeDay, timeMonth } from 'd3-time';
import { timeFormat } from 'd3-time-format';
import { max } from 'd3-array';
import type { DayEntry, HeatmapData, HeatmapConfig } from './types';
import type { DayEntry, HeatmapData, HeatmapConfig, ProjectOption } from './types';
const DEFAULT_CONFIG: HeatmapConfig = {
cellSize: 13,
@ -85,12 +85,14 @@ export function renderHeatmap(
container: HTMLElement,
data: HeatmapData,
config: HeatmapConfig = DEFAULT_CONFIG,
onCellClick?: (dateStr: string) => void,
emptyMessage?: string,
): void {
container.innerHTML = '';
if (!data.days || data.days.length === 0) {
const msg = document.createElement('div');
msg.textContent = 'No tracking data available';
msg.textContent = emptyMessage || 'No tracking data available';
msg.style.padding = '1rem';
msg.style.color = 'var(--tblr-secondary, #6c757d)';
container.appendChild(msg);
@ -192,26 +194,100 @@ export function renderHeatmap(
})
.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 url = container.getAttribute('data-url');
if (!url) {
const baseUrl = container.getAttribute('data-url');
if (!baseUrl) {
console.error('KimaiHeatmap: missing data-url attribute');
return;
}
fetch(url)
.then((res) => {
const timesheetUrl = container.getAttribute('data-timesheet-url') || '/en/timesheet/';
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<HeatmapData>;
})
.then((data) => {
renderHeatmap(container, data);
.then(data => {
renderHeatmap(svgArea, data, DEFAULT_CONFIG, onCellClick, 'No tracking data for this project');
})
.catch((err) => {
.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<HeatmapData>;
})
.then(data => {
renderHeatmap(svgArea, data, DEFAULT_CONFIG, onCellClick);
})
.catch(err => {
console.error('KimaiHeatmap: failed to load data', err);
container.textContent = 'Failed to load heatmap data';
svgArea.textContent = 'Failed to load heatmap data';
});
}

View file

@ -19,3 +19,8 @@ export interface HeatmapConfig {
marginLeft: number;
marginBottom: number;
}
export interface ProjectOption {
id: number;
name: string;
}