Added stats page
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
/**
|
||||
* Three screens, so no router dependency — just enough hash handling that the
|
||||
* A few screens, so no router dependency — just enough hash handling that the
|
||||
* Android back button behaves inside an installed PWA.
|
||||
*
|
||||
* `route === null` means "wherever the day state says", which is the default the
|
||||
* worker should land on.
|
||||
*/
|
||||
|
||||
const ROUTES = ['resume', 'today', 'settings'];
|
||||
const ROUTES = ['resume', 'today', 'settings', 'stats'];
|
||||
|
||||
function fromHash() {
|
||||
const h = location.hash.replace(/^#\/?/, '');
|
||||
|
||||
123
src/lib/stats.js
Normal file
123
src/lib/stats.js
Normal file
@@ -0,0 +1,123 @@
|
||||
import { addDays, localDateKey, roundTs, weekday } from './time.js';
|
||||
|
||||
const sum = (rows, field) => rows.reduce((total, row) => total + row[field], 0);
|
||||
const average = (rows, field) => rows.length ? sum(rows, field) / rows.length : 0;
|
||||
const descending = (a, b) => b.ms - a.ms || a.name.localeCompare(b.name, 'fr');
|
||||
export const percent = (part, total) => total > 0 ? 100 * part / total : 0;
|
||||
|
||||
export function statsRange(preset, now = Date.now()) {
|
||||
const today = localDateKey(now);
|
||||
if (preset === 'month') return { from: `${today.slice(0, 7)}-01`, to: today };
|
||||
if (preset === 'year') return { from: `${today.slice(0, 4)}-01-01`, to: today };
|
||||
if (preset === '30days') return { from: addDays(today, -29), to: today };
|
||||
return { from: '', to: '' };
|
||||
}
|
||||
|
||||
function addBucket(map, key, name, date, ms) {
|
||||
if (!map.has(key)) map.set(key, { key, name, ms: 0, count: 0, dates: new Set() });
|
||||
const bucket = map.get(key);
|
||||
bucket.ms += ms; bucket.count++; bucket.dates.add(date);
|
||||
return bucket;
|
||||
}
|
||||
|
||||
function finish(map) {
|
||||
return [...map.values()].map(({ dates, ...row }) => ({ ...row, days: dates.size, averageMs: dates.size ? row.ms / dates.size : 0 })).sort(descending);
|
||||
}
|
||||
|
||||
function coverage(intervals, start, end) {
|
||||
const sorted = intervals.map(([a, b]) => [Math.max(start, a), Math.min(end, b)])
|
||||
.filter(([a, b]) => b > a).sort((a, b) => a[0] - b[0]);
|
||||
let covered = 0, cursor = start;
|
||||
for (const [a, b] of sorted) { covered += Math.max(0, b - Math.max(cursor, a)); cursor = Math.max(cursor, b); }
|
||||
return { uncoveredMs: Math.max(0, end - start - covered), overlapMs: sorted.reduce((n, [a, b]) => n + b - a, 0) - covered };
|
||||
}
|
||||
|
||||
/** Same rounded entry boundaries as the recap; meals never count as work. */
|
||||
export function buildStats(days, entries, { from = '', to = '', includeActive = false, roundingMinutes = 15, now = Date.now() } = {}) {
|
||||
const selected = days.filter((d) => (!from || d.date >= from) && (!to || d.date <= to) && (d.status === 'closed' || includeActive))
|
||||
.sort((a, b) => a.date.localeCompare(b.date));
|
||||
const byDate = new Map();
|
||||
for (const entry of entries) {
|
||||
if (!byDate.has(entry.date)) byDate.set(entry.date, []);
|
||||
byDate.get(entry.date).push(entry);
|
||||
}
|
||||
const clients = new Map(), tasks = new Map(), pairs = new Map(), restaurants = new Map();
|
||||
const meals = new Map();
|
||||
for (const [key, name] of [['panier', 'Panier'], ['restaurant', 'Restaurant'], ['unknown', 'Type non renseigné']]) {
|
||||
meals.set(key, { key, name, ms: 0, count: 0, dates: new Set() });
|
||||
}
|
||||
const quality = { missingClientMs: 0, missingClientCount: 0, zeroEntries: 0, uncoveredMs: 0, overlapMs: 0, emptyDays: 0, missingRestaurantDays: 0 };
|
||||
const daily = selected.map((day) => {
|
||||
const list = byDate.get(day.date) ?? [];
|
||||
const dayEnd = day.status === 'active' ? now : day.endedTs;
|
||||
const start = roundTs(day.startedTs, roundingMinutes), end = roundTs(dayEnd, roundingMinutes);
|
||||
const row = { date: day.date, status: day.status, workedMs: 0, lunchMs: 0, travelMs: 0, spanMs: Math.max(0, end - start), entries: list.length, meal: false, clients: new Set() };
|
||||
const intervals = [];
|
||||
for (const entry of list) {
|
||||
const startTs = roundTs(entry.startTs, roundingMinutes);
|
||||
const endTs = roundTs(entry.endTs ?? dayEnd, roundingMinutes);
|
||||
const ms = Math.max(0, endTs - startTs);
|
||||
intervals.push([startTs, endTs]);
|
||||
if (!ms) quality.zeroEntries++;
|
||||
if (entry.kind === 'lunch') { row.lunchMs += ms; row.meal = true; continue; }
|
||||
row.workedMs += ms;
|
||||
if (entry.kind === 'travel') row.travelMs += ms;
|
||||
const client = entry.client || null, task = entry.task || 'Tâche non renseignée';
|
||||
const clientName = client ?? 'Client non renseigné';
|
||||
if (client) row.clients.add(client);
|
||||
else { quality.missingClientMs += ms; quality.missingClientCount++; }
|
||||
addBucket(clients, client, clientName, day.date, ms);
|
||||
addBucket(tasks, task, task, day.date, ms);
|
||||
const pair = addBucket(pairs, JSON.stringify([client, task]), task, day.date, ms);
|
||||
pair.client = client; pair.clientName = clientName;
|
||||
}
|
||||
if (row.meal) {
|
||||
const type = ['panier', 'restaurant'].includes(day.lunchType) ? day.lunchType : 'unknown';
|
||||
addBucket(meals, type, meals.get(type).name, day.date, row.lunchMs);
|
||||
if (type === 'restaurant') {
|
||||
addBucket(restaurants, day.restaurant || null, day.restaurant || 'Restaurant non renseigné', day.date, row.lunchMs);
|
||||
if (!day.restaurant) quality.missingRestaurantDays++;
|
||||
}
|
||||
}
|
||||
if (!list.length) quality.emptyDays++;
|
||||
const covered = coverage(intervals, start, Math.max(start, end));
|
||||
quality.uncoveredMs += covered.uncoveredMs; quality.overlapMs += covered.overlapMs;
|
||||
return { ...row, clients: row.clients.size, ...covered };
|
||||
});
|
||||
const closed = daily.filter((d) => d.status === 'closed');
|
||||
const sortedClosed = [...closed].sort((a, b) => a.workedMs - b.workedMs || a.date.localeCompare(b.date));
|
||||
const mealsClosed = closed.filter((d) => d.meal);
|
||||
const middle = Math.floor(closed.length / 2);
|
||||
const medianMs = !closed.length ? 0 : closed.length % 2 ? sortedClosed[middle].workedMs : (sortedClosed[middle - 1].workedMs + sortedClosed[middle].workedMs) / 2;
|
||||
const weekdays = Array.from({ length: 7 }, (_, i) => {
|
||||
const index = (i + 1) % 7, rows = closed.filter((d) => weekday(d.date) === index);
|
||||
return { index, days: rows.length, ms: sum(rows, 'workedMs'), averageMs: average(rows, 'workedMs') };
|
||||
}).filter((row) => row.days);
|
||||
const clientRows = finish(clients), taskRows = finish(tasks);
|
||||
const topClient = clientRows.find((row) => row.key !== null) ?? null;
|
||||
const workedMs = sum(daily, 'workedMs');
|
||||
return {
|
||||
daily: [...daily].reverse(), clients: clientRows, tasks: taskRows, pairs: finish(pairs), restaurants: finish(restaurants), meals: finish(meals), weekdays, quality,
|
||||
days: daily.length, closedDays: closed.length, activeDays: daily.length - closed.length,
|
||||
entries: sum(daily, 'entries'), clientCount: clientRows.filter((row) => row.key !== null).length,
|
||||
taskCount: taskRows.length, workedMs, lunchMs: sum(daily, 'lunchMs'), travelMs: sum(daily, 'travelMs'), spanMs: sum(daily, 'spanMs'),
|
||||
mealDays: daily.filter((d) => d.meal).length, noMealDays: closed.filter((d) => !d.meal).length,
|
||||
averageWorkMs: average(closed, 'workedMs'), medianWorkMs: medianMs,
|
||||
averageLunchMs: average(mealsClosed, 'lunchMs'), averageSpanMs: average(closed, 'spanMs'),
|
||||
shortest: sortedClosed[0] ?? null, longest: sortedClosed.at(-1) ?? null,
|
||||
topClient, topClientShare: percent(topClient?.ms ?? 0, workedMs), topTaskShare: percent(taskRows[0]?.ms ?? 0, workedMs),
|
||||
firstDate: daily[0]?.date ?? null, lastDate: daily.at(-1)?.date ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function statsPeriods(daily, mode = 'month') {
|
||||
const periods = new Map();
|
||||
for (const day of daily) {
|
||||
const key = mode === 'week' ? addDays(day.date, -((weekday(day.date) + 6) % 7)) : day.date.slice(0, 7);
|
||||
if (!periods.has(key)) periods.set(key, { key, days: 0, activeDays: 0, workedMs: 0, lunchMs: 0 });
|
||||
const row = periods.get(key);
|
||||
row.days++; row.activeDays += day.status === 'active' ? 1 : 0;
|
||||
row.workedMs += day.workedMs; row.lunchMs += day.lunchMs;
|
||||
}
|
||||
return [...periods.values()].sort((a, b) => b.key.localeCompare(a.key));
|
||||
}
|
||||
126
src/lib/stats.test.js
Normal file
126
src/lib/stats.test.js
Normal file
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildStats, percent, statsPeriods, statsRange } from './stats.js';
|
||||
import { buildResume } from './resume.js';
|
||||
import { atLocalTime, HOUR, MINUTE } from './time.js';
|
||||
|
||||
const d1 = '2026-09-07', d2 = '2026-09-08', d3 = '2026-09-09';
|
||||
const at = (date, time) => atLocalTime(date, time);
|
||||
const day = (date, start, end, extra = {}) => ({ date, status: end ? 'closed' : 'active', startedTs: at(date, start), endedTs: end ? at(date, end) : null, lunchType: 'panier', restaurant: null, ...extra });
|
||||
const entry = (date, seq, start, end, kind = 'work', client = 'A', task = 'Travaux') => ({ date, seq, startTs: at(date, start), endTs: end ? at(date, end) : null, kind, client, task });
|
||||
const days = [day(d1, '08:00', '17:00', { lunchType: 'restaurant', restaurant: 'Le Relais' }), day(d2, '08:00', '16:00'), day(d3, '09:00', null)];
|
||||
const entries = [
|
||||
entry(d1, 0, '08:00', '09:00', 'travel', 'A', 'Transport'), entry(d1, 1, '09:00', '12:00'),
|
||||
entry(d1, 2, '12:00', '13:00', 'lunch', null, 'Pause déjeuner'), entry(d1, 3, '13:00', '17:00', 'work', 'B'),
|
||||
entry(d2, 0, '08:00', '12:00'), entry(d2, 1, '12:00', '12:30', 'lunch', null, 'Pause déjeuner'), entry(d2, 2, '12:30', '16:00'),
|
||||
entry(d3, 0, '09:00', null),
|
||||
];
|
||||
const options = { now: at(d3, '11:00'), roundingMinutes: 15 };
|
||||
|
||||
describe('statistical totals', () => {
|
||||
it('excludes meals and running days from work by default, includes recorded transport', () => {
|
||||
const s = buildStats(days, entries, options);
|
||||
expect(s.days).toBe(2); expect(s.entries).toBe(7);
|
||||
expect(s.workedMs).toBe(15.5 * HOUR); expect(s.lunchMs).toBe(1.5 * HOUR); expect(s.travelMs).toBe(HOUR);
|
||||
expect(s.spanMs).toBe(17 * HOUR); expect(s.averageWorkMs).toBe(7.75 * HOUR);
|
||||
expect(s.medianWorkMs).toBe(7.75 * HOUR); expect(s.averageLunchMs).toBe(.75 * HOUR);
|
||||
expect(s.longest.date).toBe(d1); expect(s.shortest.date).toBe(d2);
|
||||
expect(s.daily.map(d => d.date)).toEqual([d2, d1]);
|
||||
expect(s.quality.uncoveredMs).toBe(0); expect(s.quality.overlapMs).toBe(0);
|
||||
});
|
||||
|
||||
it('groups repeated tasks across clients without losing their cross breakdown', () => {
|
||||
const s = buildStats(days, entries, options);
|
||||
expect(s.clients.map(c => [c.name, c.ms, c.days, c.count])).toEqual([['A', 11.5 * HOUR, 2, 4], ['B', 4 * HOUR, 1, 1]]);
|
||||
expect(s.tasks.map(t => [t.name, t.ms])).toEqual([['Travaux', 14.5 * HOUR], ['Transport', HOUR]]);
|
||||
expect(s.pairs.find(p => p.client === 'A' && p.name === 'Travaux').ms).toBe(10.5 * HOUR);
|
||||
expect(s.pairs.reduce((n, p) => n + p.ms, 0)).toBe(s.workedMs);
|
||||
expect(s.topClientShare).toBeCloseTo(100 * 11.5 / 15.5);
|
||||
});
|
||||
|
||||
it('counts meal days and restaurants separately from work clients', () => {
|
||||
const s = buildStats(days, entries, options);
|
||||
expect(s.mealDays).toBe(2); expect(s.noMealDays).toBe(0);
|
||||
expect(s.meals.find(m => m.key === 'restaurant')).toMatchObject({ days: 1, ms: HOUR, averageMs: HOUR });
|
||||
expect(s.meals.find(m => m.key === 'panier')).toMatchObject({ days: 1, ms: .5 * HOUR });
|
||||
expect(s.restaurants[0]).toMatchObject({ name: 'Le Relais', days: 1, ms: HOUR });
|
||||
expect(s.clientCount).toBe(2); expect(s.taskCount).toBe(2);
|
||||
});
|
||||
|
||||
it('counts several meal fragments once per day', () => {
|
||||
const split = entries.filter(e => !(e.date === d1 && e.kind === 'lunch'));
|
||||
split.push(entry(d1, 2, '12:00', '12:15', 'lunch'), entry(d1, 3, '12:15', '13:00', 'lunch'));
|
||||
const s = buildStats(days, split, options);
|
||||
expect(s.mealDays).toBe(2); expect(s.meals.find(m => m.key === 'restaurant').days).toBe(1);
|
||||
expect(s.averageLunchMs).toBe(.75 * HOUR);
|
||||
});
|
||||
|
||||
it('includes current work up to now but keeps completed-day averages and extremes', () => {
|
||||
const s = buildStats(days, entries, { ...options, includeActive: true });
|
||||
expect(s.workedMs).toBe(17.5 * HOUR); expect(s.activeDays).toBe(1); expect(s.days).toBe(3);
|
||||
expect(s.averageWorkMs).toBe(7.75 * HOUR); expect(s.shortest.date).toBe(d2);
|
||||
expect(s.noMealDays).toBe(0);
|
||||
});
|
||||
|
||||
it.each([1, 5, 15, 30])('matches recap rounding at %i minutes', (roundingMinutes) => {
|
||||
const modified = entries.map(e => ({ ...e, startTs: e.startTs + 7 * MINUTE, endTs: e.endTs == null ? null : e.endTs + 7 * MINUTE }));
|
||||
const s = buildStats(days, modified, { ...options, roundingMinutes });
|
||||
const recaps = days.filter(d => d.status === 'closed').map(d => buildResume(d, modified.filter(e => e.date === d.date), roundingMinutes));
|
||||
expect(s.workedMs).toBe(recaps.reduce((n, r) => n + r.workedMs, 0));
|
||||
expect(s.lunchMs).toBe(recaps.reduce((n, r) => n + (r.lunch?.ms ?? 0), 0));
|
||||
});
|
||||
|
||||
it('keeps every zero-data metric finite and never infers meals from defaults', () => {
|
||||
const empty = buildStats([], [], options);
|
||||
expect(empty.days).toBe(0); expect(empty.longest).toBeNull(); expect(empty.topClient).toBeNull();
|
||||
expect(empty.meals.every(m => m.averageMs === 0)).toBe(true);
|
||||
expect(percent(0, 0)).toBe(0);
|
||||
const s = buildStats([days[0]], [], options);
|
||||
expect(s.mealDays).toBe(0); expect(s.noMealDays).toBe(1);
|
||||
expect(s.quality.emptyDays).toBe(1); expect(s.quality.uncoveredMs).toBe(9 * HOUR);
|
||||
});
|
||||
|
||||
it('reports missing clients, missing restaurants, gaps and zero rounded durations', () => {
|
||||
const rows = [entry(d1, 0, '08:00', '10:00', 'work', null), entry(d1, 1, '11:00', '12:00'), entry(d1, 2, '12:00', '13:00', 'lunch'), entry(d1, 3, '13:00', '13:02')];
|
||||
const s = buildStats([{ ...days[0], restaurant: null }], rows, options);
|
||||
expect(s.quality).toMatchObject({ missingClientMs: 2 * HOUR, missingClientCount: 1, zeroEntries: 1, uncoveredMs: 5 * HOUR, overlapMs: 0, missingRestaurantDays: 1 });
|
||||
expect(s.clientCount).toBe(1); expect(s.clients).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('measures overlaps as the excess beyond union coverage', () => {
|
||||
const s = buildStats([day(d1, '08:00', '12:00')], [entry(d1, 0, '08:00', '11:00'), entry(d1, 1, '10:00', '12:00')], options);
|
||||
expect(s.quality.overlapMs).toBe(HOUR); expect(s.quality.uncoveredMs).toBe(0);
|
||||
});
|
||||
|
||||
it('computes the median for an odd number of completed days', () => {
|
||||
const s = buildStats([...days.slice(0, 2), day(d3, '09:00', '11:00')], entries.map(e => e.date === d3 ? { ...e, endTs: at(d3, '11:00') } : e), options);
|
||||
expect(s.medianWorkMs).toBe(7.5 * HOUR);
|
||||
});
|
||||
});
|
||||
|
||||
describe('periods and filters', () => {
|
||||
it('filters inclusively by the recorded date, including entries crossing midnight', () => {
|
||||
const s = buildStats(days, entries, { ...options, from: d2, to: d2 });
|
||||
expect(s.days).toBe(1); expect(s.workedMs).toBe(7.5 * HOUR);
|
||||
const overnight = entry(d1, 0, '23:00', '23:00'); overnight.endTs = at(d2, '02:00');
|
||||
expect(buildStats([day(d1, '23:00', '23:00')], [overnight], { ...options, from: d1, to: d1 }).workedMs).toBe(3 * HOUR);
|
||||
});
|
||||
|
||||
it('builds month, year and 30-calendar-day ranges through leap days', () => {
|
||||
const now = at('2024-03-01', '10:00');
|
||||
expect(statsRange('month', now)).toEqual({ from: '2024-03-01', to: '2024-03-01' });
|
||||
expect(statsRange('year', now)).toEqual({ from: '2024-01-01', to: '2024-03-01' });
|
||||
expect(statsRange('30days', now)).toEqual({ from: '2024-02-01', to: '2024-03-01' });
|
||||
expect(statsRange('all', now)).toEqual({ from: '', to: '' });
|
||||
});
|
||||
|
||||
it('groups by calendar month or Monday across year boundaries', () => {
|
||||
const rows = [{ date: '2025-12-31', status: 'closed', workedMs: HOUR, lunchMs: 0 }, { date: '2026-01-01', status: 'active', workedMs: 2 * HOUR, lunchMs: HOUR }];
|
||||
expect(statsPeriods(rows, 'month').map(p => p.key)).toEqual(['2026-01', '2025-12']);
|
||||
expect(statsPeriods(rows, 'week')).toEqual([{ key: '2025-12-29', days: 2, activeDays: 1, workedMs: 3 * HOUR, lunchMs: HOUR }]);
|
||||
});
|
||||
|
||||
it('calculates weekdays from completed recorded days, not calendar-day counts', () => {
|
||||
const s = buildStats(days, entries, { ...options, includeActive: true });
|
||||
expect(s.weekdays.map(w => [w.index, w.days, w.averageMs])).toEqual([[1, 1, 8 * HOUR], [2, 1, 7.5 * HOUR]]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user