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