154 lines
5 KiB
TypeScript
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;
|
|
}
|
|
}
|