feat(07-01): mode switcher and metric toggle controls

- createModeControl/createMetricControl with Tabler nav-segmented
- Twig template heatmap-controls container in card header
- Conditional stats row and scroll for year mode only
- heatmap-week-cell CSS class
This commit is contained in:
Christopher Mühl 2026-04-09 11:15:50 +02:00
parent cab07eedc3
commit cd1ac52f7e
No known key found for this signature in database
GPG key ID: 925AC7D69955293F
5 changed files with 140 additions and 6 deletions

View file

@ -93,3 +93,7 @@
color: var(--tblr-body-color); color: var(--tblr-body-color);
font-weight: 600; font-weight: 600;
} }
.heatmap-week-cell {
cursor: default;
}

View file

@ -2314,6 +2314,39 @@ var KimaiHeatmap = (() => {
container.appendChild(statsDiv); container.appendChild(statsDiv);
} }
// assets/src/ui/controls.ts
function createModeControl(activeMode, modes, onChange) {
const nav = document.createElement("nav");
nav.className = "nav nav-segmented";
nav.setAttribute("role", "tablist");
for (const m of modes) {
const btn = document.createElement("button");
btn.className = m.key === activeMode ? "nav-link active" : "nav-link";
btn.setAttribute("role", "tab");
btn.setAttribute("aria-selected", m.key === activeMode ? "true" : "false");
btn.textContent = m.label;
btn.addEventListener("click", () => {
nav.querySelectorAll("button").forEach((b) => {
b.classList.remove("active");
b.setAttribute("aria-selected", "false");
});
btn.classList.add("active");
btn.setAttribute("aria-selected", "true");
onChange(m.key);
});
nav.appendChild(btn);
}
return nav;
}
function createMetricControl(activeMetric, onChange) {
const nav = createModeControl(activeMetric, [
{ key: "hours", label: "Hours" },
{ key: "count", label: "Count" }
], onChange);
nav.className = "nav nav-segmented nav-sm";
return nav;
}
// assets/src/heatmap.ts // assets/src/heatmap.ts
registerRenderer(new YearModeRenderer()); registerRenderer(new YearModeRenderer());
function init(container) { function init(container) {
@ -2341,6 +2374,22 @@ var KimaiHeatmap = (() => {
const svgArea = document.createElement("div"); const svgArea = document.createElement("div");
svgArea.className = "heatmap-svg-area"; svgArea.className = "heatmap-svg-area";
wrapper.appendChild(svgArea); wrapper.appendChild(svgArea);
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;
doRender();
});
const metricControl = createMetricControl(state.metric, (metric) => {
state.metric = metric;
doRender();
});
controlsContainer.appendChild(modeControl);
controlsContainer.appendChild(metricControl);
}
const doRender = () => { const doRender = () => {
if (!state.data) return; if (!state.data) return;
const renderer = getRenderer(state.mode); const renderer = getRenderer(state.mode);
@ -2353,8 +2402,15 @@ var KimaiHeatmap = (() => {
onCellClick, onCellClick,
emptyMessage: state.filters.projectId ? "No tracking data for this project" : void 0 emptyMessage: state.filters.projectId ? "No tracking data for this project" : void 0
}); });
renderStats(container, state.data.days); if (state.mode === "year") {
svgArea.scrollLeft = svgArea.scrollWidth; renderStats(container, state.data.days);
} else {
const existingStats = container.querySelector(".heatmap-stats");
if (existingStats) existingStats.remove();
}
if (state.mode === "year") {
svgArea.scrollLeft = svgArea.scrollWidth;
}
}; };
if (projects.length > 0) { if (projects.length > 0) {
const filterDiv = document.createElement("div"); const filterDiv = document.createElement("div");

View file

@ -1,6 +1,9 @@
{% embed '@theme/embeds/card.html.twig' with {'margin_bottom': 0} %} {% embed '@theme/embeds/card.html.twig' with {'margin_bottom': 0} %}
{% block box_title %} {% block box_title %}
{{ title }} <div style="display: flex; align-items: center; gap: 12px; flex-wrap: wrap;">
{{ title }}
<div id="heatmap-controls" style="display: flex; gap: 8px; margin-left: auto;"></div>
</div>
{% endblock %} {% endblock %}
{% block box_body %} {% block box_body %}
<link rel="stylesheet" href="{{ asset('bundles/kimaiheatmap/heatmap.css') }}"> <link rel="stylesheet" href="{{ asset('bundles/kimaiheatmap/heatmap.css') }}">

View file

@ -1,9 +1,10 @@
import type { HeatmapData, ProjectOption } from './types'; import type { HeatmapData, HeatmapMode, DisplayMetric, ProjectOption } from './types';
import { createInitialState } from './state'; import { createInitialState } from './state';
import type { HeatmapState } from './state'; import type { HeatmapState } from './state';
import { getRenderer, registerRenderer } from './renderers/registry'; import { getRenderer, registerRenderer } from './renderers/registry';
import { YearModeRenderer } from './renderers/year'; import { YearModeRenderer } from './renderers/year';
import { renderStats } from './shared/stats'; import { renderStats } from './shared/stats';
import { createModeControl, createMetricControl } from './ui/controls';
// Register built-in renderers // Register built-in renderers
registerRenderer(new YearModeRenderer()); registerRenderer(new YearModeRenderer());
@ -40,6 +41,26 @@ export function init(container: HTMLElement): void {
svgArea.className = 'heatmap-svg-area'; svgArea.className = 'heatmap-svg-area';
wrapper.appendChild(svgArea); 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 = () => { const doRender = () => {
if (!state.data) return; if (!state.data) return;
const renderer = getRenderer(state.mode); const renderer = getRenderer(state.mode);
@ -52,8 +73,15 @@ export function init(container: HTMLElement): void {
onCellClick, onCellClick,
emptyMessage: state.filters.projectId ? 'No tracking data for this project' : undefined, emptyMessage: state.filters.projectId ? 'No tracking data for this project' : undefined,
}); });
renderStats(container, state.data.days); if (state.mode === 'year') {
svgArea.scrollLeft = svgArea.scrollWidth; 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) // Build filter dropdown (only if projects exist)

43
assets/src/ui/controls.ts Normal file
View file

@ -0,0 +1,43 @@
export function createModeControl(
activeMode: string,
modes: Array<{ key: string; label: string }>,
onChange: (mode: string) => void,
): HTMLElement {
const nav = document.createElement('nav');
nav.className = 'nav nav-segmented';
nav.setAttribute('role', 'tablist');
for (const m of modes) {
const btn = document.createElement('button');
btn.className = m.key === activeMode ? 'nav-link active' : 'nav-link';
btn.setAttribute('role', 'tab');
btn.setAttribute('aria-selected', m.key === activeMode ? 'true' : 'false');
btn.textContent = m.label;
btn.addEventListener('click', () => {
nav.querySelectorAll('button').forEach((b) => {
b.classList.remove('active');
b.setAttribute('aria-selected', 'false');
});
btn.classList.add('active');
btn.setAttribute('aria-selected', 'true');
onChange(m.key);
});
nav.appendChild(btn);
}
return nav;
}
export function createMetricControl(
activeMetric: string,
onChange: (metric: string) => void,
): HTMLElement {
const nav = createModeControl(activeMetric, [
{ key: 'hours', label: 'Hours' },
{ key: 'count', label: 'Count' },
], onChange);
nav.className = 'nav nav-segmented nav-sm';
return nav;
}