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);
|
color: var(--tblr-body-color);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.heatmap-week-cell {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
});
|
});
|
||||||
|
if (state.mode === "year") {
|
||||||
renderStats(container, state.data.days);
|
renderStats(container, state.data.days);
|
||||||
|
} else {
|
||||||
|
const existingStats = container.querySelector(".heatmap-stats");
|
||||||
|
if (existingStats) existingStats.remove();
|
||||||
|
}
|
||||||
|
if (state.mode === "year") {
|
||||||
svgArea.scrollLeft = svgArea.scrollWidth;
|
svgArea.scrollLeft = svgArea.scrollWidth;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
if (projects.length > 0) {
|
if (projects.length > 0) {
|
||||||
const filterDiv = document.createElement("div");
|
const filterDiv = document.createElement("div");
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
<div style="display: flex; align-items: center; gap: 12px; flex-wrap: wrap;">
|
||||||
{{ title }}
|
{{ 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') }}">
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
if (state.mode === 'year') {
|
||||||
renderStats(container, state.data.days);
|
renderStats(container, state.data.days);
|
||||||
|
} else {
|
||||||
|
const existingStats = container.querySelector('.heatmap-stats');
|
||||||
|
if (existingStats) existingStats.remove();
|
||||||
|
}
|
||||||
|
if (state.mode === 'year') {
|
||||||
svgArea.scrollLeft = svgArea.scrollWidth;
|
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
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