Added stats page

This commit is contained in:
2026-09-20 13:40:27 +02:00
parent 6f64475050
commit daa4bbd028
9 changed files with 547 additions and 15 deletions

View File

@@ -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. 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 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 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 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 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 Les portions avant et après sont
conservées avec leur tâche et leur client. Par exemple, un poste 08:00–12:00 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. 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, panier ou restaurant. Pour ajouter juste après une frontière existante,
choisissez le poste qui commence à cette frontière. 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 hors de la plage choisie et les formulaires devenus périmés après une autre
modification sont refusés sans écriture partielle. 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 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. (useful for exercising the suggestion engine) and `timelog.wipe()` to clear it.

View File

@@ -6,6 +6,7 @@
import Resume from './routes/Resume.svelte'; import Resume from './routes/Resume.svelte';
import Settings from './routes/Settings.svelte'; import Settings from './routes/Settings.svelte';
import Today from './routes/Today.svelte'; import Today from './routes/Today.svelte';
import Stats from './routes/Stats.svelte';
let ready = $state(false); let ready = $state(false);
@@ -54,11 +55,13 @@
<Settings /> <Settings />
{:else if view === 'today'} {:else if view === 'today'}
<Today /> <Today />
{:else if view === 'stats'}
<Stats />
{:else} {:else}
<Resume /> <Resume />
{/if} {/if}
<nav> <nav class:with-stats={view === 'stats'}>
<button <button
type="button" type="button"
class:sel={view === 'resume'} class:sel={view === 'resume'}
@@ -80,6 +83,9 @@
aria-current={view === 'settings'} aria-current={view === 'settings'}
onclick={() => go('settings')}>Réglages</button onclick={() => go('settings')}>Réglages</button
> >
{#if view === 'stats'}
<button type="button" class="sel" disabled={dayState.importing} aria-current="page" onclick={() => go('stats')}>Stats</button>
{/if}
</nav> </nav>
{/if} {/if}
@@ -135,4 +141,6 @@
background: var(--accent); background: var(--accent);
color: var(--accent-fg); 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; }
</style> </style>

View File

@@ -93,11 +93,13 @@
{:else} {:else}
<p class="dim hint">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.</p> <p class="dim hint">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.</p>
{/if} {/if}
<label class="stack">Type de poste <label class="meal-toggle">
<select bind:value={draft.kind}> <input type="checkbox" checked={draft.kind === 'lunch'} onchange={(event) => {
<option value="work">Travail</option><option value="travel">Transport</option><option value="lunch">Repas</option> draft.kind = event.currentTarget.checked ? 'lunch' : mode === 'edit' && original.kind === 'travel' ? 'travel' : 'work';
</select> }} />
Pause déjeuner
</label> </label>
{#if draft.kind === 'lunch'}<p class="dim hint">Cette pause est exclue du total travaillé.</p>{/if}
<TimeField label="Début du poste" ts={draft.startTs} rounding={settings.roundingMinutes} <TimeField label="Début du poste" ts={draft.startTs} rounding={settings.roundingMinutes}
min={mode === 'insert' ? original.startTs : previous?.startTs ?? startOfDay(day.date)} min={mode === 'insert' ? original.startTs : previous?.startTs ?? startOfDay(day.date)}
max={draft.endTs} onchange={(ts) => (draft.startTs = ts)} /> max={draft.endTs} onchange={(ts) => (draft.startTs = ts)} />
@@ -144,4 +146,6 @@
.hint { font-size: 0.85rem; } .hint { font-size: 0.85rem; }
select { width: 100%; min-width: 0; } select { width: 100%; min-width: 0; }
[role='alert'] { color: var(--danger); } [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); }
</style> </style>

View File

@@ -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. * Android back button behaves inside an installed PWA.
* *
* `route === null` means "wherever the day state says", which is the default the * `route === null` means "wherever the day state says", which is the default the
* worker should land on. * worker should land on.
*/ */
const ROUTES = ['resume', 'today', 'settings']; const ROUTES = ['resume', 'today', 'settings', 'stats'];
function fromHash() { function fromHash() {
const h = location.hash.replace(/^#\/?/, ''); const h = location.hash.replace(/^#\/?/, '');

123
src/lib/stats.js Normal file
View 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
View 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]]);
});
});

