feat: add streak, stats, weekend styling, week-start preference

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Christopher Mühl 2026-04-08 16:05:44 +02:00
parent c79834cbea
commit d0dc64f333
No known key found for this signature in database
GPG key ID: 925AC7D69955293F
7 changed files with 304 additions and 2296 deletions

View file

@ -55,3 +55,25 @@
min-width: 140px; min-width: 140px;
max-width: 200px; max-width: 200px;
} }
.heatmap-weekend {
opacity: 0.8;
}
.heatmap-weekend:hover {
opacity: 0.65;
}
.heatmap-stats {
display: flex;
gap: 16px;
padding: 8px 0 0;
font-size: 0.8125rem;
color: var(--tblr-secondary, #6c757d);
flex-wrap: wrap;
}
.heatmap-stats .stat-value {
color: var(--tblr-body-color);
font-weight: 600;
}

File diff suppressed because one or more lines are too long

View file

@ -8,6 +8,7 @@
data-url="{{ path('heatmap_data') }}" data-url="{{ path('heatmap_data') }}"
data-projects="{{ data.projects|json_encode }}" data-projects="{{ data.projects|json_encode }}"
data-timesheet-url="{{ path('timesheet') }}" data-timesheet-url="{{ path('timesheet') }}"
data-week-start="{{ data.weekStart }}"
style="min-height: 150px;"> style="min-height: 150px;">
</div> </div>
<script src="{{ asset('bundles/kimaiheatmap/heatmap.js') }}"></script> <script src="{{ asset('bundles/kimaiheatmap/heatmap.js') }}"></script>

View file

@ -43,6 +43,7 @@ class HeatmapWidget extends AbstractWidget
return [ return [
'projects' => $this->service->getUserProjects($user), 'projects' => $this->service->getUserProjects($user),
'weekStart' => $user->getFirstDayOfWeek(),
]; ];
} }

View file

