kimai-plugin-heatmap/assets/src/renderers/week.ts
Christopher Mühl 12e734a911
fix(07): uniform control sizing, min cell 13px, center week view
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 16:56:34 +02:00

154 lines
5 KiB
TypeScript

import { select } from 'd3-selection';
import type { ModeRenderer, RenderContext } from './types';
import { createTooltip, showTooltip, hideTooltip } from '../shared/tooltip';
import { buildColorScale } from '../shared/color-scale';
import type { DayEntry } from '../types';
interface WeekdayAggregate {
dayIndex: number;
label: string;
shortLabel: string;
totalHours: number;
totalCount: number;
dayCount: number;
}
const WEEKDAY_NAMES_MONDAY = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
const WEEKDAY_NAMES_SUNDAY = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
const WEEKDAY_SHORT_MONDAY = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const WEEKDAY_SHORT_SUNDAY = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
function aggregateByWeekday(days: DayEntry[], weekStart: string): WeekdayAggregate[] {
const names = weekStart === 'sunday' ? WEEKDAY_NAMES_SUNDAY : WEEKDAY_NAMES_MONDAY;
const shorts = weekStart === 'sunday' ? WEEKDAY_SHORT_SUNDAY : WEEKDAY_SHORT_MONDAY;
const buckets: WeekdayAggregate[] = Array.from({ length: 7 }, (_, i) => ({
dayIndex: i,
label: names[i],
shortLabel: shorts[i],
totalHours: 0,
totalCount: 0,
dayCount: 0,
}));
const seenDates = new Map<number, Set<string>>();
for (const d of days) {
const jsDay = new Date(d.date + 'T00:00:00').getDay();
const idx = weekStart === 'sunday' ? jsDay : (jsDay + 6) % 7;
buckets[idx].totalHours += d.hours;
buckets[idx].totalCount += d.count;
if (!seenDates.has(idx)) seenDates.set(idx, new Set());
seenDates.get(idx)!.add(d.date);
}
for (const [idx, dates] of seenDates) {
buckets[idx].dayCount = dates.size;
}
return buckets;
}
export class WeekModeRenderer implements ModeRenderer {
readonly mode = 'week';
private tooltip: HTMLDivElement | null = null;
render(ctx: RenderContext): void {
ctx.container.innerHTML = '';
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;
}
this.destroy();
this.tooltip = createTooltip();
const aggregates = aggregateByWeekday(ctx.data.days, ctx.state.weekStart);
// Build color scale from aggregates that have data
const syntheticDays: DayEntry[] = aggregates
.filter(a => a.dayCount > 0)
.map(a => ({ date: '', hours: a.totalHours, count: a.totalCount }));
const colorScale = buildColorScale(syntheticDays, ctx.state.metric);
// Layout constants per UI-SPEC
const cellWidth = 60;
const cellHeight = 40;
const cellGap = 4;
const labelWidth = 50;
const labelHeight = 16;
const totalWidth = labelWidth + 7 * (cellWidth + cellGap);
const totalHeight = labelHeight + cellHeight;
const wrapper = document.createElement('div');
wrapper.style.maxWidth = `${totalWidth}px`;
wrapper.style.margin = '0 auto';
ctx.container.appendChild(wrapper);
const svg = select(wrapper)
.append('svg')
.attr('width', totalWidth)
.attr('height', totalHeight)
.attr('class', 'heatmap-svg');
const tooltip = this.tooltip;
const metric = ctx.state.metric;
// Render labels above cells
aggregates.forEach((agg, i) => {
svg.append('text')
.attr('class', 'heatmap-label')
.attr('x', labelWidth + i * (cellWidth + cellGap) + cellWidth / 2)
.attr('y', labelHeight - 2)
.attr('text-anchor', 'middle')
.text(agg.shortLabel);
});
// Render 7 weekday rects
aggregates.forEach((agg, i) => {
const hasData = agg.dayCount > 0;
const cls = 'heatmap-week-cell' + (hasData ? '' : ' heatmap-empty');
const fill = hasData
? colorScale(metric === 'hours' ? agg.totalHours : agg.totalCount)
: '';
svg.append('rect')
.attr('class', cls)
.attr('x', labelWidth + i * (cellWidth + cellGap))
.attr('y', labelHeight)
.attr('width', cellWidth)
.attr('height', cellHeight)
.attr('fill', fill)
.attr('rx', 2)
.attr('ry', 2)
.on('mouseenter', function (event: MouseEvent) {
let html: string;
if (hasData) {
if (metric === 'hours') {
html = `<strong>${agg.label}</strong><br>${agg.totalHours.toFixed(1)}h (${agg.totalCount} entries)`;
} else {
html = `<strong>${agg.label}</strong><br>${agg.totalCount} entries (${agg.totalHours.toFixed(1)}h)`;
}
} else {
html = `<strong>${agg.label}</strong><br>No tracked time`;
}
const rect = (event.target as SVGRectElement).getBoundingClientRect();
showTooltip(tooltip, html, rect, cellWidth);
})
.on('mouseleave', function () {
hideTooltip(tooltip);
});
});
}
destroy(): void {
this.tooltip?.remove();
this.tooltip = null;
}
}