feat(06-02): create YearModeRenderer and rewrite heatmap.ts as orchestrator

This commit is contained in:
Christopher Mühl 2026-04-09 09:45:49 +02:00
parent 7ee3f92b85
commit aab3915681
No known key found for this signature in database
GPG key ID: 925AC7D69955293F
4 changed files with 2583 additions and 324 deletions

File diff suppressed because one or more lines are too long

View file

@ -1,305 +1,12 @@
import { select } from 'd3-selection';
import { scaleQuantize } from 'd3-scale';
import { timeMonday, timeSunday, timeDay, timeMonth } from 'd3-time';
import { timeFormat } from 'd3-time-format';
import { max } from 'd3-array';
import type { DayEntry, HeatmapData, HeatmapConfig, ProjectOption } from './types';
import type { HeatmapData, 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';
const DEFAULT_CONFIG: HeatmapConfig = {
cellSize: 13,
cellGap: 2,
marginTop: 20,
marginLeft: 30,
marginBottom: 4,
};
const FALLBACK_COLORS = ['#9be9a8', '#40c463', '#30a14e', '#216e39'];
const DAY_LABELS_MONDAY = ['Mon', '', 'Wed', '', 'Fri', '', ''];
const DAY_LABELS_SUNDAY = ['Sun', '', 'Tue', '', 'Thu', '', 'Sat'];
function getDayLabels(weekStart: string): string[] {
return weekStart === 'sunday' ? DAY_LABELS_SUNDAY : DAY_LABELS_MONDAY;
}
const MONTH_FORMAT = timeFormat('%b');
const DATE_FORMAT = timeFormat('%Y-%m-%d');
const DISPLAY_FORMAT = timeFormat('%a, %b %-d, %Y');
interface DayCell {
date: Date;
dateStr: string;
entry: DayEntry | null;
week: number;
day: number;
isWeekend: boolean;
}
function resolveColors(container: HTMLElement): string[] {
try {
const style = getComputedStyle(container);
const test = style.getPropertyValue('--tblr-bg-surface');
if (!test) return FALLBACK_COLORS;
return FALLBACK_COLORS; // Use hardcoded greens — Tabler doesn't expose a green scale via CSS vars
} catch {
return FALLBACK_COLORS;
}
}
function buildDateMap(days: DayEntry[]): Map<string, DayEntry> {
const map = new Map<string, DayEntry>();
for (const d of days) {
map.set(d.date, d);
}
return map;
}
function getWeekInterval(weekStart: string) {
return weekStart === 'sunday' ? timeSunday : timeMonday;
}
function generateCells(
begin: Date,
end: Date,
dateMap: Map<string, DayEntry>,
weekStart: string = 'monday',
): DayCell[] {
const weekInterval = getWeekInterval(weekStart);
const firstWeekDay = weekInterval.floor(begin);
const cells: DayCell[] = [];
let current = new Date(begin);
while (current <= end) {
const dateStr = DATE_FORMAT(current);
const weeksSinceStart = weekInterval.count(firstWeekDay, current);
const jsDay = current.getDay(); // 0=Sunday, 6=Saturday
const dayOfWeek = weekStart === 'sunday'
? jsDay // Sunday=0 already first
: (jsDay + 6) % 7; // Monday=0, Sunday=6
const isWeekend = jsDay === 0 || jsDay === 6;
cells.push({
date: new Date(current),
dateStr,
entry: dateMap.get(dateStr) || null,
week: weeksSinceStart,
day: dayOfWeek,
isWeekend,
});
current = timeDay.offset(current, 1);
}
return cells;
}
function createTooltip(): HTMLDivElement {
const tip = document.createElement('div');
tip.className = 'heatmap-tooltip';
tip.style.display = 'none';
return tip;
}
export function calculateStreak(days: DayEntry[]): number {
if (days.length === 0) return 0;
const tracked = new Set(
days.filter((d) => d.hours > 0).map((d) => d.date),
);
if (tracked.size === 0) return 0;
const today = new Date();
today.setHours(0, 0, 0, 0);
let current = new Date(today);
// If today has no entry, start from yesterday
if (!tracked.has(DATE_FORMAT(current))) {
current = timeDay.offset(current, -1);
}
let streak = 0;
while (tracked.has(DATE_FORMAT(current))) {
streak++;
current = timeDay.offset(current, -1);
}
return streak;
}
export interface HeatmapStats {
totalHours: number;
avgHours: number;
busiestDay: { date: string; hours: number } | null;
}
export function calculateStats(days: DayEntry[]): HeatmapStats {
const withEntries = days.filter((d) => d.hours > 0);
if (withEntries.length === 0) {
return { totalHours: 0, avgHours: 0, busiestDay: null };
}
const totalHours = Math.round(withEntries.reduce((sum, d) => sum + d.hours, 0) * 10) / 10;
const avgHours = Math.round((totalHours / withEntries.length) * 10) / 10;
const busiest = withEntries.reduce((best, d) => (d.hours > best.hours ? d : best));
return {
totalHours,
avgHours,
busiestDay: { date: busiest.date, hours: busiest.hours },
};
}
function renderStats(container: HTMLElement, days: DayEntry[]): void {
// Remove existing stats
const existing = container.querySelector('.heatmap-stats');
if (existing) existing.remove();
const streak = calculateStreak(days);
const stats = calculateStats(days);
const statsDiv = document.createElement('div');
statsDiv.className = 'heatmap-stats';
const parts: string[] = [];
parts.push(`<span>\u{1F525} <span class="stat-value">${streak}</span> days</span>`);
parts.push(`<span>Total: <span class="stat-value">${stats.totalHours}h</span></span>`);
parts.push(`<span>Avg: <span class="stat-value">${stats.avgHours}h/day</span></span>`);
if (stats.busiestDay) {
const d = new Date(stats.busiestDay.date + 'T00:00:00');
const label = DISPLAY_FORMAT(d);
parts.push(`<span>Busiest: <span class="stat-value">${label} \u2014 ${stats.busiestDay.hours.toFixed(1)}h</span></span>`);
}
statsDiv.innerHTML = parts.join('');
container.appendChild(statsDiv);
}
export function renderHeatmap(
container: HTMLElement,
data: HeatmapData,
config: HeatmapConfig = DEFAULT_CONFIG,
onCellClick?: (dateStr: string) => void,
emptyMessage?: string,
weekStart: string = 'monday',
): void {
container.innerHTML = '';
if (!data.days || data.days.length === 0) {
const msg = document.createElement('div');
msg.textContent = emptyMessage || 'No tracking data available';
msg.style.padding = '1rem';
msg.style.color = 'var(--tblr-secondary, #6c757d)';
container.appendChild(msg);
return;
}
const dateMap = buildDateMap(data.days);
const begin = new Date(data.range.begin);
const end = new Date(data.range.end);
const cells = generateCells(begin, end, dateMap, weekStart);
const maxHours = max(data.days, (d) => d.hours) || 1;
const colors = resolveColors(container);
const colorScale = scaleQuantize<string>()
.domain([0, maxHours])
.range(colors);
const { cellGap, marginTop, marginLeft, marginBottom } = config;
const numWeeks = (max(cells, (c) => c.week) ?? 0) + 1;
// Compute cell size to fill available width, capped at 16px
const containerWidth = container.clientWidth || 800;
const maxCellSize = 22;
const minCellSize = 10;
const cellSize = Math.min(maxCellSize, Math.max(minCellSize, Math.floor((containerWidth - marginLeft) / numWeeks) - cellGap));
const step = cellSize + cellGap;
const svgWidth = marginLeft + numWeeks * step;
const svgHeight = marginTop + 7 * step + marginBottom;
const wrapper = document.createElement('div');
wrapper.style.maxWidth = `${svgWidth}px`;
wrapper.style.margin = '0 auto';
container.appendChild(wrapper);
const svg = select(wrapper)
.append('svg')
.attr('width', svgWidth)
.attr('height', svgHeight)
.attr('class', 'heatmap-svg');
// Month labels
const weekInterval = getWeekInterval(weekStart);
const months: { date: Date; week: number }[] = [];
const firstWeekDay = weekInterval.floor(begin);
timeMonth.range(timeMonth.ceil(begin), end).forEach((m) => {
months.push({
date: m,
week: weekInterval.count(firstWeekDay, m),
});
});
svg
.selectAll('.month-label')
.data(months)
.join('text')
.attr('class', 'heatmap-label month-label')
.attr('x', (d) => marginLeft + d.week * step)
.attr('y', marginTop - 6)
.text((d) => MONTH_FORMAT(d.date));
// Day labels
svg
.selectAll('.day-label')
.data(getDayLabels(weekStart))
.join('text')
.attr('class', 'heatmap-label day-label')
.attr('x', marginLeft - 6)
.attr('y', (_d, i) => marginTop + i * step + cellSize - 2)
.attr('text-anchor', 'end')
.text((d) => d);
// Tooltip (fixed positioning to escape overflow clipping)
// Remove any stale tooltip from previous renders
document.querySelectorAll('.heatmap-tooltip').forEach(el => el.remove());
const tooltip = createTooltip();
tooltip.style.position = 'fixed';
document.body.appendChild(tooltip);
// Cells
svg
.selectAll('.heatmap-cell')
.data(cells)
.join('rect')
.attr('class', (d) => {
let cls = 'heatmap-cell';
if (!d.entry) cls += ' heatmap-empty';
if (d.isWeekend) cls += ' heatmap-weekend';
return cls;
})
.attr('x', (d) => marginLeft + d.week * step)
.attr('y', (d) => marginTop + d.day * step)
.attr('width', cellSize)
.attr('height', cellSize)
.attr('fill', (d) => (d.entry ? colorScale(d.entry.hours) : ''))
.on('mouseenter', function (event: MouseEvent, d: DayCell) {
const hours = d.entry ? d.entry.hours.toFixed(1) : '0.0';
const count = d.entry ? d.entry.count : 0;
tooltip.innerHTML = `<strong>${DISPLAY_FORMAT(d.date)}</strong><br>${hours}h (${count} entries)`;
tooltip.style.display = 'block';
const rect = (event.target as SVGRectElement).getBoundingClientRect();
tooltip.style.left = `${rect.left + cellSize / 2}px`;
tooltip.style.top = `${rect.top - tooltip.offsetHeight - 8}px`;
})
.on('mouseleave', function () {
tooltip.style.display = 'none';
})
.on('click', function (_event: MouseEvent, d: DayCell) {
if (!onCellClick) return;
onCellClick(d.dateStr);
});
}
// Register built-in renderers
registerRenderer(new YearModeRenderer());
export function init(container: HTMLElement): void {
const baseUrl = container.getAttribute('data-url');
@ -313,13 +20,13 @@ export function init(container: HTMLElement): void {
const projectsJson = container.getAttribute('data-projects');
const projects: ProjectOption[] = projectsJson ? JSON.parse(projectsJson) : [];
let activeProjectId: number | null = null;
const state: HeatmapState = createInitialState(weekStart);
const onCellClick = (dateStr: string): void => {
const daterange = `${dateStr} - ${dateStr}`;
let url = `${timesheetUrl}?daterange=${encodeURIComponent(daterange)}`;
if (activeProjectId) {
url += `&projects[]=${activeProjectId}`;
if (state.filters.projectId) {
url += `&projects[]=${state.filters.projectId}`;
}
window.location.href = url;
};
@ -333,13 +40,19 @@ export function init(container: HTMLElement): void {
svgArea.className = 'heatmap-svg-area';
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);
const doRender = () => {
if (!state.data) return;
const renderer = getRenderer(state.mode);
renderer.destroy?.();
renderer.render({
container: svgArea,
data: state.data,
state,
config: { cellSize: 13, cellGap: 2, marginTop: 20, marginLeft: 30, marginBottom: 4 },
onCellClick,
emptyMessage: state.filters.projectId ? 'No tracking data for this project' : undefined,
});
renderStats(container, state.data.days);
svgArea.scrollLeft = svgArea.scrollWidth;
};
@ -348,25 +61,25 @@ export function init(container: HTMLElement): void {
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 selectEl = document.createElement('select');
selectEl.className = 'form-select form-select-sm';
selectEl.setAttribute('aria-label', 'Filter by project');
const defaultOpt = document.createElement('option');
defaultOpt.value = '';
defaultOpt.textContent = 'All Projects';
select.appendChild(defaultOpt);
selectEl.appendChild(defaultOpt);
for (const p of projects) {
const opt = document.createElement('option');
opt.value = String(p.id);
opt.textContent = p.name;
select.appendChild(opt);
selectEl.appendChild(opt);
}
select.addEventListener('change', () => {
const val = select.value;
activeProjectId = val ? parseInt(val, 10) : null;
selectEl.addEventListener('change', () => {
const val = selectEl.value;
state.filters.projectId = val ? parseInt(val, 10) : null;
const fetchUrl = val ? `${baseUrl}?project=${val}` : baseUrl;
fetch(fetchUrl)
@ -375,14 +88,15 @@ export function init(container: HTMLElement): void {
return res.json() as Promise<HeatmapData>;
})
.then(data => {
doRender(data, 'No tracking data for this project');
state.data = data;
doRender();
})
.catch(err => {
console.error('KimaiHeatmap: failed to load filtered data', err);
});
});
filterDiv.appendChild(select);
filterDiv.appendChild(selectEl);
wrapper.appendChild(filterDiv);
}
@ -393,7 +107,7 @@ export function init(container: HTMLElement): void {
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
if (currentData) doRender(currentData);
if (state.data) doRender();
}, 200);
});
@ -404,7 +118,8 @@ export function init(container: HTMLElement): void {
return res.json() as Promise<HeatmapData>;
})
.then(data => {
doRender(data);
state.data = data;
doRender();
})
.catch(err => {
console.error('KimaiHeatmap: failed to load data', err);

View file

@ -7,6 +7,7 @@ export interface RenderContext {
state: HeatmapState;
config: HeatmapConfig;
onCellClick?: (dateStr: string) => void;
emptyMessage?: string;
}
export interface ModeRenderer {

View file

@ -0,0 +1,131 @@
import { select } from 'd3-selection';
import { timeMonth } from 'd3-time';
import { max } from 'd3-array';
import type { ModeRenderer, RenderContext } from './types';
import { createTooltip, showTooltip, hideTooltip } from '../shared/tooltip';
import { buildColorScale } from '../shared/color-scale';
import {
buildDateMap, generateCells, getWeekInterval, getDayLabels,
MONTH_FORMAT, DISPLAY_FORMAT, type DayCell,
} from '../shared/date-utils';
export class YearModeRenderer implements ModeRenderer {
readonly mode = 'year';
private tooltip: HTMLDivElement | null = null;
render(ctx: RenderContext): void {
ctx.container.innerHTML = '';
// Handle empty data
if (!ctx.data.days || ctx.data.days.length === 0) {
const msg = document.createElement('div');
msg.textContent = ctx.emptyMessage || 'No tracking data available';
msg.style.padding = '1rem';
msg.style.color = 'var(--tblr-secondary, #6c757d)';
ctx.container.appendChild(msg);
return;
}
// Destroy previous tooltip
this.destroy();
this.tooltip = createTooltip();
const weekStart = ctx.state.weekStart;
const dateMap = buildDateMap(ctx.data.days);
const begin = new Date(ctx.data.range.begin);
const end = new Date(ctx.data.range.end);
const cells = generateCells(begin, end, dateMap, weekStart);
// Build color scale using state metric (hours vs count)
const colorScale = buildColorScale(ctx.data.days, ctx.state.metric);
const { cellGap, marginTop, marginLeft, marginBottom } = ctx.config;
const numWeeks = (max(cells, (c) => c.week) ?? 0) + 1;
// Compute cell size to fill available width, capped
const containerWidth = ctx.container.clientWidth || 800;
const maxCellSize = 22;
const minCellSize = 10;
const cellSize = Math.min(maxCellSize, Math.max(minCellSize, Math.floor((containerWidth - marginLeft) / numWeeks) - cellGap));
const step = cellSize + cellGap;
const svgWidth = marginLeft + numWeeks * step;
const svgHeight = marginTop + 7 * step + marginBottom;
const wrapper = document.createElement('div');
wrapper.style.maxWidth = `${svgWidth}px`;
wrapper.style.margin = '0 auto';
ctx.container.appendChild(wrapper);
const svg = select(wrapper)
.append('svg')
.attr('width', svgWidth)
.attr('height', svgHeight)
.attr('class', 'heatmap-svg');
// Month labels
const weekInterval = getWeekInterval(weekStart);
const months: { date: Date; week: number }[] = [];
const firstWeekDay = weekInterval.floor(begin);
timeMonth.range(timeMonth.ceil(begin), end).forEach((m) => {
months.push({ date: m, week: weekInterval.count(firstWeekDay, m) });
});
svg.selectAll('.month-label')
.data(months)
.join('text')
.attr('class', 'heatmap-label month-label')
.attr('x', (d) => marginLeft + d.week * step)
.attr('y', marginTop - 6)
.text((d) => MONTH_FORMAT(d.date));
// Day labels
svg.selectAll('.day-label')
.data(getDayLabels(weekStart))
.join('text')
.attr('class', 'heatmap-label day-label')
.attr('x', marginLeft - 6)
.attr('y', (_d, i) => marginTop + i * step + cellSize - 2)
.attr('text-anchor', 'end')
.text((d) => d);
// Cells with tooltip and click
const tooltip = this.tooltip;
svg.selectAll('.heatmap-cell')
.data(cells)
.join('rect')
.attr('class', (d) => {
let cls = 'heatmap-cell';
if (!d.entry) cls += ' heatmap-empty';
if (d.isWeekend) cls += ' heatmap-weekend';
return cls;
})
.attr('x', (d) => marginLeft + d.week * step)
.attr('y', (d) => marginTop + d.day * step)
.attr('width', cellSize)
.attr('height', cellSize)
.attr('fill', (d) => {
if (!d.entry) return '';
const val = ctx.state.metric === 'hours' ? d.entry.hours : d.entry.count;
return colorScale(val);
})
.on('mouseenter', function (event: MouseEvent, d: DayCell) {
const hours = d.entry ? d.entry.hours.toFixed(1) : '0.0';
const count = d.entry ? d.entry.count : 0;
const html = `<strong>${DISPLAY_FORMAT(d.date)}</strong><br>${hours}h (${count} entries)`;
const rect = (event.target as SVGRectElement).getBoundingClientRect();
showTooltip(tooltip, html, rect, cellSize);
})
.on('mouseleave', function () {
hideTooltip(tooltip);
})
.on('click', function (_event: MouseEvent, d: DayCell) {
if (!ctx.onCellClick) return;
ctx.onCellClick(d.dateStr);
});
}
destroy(): void {
this.tooltip?.remove();
this.tooltip = null;
}
}