feat(04-01): add click navigation, project filter dropdown, and hover affordance
This commit is contained in:
parent
3df754ea62
commit
b57eb93097
4 changed files with 2402 additions and 11 deletions
|
|
@ -15,6 +15,13 @@
|
|||
.heatmap-cell {
|
||||
rx: 2;
|
||||
ry: 2;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.1s ease;
|
||||
pointer-events: fill;
|
||||
}
|
||||
|
||||
.heatmap-cell:hover {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.heatmap-empty {
|
||||
|
|
@ -26,3 +33,26 @@
|
|||
font-size: 10px;
|
||||
font-family: var(--tblr-font-sans-serif);
|
||||
}
|
||||
|
||||
/* Layout: heatmap + filter side by side */
|
||||
.heatmap-wrapper {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.heatmap-wrapper .heatmap-svg-area {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.heatmap-wrapper .heatmap-filter {
|
||||
flex-shrink: 0;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.heatmap-filter select {
|
||||
min-width: 140px;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -3,7 +3,7 @@ import { scaleQuantize } from 'd3-scale';
|
|||
import { timeMonday, timeDay, timeMonth } from 'd3-time';
|
||||
import { timeFormat } from 'd3-time-format';
|
||||
import { max } from 'd3-array';
|
||||
import type { DayEntry, HeatmapData, HeatmapConfig } from './types';
|
||||
import type { DayEntry, HeatmapData, HeatmapConfig, ProjectOption } from './types';
|
||||
|
||||
const DEFAULT_CONFIG: HeatmapConfig = {
|
||||
cellSize: 13,
|
||||
|
|
@ -85,12 +85,14 @@ export function renderHeatmap(
|
|||
container: HTMLElement,
|
||||
data: HeatmapData,
|
||||
config: HeatmapConfig = DEFAULT_CONFIG,
|
||||
onCellClick?: (dateStr: string) => void,
|
||||
emptyMessage?: string,
|
||||
): void {
|
||||
container.innerHTML = '';
|
||||
|
||||
if (!data.days || data.days.length === 0) {
|
||||
const msg = document.createElement('div');
|
||||
msg.textContent = 'No tracking data available';
|
||||
msg.textContent = emptyMessage || 'No tracking data available';
|
||||
msg.style.padding = '1rem';
|
||||
msg.style.color = 'var(--tblr-secondary, #6c757d)';
|
||||
container.appendChild(msg);
|
||||
|
|
@ -192,26 +194,100 @@ export function renderHeatmap(
|
|||
})
|
||||
.on('mouseleave', function () {
|
||||
tooltip.style.display = 'none';
|
||||
})
|
||||
.on('click', function (_event: MouseEvent, d: DayCell) {
|
||||
if (!onCellClick) return;
|
||||
onCellClick(d.dateStr);
|
||||
});
|
||||
}
|
||||
|
||||
export function init(container: HTMLElement): void {
|
||||
const url = container.getAttribute('data-url');
|
||||
if (!url) {
|
||||
const baseUrl = container.getAttribute('data-url');
|
||||
if (!baseUrl) {
|
||||
console.error('KimaiHeatmap: missing data-url attribute');
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(url)
|
||||
.then((res) => {
|
||||
const timesheetUrl = container.getAttribute('data-timesheet-url') || '/en/timesheet/';
|
||||
const projectsJson = container.getAttribute('data-projects');
|
||||
const projects: ProjectOption[] = projectsJson ? JSON.parse(projectsJson) : [];
|
||||
|
||||
let activeProjectId: number | null = null;
|
||||
|
||||
const onCellClick = (dateStr: string): void => {
|
||||
const daterange = `${dateStr} - ${dateStr}`;
|
||||
let url = `${timesheetUrl}?daterange=${encodeURIComponent(daterange)}`;
|
||||
if (activeProjectId) {
|
||||
url += `&projects[]=${activeProjectId}`;
|
||||
}
|
||||
window.location.href = url;
|
||||
};
|
||||
|
||||
// Build wrapper layout
|
||||
container.innerHTML = '';
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'heatmap-wrapper';
|
||||
|
||||
const svgArea = document.createElement('div');
|
||||
svgArea.className = 'heatmap-svg-area';
|
||||
wrapper.appendChild(svgArea);
|
||||
|
||||
// Build filter dropdown (only if projects exist)
|
||||
if (projects.length > 0) {
|
||||
const filterDiv = document.createElement('div');
|
||||
filterDiv.className = 'heatmap-filter';
|
||||
|
||||
const select = document.createElement('select');
|
||||
select.className = 'form-select form-select-sm';
|
||||
select.setAttribute('aria-label', 'Filter by project');
|
||||
|
||||
const defaultOpt = document.createElement('option');
|
||||
defaultOpt.value = '';
|
||||
defaultOpt.textContent = 'All Projects';
|
||||
select.appendChild(defaultOpt);
|
||||
|
||||
for (const p of projects) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = String(p.id);
|
||||
opt.textContent = p.name;
|
||||
select.appendChild(opt);
|
||||
}
|
||||
|
||||
select.addEventListener('change', () => {
|
||||
const val = select.value;
|
||||
activeProjectId = val ? parseInt(val, 10) : null;
|
||||
const fetchUrl = val ? `${baseUrl}?project=${val}` : baseUrl;
|
||||
|
||||
fetch(fetchUrl)
|
||||
.then(res => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json() as Promise<HeatmapData>;
|
||||
})
|
||||
.then((data) => {
|
||||
renderHeatmap(container, data);
|
||||
.then(data => {
|
||||
renderHeatmap(svgArea, data, DEFAULT_CONFIG, onCellClick, 'No tracking data for this project');
|
||||
})
|
||||
.catch((err) => {
|
||||
.catch(err => {
|
||||
console.error('KimaiHeatmap: failed to load filtered data', err);
|
||||
});
|
||||
});
|
||||
|
||||
filterDiv.appendChild(select);
|
||||
wrapper.appendChild(filterDiv);
|
||||
}
|
||||
|
||||
container.appendChild(wrapper);
|
||||
|
||||
// Initial data fetch
|
||||
fetch(baseUrl)
|
||||
.then(res => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json() as Promise<HeatmapData>;
|
||||
})
|
||||
.then(data => {
|
||||
renderHeatmap(svgArea, data, DEFAULT_CONFIG, onCellClick);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('KimaiHeatmap: failed to load data', err);
|
||||
container.textContent = 'Failed to load heatmap data';
|
||||
svgArea.textContent = 'Failed to load heatmap data';
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,3 +19,8 @@ export interface HeatmapConfig {
|
|||
marginLeft: number;
|
||||
marginBottom: number;
|
||||
}
|
||||
|
||||
export interface ProjectOption {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue