394 lines
12 KiB
TypeScript
394 lines
12 KiB
TypeScript
import { select } from 'd3-selection';
|
|
import { scaleQuantize } from 'd3-scale';
|
|
import { timeMonday, timeSunday, timeDay, timeMonth } from 'd3-time';
|
|
import { timeFormat } from 'd3-time-format';
|
|
import { max } from 'd3-array';
|
|
import type { DayEntry, HeatmapData, HeatmapConfig, ProjectOption } from './types';
|
|
|
|
const DEFAULT_CONFIG: HeatmapConfig = {
|
|
cellSize: 13,
|
|
cellGap: 2,
|
|
marginTop: 20,
|
|
marginLeft: 30,
|
|
marginBottom: 4,
|
|
};
|
|
|
|
const FALLBACK_COLORS = ['#9be9a8', '#40c463', '#30a14e', '#216e39'];
|
|
const DAY_LABELS_MONDAY = ['Mon', '', 'Wed', '', 'Fri', '', ''];
|
|
const DAY_LABELS_SUNDAY = ['Sun', '', 'Tue', '', 'Thu', '', 'Sat'];
|
|
|
|
function getDayLabels(weekStart: string): string[] {
|
|
return weekStart === 'sunday' ? DAY_LABELS_SUNDAY : DAY_LABELS_MONDAY;
|
|
}
|
|
const MONTH_FORMAT = timeFormat('%b');
|
|
const DATE_FORMAT = timeFormat('%Y-%m-%d');
|
|
const DISPLAY_FORMAT = timeFormat('%a, %b %-d, %Y');
|
|
|
|
interface DayCell {
|
|
date: Date;
|
|
dateStr: string;
|
|
entry: DayEntry | null;
|
|
week: number;
|
|
day: number;
|
|
isWeekend: boolean;
|
|
}
|
|
|
|
function resolveColors(container: HTMLElement): string[] {
|
|
try {
|
|
const style = getComputedStyle(container);
|
|
const test = style.getPropertyValue('--tblr-bg-surface');
|
|
if (!test) return FALLBACK_COLORS;
|
|
return FALLBACK_COLORS; // Use hardcoded greens — Tabler doesn't expose a green scale via CSS vars
|
|
} catch {
|
|
return FALLBACK_COLORS;
|
|
}
|
|
}
|
|
|
|
function buildDateMap(days: DayEntry[]): Map<string, DayEntry> {
|
|
const map = new Map<string, DayEntry>();
|
|
for (const d of days) {
|
|
map.set(d.date, d);
|
|
}
|
|
return map;
|
|
}
|
|
|
|
function getWeekInterval(weekStart: string) {
|
|
return weekStart === 'sunday' ? timeSunday : timeMonday;
|
|
}
|
|
|
|
function generateCells(
|
|
begin: Date,
|
|
end: Date,
|
|
dateMap: Map<string, DayEntry>,
|
|
weekStart: string = 'monday',
|
|
): DayCell[] {
|
|
const weekInterval = getWeekInterval(weekStart);
|
|
const firstWeekDay = weekInterval.floor(begin);
|
|
const cells: DayCell[] = [];
|
|
let current = new Date(begin);
|
|
|
|
while (current <= end) {
|
|
const dateStr = DATE_FORMAT(current);
|
|
const weeksSinceStart = weekInterval.count(firstWeekDay, current);
|
|
const jsDay = current.getDay(); // 0=Sunday, 6=Saturday
|
|
const dayOfWeek = weekStart === 'sunday'
|
|
? jsDay // Sunday=0 already first
|
|
: (jsDay + 6) % 7; // Monday=0, Sunday=6
|
|
const isWeekend = jsDay === 0 || jsDay === 6;
|
|
|
|
cells.push({
|
|
date: new Date(current),
|
|
dateStr,
|
|
entry: dateMap.get(dateStr) || null,
|
|
week: weeksSinceStart,
|
|
day: dayOfWeek,
|
|
isWeekend,
|
|
});
|
|
|
|
current = timeDay.offset(current, 1);
|
|
}
|
|
|
|
return cells;
|
|
}
|
|
|
|
function createTooltip(): HTMLDivElement {
|
|
const tip = document.createElement('div');
|
|
tip.className = 'heatmap-tooltip';
|
|
tip.style.display = 'none';
|
|
return tip;
|
|
}
|
|
|
|
export function calculateStreak(days: DayEntry[]): number {
|
|
if (days.length === 0) return 0;
|
|
|
|
const tracked = new Set(
|
|
days.filter((d) => d.hours > 0).map((d) => d.date),
|
|
);
|
|
if (tracked.size === 0) return 0;
|
|
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
let current = new Date(today);
|
|
|
|
// If today has no entry, start from yesterday
|
|
if (!tracked.has(DATE_FORMAT(current))) {
|
|
current = timeDay.offset(current, -1);
|
|
}
|
|
|
|
let streak = 0;
|
|
while (tracked.has(DATE_FORMAT(current))) {
|
|
streak++;
|
|
current = timeDay.offset(current, -1);
|
|
}
|
|
|
|
return streak;
|
|
}
|
|
|
|
export interface HeatmapStats {
|
|
totalHours: number;
|
|
avgHours: number;
|
|
busiestDay: { date: string; hours: number } | null;
|
|
}
|
|
|
|
export function calculateStats(days: DayEntry[]): HeatmapStats {
|
|
const withEntries = days.filter((d) => d.hours > 0);
|
|
if (withEntries.length === 0) {
|
|
return { totalHours: 0, avgHours: 0, busiestDay: null };
|
|
}
|
|
|
|
const totalHours = Math.round(withEntries.reduce((sum, d) => sum + d.hours, 0) * 10) / 10;
|
|
const avgHours = Math.round((totalHours / withEntries.length) * 10) / 10;
|
|
const busiest = withEntries.reduce((best, d) => (d.hours > best.hours ? d : best));
|
|
|
|
return {
|
|
totalHours,
|
|
avgHours,
|
|
busiestDay: { date: busiest.date, hours: busiest.hours },
|
|
};
|
|
}
|
|
|
|
function renderStats(container: HTMLElement, days: DayEntry[]): void {
|
|
// Remove existing stats
|
|
const existing = container.querySelector('.heatmap-stats');
|
|
if (existing) existing.remove();
|
|
|
|
const streak = calculateStreak(days);
|
|
const stats = calculateStats(days);
|
|
|
|
const statsDiv = document.createElement('div');
|
|
statsDiv.className = 'heatmap-stats';
|
|
|
|
const parts: string[] = [];
|
|
parts.push(`<span>\u{1F525} <span class="stat-value">${streak}</span> days</span>`);
|
|
parts.push(`<span>Total: <span class="stat-value">${stats.totalHours}h</span></span>`);
|
|
parts.push(`<span>Avg: <span class="stat-value">${stats.avgHours}h/day</span></span>`);
|
|
|
|
if (stats.busiestDay) {
|
|
const d = new Date(stats.busiestDay.date + 'T00:00:00');
|
|
const label = DISPLAY_FORMAT(d);
|
|
parts.push(`<span>Busiest: <span class="stat-value">${label} \u2014 ${stats.busiestDay.hours.toFixed(1)}h</span></span>`);
|
|
}
|
|
|
|
statsDiv.innerHTML = parts.join('');
|
|
container.appendChild(statsDiv);
|
|
}
|
|
|
|
export function renderHeatmap(
|
|
container: HTMLElement,
|
|
data: HeatmapData,
|
|
config: HeatmapConfig = DEFAULT_CONFIG,
|
|
onCellClick?: (dateStr: string) => void,
|
|
emptyMessage?: string,
|
|
weekStart: string = 'monday',
|
|
): void {
|
|
container.innerHTML = '';
|
|
|
|
if (!data.days || data.days.length === 0) {
|
|
const msg = document.createElement('div');
|
|
msg.textContent = emptyMessage || 'No tracking data available';
|
|
msg.style.padding = '1rem';
|
|
msg.style.color = 'var(--tblr-secondary, #6c757d)';
|
|
container.appendChild(msg);
|
|
return;
|
|
}
|
|
|
|
const dateMap = buildDateMap(data.days);
|
|
const begin = new Date(data.range.begin);
|
|
const end = new Date(data.range.end);
|
|
const cells = generateCells(begin, end, dateMap, weekStart);
|
|
|
|
const maxHours = max(data.days, (d) => d.hours) || 1;
|
|
const colors = resolveColors(container);
|
|
|
|
const colorScale = scaleQuantize<string>()
|
|
.domain([0, maxHours])
|
|
.range(colors);
|
|
|
|
const { cellGap, marginTop, marginLeft, marginBottom } = config;
|
|
const numWeeks = (max(cells, (c) => c.week) ?? 0) + 1;
|
|
|
|
// Compute cell size to fill available width, capped at 16px
|
|
const containerWidth = container.clientWidth || 800;
|
|
const maxCellSize = 18;
|
|
const cellSize = Math.min(maxCellSize, Math.max(2, Math.floor((containerWidth - marginLeft) / numWeeks) - cellGap));
|
|
const step = cellSize + cellGap;
|
|
const svgWidth = marginLeft + numWeeks * step;
|
|
const svgHeight = marginTop + 7 * step + marginBottom;
|
|
|
|
const wrapper = document.createElement('div');
|
|
wrapper.style.maxWidth = `${svgWidth}px`;
|
|
wrapper.style.margin = '0 auto';
|
|
container.appendChild(wrapper);
|
|
|
|
const svg = select(wrapper)
|
|
.append('svg')
|
|
.attr('width', svgWidth)
|
|
.attr('height', svgHeight)
|
|
.attr('class', 'heatmap-svg');
|
|
|
|
// Month labels
|
|
const weekInterval = getWeekInterval(weekStart);
|
|
const months: { date: Date; week: number }[] = [];
|
|
const firstWeekDay = weekInterval.floor(begin);
|
|
timeMonth.range(timeMonth.ceil(begin), end).forEach((m) => {
|
|
months.push({
|
|
date: m,
|
|
week: weekInterval.count(firstWeekDay, m),
|
|
});
|
|
});
|
|
|
|
svg
|
|
.selectAll('.month-label')
|
|
.data(months)
|
|
.join('text')
|
|
.attr('class', 'heatmap-label month-label')
|
|
.attr('x', (d) => marginLeft + d.week * step)
|
|
.attr('y', marginTop - 6)
|
|
.text((d) => MONTH_FORMAT(d.date));
|
|
|
|
// Day labels
|
|
svg
|
|
.selectAll('.day-label')
|
|
.data(getDayLabels(weekStart))
|
|
.join('text')
|
|
.attr('class', 'heatmap-label day-label')
|
|
.attr('x', marginLeft - 6)
|
|
.attr('y', (_d, i) => marginTop + i * step + cellSize - 2)
|
|
.attr('text-anchor', 'end')
|
|
.text((d) => d);
|
|
|
|
// Tooltip (fixed positioning to escape overflow clipping)
|
|
// Remove any stale tooltip from previous renders
|
|
document.querySelectorAll('.heatmap-tooltip').forEach(el => el.remove());
|
|
const tooltip = createTooltip();
|
|
tooltip.style.position = 'fixed';
|
|
document.body.appendChild(tooltip);
|
|
|
|
// Cells
|
|
svg
|
|
.selectAll('.heatmap-cell')
|
|
.data(cells)
|
|
.join('rect')
|
|
.attr('class', (d) => {
|
|
let cls = 'heatmap-cell';
|
|
if (!d.entry) cls += ' heatmap-empty';
|
|
if (d.isWeekend) cls += ' heatmap-weekend';
|
|
return cls;
|
|
})
|
|
.attr('x', (d) => marginLeft + d.week * step)
|
|
.attr('y', (d) => marginTop + d.day * step)
|
|
.attr('width', cellSize)
|
|
.attr('height', cellSize)
|
|
.attr('fill', (d) => (d.entry ? colorScale(d.entry.hours) : ''))
|
|
.on('mouseenter', function (event: MouseEvent, d: DayCell) {
|
|
const hours = d.entry ? d.entry.hours.toFixed(1) : '0.0';
|
|
const count = d.entry ? d.entry.count : 0;
|
|
tooltip.innerHTML = `<strong>${DISPLAY_FORMAT(d.date)}</strong><br>${hours}h (${count} entries)`;
|
|
tooltip.style.display = 'block';
|
|
|
|
const rect = (event.target as SVGRectElement).getBoundingClientRect();
|
|
tooltip.style.left = `${rect.left + cellSize / 2}px`;
|
|
tooltip.style.top = `${rect.top - tooltip.offsetHeight - 8}px`;
|
|
})
|
|
.on('mouseleave', function () {
|
|
tooltip.style.display = 'none';
|
|
})
|
|
.on('click', function (_event: MouseEvent, d: DayCell) {
|
|
if (!onCellClick) return;
|
|
onCellClick(d.dateStr);
|
|
});
|
|
}
|
|
|
|
export function init(container: HTMLElement): void {
|
|
const baseUrl = container.getAttribute('data-url');
|
|
if (!baseUrl) {
|
|
console.error('KimaiHeatmap: missing data-url attribute');
|
|
return;
|
|
}
|
|
|
|
const timesheetUrl = container.getAttribute('data-timesheet-url') || '/en/timesheet/';
|
|
const weekStart = container.getAttribute('data-week-start') || 'monday';
|
|
const projectsJson = container.getAttribute('data-projects');
|
|
const projects: ProjectOption[] = projectsJson ? JSON.parse(projectsJson) : [];
|
|
|
|
let activeProjectId: number | null = null;
|
|
|
|
const onCellClick = (dateStr: string): void => {
|
|
const daterange = `${dateStr} - ${dateStr}`;
|
|
let url = `${timesheetUrl}?daterange=${encodeURIComponent(daterange)}`;
|
|
if (activeProjectId) {
|
|
url += `&projects[]=${activeProjectId}`;
|
|
}
|
|
window.location.href = url;
|
|
};
|
|
|
|
// Build wrapper layout
|
|
container.innerHTML = '';
|
|
const wrapper = document.createElement('div');
|
|
wrapper.className = 'heatmap-wrapper';
|
|
|
|
const svgArea = document.createElement('div');
|
|
svgArea.className = 'heatmap-svg-area';
|
|
wrapper.appendChild(svgArea);
|
|
|
|
// Build filter dropdown (only if projects exist)
|
|
if (projects.length > 0) {
|
|
const filterDiv = document.createElement('div');
|
|
filterDiv.className = 'heatmap-filter';
|
|
|
|
const select = document.createElement('select');
|
|
select.className = 'form-select form-select-sm';
|
|
select.setAttribute('aria-label', 'Filter by project');
|
|
|
|
const defaultOpt = document.createElement('option');
|
|
defaultOpt.value = '';
|
|
defaultOpt.textContent = 'All Projects';
|
|
select.appendChild(defaultOpt);
|
|
|
|
for (const p of projects) {
|
|
const opt = document.createElement('option');
|
|
opt.value = String(p.id);
|
|
opt.textContent = p.name;
|
|
select.appendChild(opt);
|
|
}
|
|
|
|
select.addEventListener('change', () => {
|
|
const val = select.value;
|
|
activeProjectId = val ? parseInt(val, 10) : null;
|
|
const fetchUrl = val ? `${baseUrl}?project=${val}` : baseUrl;
|
|
|
|
fetch(fetchUrl)
|
|
.then(res => {
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
return res.json() as Promise<HeatmapData>;
|
|
})
|
|
.then(data => {
|
|
renderHeatmap(svgArea, data, DEFAULT_CONFIG, onCellClick, 'No tracking data for this project', weekStart);
|
|
renderStats(wrapper, data.days);
|
|
})
|
|
.catch(err => {
|
|
console.error('KimaiHeatmap: failed to load filtered data', err);
|
|
});
|
|
});
|
|
|
|
filterDiv.appendChild(select);
|
|
wrapper.appendChild(filterDiv);
|
|
}
|
|
|
|
container.appendChild(wrapper);
|
|
|
|
// Initial data fetch
|
|
fetch(baseUrl)
|
|
.then(res => {
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
return res.json() as Promise<HeatmapData>;
|
|
})
|
|
.then(data => {
|
|
renderHeatmap(svgArea, data, DEFAULT_CONFIG, onCellClick, undefined, weekStart);
|
|
renderStats(wrapper, data.days);
|
|
})
|
|
.catch(err => {
|
|
console.error('KimaiHeatmap: failed to load data', err);
|
|
svgArea.textContent = 'Failed to load heatmap data';
|
|
});
|
|
}
|