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>(); 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 = `${agg.label}
${agg.totalHours.toFixed(1)}h (${agg.totalCount} entries)`; } else { html = `${agg.label}
${agg.totalCount} entries (${agg.totalHours.toFixed(1)}h)`; } } else { html = `${agg.label}
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; } }