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

@@ -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 @@
<Settings />
{:else if view === 'today'}
<Today />
{:else if view === 'stats'}
<Stats />
{:else}
<Resume />
{/if}
<nav>
<nav class:with-stats={view === 'stats'}>
<button
type="button"
class:sel={view === 'resume'}
@@ -80,6 +83,9 @@
aria-current={view === 'settings'}
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>
{/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; }
</style>

View File

@@ -93,11 +93,13 @@
{: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>
{/if}
<label class="stack">Type de poste
<select bind:value={draft.kind}>
<option value="work">Travail</option><option value="travel">Transport</option><option value="lunch">Repas</option>
</select>
<label class="meal-toggle">
<input type="checkbox" checked={draft.kind === 'lunch'} onchange={(event) => {
draft.kind = event.currentTarget.checked ? 'lunch' : mode === 'edit' && original.kind === 'travel' ? 'travel' : 'work';
}} />
Pause déjeuner
</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}
min={mode === 'insert' ? original.startTs : previous?.startTs ?? startOfDay(day.date)}
max={draft.endTs} onchange={(ts) => (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); }
</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.
*
* `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
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 dayState.closedDays.length > 1}
<label class="stack">
<span>Journée à consulter</span>
<select value={selectedDay?.date} onchange={(event) => { selectedDate = event.currentTarget.value; copied = false; editing = false; }}>
{#each dayState.closedDays as day (day.date)}
<option value={day.date}>{fmtDayLabel(day.date)} {day.date.slice(0, 4)}</option>
@@ -72,10 +71,6 @@
</p>
</div>
{:else}
<div class="row-between">
<h1>{resume.label}</h1>
</div>
<div class="card stack">
{#each resume.clients as client (client.name)}
<div class="client">

View File

@@ -1,5 +1,6 @@
<script>
import BackupPanel from '../components/BackupPanel.svelte';
import { go } from '../lib/router.svelte.js';
import { cleanupOldDays } from '../lib/db.js';
import { dayState, refresh } from '../lib/day.svelte.js';
import {
@@ -27,7 +28,11 @@
</script>
<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}
<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>