@ -1,6 +1,6 @@
import { select } from 'd3-selection'; import { select } from 'd3-selection';
import { scaleQuantize } from 'd3-scale'; import { scaleQuantize } from 'd3-scale';
import { timeMonday, timeDay, timeMonth } from 'd3-time'; import { timeMonday, timeSunday, timeDay, timeMonth } from 'd3-time';
import { timeFormat } from 'd3-time-format'; import { timeFormat } from 'd3-time-format';
import { max } from 'd3-array'; import { max } from 'd3-array';
import type { DayEntry, HeatmapData, HeatmapConfig, ProjectOption } from './types'; import type { DayEntry, HeatmapData, HeatmapConfig, ProjectOption } from './types';
@ -14,7 +14,12 @@ const DEFAULT_CONFIG: HeatmapConfig = {
}; };
const FALLBACK_COLORS = ['#9be9a8', '#40c463', '#30a14e', '#216e39']; const FALLBACK_COLORS = ['#9be9a8', '#40c463', '#30a14e', '#216e39'];
const DAY_LABELS = ['Mon', '', 'Wed', '', 'Fri', '', '']; 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 MONTH_FORMAT = timeFormat('%b');
const DATE_FORMAT = timeFormat('%Y-%m-%d'); const DATE_FORMAT = timeFormat('%Y-%m-%d');
const DISPLAY_FORMAT = timeFormat('%a, %b %-d, %Y'); const DISPLAY_FORMAT = timeFormat('%a, %b %-d, %Y');
@ -25,6 +30,7 @@ interface DayCell {
entry: DayEntry | null; entry: DayEntry | null;
week: number; week: number;
day: number; day: number;
isWeekend: boolean;
} }
function resolveColors(container: HTMLElement): string[] { function resolveColors(container: HTMLElement): string[] {
@ -46,19 +52,29 @@ function buildDateMap(days: DayEntry[]): Map<string, DayEntry> {
return map; return map;
} }
function getWeekInterval(weekStart: string) {
return weekStart === 'sunday' ? timeSunday : timeMonday;
}
function generateCells( function generateCells(
begin: Date, begin: Date,
end: Date, end: Date,
dateMap: Map<string, DayEntry>, dateMap: Map<string, DayEntry>,
weekStart: string = 'monday',
): DayCell[] { ): DayCell[] {
const firstMonday = timeMonday.floor(begin); const weekInterval = getWeekInterval(weekStart);
const firstWeekDay = weekInterval.floor(begin);
const cells: DayCell[] = []; const cells: DayCell[] = [];
let current = new Date(begin); let current = new Date(begin);
while (current <= end) { while (current <= end) {
const dateStr = DATE_FORMAT(current); const dateStr = DATE_FORMAT(current);
const weeksSinceStart = timeMonday.count(firstMonday, current); const weeksSinceStart = weekInterval.count(firstWeekDay, current);
const dayOfWeek = (current.getDay() + 6) % 7; // Monday=0, Sunday=6 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({ cells.push({
date: new Date(current), date: new Date(current),
@ -66,6 +82,7 @@ function generateCells(
entry: dateMap.get(dateStr) || null, entry: dateMap.get(dateStr) || null,
week: weeksSinceStart, week: weeksSinceStart,
day: dayOfWeek, day: dayOfWeek,
isWeekend,
}); });
current = timeDay.offset(current, 1); current = timeDay.offset(current, 1);
@ -81,12 +98,88 @@ function createTooltip(): HTMLDivElement {
return tip; 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( export function renderHeatmap(
container: HTMLElement, container: HTMLElement,
data: HeatmapData, data: HeatmapData,
config: HeatmapConfig = DEFAULT_CONFIG, config: HeatmapConfig = DEFAULT_CONFIG,
onCellClick?: (dateStr: string) => void, onCellClick?: (dateStr: string) => void,
emptyMessage?: string, emptyMessage?: string,
weekStart: string = 'monday',
): void { ): void {
container.innerHTML = ''; container.innerHTML = '';
@ -102,7 +195,7 @@ export function renderHeatmap(
const dateMap = buildDateMap(data.days); const dateMap = buildDateMap(data.days);
const begin = new Date(data.range.begin); const begin = new Date(data.range.begin);
const end = new Date(data.range.end); const end = new Date(data.range.end);
const cells = generateCells(begin, end, dateMap); const cells = generateCells(begin, end, dateMap, weekStart);
const maxHours = max(data.days, (d) => d.hours) || 1; const maxHours = max(data.days, (d) => d.hours) || 1;
const colors = resolveColors(container); const colors = resolveColors(container);
@ -134,12 +227,13 @@ export function renderHeatmap(
.attr('class', 'heatmap-svg'); .attr('class', 'heatmap-svg');
// Month labels // Month labels
const weekInterval = getWeekInterval(weekStart);
const months: { date: Date; week: number }[] = []; const months: { date: Date; week: number }[] = [];
const firstMonday = timeMonday.floor(begin); const firstWeekDay = weekInterval.floor(begin);
timeMonth.range(timeMonth.ceil(begin), end).forEach((m) => { timeMonth.range(timeMonth.ceil(begin), end).forEach((m) => {
months.push({ months.push({
date: m, date: m,
week: timeMonday.count(firstMonday, m), week: weekInterval.count(firstWeekDay, m),
}); });
}); });
@ -152,10 +246,10 @@ export function renderHeatmap(
.attr('y', marginTop - 6) .attr('y', marginTop - 6)
.text((d) => MONTH_FORMAT(d.date)); .text((d) => MONTH_FORMAT(d.date));
// Day labels (Mon, Wed, Fri) // Day labels
svg svg
.selectAll('.day-label') .selectAll('.day-label')
.data(DAY_LABELS) .data(getDayLabels(weekStart))
.join('text') .join('text')
.attr('class', 'heatmap-label day-label') .attr('class', 'heatmap-label day-label')
.attr('x', marginLeft - 6) .attr('x', marginLeft - 6)
@ -175,9 +269,12 @@ export function renderHeatmap(
.selectAll('.heatmap-cell') .selectAll('.heatmap-cell')
.data(cells) .data(cells)
.join('rect') .join('rect')
.attr('class', (d) => .attr('class', (d) => {
d.entry ? 'heatmap-cell' : 'heatmap-cell heatmap-empty', 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('x', (d) => marginLeft + d.week * step)
.attr('y', (d) => marginTop + d.day * step) .attr('y', (d) => marginTop + d.day * step)
.attr('width', cellSize) .attr('width', cellSize)
@ -210,6 +307,7 @@ export function init(container: HTMLElement): void {
} }
const timesheetUrl = container.getAttribute('data-timesheet-url') || '/en/timesheet/'; const timesheetUrl = container.getAttribute('data-timesheet-url') || '/en/timesheet/';
const weekStart = container.getAttribute('data-week-start') || 'monday';
const projectsJson = container.getAttribute('data-projects'); const projectsJson = container.getAttribute('data-projects');
const projects: ProjectOption[] = projectsJson ? JSON.parse(projectsJson) : []; const projects: ProjectOption[] = projectsJson ? JSON.parse(projectsJson) : [];
@ -265,7 +363,8 @@ export function init(container: HTMLElement): void {
return res.json() as Promise<HeatmapData>; return res.json() as Promise<HeatmapData>;
}) })
.then(data => { .then(data => {
renderHeatmap(svgArea, data, DEFAULT_CONFIG, onCellClick, 'No tracking data for this project'); renderHeatmap(svgArea, data, DEFAULT_CONFIG, onCellClick, 'No tracking data for this project', weekStart);
renderStats(wrapper, data.days);
}) })
.catch(err => { .catch(err => {
console.error('KimaiHeatmap: failed to load filtered data', err); console.error('KimaiHeatmap: failed to load filtered data', err);
@ -285,7 +384,8 @@ export function init(container: HTMLElement): void {
return res.json() as Promise<HeatmapData>; return res.json() as Promise<HeatmapData>;
}) })
.then(data => { .then(data => {
renderHeatmap(svgArea, data, DEFAULT_CONFIG, onCellClick); renderHeatmap(svgArea, data, DEFAULT_CONFIG, onCellClick, undefined, weekStart);
renderStats(wrapper, data.days);
}) })
.catch(err => { .catch(err => {
console.error('KimaiHeatmap: failed to load data', err); console.error('KimaiHeatmap: failed to load data', err);

View file

@ -133,4 +133,40 @@ describe('renderHeatmap', () => {
const rects = container.querySelectorAll('rect.heatmap-cell'); const rects = container.querySelectorAll('rect.heatmap-cell');
expect(rects.length).toBe(7); expect(rects.length).toBe(7);
}); });
it('applies heatmap-weekend class to Saturday and Sunday cells', () => {
// 2025-01-04 is Saturday, 2025-01-05 is Sunday
const data: HeatmapData = {
days: [
{ date: '2025-01-04', hours: 2.0, count: 1 },
],
range: { begin: '2025-01-01', end: '2025-01-07' },
};
renderHeatmap(container, data);
const weekendRects = container.querySelectorAll('rect.heatmap-weekend');
// Jan 1 (Wed) through Jan 7 (Tue): Sat Jan 4 + Sun Jan 5 = 2 weekend cells
expect(weekendRects.length).toBe(2);
});
it('does not apply heatmap-weekend class to weekday cells', () => {
const data: HeatmapData = {
days: [
{ date: '2025-01-06', hours: 2.0, count: 1 },
],
range: { begin: '2025-01-06', end: '2025-01-10' },
};
renderHeatmap(container, data);
const weekendRects = container.querySelectorAll('rect.heatmap-weekend');
// Mon-Fri, no weekends
expect(weekendRects.length).toBe(0);
});
it('renders day labels for Sunday week start', () => {
renderHeatmap(container, makeMockData(), undefined, undefined, undefined, 'sunday');
const dayLabels = container.querySelectorAll('text.day-label');
const texts = Array.from(dayLabels).map((el) => el.textContent);
expect(texts).toContain('Sun');
expect(texts).toContain('Tue');
expect(texts).toContain('Thu');
});
}); });

128
assets/test/stats.test.ts Normal file
View file

@ -0,0 +1,128 @@
import { describe, it, expect } from 'vitest';
import { timeFormat } from 'd3-time-format';
import { timeDay } from 'd3-time';
import { calculateStreak, calculateStats } from '../src/heatmap';
import type { DayEntry } from '../src/types';
const DATE_FORMAT = timeFormat('%Y-%m-%d');
function daysAgo(n: number): string {
const d = new Date();
d.setHours(0, 0, 0, 0);
return DATE_FORMAT(timeDay.offset(d, -n));
}
describe('calculateStreak', () => {
it('returns 0 for empty data', () => {
expect(calculateStreak([])).toBe(0);
});
it('returns 0 when no days have hours > 0', () => {
const days: DayEntry[] = [
{ date: daysAgo(0), hours: 0, count: 0 },
];
expect(calculateStreak(days)).toBe(0);
});
it('returns 1 for a single entry today', () => {
const days: DayEntry[] = [
{ date: daysAgo(0), hours: 2.0, count: 1 },
];
expect(calculateStreak(days)).toBe(1);
});
it('counts consecutive days ending today', () => {
const days: DayEntry[] = [
{ date: daysAgo(0), hours: 1.0, count: 1 },
{ date: daysAgo(1), hours: 2.0, count: 1 },
{ date: daysAgo(2), hours: 3.0, count: 1 },
];
expect(calculateStreak(days)).toBe(3);
});
it('starts from yesterday if today has no entry', () => {
const days: DayEntry[] = [
{ date: daysAgo(1), hours: 2.0, count: 1 },
{ date: daysAgo(2), hours: 3.0, count: 1 },
];
expect(calculateStreak(days)).toBe(2);
});
it('breaks streak at gaps', () => {
const days: DayEntry[] = [
{ date: daysAgo(0), hours: 1.0, count: 1 },
{ date: daysAgo(1), hours: 2.0, count: 1 },
// gap at daysAgo(2)
{ date: daysAgo(3), hours: 3.0, count: 1 },
];
expect(calculateStreak(days)).toBe(2);
});
it('does not count days with 0 hours as tracked', () => {
const days: DayEntry[] = [
{ date: daysAgo(0), hours: 1.0, count: 1 },
{ date: daysAgo(1), hours: 0, count: 0 },
{ date: daysAgo(2), hours: 2.0, count: 1 },
];
expect(calculateStreak(days)).toBe(1);
});
});
describe('calculateStats', () => {
it('returns zeros for empty data', () => {
const stats = calculateStats([]);
expect(stats.totalHours).toBe(0);
expect(stats.avgHours).toBe(0);
expect(stats.busiestDay).toBeNull();
});
it('returns zeros when all hours are 0', () => {
const days: DayEntry[] = [
{ date: '2025-01-01', hours: 0, count: 0 },
];
const stats = calculateStats(days);
expect(stats.totalHours).toBe(0);
expect(stats.busiestDay).toBeNull();
});
it('computes total hours correctly', () => {
const days: DayEntry[] = [
{ date: '2025-01-01', hours: 2.5, count: 1 },
{ date: '2025-01-02', hours: 3.5, count: 2 },
{ date: '2025-01-03', hours: 4.0, count: 1 },
];
const stats = calculateStats(days);
expect(stats.totalHours).toBe(10);
});
it('computes average over days with entries only', () => {
const days: DayEntry[] = [
{ date: '2025-01-01', hours: 3.0, count: 1 },
{ date: '2025-01-02', hours: 0, count: 0 },
{ date: '2025-01-03', hours: 6.0, count: 2 },
];
const stats = calculateStats(days);
// avg = 9.0 / 2 = 4.5 (not 9/3)
expect(stats.avgHours).toBe(4.5);
});
it('identifies the busiest day', () => {
const days: DayEntry[] = [
{ date: '2025-01-01', hours: 2.0, count: 1 },
{ date: '2025-01-02', hours: 8.5, count: 3 },
{ date: '2025-01-03', hours: 4.0, count: 2 },
];
const stats = calculateStats(days);
expect(stats.busiestDay).toEqual({ date: '2025-01-02', hours: 8.5 });
});
it('rounds to one decimal place', () => {
const days: DayEntry[] = [
{ date: '2025-01-01', hours: 1.33, count: 1 },
{ date: '2025-01-02', hours: 2.67, count: 1 },
];
const stats = calculateStats(days);
expect(stats.totalHours).toBe(4);
expect(stats.avgHours).toBe(2);
});
});