View File

@@ -56,7 +56,6 @@
{#if error}<p role="alert">{error}</p>{/if} {#if error}<p role="alert">{error}</p>{/if}
{#if dayState.closedDays.length > 1} {#if dayState.closedDays.length > 1}
<label class="stack"> <label class="stack">
<span>Journée à consulter</span>
<select value={selectedDay?.date} onchange={(event) => { selectedDate = event.currentTarget.value; copied = false; editing = false; }}> <select value={selectedDay?.date} onchange={(event) => { selectedDate = event.currentTarget.value; copied = false; editing = false; }}>
{#each dayState.closedDays as day (day.date)} {#each dayState.closedDays as day (day.date)}
<option value={day.date}>{fmtDayLabel(day.date)} {day.date.slice(0, 4)}</option> <option value={day.date}>{fmtDayLabel(day.date)} {day.date.slice(0, 4)}</option>
@@ -72,10 +71,6 @@
</p> </p>
</div> </div>
{:else} {:else}
<div class="row-between">
<h1>{resume.label}</h1>
</div>
<div class="card stack"> <div class="card stack">
{#each resume.clients as client (client.name)} {#each resume.clients as client (client.name)}
<div class="client"> <div class="client">

View File

@@ -1,5 +1,6 @@
<script> <script>
import BackupPanel from '../components/BackupPanel.svelte'; import BackupPanel from '../components/BackupPanel.svelte';
import { go } from '../lib/router.svelte.js';
import { cleanupOldDays } from '../lib/db.js'; import { cleanupOldDays } from '../lib/db.js';
import { dayState, refresh } from '../lib/day.svelte.js'; import { dayState, refresh } from '../lib/day.svelte.js';
import { import {
@@ -27,7 +28,11 @@
</script> </script>
<fieldset class="screen" disabled={dayState.importing}> <fieldset class="screen" disabled={dayState.importing}>
<h1>Réglages</h1> <section class="card stack">
<h2>Statistiques</h2>
<p class="dim small">Heures par client et tâche, repas et évolution de l’activité.</p>
<button type="button" onclick={() => go('stats')}>Consulter les statistiques</button>
</section>
{#if settingsError.message} {#if settingsError.message}
<p class="error card">{settingsError.message}</p> <p class="error card">{settingsError.message}</p>

236
src/routes/Stats.svelte Normal file
View File

@@ -0,0 +1,236 @@
<script>
import { dayState } from '../lib/day.svelte.js';
import { settings } from '../lib/settings.svelte.js';
import { go } from '../lib/router.svelte.js';
import { buildStats, percent, statsPeriods, statsRange } from '../lib/stats.js';
import { fmtDuration } from '../lib/time.js';
let now = $state(Date.now());
let preset = $state('all');
let from = $state('');
let to = $state('');
let includeActive = $state(false);
let periodMode = $state('month');
const days = $derived([...dayState.closedDays, ...(dayState.day ? [dayState.day] : [])]);
const range = $derived(preset === 'custom' ? { from, to } : statsRange(preset, now));
const invalid = $derived(Boolean(range.from && range.to && range.from > range.to));
const stats = $derived(buildStats(invalid ? [] : days, dayState.historyEntries, {
...range, includeActive, roundingMinutes: settings.roundingMinutes, now,
}));
const periods = $derived(statsPeriods(stats.daily, periodMode));
const largestPeriod = $derived(Math.max(1, ...periods.map((p) => p.workedMs)));
const weekdayLabels = ['Dimanche', 'Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi'];
const pct = (value) => `${value.toLocaleString('fr-FR', { maximumFractionDigits: 1 })} %`;
const dateLabel = (date) => date ? date.split('-').reverse().join('/') : '—';
const periodLabel = (key) => periodMode === 'week' ? `Semaine du ${dateLabel(key)}` : `${key.slice(5)}/${key.slice(0, 4)}`;
$effect(() => {
const timer = setInterval(() => (now = Date.now()), 30_000);
return () => clearInterval(timer);
});
</script>
<div class="screen stats">
<h1>Statistiques</h1>
<button type="button" onclick={() => go('settings')}>Retour aux réglages</button>
<section class="card stack" aria-label="Filtres statistiques">
<label class="stack">Période
<select aria-label="Période" bind:value={preset}>
<option value="all">Tout l’historique</option><option value="month">Ce mois</option>
<option value="30days">30 derniers jours</option><option value="year">Cette année</option>
<option value="custom">Période personnalisée</option>
</select>
</label>
{#if preset === 'custom'}
<div class="date-range">
<label class="stack">Du<input type="date" bind:value={from} /></label>
<label class="stack">Au<input type="date" bind:value={to} /></label>
</div>
{/if}
<label class="check"><input type="checkbox" bind:checked={includeActive} />Inclure la journée en cours (provisoire)</label>
<p class="small dim">Travail hors repas, transport compris. Arrondi : {settings.roundingMinutes} min, comme dans le récapitulatif.</p>
<p class="small dim">Seules les données encore conservées sont comptées. Une journée absente n’est pas considérée comme un jour non travaillé.</p>
{#if invalid}<p role="alert">La date de début doit précéder ou égaler la date de fin.</p>{/if}
</section>
{#if !invalid && !stats.days}
<section class="card stack"><h2>Aucune journée sur cette période</h2><p>Élargissez la période ou incluez la journée en cours si vous souhaitez la consulter.</p></section>
{:else if !invalid}
<p class="small dim">Données du {dateLabel(stats.firstDate)} au {dateLabel(stats.lastDate)} · {stats.closedDays} journée(s) terminée(s){stats.activeDays ? ' et 1 en cours' : ''}.</p>
<section class="card stack" aria-labelledby="stats-overview">
<h2 id="stats-overview">Vue d’ensemble</h2>
<div class="metrics">
<div><span>Temps travaillé</span><strong class="tnum" data-testid="stats-worked">{fmtDuration(stats.workedMs)}</strong></div>
<div><span>Temps de repas</span><strong class="tnum" data-testid="stats-lunch">{fmtDuration(stats.lunchMs)}</strong></div>
<div><span>Journées enregistrées</span><strong>{stats.days}</strong></div>
<div><span>Postes enregistrés</span><strong>{stats.entries}</strong></div>
<div><span>Clients renseignés</span><strong>{stats.clientCount}</strong></div>
<div><span>Tâches distinctes</span><strong>{stats.taskCount}</strong></div>
</div>
<p class="small">Transport identifié : <strong>{fmtDuration(stats.travelMs)}</strong> ({pct(percent(stats.travelMs, stats.workedMs))} du temps travaillé). Ce chiffre dépend des postes enregistrés comme transport.</p>
{#if stats.activeDays}<p class="small" role="status">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.</p>{/if}
</section>
<section class="card stack">
<h2>Repères et observations</h2>
{#if stats.closedDays}
<dl>
<div><dt>Moyenne travaillée / jour</dt><dd>{fmtDuration(stats.averageWorkMs)}</dd></div>
<div><dt>Médiane travaillée / jour</dt><dd>{fmtDuration(stats.medianWorkMs)}</dd></div>
<div><dt>Amplitude moyenne, repas compris</dt><dd>{fmtDuration(stats.averageSpanMs)}</dd></div>
<div><dt>Repas moyen, jours avec pause</dt><dd>{fmtDuration(stats.averageLunchMs)}</dd></div>
</dl>
<p class="small">Journée la plus longue en travail : <strong>{dateLabel(stats.longest.date)}, {fmtDuration(stats.longest.workedMs)}</strong>. La plus courte : {dateLabel(stats.shortest.date)}, {fmtDuration(stats.shortest.workedMs)}.</p>
{:else}<p class="small">Les moyennes seront disponibles avec une journée terminée dans cette période.</p>{/if}
{#if stats.topClient && stats.workedMs}
<p><strong>{stats.topClient.name}</strong> représente {pct(stats.topClientShare)} du temps travaillé enregistré.</p>
{/if}
{#if stats.tasks.length && stats.workedMs}
<p>La tâche la plus longue au total est <strong>{stats.tasks[0].name}</strong> : {fmtDuration(stats.tasks[0].ms)}, soit {pct(stats.topTaskShare)} du travail.</p>
{/if}
</section>
<section class="card stack">
<h2>Évolution des heures</h2>
<label class="stack">Regrouper par<select bind:value={periodMode}><option value="month">Mois</option><option value="week">Semaine</option></select></label>
<p class="small dim">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.</p>
<ul class="periods">
{#each periods as period (period.key)}
<li class="stack">
<div class="row"><strong>{periodLabel(period.key)}</strong><span class="tnum">{fmtDuration(period.workedMs)}</span></div>
<div class="track" aria-hidden="true"><div style:width={`${percent(period.workedMs, largestPeriod)}%`}></div></div>
<p class="small dim">{period.days} journée(s) · repas {fmtDuration(period.lunchMs)}{period.activeDays ? ' · en cours inclus' : ''}</p>
</li>
{/each}
</ul>
</section>
<section class="card stack">
<h2>Heures par client</h2>
<p class="small dim">Ouvrez un client pour voir ses tâches. La moyenne porte sur les journées où ce client apparaît.</p>
{#if !stats.clients.length}<p>Aucun poste de travail enregistré.</p>{/if}
{#each stats.clients as client (client.key)}
<details class="breakdown">
<summary><span>{client.name}</span><strong class="tnum">{fmtDuration(client.ms)}</strong></summary>
<div class="stack detail-body">
<p class="small">{pct(percent(client.ms, stats.workedMs))} du travail · {client.days} journée(s) · {client.count} poste(s) · moyenne {fmtDuration(client.averageMs)} / jour concerné.</p>
<ul class="lines">
{#each stats.pairs.filter((pair) => pair.client === client.key) as pair (pair.key)}
<li class="row"><span>{pair.name}</span><strong class="tnum">{fmtDuration(pair.ms)}</strong></li>
{/each}
</ul>
</div>
</details>
{/each}
</section>
<section class="card stack">
<h2>Heures par tâche</h2>
{#if !stats.tasks.length}<p>Aucun poste de travail enregistré.</p>{/if}
{#each stats.tasks as task (task.key)}
<details class="breakdown">
<summary><span>{task.name}</span><strong class="tnum">{fmtDuration(task.ms)}</strong></summary>
<div class="stack detail-body">
<p class="small">{pct(percent(task.ms, stats.workedMs))} du travail · {task.days} journée(s) · {task.count} poste(s) · moyenne {fmtDuration(task.averageMs)} / jour concerné.</p>
<ul class="lines">
{#each stats.pairs.filter((pair) => pair.name === task.key) as pair (pair.key)}
<li class="row"><span>{pair.clientName}</span><strong class="tnum">{fmtDuration(pair.ms)}</strong></li>
{/each}
</ul>
</div>
</details>
{/each}
</section>
<section class="card stack">
<h2>Répartition des repas</h2>
<p>{stats.mealDays} journée(s) avec une pause repas enregistrée · {fmtDuration(stats.lunchMs)} au total.</p>
<ul class="lines">
{#each stats.meals as meal (meal.key)}
<li class="stack"><div class="row"><strong>{meal.name}</strong><span>{meal.days} jour(s)</span></div>
<p class="small">{pct(percent(meal.days, stats.mealDays))} des journées avec repas · {fmtDuration(meal.ms)} · moyenne {fmtDuration(meal.averageMs)}.</p>
</li>
{/each}
</ul>
<p>{stats.noMealDays} journée(s) terminée(s) sans pause repas enregistrée.</p>
{#if stats.restaurants.length}
<h3>Restaurants</h3>
<ul class="lines">{#each stats.restaurants as restaurant (restaurant.key)}
<li class="stack"><strong>{restaurant.name}</strong><span class="small">{restaurant.days} jour(s) · {fmtDuration(restaurant.ms)} · moyenne {fmtDuration(restaurant.averageMs)}.</span></li>
{/each}</ul>
{/if}
</section>
{#if stats.weekdays.length}
<details class="card stack">
<summary>Rythme selon le jour de la semaine</summary>
<p class="small dim">Uniquement les journées terminées enregistrées ; les effectifs peuvent différer.</p>
<ul class="lines">{#each stats.weekdays as row (row.index)}
<li class="stack"><div class="row"><strong>{weekdayLabels[row.index]}</strong><span>{fmtDuration(row.averageMs)} / jour</span></div><p class="small">{row.days} journée(s) · {fmtDuration(row.ms)} au total.</p></li>
{/each}</ul>
</details>
{/if}
<details class="card stack">
<summary>Détail des {stats.days} journées</summary>
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Keyboard focus enables horizontal scrolling of this labelled table region.) -->
<div class="table-wrap" role="region" aria-label="Détail quotidien" tabindex="0">
<table>
<caption>Travail hors repas et pauses par journée</caption>
<thead><tr><th scope="col">Date</th><th scope="col">Travail</th><th scope="col">Repas</th><th scope="col">Amplitude</th><th scope="col">Postes</th></tr></thead>
<tbody>{#each stats.daily as day (day.date)}
<tr><th scope="row">{dateLabel(day.date)}{day.status === 'active' ? ' (en cours)' : ''}</th><td>{fmtDuration(day.workedMs)}</td><td>{fmtDuration(day.lunchMs)}</td><td>{fmtDuration(day.spanMs)}</td><td>{day.entries}</td></tr>
{/each}</tbody>
</table>
</div>
</details>
<details class="card stack">
<summary>Points à vérifier dans les saisies</summary>
<ul class="lines small">
<li>{stats.quality.missingClientCount} poste(s) sans client : {fmtDuration(stats.quality.missingClientMs)}.</li>
<li>{stats.quality.missingRestaurantDays} journée(s) au restaurant sans nom renseigné.</li>
<li>{stats.quality.zeroEntries} poste(s) affiché(s) à zéro après arrondi.</li>
<li>{stats.quality.emptyDays} journée(s) sans poste.</li>
<li>Temps sans poste à l’intérieur des journées : {fmtDuration(stats.quality.uncoveredMs)}.</li>
<li>Chevauchements de postes : {fmtDuration(stats.quality.overlapMs)}.</li>
</ul>
</details>
{/if}
</div>
<style>
.stats { min-width: 0; }
.small { font-size: 0.85rem; }
.metrics { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.metrics > div { display: flex; flex-direction: column; gap: 4px; }
.metrics span { font-size: 0.85rem; }
.metrics strong { font-size: clamp(1rem, 6vw, 1.5rem); overflow-wrap: anywhere; }
.check { display: flex; align-items: center; gap: 12px; min-height: var(--tap); }
.check input { width: 26px; height: 26px; flex: none; accent-color: var(--accent); }
.date-range { display: flex; flex-direction: column; gap: var(--gap); }
select, input[type='date'] { width: 100%; min-width: 0; font: inherit; color: var(--fg); background: var(--bg); min-height: var(--tap); border: var(--border) solid var(--line); border-radius: var(--radius); padding: 8px; }
.row, dl > div { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; }
.row > :first-child { min-width: 0; overflow-wrap: anywhere; }
.row .tnum { flex-shrink: 0; }
dl { margin: 0; display: flex; flex-direction: column; gap: 12px; font-size: 0.85rem; }
dd { margin: 0; flex-shrink: 0; font-weight: 700; }
ul { list-style: none; margin: 0; padding: 0; }
.lines > li + li, .periods > li + li { border-top: 2px solid var(--line); padding-top: 12px; margin-top: 12px; }
.track { height: 14px; background: var(--surface); border: 1px solid var(--line); border-radius: 3px; overflow: hidden; }
.track > div { height: 100%; background: var(--accent); }
summary { cursor: pointer; min-height: var(--tap); padding: 12px 0; font-weight: 700; }
.breakdown { border-top: 2px solid var(--line); }
.breakdown summary { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; }
.breakdown summary::before { content: '+'; }
.breakdown[open] summary::before { content: '−'; }
.breakdown summary span { flex: 1; overflow-wrap: anywhere; min-width: 0; }
.breakdown summary strong { flex-shrink: 0; }
.detail-body { padding-bottom: 12px; }
.table-wrap { overflow-x: auto; max-width: 100%; }
table { border-collapse: collapse; font-size: 0.85rem; width: 100%; }
th, td { text-align: left; padding: 10px 8px; border-bottom: 2px solid var(--line); white-space: nowrap; }
caption { text-align: left; padding: 8px 0; }
[role='alert'] { color: var(--danger); }
</style>