From 9dde5291a961147f6871f55adb93851be583dae2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christopher=20M=C3=BChl?= Date: Thu, 9 Apr 2026 16:32:14 +0200 Subject: [PATCH] feat(07-02): week mode renderer with aggregation and tooltips --- Resources/public/heatmap.js | 90 +++++++++++++++++++++ assets/src/heatmap.ts | 2 + assets/src/renderers/week.ts | 149 +++++++++++++++++++++++++++++++++++ 3 files changed, 241 insertions(+) create mode 100644 assets/src/renderers/week.ts diff --git a/Resources/public/heatmap.js b/Resources/public/heatmap.js index 490b162..bd4cd68 100644 --- a/Resources/public/heatmap.js +++ b/Resources/public/heatmap.js @@ -2258,6 +2258,95 @@ var KimaiHeatmap = (() => { } }; + // assets/src/renderers/week.ts + var WEEKDAY_NAMES_MONDAY = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]; + var WEEKDAY_NAMES_SUNDAY = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + var WEEKDAY_SHORT_MONDAY = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; + var WEEKDAY_SHORT_SUNDAY = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + function aggregateByWeekday(days, weekStart) { + const names = weekStart === "sunday" ? WEEKDAY_NAMES_SUNDAY : WEEKDAY_NAMES_MONDAY; + const shorts = weekStart === "sunday" ? WEEKDAY_SHORT_SUNDAY : WEEKDAY_SHORT_MONDAY; + const buckets = Array.from({ length: 7 }, (_, i) => ({ + dayIndex: i, + label: names[i], + shortLabel: shorts[i], + totalHours: 0, + totalCount: 0, + dayCount: 0 + })); + const seenDates = /* @__PURE__ */ new Map(); + for (const d of days) { + const jsDay = (/* @__PURE__ */ 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, /* @__PURE__ */ new Set()); + seenDates.get(idx).add(d.date); + } + for (const [idx, dates] of seenDates) { + buckets[idx].dayCount = dates.size; + } + return buckets; + } + var WeekModeRenderer = class { + mode = "week"; + tooltip = null; + render(ctx) { + 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); + const syntheticDays = aggregates.filter((a) => a.dayCount > 0).map((a) => ({ date: "", hours: a.totalHours, count: a.totalCount })); + const colorScale = buildColorScale(syntheticDays, ctx.state.metric); + 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 svg = select_default2(ctx.container).append("svg").attr("width", totalWidth).attr("height", totalHeight).attr("class", "heatmap-svg"); + const tooltip = this.tooltip; + const metric = ctx.state.metric; + 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); + }); + 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) { + let html; + 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.getBoundingClientRect(); + showTooltip(tooltip, html, rect, cellWidth); + }).on("mouseleave", function() { + hideTooltip(tooltip); + }); + }); + } + destroy() { + this.tooltip?.remove(); + this.tooltip = null; + } + }; + // assets/src/shared/stats.ts var DATE_FORMAT2 = timeFormat("%Y-%m-%d"); var DISPLAY_FORMAT2 = timeFormat("%a, %b %-d, %Y"); @@ -2349,6 +2438,7 @@ var KimaiHeatmap = (() => { // assets/src/heatmap.ts registerRenderer(new YearModeRenderer()); + registerRenderer(new WeekModeRenderer()); function init(container) { const baseUrl = container.getAttribute("data-url"); if (!baseUrl) { diff --git a/assets/src/heatmap.ts b/assets/src/heatmap.ts index 36d6fe9..cc2afcb 100644 --- a/assets/src/heatmap.ts +++ b/assets/src/heatmap.ts @@ -3,11 +3,13 @@ import { createInitialState } from './state'; import type { HeatmapState } from './state'; import { getRenderer, registerRenderer } from './renderers/registry'; import { YearModeRenderer } from './renderers/year'; +import { WeekModeRenderer } from './renderers/week'; import { renderStats } from './shared/stats'; import { createModeControl, createMetricControl } from './ui/controls'; // Register built-in renderers registerRenderer(new YearModeRenderer()); +registerRenderer(new WeekModeRenderer()); export function init(container: HTMLElement): void { const baseUrl = container.getAttribute('data-url'); diff --git a/assets/src/renderers/week.ts b/assets/src/renderers/week.ts new file mode 100644 index 0000000..50d3ec2 --- /dev/null +++ b/assets/src/renderers/week.ts @@ -0,0 +1,149 @@ +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 svg = select(ctx.container) + .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; + } +}