fix: layout polish — responsive filter, scroll, resize, min cell size

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Christopher Mühl 2026-04-08 17:14:48 +02:00
parent 843ac84805
commit 338ffb9c19
No known key found for this signature in database
GPG key ID: 925AC7D69955293F
5 changed files with 54 additions and 10 deletions

View file

@ -42,8 +42,8 @@
} }
.heatmap-wrapper .heatmap-svg-area { .heatmap-wrapper .heatmap-svg-area {
flex: 1; width: 100%;
min-width: 0; overflow-x: scroll;
} }
.heatmap-wrapper .heatmap-filter { .heatmap-wrapper .heatmap-filter {
@ -56,6 +56,22 @@
max-width: 200px; max-width: 200px;
} }
/* Small screens: filter above heatmap */
@media (max-width: 1330px) {
.heatmap-wrapper {
flex-direction: column-reverse;
}
.heatmap-wrapper .heatmap-filter {
padding-top: 0;
width: 100%;
}
.heatmap-filter select {
max-width: none;
}
}
.heatmap-weekend { .heatmap-weekend {
opacity: 0.8; opacity: 0.8;
} }
@ -67,7 +83,7 @@
.heatmap-stats { .heatmap-stats {
display: flex; display: flex;
gap: 16px; gap: 16px;
padding: 8px 0 0; padding: 12px 0 0;
font-size: 0.8125rem; font-size: 0.8125rem;
color: var(--tblr-secondary, #6c757d); color: var(--tblr-secondary, #6c757d);
flex-wrap: wrap; flex-wrap: wrap;

File diff suppressed because one or more lines are too long

View file

@ -209,8 +209,9 @@ export function renderHeatmap(
// Compute cell size to fill available width, capped at 16px // Compute cell size to fill available width, capped at 16px
const containerWidth = container.clientWidth || 800; const containerWidth = container.clientWidth || 800;
const maxCellSize = 18; const maxCellSize = 22;
const cellSize = Math.min(maxCellSize, Math.max(2, Math.floor((containerWidth - marginLeft) / numWeeks) - cellGap)); const minCellSize = 10;
const cellSize = Math.min(maxCellSize, Math.max(minCellSize, Math.floor((containerWidth - marginLeft) / numWeeks) - cellGap));
const step = cellSize + cellGap; const step = cellSize + cellGap;
const svgWidth = marginLeft + numWeeks * step; const svgWidth = marginLeft + numWeeks * step;
const svgHeight = marginTop + 7 * step + marginBottom; const svgHeight = marginTop + 7 * step + marginBottom;
@ -297,6 +298,7 @@ export function renderHeatmap(
if (!onCellClick) return; if (!onCellClick) return;
onCellClick(d.dateStr); onCellClick(d.dateStr);
}); });
} }
export function init(container: HTMLElement): void { export function init(container: HTMLElement): void {
@ -331,6 +333,16 @@ export function init(container: HTMLElement): void {
svgArea.className = 'heatmap-svg-area'; svgArea.className = 'heatmap-svg-area';
wrapper.appendChild(svgArea); wrapper.appendChild(svgArea);
// Shared state for current data (used by resize re-render and filter)
let currentData: HeatmapData | null = null;
const doRender = (data: HeatmapData, emptyMsg?: string) => {
currentData = data;
renderHeatmap(svgArea, data, DEFAULT_CONFIG, onCellClick, emptyMsg, weekStart);
renderStats(container, data.days);
svgArea.scrollLeft = svgArea.scrollWidth;
};
// Build filter dropdown (only if projects exist) // Build filter dropdown (only if projects exist)
if (projects.length > 0) { if (projects.length > 0) {
const filterDiv = document.createElement('div'); const filterDiv = document.createElement('div');
@ -363,8 +375,7 @@ export function init(container: HTMLElement): void {
return res.json() as Promise<HeatmapData>; return res.json() as Promise<HeatmapData>;
}) })
.then(data => { .then(data => {
renderHeatmap(svgArea, data, DEFAULT_CONFIG, onCellClick, 'No tracking data for this project', weekStart); doRender(data, 'No tracking data for this project');
renderStats(wrapper, data.days);
}) })
.catch(err => { .catch(err => {
console.error('KimaiHeatmap: failed to load filtered data', err); console.error('KimaiHeatmap: failed to load filtered data', err);
@ -377,6 +388,15 @@ export function init(container: HTMLElement): void {
container.appendChild(wrapper); container.appendChild(wrapper);
// Re-render on window resize (debounced)
let resizeTimer: ReturnType<typeof setTimeout>;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
if (currentData) doRender(currentData);
}, 200);
});
// Initial data fetch // Initial data fetch
fetch(baseUrl) fetch(baseUrl)
.then(res => { .then(res => {
@ -384,8 +404,7 @@ export function init(container: HTMLElement): void {
return res.json() as Promise<HeatmapData>; return res.json() as Promise<HeatmapData>;
}) })
.then(data => { .then(data => {
renderHeatmap(svgArea, data, DEFAULT_CONFIG, onCellClick, undefined, weekStart); doRender(data);
renderStats(wrapper, data.days);
}) })
.catch(err => { .catch(err => {
console.error('KimaiHeatmap: failed to load data', err); console.error('KimaiHeatmap: failed to load data', err);

8
assets/test/setup.ts Normal file
View file

@ -0,0 +1,8 @@
// jsdom doesn't provide ResizeObserver
if (typeof globalThis.ResizeObserver === 'undefined') {
globalThis.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
} as unknown as typeof globalThis.ResizeObserver;
}

View file

@ -4,5 +4,6 @@ export default defineConfig({
test: { test: {
environment: 'jsdom', environment: 'jsdom',
globals: true, globals: true,
setupFiles: ['./assets/test/setup.ts'],
}, },
}); });