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:
parent
cab07eedc3
commit
cd1ac52f7e
5 changed files with 140 additions and 6 deletions
|
|
@ -93,3 +93,7 @@
|
|||
color: var(--tblr-body-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.heatmap-week-cell {
|
||||
cursor: default;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
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");
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
{% embed '@theme/embeds/card.html.twig' with {'margin_bottom': 0} %}
|
||||
{% block box_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 %}
|
||||
{% block box_body %}
|
||||
<link rel="stylesheet" href="{{ asset('bundles/kimaiheatmap/heatmap.css') }}">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
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)
|
||||
|
|
|
|||
43
assets/src/ui/controls.ts
Normal file
43
assets/src/ui/controls.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue