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 }}
+
{% 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;
+}