From cd1ac52f7e859e09dc5f942f9c2cb507409d5e4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christopher=20M=C3=BChl?= Date: Thu, 9 Apr 2026 11:15:50 +0200 Subject: [PATCH] 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 --- Resources/public/heatmap.css | 4 ++ Resources/public/heatmap.js | 60 +++++++++++++++++++++++- Resources/views/widget/heatmap.html.twig | 5 +- assets/src/heatmap.ts | 34 ++++++++++++-- assets/src/ui/controls.ts | 43 +++++++++++++++++ 5 files changed, 140 insertions(+), 6 deletions(-) create mode 100644 assets/src/ui/controls.ts diff --git a/Resources/public/heatmap.css b/Resources/public/heatmap.css index 1b5ddd6..f768574 100644 --- a/Resources/public/heatmap.css +++ b/Resources/public/heatmap.css @@ -93,3 +93,7 @@ color: var(--tblr-body-color); font-weight: 600; } + +.heatmap-week-cell { + cursor: default; +} diff --git a/Resources/public/heatmap.js b/Resources/public/heatmap.js index e1e2f8b..490b162 100644 --- a/Resources/public/heatmap.js +++ b/Resources/public/heatmap.js @@ -2314,6 +2314,39 @@ var KimaiHeatmap = (() => { 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 registerRenderer(new YearModeRenderer()); function init(container) { @@ -2341,6 +2374,22 @@ var KimaiHeatmap = (() => { const svgArea = document.createElement("div"); svgArea.className = "heatmap-svg-area"; 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 = () => { if (!state.data) return; const renderer = getRenderer(state.mode); @@ -2353,8 +2402,15 @@ var KimaiHeatmap = (() => { onCellClick, emptyMessage: state.filters.projectId ? "No tracking data for this project" : void 0 }); - renderStats(container, state.data.days); - svgArea.scrollLeft = svgArea.scrollWidth; + 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; + } }; if (projects.length > 0) { const filterDiv = document.createElement("div"); diff --git a/Resources/views/widget/heatmap.html.twig b/Resources/views/widget/heatmap.html.twig index 553bc42..5d5116d 100644 --- a/Resources/views/widget/heatmap.html.twig +++ b/Resources/views/widget/heatmap.html.twig @@ -1,6 +1,9 @@ {% embed '@theme/embeds/card.html.twig' with {'margin_bottom': 0} %} {% block box_title %} - {{ title }} +
+ {{ title }} +
+
{% endblock %} {% block box_body %} diff --git a/assets/src/heatmap.ts b/assets/src/heatmap.ts index fae61ed..36d6fe9 100644 --- a/assets/src/heatmap.ts +++ b/assets/src/heatmap.ts @@ -1,9 +1,10 @@ -import type { HeatmapData, ProjectOption } from './types'; +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 { renderStats } from './shared/stats'; +import { createModeControl, createMetricControl } from './ui/controls'; // Register built-in renderers registerRenderer(new YearModeRenderer()); @@ -40,6 +41,26 @@ export function init(container: HTMLElement): void { 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); @@ -52,8 +73,15 @@ export function init(container: HTMLElement): void { onCellClick, emptyMessage: state.filters.projectId ? 'No tracking data for this project' : undefined, }); - renderStats(container, state.data.days); - svgArea.scrollLeft = svgArea.scrollWidth; + 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) diff --git a/assets/src/ui/controls.ts b/assets/src/ui/controls.ts new file mode 100644 index 0000000..bf67a9e --- /dev/null +++ b/assets/src/ui/controls.ts @@ -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; +}