From daa4bbd028a08f51d8dadb9adc3a689a67822ebb Mon Sep 17 00:00:00 2001
From: "Adrien W."
Date: Sun, 20 Sep 2026 13:40:27 +0200
Subject: [PATCH] Added stats page
---
README.md | 39 +++++-
src/App.svelte | 10 +-
src/components/DayEditor.svelte | 12 +-
src/lib/router.svelte.js | 4 +-
src/lib/stats.js | 123 +++++++++++++++++
src/lib/stats.test.js | 126 +++++++++++++++++
src/routes/Resume.svelte | 5 -
src/routes/Settings.svelte | 7 +-
src/routes/Stats.svelte | 236 ++++++++++++++++++++++++++++++++
9 files changed, 547 insertions(+), 15 deletions(-)
create mode 100644 src/lib/stats.js
create mode 100644 src/lib/stats.test.js
create mode 100644 src/routes/Stats.svelte
diff --git a/README.md b/README.md
index a7297bf..588ca75 100644
--- a/README.md
+++ b/README.md
@@ -87,7 +87,10 @@ Le **Total travaillé** exclut le repas, qui reste affiché séparément dans le
récapitulatif et le texte copié. Les totaux suivent le réglage d'arrondi existant.
Dans **Journée**, touchez un poste dans **Postes de la journée** pour modifier
-son début, sa fin, sa tâche, son client ou son type (travail, transport, repas).
+son début, sa fin, sa tâche ou son client. La case **Pause déjeuner** distingue
+le repas du travail : il n'y a plus de menu « Type de poste ». Le type interne
+d'un transport déjà enregistré est conservé quand on corrige ses horaires ou
+son libellé.
Les sélecteurs d'heure ne proposent pas de date : ils conservent la date du
poste. Les minutes et les boutons d'ajustement suivent l'arrondi choisi dans
les réglages (1, 5, 15 ou 30 minutes). Les tâches, clients et restaurants utilisent
@@ -107,7 +110,7 @@ Il suffit de changer une seule borne pour corriger le début ou la fin de la pla
Les portions avant et après sont
conservées avec leur tâche et leur client. Par exemple, un poste 08:00–12:00
peut devenir 08:00–09:00, une tâche oubliée 09:00–10:00, puis 10:00–12:00.
-Le type **Repas** permet aussi d'ajouter une pause oubliée et de renseigner
+La case **Pause déjeuner** permet aussi d'ajouter une pause oubliée et de renseigner
panier ou restaurant. Pour ajouter juste après une frontière existante,
choisissez le poste qui commence à cette frontière.
@@ -123,6 +126,38 @@ Chaque correction est transactionnelle. Les horaires inversés, les insertions
hors de la plage choisie et les formulaires devenus périmés après une autre
modification sont refusés sans écriture partielle.
+## Statistiques
+
+Ouvrez **Réglages → Consulter les statistiques**. Le bouton **Stats** de la barre
+du bas n'apparaît que sur cette page ; les autres écrans gardent leurs trois
+boutons habituels. La page fonctionne hors ligne et ne transmet aucune donnée.
+
+Les filtres couvrent tout l'historique, le mois, l'année, les 30 derniers jours
+ou une plage de dates inclusive. Les journées en cours sont exclues par défaut.
+L'option pour les inclure affiche des cumuls et détails provisoires. Les moyennes
+de « Repères et observations », la médiane, les extrêmes et les profils par jour
+de semaine restent calculés sur les seules journées terminées enregistrées.
+
+Les statistiques présentent :
+
+- Le travail hors repas, les pauses, le transport identifié, les nombres de
+ journées, postes, tâches et clients.
+- Les heures par client et par tâche, leurs parts du travail, les nombres de
+ journées concernées et les détails croisés, accessibles en ouvrant une ligne.
+- L'évolution par mois ou semaine, sans inventer des heures pour les jours absents.
+- Les journées avec repas panier ou restaurant, les restaurants fréquentés,
+ les durées de pause et les journées sans pause enregistrée. Plusieurs fragments
+ de repas le même jour ne créent pas plusieurs journées de repas.
+- Les moyennes, la médiane, les journées extrêmes, les principales concentrations
+ de temps par client et tâche, et le détail quotidien.
+- Les clients ou restaurants non renseignés, les postes à zéro après arrondi,
+ les journées vides, les plages non couvertes et les chevauchements éventuels.
+
+Le calcul reprend l'arrondi du récapitulatif et exclut toujours le repas du travail.
+Seules les données encore conservées sont analysées : les jours absents ou purgés
+ne permettent pas de conclure à une absence de travail. Le transport identifié
+repose sur le type enregistré, pas sur une déduction à partir du nom de la tâche.
+
In dev the console exposes `timelog.seed()` to generate 20 days of realistic history
(useful for exercising the suggestion engine) and `timelog.wipe()` to clear it.
diff --git a/src/App.svelte b/src/App.svelte
index 2ab90e0..b78f250 100644
--- a/src/App.svelte
+++ b/src/App.svelte
@@ -6,6 +6,7 @@
import Resume from './routes/Resume.svelte';
import Settings from './routes/Settings.svelte';
import Today from './routes/Today.svelte';
+ import Stats from './routes/Stats.svelte';
let ready = $state(false);
@@ -54,11 +55,13 @@
{:else if view === 'today'}
+ {:else if view === 'stats'}
+
{:else}
{/if}
-
+
go('settings')}>Réglages
+ {#if view === 'stats'}
+ go('stats')}>Stats
+ {/if}
{/if}
@@ -135,4 +141,6 @@
background: var(--accent);
color: var(--accent-fg);
}
+ nav.with-stats { grid-template-columns: repeat(4, 1fr); }
+ nav.with-stats button { padding: 0 4px; font-size: 0.75rem; min-width: 0; }
diff --git a/src/components/DayEditor.svelte b/src/components/DayEditor.svelte
index 625652a..183f9b4 100644
--- a/src/components/DayEditor.svelte
+++ b/src/components/DayEditor.svelte
@@ -93,11 +93,13 @@
{:else}
Changer une limite ajuste aussi le poste voisin. Le premier début et la dernière fin définissent la durée de la journée.
{/if}
- Type de poste
-
- Travail Transport Repas
-
+
+ {
+ draft.kind = event.currentTarget.checked ? 'lunch' : mode === 'edit' && original.kind === 'travel' ? 'travel' : 'work';
+ }} />
+ Pause déjeuner
+ {#if draft.kind === 'lunch'}Cette pause est exclue du total travaillé.
{/if}
(draft.startTs = ts)} />
@@ -144,4 +146,6 @@
.hint { font-size: 0.85rem; }
select { width: 100%; min-width: 0; }
[role='alert'] { color: var(--danger); }
+ .meal-toggle { display: flex; align-items: center; gap: 12px; min-height: var(--tap); font-weight: 700; }
+ .meal-toggle input { width: 26px; height: 26px; accent-color: var(--accent); }
diff --git a/src/lib/router.svelte.js b/src/lib/router.svelte.js
index aed7e2b..faf9aa9 100644
--- a/src/lib/router.svelte.js
+++ b/src/lib/router.svelte.js
@@ -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(/^#\/?/, '');
diff --git a/src/lib/stats.js b/src/lib/stats.js
new file mode 100644
index 0000000..0b58f4a
--- /dev/null
+++ b/src/lib/stats.js
@@ -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));
+}
diff --git a/src/lib/stats.test.js b/src/lib/stats.test.js
new file mode 100644
index 0000000..948d57d
--- /dev/null
+++ b/src/lib/stats.test.js
@@ -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]]);
+ });
+});
diff --git a/src/routes/Resume.svelte b/src/routes/Resume.svelte
index f65ff8d..44f6be7 100644
--- a/src/routes/Resume.svelte
+++ b/src/routes/Resume.svelte
@@ -56,7 +56,6 @@
{#if error}{error}
{/if}
{#if dayState.closedDays.length > 1}
- Journée à consulter
{ selectedDate = event.currentTarget.value; copied = false; editing = false; }}>
{#each dayState.closedDays as day (day.date)}
{fmtDayLabel(day.date)} {day.date.slice(0, 4)}
@@ -72,10 +71,6 @@
{:else}
-
-
{resume.label}
-
-
{#each resume.clients as client (client.name)}
diff --git a/src/routes/Settings.svelte b/src/routes/Settings.svelte
index 315037f..c90d103 100644
--- a/src/routes/Settings.svelte
+++ b/src/routes/Settings.svelte
@@ -1,5 +1,6 @@
- Réglages
+
+ Statistiques
+ Heures par client et tâche, repas et évolution de l’activité.
+ go('stats')}>Consulter les statistiques
+
{#if settingsError.message}
{settingsError.message}
diff --git a/src/routes/Stats.svelte b/src/routes/Stats.svelte
new file mode 100644
index 0000000..06c0101
--- /dev/null
+++ b/src/routes/Stats.svelte
@@ -0,0 +1,236 @@
+
+
+
+
Statistiques
+
go('settings')}>Retour aux réglages
+
+
+
+ {#if !invalid && !stats.days}
+
Aucune journée sur cette période Élargissez la période ou incluez la journée en cours si vous souhaitez la consulter.
+ {:else if !invalid}
+
Données du {dateLabel(stats.firstDate)} au {dateLabel(stats.lastDate)} · {stats.closedDays} journée(s) terminée(s){stats.activeDays ? ' et 1 en cours' : ''}.
+
+ Vue d’ensemble
+
+
Temps travaillé {fmtDuration(stats.workedMs)}
+
Temps de repas {fmtDuration(stats.lunchMs)}
+
Journées enregistrées {stats.days}
+
Postes enregistrés {stats.entries}
+
Clients renseignés {stats.clientCount}
+
Tâches distinctes {stats.taskCount}
+
+ Transport identifié : {fmtDuration(stats.travelMs)} ({pct(percent(stats.travelMs, stats.workedMs))} du temps travaillé). Ce chiffre dépend des postes enregistrés comme transport.
+ {#if stats.activeDays}Les cumuls et les détails par client, tâche ou repas incluent la journée en cours jusqu’à maintenant. Les moyennes de « Repères et observations » restent limitées aux journées terminées.
{/if}
+
+
+
+ Repères et observations
+ {#if stats.closedDays}
+
+
Moyenne travaillée / jour {fmtDuration(stats.averageWorkMs)}
+
Médiane travaillée / jour {fmtDuration(stats.medianWorkMs)}
+
Amplitude moyenne, repas compris {fmtDuration(stats.averageSpanMs)}
+
Repas moyen, jours avec pause {fmtDuration(stats.averageLunchMs)}
+
+ Journée la plus longue en travail : {dateLabel(stats.longest.date)}, {fmtDuration(stats.longest.workedMs)} . La plus courte : {dateLabel(stats.shortest.date)}, {fmtDuration(stats.shortest.workedMs)}.
+ {:else}Les moyennes seront disponibles avec une journée terminée dans cette période.
{/if}
+ {#if stats.topClient && stats.workedMs}
+ {stats.topClient.name} représente {pct(stats.topClientShare)} du temps travaillé enregistré.
+ {/if}
+ {#if stats.tasks.length && stats.workedMs}
+ La tâche la plus longue au total est {stats.tasks[0].name} : {fmtDuration(stats.tasks[0].ms)}, soit {pct(stats.topTaskShare)} du travail.
+ {/if}
+
+
+
+ Évolution des heures
+ Regrouper parMois Semaine
+ Les barres montrent le travail hors repas. Les périodes sans données ne sont pas affichées ; les premières et dernières peuvent être partielles.
+
+
+
+
+ Heures par client
+ Ouvrez un client pour voir ses tâches. La moyenne porte sur les journées où ce client apparaît.
+ {#if !stats.clients.length}Aucun poste de travail enregistré.
{/if}
+ {#each stats.clients as client (client.key)}
+
+ {client.name} {fmtDuration(client.ms)}
+
+
{pct(percent(client.ms, stats.workedMs))} du travail · {client.days} journée(s) · {client.count} poste(s) · moyenne {fmtDuration(client.averageMs)} / jour concerné.
+
+ {#each stats.pairs.filter((pair) => pair.client === client.key) as pair (pair.key)}
+ {pair.name} {fmtDuration(pair.ms)}
+ {/each}
+
+
+
+ {/each}
+
+
+
+ Heures par tâche
+ {#if !stats.tasks.length}Aucun poste de travail enregistré.
{/if}
+ {#each stats.tasks as task (task.key)}
+
+ {task.name} {fmtDuration(task.ms)}
+
+
{pct(percent(task.ms, stats.workedMs))} du travail · {task.days} journée(s) · {task.count} poste(s) · moyenne {fmtDuration(task.averageMs)} / jour concerné.
+
+ {#each stats.pairs.filter((pair) => pair.name === task.key) as pair (pair.key)}
+ {pair.clientName} {fmtDuration(pair.ms)}
+ {/each}
+
+
+
+ {/each}
+
+
+
+ Répartition des repas
+ {stats.mealDays} journée(s) avec une pause repas enregistrée · {fmtDuration(stats.lunchMs)} au total.
+
+ {#each stats.meals as meal (meal.key)}
+ {meal.name} {meal.days} jour(s)
+ {pct(percent(meal.days, stats.mealDays))} des journées avec repas · {fmtDuration(meal.ms)} · moyenne {fmtDuration(meal.averageMs)}.
+
+ {/each}
+
+ {stats.noMealDays} journée(s) terminée(s) sans pause repas enregistrée.
+ {#if stats.restaurants.length}
+ Restaurants
+ {#each stats.restaurants as restaurant (restaurant.key)}
+ {restaurant.name} {restaurant.days} jour(s) · {fmtDuration(restaurant.ms)} · moyenne {fmtDuration(restaurant.averageMs)}.
+ {/each}
+ {/if}
+
+
+ {#if stats.weekdays.length}
+
+ Rythme selon le jour de la semaine
+ Uniquement les journées terminées enregistrées ; les effectifs peuvent différer.
+
+
+ {/if}
+
+
+ Détail des {stats.days} journées
+
+
+
+ Travail hors repas et pauses par journée
+ Date Travail Repas Amplitude Postes
+ {#each stats.daily as day (day.date)}
+ {dateLabel(day.date)}{day.status === 'active' ? ' (en cours)' : ''} {fmtDuration(day.workedMs)} {fmtDuration(day.lunchMs)} {fmtDuration(day.spanMs)} {day.entries}
+ {/each}
+
+
+
+
+
+ Points à vérifier dans les saisies
+
+ {stats.quality.missingClientCount} poste(s) sans client : {fmtDuration(stats.quality.missingClientMs)}.
+ {stats.quality.missingRestaurantDays} journée(s) au restaurant sans nom renseigné.
+ {stats.quality.zeroEntries} poste(s) affiché(s) à zéro après arrondi.
+ {stats.quality.emptyDays} journée(s) sans poste.
+ Temps sans poste à l’intérieur des journées : {fmtDuration(stats.quality.uncoveredMs)}.
+ Chevauchements de postes : {fmtDuration(stats.quality.overlapMs)}.
+
+
+ {/if}
+
+
+