feat: add streak, stats, weekend styling, week-start preference
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c79834cbea
commit
d0dc64f333
7 changed files with 304 additions and 2296 deletions
|
|
@ -55,3 +55,25 @@
|
|||
min-width: 140px;
|
||||
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
|
|
@ -8,6 +8,7 @@
|
|||
data-url="{{ path('heatmap_data') }}"
|
||||
data-projects="{{ data.projects|json_encode }}"
|
||||
data-timesheet-url="{{ path('timesheet') }}"
|
||||
data-week-start="{{ data.weekStart }}"
|
||||
style="min-height: 150px;">
|
||||
</div>
|
||||
<script src="{{ asset('bundles/kimaiheatmap/heatmap.js') }}"></script>
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ class HeatmapWidget extends AbstractWidget
|
|||
|
||||
return [
|
||||
'projects' => $this->service->getUserProjects($user),
|
||||
'weekStart' => $user->getFirstDayOfWeek(),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { select } from 'd3-selection';
|
||||
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 { max } from 'd3-array';
|
||||
import type { DayEntry, HeatmapData, HeatmapConfig, ProjectOption } from './types';
|
||||
|
|
@ -14,7 +14,12 @@ const DEFAULT_CONFIG: HeatmapConfig = {
|
|||
};
|
||||
|
||||
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 DATE_FORMAT = timeFormat('%Y-%m-%d');
|
||||
const DISPLAY_FORMAT = timeFormat('%a, %b %-d, %Y');
|
||||
|
|
@ -25,6 +30,7 @@ interface DayCell {
|
|||
entry: DayEntry | null;
|
||||
week: number;
|
||||
day: number;
|
||||
isWeekend: boolean;
|
||||
}
|
||||
|
||||
function resolveColors(container: HTMLElement): string[] {
|
||||
|
|
@ -46,19 +52,29 @@ function buildDateMap(days: DayEntry[]): Map<string, DayEntry> {
|
|||
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 firstMonday = timeMonday.floor(begin);
|
||||
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 = timeMonday.count(firstMonday, current);
|
||||
const dayOfWeek = (current.getDay() + 6) % 7; // Monday=0, Sunday=6
|
||||
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),
|
||||
|
|
@ -66,6 +82,7 @@ function generateCells(
|
|||
entry: dateMap.get(dateStr) || null,
|
||||
week: weeksSinceStart,
|
||||
day: dayOfWeek,
|
||||
isWeekend,
|
||||
});
|
||||
|
||||
current = timeDay.offset(current, 1);
|
||||
|
|
@ -81,12 +98,88 @@ function createTooltip(): HTMLDivElement {
|
|||
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 = '';
|
||||
|
||||
|
|
@ -102,7 +195,7 @@ export function renderHeatmap(
|
|||
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);
|
||||
const cells = generateCells(begin, end, dateMap, weekStart);
|
||||
|
||||
const maxHours = max(data.days, (d) => d.hours) || 1;
|
||||
const colors = resolveColors(container);
|
||||
|
|
@ -134,12 +227,13 @@ export function renderHeatmap(
|
|||
.attr('class', 'heatmap-svg');
|
||||
|
||||
// Month labels
|
||||
const weekInterval = getWeekInterval(weekStart);
|
||||
const months: { date: Date; week: number }[] = [];
|
||||
const firstMonday = timeMonday.floor(begin);
|
||||
const firstWeekDay = weekInterval.floor(begin);
|
||||
timeMonth.range(timeMonth.ceil(begin), end).forEach((m) => {
|
||||
months.push({
|
||||
date: m,
|
||||
week: timeMonday.count(firstMonday, m),
|
||||
week: weekInterval.count(firstWeekDay, m),
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -152,10 +246,10 @@ export function renderHeatmap(
|
|||
.attr('y', marginTop - 6)
|
||||
.text((d) => MONTH_FORMAT(d.date));
|
||||
|
||||
// Day labels (Mon, Wed, Fri)
|
||||
// Day labels
|
||||
svg
|
||||
.selectAll('.day-label')
|
||||
.data(DAY_LABELS)
|
||||
.data(getDayLabels(weekStart))
|
||||
.join('text')
|
||||
.attr('class', 'heatmap-label day-label')
|
||||
.attr('x', marginLeft - 6)
|
||||
|
|
@ -175,9 +269,12 @@ export function renderHeatmap(
|
|||
.selectAll('.heatmap-cell')
|
||||
.data(cells)
|
||||
.join('rect')
|
||||
.attr('class', (d) =>
|
||||
d.entry ? 'heatmap-cell' : 'heatmap-cell heatmap-empty',
|
||||
)
|
||||
.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)
|
||||
|
|
@ -210,6 +307,7 @@ export function init(container: HTMLElement): void {
|
|||
}
|
||||
|
||||
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) : [];
|
||||
|
||||
|
|
@ -265,7 +363,8 @@ export function init(container: HTMLElement): void {
|
|||
return res.json() as Promise<HeatmapData>;
|
||||
})
|
||||
.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 => {
|
||||
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>;
|
||||
})
|
||||
.then(data => {
|
||||
renderHeatmap(svgArea, data, DEFAULT_CONFIG, onCellClick);
|
||||
renderHeatmap(svgArea, data, DEFAULT_CONFIG, onCellClick, undefined, weekStart);
|
||||
renderStats(wrapper, data.days);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('KimaiHeatmap: failed to load data', err);
|
||||
|
|
|
|||
|
|
@ -133,4 +133,40 @@ describe('renderHeatmap', () => {
|
|||
const rects = container.querySelectorAll('rect.heatmap-cell');
|
||||
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
128
assets/test/stats.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue