feat(07-02): week mode renderer with aggregation and tooltips

This commit is contained in:
Christopher Mühl 2026-04-09 16:32:14 +02:00
parent 7fba98f89d
commit 9dde5291a9
No known key found for this signature in database
GPG key ID: 925AC7D69955293F
3 changed files with 241 additions and 0 deletions

View file

@ -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 = `<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.getBoundingClientRect();
showTooltip(tooltip, html, rect, cellWidth);
}).on("mouseleave", function() {
hideTooltip(tooltip);
});
});
}
destroy() {
this.tooltip?.remove();
this.tooltip = null;
}
};
// assets/src/shared/stats.ts // assets/src/shared/stats.ts
var DATE_FORMAT2 = timeFormat("%Y-%m-%d"); var DATE_FORMAT2 = timeFormat("%Y-%m-%d");
var DISPLAY_FORMAT2 = timeFormat("%a, %b %-d, %Y"); var DISPLAY_FORMAT2 = timeFormat("%a, %b %-d, %Y");
@ -2349,6 +2438,7 @@ var KimaiHeatmap = (() => {
// assets/src/heatmap.ts // assets/src/heatmap.ts
registerRenderer(new YearModeRenderer()); registerRenderer(new YearModeRenderer());
registerRenderer(new WeekModeRenderer());
function init(container) { function init(container) {
const baseUrl = container.getAttribute("data-url"); const baseUrl = container.getAttribute("data-url");
if (!baseUrl) { if (!baseUrl) {

View file

@ -3,11 +3,13 @@ import { createInitialState } from './state';
import type { HeatmapState } from './state'; import type { HeatmapState } from './state';
import { getRenderer, registerRenderer } from './renderers/registry'; import { getRenderer, registerRenderer } from './renderers/registry';
import { YearModeRenderer } from './renderers/year'; import { YearModeRenderer } from './renderers/year';
import { WeekModeRenderer } from './renderers/week';
import { renderStats } from './shared/stats'; import { renderStats } from './shared/stats';
import { createModeControl, createMetricControl } from './ui/controls'; import { createModeControl, createMetricControl } from './ui/controls';
// Register built-in renderers // Register built-in renderers
registerRenderer(new YearModeRenderer()); registerRenderer(new YearModeRenderer());
registerRenderer(new WeekModeRenderer());
export function init(container: HTMLElement): void { export function init(container: HTMLElement): void {
const baseUrl = container.getAttribute('data-url'); const baseUrl = container.getAttribute('data-url');

View file

@ -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<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 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 = `<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;
}
}