feat(07-02): week mode renderer with aggregation and tooltips
This commit is contained in:
parent
7fba98f89d
commit
9dde5291a9
3 changed files with 241 additions and 0 deletions
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
149
assets/src/renderers/week.ts
Normal file
149
assets/src/renderers/week.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue