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;
+ }
+}