From c9c524ff2a607b8f90e6bf554dd134f5b0d636e2 Mon Sep 17 00:00:00 2001
From: "Adrien W."
Date: Sun, 20 Sep 2026 14:38:17 +0200
Subject: [PATCH] Added automation
---
README.md | 50 ++++++-
src/components/Timeline.svelte | 1 +
src/lib/automation-options.js | 17 +++
src/lib/automation.js | 105 +++++++++++++++
src/lib/automation.test.js | 235 +++++++++++++++++++++++++++++++++
src/lib/backup.js | 6 +
src/lib/day.svelte.js | 40 +++---
src/lib/db.js | 12 +-
src/lib/editing.js | 17 ++-
src/lib/settings.svelte.js | 2 +
src/routes/Resume.svelte | 5 +
src/routes/Settings.svelte | 24 +++-
src/routes/Stats.svelte | 3 +
13 files changed, 489 insertions(+), 28 deletions(-)
create mode 100644 src/lib/automation-options.js
create mode 100644 src/lib/automation.js
create mode 100644 src/lib/automation.test.js
diff --git a/README.md b/README.md
index 588ca75..a99b59b 100644
--- a/README.md
+++ b/README.md
@@ -73,14 +73,60 @@ il n'est pas publié avec l'application.
ne peut pas recevoir un nouveau poste actif. La purge épargne les journées
actives et conserve toujours la dernière journée terminée.
- Les horaires de déjeuner affichent un rappel puis une heure indicative de
- reprise, calculée depuis le début réel de la pause. Les transitions restent
- manuelles : aucun temps de travail n'est transformé automatiquement en pause.
+ reprise. Les transitions restent manuelles sauf si l'option **Pause déjeuner**
+ est activée dans les automatismes.
- Dans **Récap**, le sélecteur **Journée à consulter** permet d'afficher et de copier
chaque journée terminée encore conservée, y compris les journées importées.
Les tests couvrent notamment les clôtures oubliées, les reprises, les appels
concurrents, les retours d'horloge, les échecs transactionnels et la conservation.
+## Automatismes
+
+Dans **Réglages → Automatismes**, chaque comportement a son interrupteur :
+
+| Option | Comportement |
+| --- | --- |
+| Ouverture | Démarrer aux horaires et jours prévus. Activée par défaut, comme auparavant. |
+| Début de journée | Au bouton « Arrivé sur site », proposer la dernière tâche de travail de la précédente journée enregistrée, hors repas et transport. |
+| Pause déjeuner | Ouvrir la pause à l'heure habituelle et reprendre la tâche précédente après la durée réglée. Reprendre le type de repas et le restaurant de la précédente journée enregistrée. |
+| Arrivée sur chantier | Lors du repas automatique, transformer un transport initial non renseigné en un transport de 1/5/15/30 minutes, selon l'arrondi, suivi de la tâche précédente. |
+| Fin de demi-journée | À la pause ou à la clôture, remplacer une plage automatique non renseignée par la dernière tâche de travail connue. Conserver tout poste manuel. |
+| Fin de journée | Clôturer à l'heure prévue sans confirmation. |
+
+Les cinq nouvelles options sont désactivées par défaut. Elles sont conservées
+dans les sauvegardes et rétablies lors d'un import en remplacement. L'option
+**Arrivée sur chantier** intervient seulement avec un repas automatique.
+Sans cette estimation de transport, **Fin de demi-journée** peut remplacer le
+transport initial non renseigné par du travail sur toute la plage. Une tâche de
+travail déjà présente suffit : aucun doublon n'est ajouté à la demi-journée.
+
+« Précédente » désigne la dernière journée terminée enregistrée avant la date
+courante, y compris après un week-end. La complétion utilise la dernière tâche
+de travail connue ; sans historique de travail, elle utilise **Travaux sur site**.
+Les postes produits automatiquement portent la mention **Auto**. Les corriger
+les rend manuels et les protège des déductions suivantes. Les saisies anciennes
+sans cette information sont considérées comme manuelles.
+
+Une pause commencée ou corrigée manuellement n'est pas terminée par l'automatisme
+de déjeuner. Un choix manuel du type ou du restaurant est conservé. Une pause
+supprimée n'est pas recréée. Désactiver l'option arrête les transitions suivantes ;
+cela ne supprime pas les postes déjà enregistrés. Une durée de déjeuner nulle
+n'ajoute aucune pause automatique. La durée d'une pause automatique déjà commencée
+est conservée si le réglage change ensuite.
+
+L'app vérifie l'horloge toutes les 30 secondes et au retour à l'écran. Une PWA
+fermée ou suspendue ne peut pas exécuter du code à heure fixe : à la réouverture,
+les étapes manquées sont rattrapées avec leurs horaires prévus, sans écraser une
+action manuelle plus tardive. Les journées déjà clôturées restent intactes et les
+dates entièrement absentes ne sont pas créées. Pour une journée sans intervention,
+activer les options voulues et ouvrir l'app au moins une fois pour cette journée.
+
+Les prolongations et la reprise d'une journée sont respectées. Un démarrage
+manuel laisse au moins une heure avant une clôture automatique. Les transitions
+automatiques sont transactionnelles : une erreur annule toute l'opération et
+des appels simultanés ne créent pas de doublons.
+
## Corriger les postes et les journées terminées
Le **Total travaillé** exclut le repas, qui reste affiché séparément dans le
diff --git a/src/components/Timeline.svelte b/src/components/Timeline.svelte
index 62a93d9..f46a1c2 100644
--- a/src/components/Timeline.svelte
+++ b/src/components/Timeline.svelte
@@ -39,6 +39,7 @@
{fmtDuration(duration(entry))}
{entry.task}
+ {#if entry.automatic}Auto {/if}
{#if entry.client}{entry.client} {/if}
{#if onselect}✎ {/if}
diff --git a/src/lib/automation-options.js b/src/lib/automation-options.js
new file mode 100644
index 0000000..fcba001
--- /dev/null
+++ b/src/lib/automation-options.js
@@ -0,0 +1,17 @@
+export const AUTOMATION_DEFAULTS = {
+ autoStartDay: true,
+ autoPreviousTask: false,
+ autoLunch: false,
+ autoArrival: false,
+ autoFillHalfDay: false,
+ autoEndDay: false,
+};
+
+export const AUTOMATION_OPTIONS = [
+ { key: 'autoStartDay', name: 'Ouverture', description: 'Démarrer la journée à l’heure prévue, les jours travaillés.' },
+ { key: 'autoPreviousTask', name: 'Début de journée', description: 'À l’arrivée sur chantier, reprendre la dernière tâche de travail de la journée précédente enregistrée.' },
+ { key: 'autoLunch', name: 'Pause déjeuner', description: 'Commencer le repas à l’heure habituelle, puis reprendre le travail après la durée prévue. Reprendre le type de repas et le restaurant de la journée précédente enregistrée.' },
+ { key: 'autoArrival', name: 'Arrivée sur chantier', description: 'Au repas automatique, limiter le transport non renseigné au pas d’arrondi choisi, puis remplir la matinée avec la tâche précédente.' },
+ { key: 'autoFillHalfDay', name: 'Fin de demi-journée', description: 'À la pause ou à la clôture, compléter une plage non renseignée avec la dernière tâche de travail connue, sans modifier les postes manuels.' },
+ { key: 'autoEndDay', name: 'Fin de journée', description: 'Clôturer la journée à l’heure de fin prévue, sans demander de confirmation.' },
+];
diff --git a/src/lib/automation.js b/src/lib/automation.js
new file mode 100644
index 0000000..c24f2eb
--- /dev/null
+++ b/src/lib/automation.js
@@ -0,0 +1,105 @@
+import { closeAndStart, closeDay, db, getDay, getEntries, getHistory, readSettings } from './db.js';
+import { SEED_TASKS } from './predict.js';
+import { atLocalTime, MINUTE } from './time.js';
+
+/** Previous means the latest closed recorded day, not necessarily yesterday. */
+export function previousContext(days, entries, date) {
+ const previousDays = days.filter((d) => d.date < date && d.status === 'closed').sort((a, b) => b.date.localeCompare(a.date));
+ const previousDay = previousDays[0] ?? null;
+ const previousWork = previousDay ? entries.filter((e) => e.date === previousDay.date && e.kind === 'work').sort((a, b) => b.seq - a.seq)[0] : null;
+ const dates = new Set(previousDays.map((d) => d.date));
+ const lastKnownWork = entries.filter((e) => dates.has(e.date) && e.kind === 'work')
+ .sort((a, b) => b.date.localeCompare(a.date) || b.seq - a.seq)[0] ?? null;
+ return { previousDay, previousWork: previousWork ?? null, lastKnownWork };
+}
+
+/** All clock events and their inferred entries commit together, across tabs too. */
+export function automateDay(date, config, now = Date.now(), command = {}) {
+ return db.transaction('rw', db.days, db.entries, db.settings, async () => {
+ config = { ...config, ...await readSettings() };
+ if (!command.lunch && !command.end && !command.stale && !config.autoLunch && !config.autoEndDay) return false;
+ let day = await getDay(date);
+ if (day?.status !== 'active') return false;
+ let entries = await getEntries(date);
+ const current = () => entries.find((e) => e.endTs == null);
+ const reload = async () => { entries = await getEntries(date); day = await getDay(date); };
+ if (command.lunch && command.expectedId !== (current()?.id ?? null)) return false;
+ const history = await getHistory();
+ const { previousDay, lastKnownWork } = previousContext(history.days, history.entries, date);
+ const lastWork = () => [...entries].reverse().find((e) => e.kind === 'work') ?? lastKnownWork;
+ /** @returns {{kind: 'work', task: string, client: string|null, automatic: boolean}} */
+ const slot = () => ({ kind: 'work', task: lastWork()?.task || SEED_TASKS[1], client: lastWork()?.client ?? current()?.client ?? null, automatic: true });
+ const lastActivity = () => entries.reduce((ts, e) => Math.max(ts, e.startTs, e.endTs ?? e.startTs), day.startedTs);
+ const finishAt = command.end ? Math.max(now, lastActivity()) :
+ command.stale || config.autoEndDay && now >= day.plannedEndTs ? Math.max(day.plannedEndTs, lastActivity()) : null;
+ const horizon = finishAt ?? now;
+
+ async function completeHalf(boundary, inferArrival) {
+ const running = current();
+ const halfStart = [...entries].reverse().find((e) => e.kind === 'lunch' && e.endTs != null && e.endTs <= boundary)?.endTs ?? day.startedTs;
+ const half = entries.filter((e) => e.startTs < boundary && (e.endTs ?? boundary) > halfStart);
+ if (half.some((e) => e.kind === 'work')) return;
+ // Missing provenance on old records is treated as manual, never inferred.
+ if (running?.kind === 'travel' && running.automatic === true && half.every((e) => e.automatic === true)) {
+ if (inferArrival && entries.length === 1) {
+ const arrived = running.startTs + config.roundingMinutes * MINUTE;
+ if (arrived < boundary) {
+ await closeAndStart(date, arrived, slot()); await reload();
+ }
+ } else if (config.autoFillHalfDay) {
+ await db.entries.update(running.id, slot()); await reload();
+ }
+ } else if (!entries.length && config.autoFillHalfDay && boundary > day.startedTs) {
+ await closeAndStart(date, day.startedTs, slot()); await reload();
+ }
+ }
+
+ async function beginLunch(at, automatic) {
+ await completeHalf(at, automatic && config.autoArrival);
+ await closeAndStart(date, at, { kind: 'lunch', task: 'Pause déjeuner', client: null, automatic });
+ const patch = { autoLunchHandled: true, autoLunchEndTs: automatic ? at + config.lunchMinutes * MINUTE : null };
+ if (automatic && !day.lunchManual && previousDay) {
+ Object.assign(patch, { lunchType: previousDay.lunchType ?? config.lunchType, restaurant: previousDay.lunchType === 'restaurant' ? previousDay.restaurant ?? null : null });
+ }
+ await db.days.update(date, patch); await reload();
+ }
+
+ if (command.lunch) {
+ if (current()?.kind === 'lunch') return false;
+ await beginLunch(Math.max(now, lastActivity()), false);
+ return true;
+ }
+
+ const lunchAt = atLocalTime(date, config.lunchStart);
+ if (config.autoLunch && config.lunchMinutes > 0 && !day.autoLunchHandled &&
+ !entries.some((e) => e.kind === 'lunch') && lunchAt >= day.startedTs &&
+ lunchAt < day.plannedEndTs && lunchAt < (finishAt ?? Infinity) && lunchAt <= horizon) {
+ // A late manual action blocks backdated replacement of the morning.
+ if (current() && lastActivity() <= lunchAt) await beginLunch(lunchAt, true);
+ else { await db.days.update(date, { autoLunchHandled: true }); await reload(); }
+ }
+
+ if (config.autoLunch && current()?.kind === 'lunch' && current().automatic === true &&
+ day.autoLunchEndTs != null && day.autoLunchEndTs <= horizon &&
+ (finishAt == null || day.autoLunchEndTs < finishAt)) {
+ await closeAndStart(date, Math.max(day.autoLunchEndTs, current().startTs), slot()); await reload();
+ }
+ if (finishAt != null) {
+ await completeHalf(finishAt, false);
+ await closeDay(date, finishAt);
+ }
+ return true;
+ });
+}
+
+/** A manual transition claims its old entry as well as its new one. */
+export function manualAdvance(date, ts, next, expectedId) {
+ return db.transaction('rw', db.days, db.entries, async () => {
+ const id = await closeAndStart(date, ts, next, expectedId);
+ if (id != null && expectedId != null) {
+ const old = await db.entries.get(expectedId);
+ if (old?.automatic) await db.entries.update(expectedId, { automatic: false });
+ }
+ return id;
+ });
+}
diff --git a/src/lib/automation.test.js b/src/lib/automation.test.js
new file mode 100644
index 0000000..edbfe08
--- /dev/null
+++ b/src/lib/automation.test.js
@@ -0,0 +1,235 @@
+import 'fake-indexeddb/auto';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { automateDay } from './automation.js';
+import { AUTOMATION_DEFAULTS } from './automation-options.js';
+import { parseBackup, parseSettings } from './backup.js';
+import { db, exportBackup, getDay, getEntries, importBackup, readSettings, writeSettings } from './db.js';
+import { advance, dayState, editEntry, endDay, keepWorking, predictNext, reconcile, refresh, reopenToday, setLunch, startDayNow, startLunch } from './day.svelte.js';
+import { removeRecordedEntry, saveRecordedEntry } from './editing.js';
+import { DEFAULT_SETTINGS, loadSettings, settings } from './settings.svelte.js';
+import { buildResume } from './resume.js';
+import { atLocalTime, HOUR, MINUTE } from './time.js';
+
+const DATE = '2026-09-07', PREV = '2026-09-04';
+const at = (time, date = DATE) => atLocalTime(date, time);
+const allOn = () => Object.assign(settings, Object.fromEntries(Object.keys(AUTOMATION_DEFAULTS).map((key) => [key, true])));
+const tick = async (time, date = DATE) => { vi.setSystemTime(at(time, date)); await reconcile(at(time, date)); };
+const rows = () => getEntries(DATE);
+const snapshot = async () => ({ days: await db.days.toArray(), entries: await db.entries.toArray() });
+
+beforeEach(async () => {
+ vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(at('07:00'));
+ vi.stubGlobal('document', { documentElement: { dataset: {} } });
+ await db.days.clear(); await db.entries.clear(); await db.settings.clear();
+ Object.assign(settings, DEFAULT_SETTINGS, { retentionDays: 0 }); dayState.importing = false;
+ await db.days.put({ date: PREV, status: 'closed', startedTs: at('08:00', PREV), endedTs: at('17:00', PREV), plannedStartTs: at('08:00', PREV), plannedEndTs: at('17:00', PREV), lunchType: 'restaurant', restaurant: 'Le Relais', closePromptedTs: null });
+ await db.entries.bulkAdd([
+ { date: PREV, seq: 0, kind: 'work', task: 'Pose', client: 'Client A', startTs: at('08:00', PREV), endTs: at('16:30', PREV) },
+ { date: PREV, seq: 1, kind: 'travel', task: 'Retour', client: 'Client A', startTs: at('16:30', PREV), endTs: at('17:00', PREV) },
+ ]);
+ await refresh();
+});
+afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); vi.useRealTimers(); });
+
+describe('hands-free day', () => {
+ it('catches up a whole day at its planned times, including the inferred arrival', async () => {
+ allOn(); await tick('18:00');
+ const day = await getDay(DATE), list = await rows();
+ expect(day).toMatchObject({ status: 'closed', startedTs: at('07:30'), endedTs: at('17:30'), lunchType: 'restaurant', restaurant: 'Le Relais' });
+ expect(list.map(e => [e.kind, e.startTs, e.endTs])).toEqual([
+ ['travel', at('07:30'), at('07:45')], ['work', at('07:45'), at('12:00')],
+ ['lunch', at('12:00'), at('13:00')], ['work', at('13:00'), at('17:30')],
+ ]);
+ expect(list.filter(e => e.kind === 'work').every(e => e.task === 'Pose' && e.client === 'Client A')).toBe(true);
+ expect(list.every(e => e.automatic)).toBe(true);
+ expect(buildResume(day, list).workedMs).toBe(9 * HOUR);
+ const before = await snapshot(); await tick('19:00'); expect(await snapshot()).toEqual(before);
+ });
+
+ it('runs each boundary once across frequent and concurrent reconciliations', async () => {
+ allOn(); await tick('11:59'); expect(await rows()).toHaveLength(1);
+ await Promise.all([reconcile(at('12:00')), reconcile(at('12:00'))]);
+ expect((await rows()).at(-1).kind).toBe('lunch'); expect(await rows()).toHaveLength(3);
+ const before = await rows(); await tick('12:30'); expect(await rows()).toEqual(before);
+ await tick('13:00'); expect((await rows()).at(-1).task).toBe('Pose');
+ await tick('17:30'); expect((await getDay(DATE)).status).toBe('closed');
+ });
+
+ it.each([1, 5, 15, 30])('uses the %i-minute precision as the inferred travel duration', async (roundingMinutes) => {
+ allOn(); settings.roundingMinutes = roundingMinutes; await tick('12:00');
+ const first = (await rows())[0]; expect(first.endTs - first.startTs).toBe(roundingMinutes * MINUTE);
+ });
+
+ it('settles a stale started day before opening today, without inventing absent days', async () => {
+ allOn(); await tick('08:00'); await tick('07:00', '2026-09-09');
+ expect((await getDay(DATE)).endedTs).toBe(at('17:30')); expect(await rows()).toHaveLength(4);
+ expect(await getDay('2026-09-08')).toBeUndefined(); expect(await getDay('2026-09-09')).toBeUndefined();
+ });
+
+ it('uses a named fallback when there is no previous work', async () => {
+ await db.days.clear(); await db.entries.clear(); allOn(); await tick('18:00');
+ expect((await rows()).filter(e => e.kind === 'work').map(e => e.task)).toEqual(['Travaux sur site', 'Travaux sur site']);
+ });
+});
+
+describe('independent options', () => {
+ it('preserves the old behavior with new options disabled', async () => {
+ await tick('18:00'); expect(await rows()).toHaveLength(1);
+ expect((await getDay(DATE)).status).toBe('active'); expect(dayState.needsClosePrompt).toBe(true);
+ });
+
+ it('can disable automatic opening while keeping manual opening available', async () => {
+ allOn(); settings.autoStartDay = false; await tick('10:00'); expect(await getDay(DATE)).toBeUndefined();
+ await startDayNow(at('10:00')); expect((await getDay(DATE)).startedTs).toBe(at('10:00'));
+ });
+
+ it('leaves an hour for a manually started day after the usual end', async () => {
+ allOn(); await startDayNow(at('18:00')); await tick('18:30');
+ expect((await getDay(DATE))).toMatchObject({ status: 'active', plannedEndTs: at('19:00') });
+ });
+
+ it('proposes the last work task, ignoring the final return trip and weekends', async () => {
+ settings.autoPreviousTask = true; await tick('08:00'); expect(predictNext().task).toBe('Pose');
+ await advance({ task: predictNext().task, client: predictNext().client }, at('08:00'));
+ expect((await rows()).at(-1).task).toBe('Pose');
+ });
+
+ it('does not infer arrival when lunch automation is off', async () => {
+ settings.autoArrival = true; await tick('12:00'); expect(await rows()).toHaveLength(1);
+ });
+
+ it('does not shorten transport unless arrival inference or half-day completion is enabled', async () => {
+ settings.autoLunch = true; await tick('13:00');
+ expect((await rows()).map(e => e.kind)).toEqual(['travel', 'lunch', 'work']);
+ expect((await rows())[0].endTs).toBe(at('12:00'));
+ });
+
+ it('fills an untouched half-day at a manual lunch or manual close', async () => {
+ settings.autoFillHalfDay = true; await tick('08:00'); await startLunch(at('12:00'));
+ expect((await rows())[0]).toMatchObject({ kind: 'work', task: 'Pose', startTs: at('07:30'), endTs: at('12:00') });
+ await advance({ task: 'Finitions', client: 'B' }, at('13:00')); await endDay(at('17:00'));
+ expect((await rows()).at(-1)).toMatchObject({ task: 'Finitions', client: 'B' });
+ });
+
+ it('fills a full untouched day when no lunch was recorded', async () => {
+ settings.autoFillHalfDay = true; await tick('08:00'); await endDay(at('17:00'));
+ expect(await rows()).toHaveLength(1); expect((await rows())[0].task).toBe('Pose');
+ });
+
+ it('keeps an automatically started pause open while the option is disabled', async () => {
+ allOn(); await tick('12:00'); settings.autoLunch = false; await tick('13:15');
+ expect((await rows()).at(-1).kind).toBe('lunch');
+ settings.autoLunch = true; await tick('13:15'); expect((await rows()).at(-1).startTs).toBe(at('13:00'));
+ });
+
+ it('keeps the original scheduled end if lunch duration changes mid-pause', async () => {
+ allOn(); await tick('12:00'); settings.lunchMinutes = 5; await tick('12:30');
+ expect((await rows()).at(-1).kind).toBe('lunch'); await tick('13:00'); expect((await rows()).at(-1).kind).toBe('work');
+ });
+});
+
+describe('manual priority and edge cases', () => {
+ it('preserves actual arrival, task, client and restaurant corrections', async () => {
+ allOn(); await tick('08:00'); await advance({ task: 'Urgence', client: 'B' }, at('09:00'));
+ await setLunch({ lunchType: 'restaurant', restaurant: 'Choix du jour' }); await tick('18:00');
+ const list = await rows(); expect(list[0].endTs).toBe(at('09:00'));
+ expect(list[1]).toMatchObject({ task: 'Urgence', client: 'B' });
+ expect(list.at(-1)).toMatchObject({ task: 'Urgence', client: 'B' });
+ expect((await getDay(DATE)).restaurant).toBe('Choix du jour');
+ });
+
+ it('does not shorten a manually edited transport placeholder', async () => {
+ allOn(); await tick('08:00'); await editEntry((await rows())[0].id, { client: 'Autre client' }); await tick('12:00');
+ expect((await rows())[0]).toMatchObject({ kind: 'travel', client: 'Autre client', endTs: at('12:00') });
+ });
+
+ it('keeps a manually started lunch manual, without a second automatic pause', async () => {
+ allOn(); await tick('08:00'); await startLunch(at('11:45')); await tick('13:30');
+ expect((await rows()).at(-1)).toMatchObject({ kind: 'lunch', startTs: at('11:45'), endTs: null });
+ await advance({ task: 'Autre', client: 'B' }, at('13:30')); await tick('14:00');
+ expect((await rows()).filter(e => e.kind === 'lunch')).toHaveLength(1);
+ });
+
+ it('stops automatic resumption after editing the automatic lunch', async () => {
+ allOn(); await tick('12:15'); const lunch = (await rows()).at(-1);
+ await saveRecordedEntry(lunch, { ...lunch, lunchType: 'panier' }, at('12:15')); await tick('13:15');
+ expect((await rows()).at(-1)).toMatchObject({ kind: 'lunch', automatic: false, endTs: null });
+ });
+
+ it('does not recreate a deleted lunch after changing the scheduled hour', async () => {
+ allOn(); await tick('13:15'); await removeRecordedEntry((await rows()).find(e => e.kind === 'lunch'));
+ settings.lunchStart = '14:00'; await tick('14:00'); expect((await rows()).some(e => e.kind === 'lunch')).toBe(false);
+ });
+
+ it('does not backdate lunch across a later manual action when enabling late', async () => {
+ await tick('08:00'); await advance({ task: 'Après-midi', client: 'B' }, at('14:00'));
+ const before = await rows(); allOn(); await tick('15:00'); expect(await rows()).toEqual(before);
+ });
+
+ it('never rewrites legacy entries without provenance', async () => {
+ await tick('08:00'); const first = (await rows())[0]; await db.entries.put({ ...first, automatic: undefined });
+ allOn(); await tick('12:00'); expect((await rows())[0].kind).toBe('travel'); expect((await rows())[0].endTs).toBe(at('12:00'));
+ });
+
+ it('skips zero-duration lunches and lunches outside the day', async () => {
+ allOn(); settings.lunchMinutes = 0; await tick('18:00'); expect((await rows()).some(e => e.kind === 'lunch')).toBe(false);
+ });
+
+ it('skips a lunch before a late start', async () => {
+ allOn(); await startDayNow(at('14:00')); await tick('18:00'); expect((await rows()).some(e => e.kind === 'lunch')).toBe(false);
+ });
+
+ it('does not create negative work when the morning is shorter than the travel estimate', async () => {
+ allOn(); await startDayNow(at('11:55')); await tick('12:00');
+ expect((await rows()).map(e => e.kind)).toEqual(['travel', 'lunch']);
+ expect((await rows())[0].endTs - (await rows())[0].startTs).toBe(5 * MINUTE);
+ });
+
+ it('closes a lunch at an early manual end without inventing afternoon work', async () => {
+ allOn(); await tick('08:00'); await endDay(at('12:30'));
+ expect((await rows()).at(-1)).toMatchObject({ kind: 'lunch', endTs: at('12:30') });
+ });
+
+ it('respects extensions and a manual reopening', async () => {
+ allOn(); await tick('16:00'); await keepWorking(at('17:30')); await tick('18:00');
+ expect((await getDay(DATE)).status).toBe('active'); await tick('18:30');
+ await reopenToday(at('18:45')); await tick('19:00'); expect((await getDay(DATE)).status).toBe('active');
+ expect((await rows()).filter(e => e.kind === 'lunch')).toHaveLength(1);
+ });
+});
+
+describe('atomicity and persistence', () => {
+ it('honors an option disabled in another tab even with stale in-memory settings', async () => {
+ allOn(); await tick('08:00'); await writeSettings({ autoLunch: false, autoEndDay: false });
+ await automateDay(DATE, settings, at('18:00'));
+ expect(await rows()).toHaveLength(1); expect((await getDay(DATE)).status).toBe('active');
+ });
+
+ it('honors opening disabled in persisted settings before reconciling', async () => {
+ allOn(); await writeSettings({ autoStartDay: false }); await tick('10:00');
+ expect(await getDay(DATE)).toBeUndefined();
+ });
+ it('serializes independent clock callers without duplicate inferred entries', async () => {
+ allOn(); await tick('08:00'); await Promise.all([automateDay(DATE, settings, at('18:00')), automateDay(DATE, settings, at('18:00'))]);
+ expect(await rows()).toHaveLength(4); expect((await getDay(DATE)).status).toBe('closed');
+ });
+
+ it('rolls back travel inference and lunch together when metadata fails', async () => {
+ allOn(); await tick('08:00'); const before = await snapshot();
+ vi.spyOn(db.days, 'update').mockRejectedValueOnce(new Error('disk'));
+ await expect(automateDay(DATE, settings, at('12:00'))).rejects.toThrow('disk');
+ expect(await snapshot()).toEqual(before);
+ });
+
+ it('round-trips switches, provenance and handled lunch flags in backups', async () => {
+ allOn(); await tick('18:00'); const config = Object.fromEntries(Object.keys(AUTOMATION_DEFAULTS).map(k => [k, settings[k]]));
+ await writeSettings(config); const backup = parseBackup(JSON.stringify(await exportBackup()));
+ expect(backup.settings).toEqual(config); expect(backup.entries.filter(e => e.date === DATE).every(e => e.automatic)).toBe(true);
+ expect(backup.days.find(d => d.date === DATE).autoLunchHandled).toBe(true);
+ await importBackup(backup, 'replace'); await loadSettings(); expect(await readSettings()).toEqual(config);
+ });
+
+ it.each(Object.keys(AUTOMATION_DEFAULTS))('rejects non-boolean %s settings', (key) => {
+ expect(() => parseSettings({ [key]: 'true' })).toThrow(); expect(parseSettings({ [key]: false })).toEqual({ [key]: false });
+ });
+});
diff --git a/src/lib/backup.js b/src/lib/backup.js
index a595809..3a7e987 100644
--- a/src/lib/backup.js
+++ b/src/lib/backup.js
@@ -8,6 +8,7 @@
*/
import { localDateKey } from './time.js';
+import { AUTOMATION_DEFAULTS } from './automation-options.js';
export const EXPORT_FORMAT = 'timelog-export';
export const EXPORT_VERSION = 1;
@@ -32,6 +33,7 @@ export function parseSettings(raw) {
if (!isObject(raw)) throw new BackupError('Réglages invalides dans le fichier.');
const clock = (v) => typeof v === 'string' && /^([01]\d|2[0-3]):[0-5]\d$/.test(v);
const rules = {
+ ...Object.fromEntries(Object.keys(AUTOMATION_DEFAULTS).map((key) => [key, (v) => typeof v === 'boolean'])),
workdays: (v) => Array.isArray(v) && v.every((n) => Number.isInteger(n) && n >= 0 && n <= 6),
dayStart: clock,
dayEnd: clock,
@@ -192,6 +194,7 @@ function normalise(rawDays, rawEntries, exportTs) {
client: typeof entry.client === 'string' && entry.client ? entry.client : null,
startTs: entry.startTs,
endTs: isTimestamp(entry.endTs) ? entry.endTs : null,
+ ...(typeof entry.automatic === 'boolean' ? { automatic: entry.automatic } : {}),
}));
for (let i = 0; i < repaired.length - 1; i++) {
@@ -227,6 +230,9 @@ function normalise(rawDays, rawEntries, exportTs) {
: null,
closePromptedTs: rawDay.status !== 'active' && isTimestamp(rawDay.closePromptedTs)
? rawDay.closePromptedTs : null,
+ ...(typeof rawDay.autoLunchHandled === 'boolean' ? { autoLunchHandled: rawDay.autoLunchHandled } : {}),
+ ...(isTimestamp(rawDay.autoLunchEndTs) ? { autoLunchEndTs: rawDay.autoLunchEndTs } : {}),
+ ...(typeof rawDay.lunchManual === 'boolean' ? { lunchManual: rawDay.lunchManual } : {}),
});
// Compare the fields we retain, not database ids or unrelated file metadata.
const changed = repaired.filter((entry, i) =>
diff --git a/src/lib/day.svelte.js b/src/lib/day.svelte.js
index 510471c..e42714d 100644
--- a/src/lib/day.svelte.js
+++ b/src/lib/day.svelte.js
@@ -8,8 +8,6 @@
import {
cleanupOldDays,
- closeAndStart,
- closeDay,
createDay,
deleteEntry,
getActiveDay,
@@ -18,10 +16,12 @@ import {
importBackup,
patchActiveDay,
patchEntry,
+ readSettings,
reopenDay,
setEntryStart,
} from './db.js';
import { buildModel, suggestClients, suggestTasks, TRANSPORT_TASK } from './predict.js';
+import { automateDay, manualAdvance, previousContext } from './automation.js';
import { loadSettings, settings } from './settings.svelte.js';
import { atLocalTime, HOUR, localDateKey, roundTs, weekday } from './time.js';
@@ -36,6 +36,7 @@ export const dayState = $state({
closedDays: [],
historyEntries: [],
todayRecord: null,
+ previousWork: null,
/** Prediction model, rebuilt whenever history changes. */
model: null,
/** True once the configured end time has passed and the worker has not answered. */
@@ -70,12 +71,15 @@ export function reconcile(now = Date.now()) {
}
async function reconcileDay(now) {
+ // A settings change in another tab must also stop this tab's clock actions.
+ const persisted = await readSettings();
+ if (persisted) Object.assign(settings, persisted);
dayState.today = localDateKey(now);
const active = await getActiveDay();
// Settle stale work before retention, so an open day cannot vanish on return.
if (active && active.date < dayState.today) {
- await closeDay(active.date, now, true);
+ await automateDay(active.date, settings, now, { stale: true });
return reconcileDay(now);
}
@@ -94,15 +98,18 @@ async function reconcileDay(now) {
// already ended must not spring back to life.
if (!active) {
const existing = await getDay(dayState.today);
- if (!existing && settings.workdays.includes(weekday(dayState.today))) {
+ if (!existing && settings.autoStartDay && settings.workdays.includes(weekday(dayState.today))) {
const plannedStart = atLocalTime(dayState.today, settings.dayStart);
if (now >= plannedStart) {
await openDay(dayState.today, plannedStart);
+ await automateDay(dayState.today, settings, now);
return refresh(now);
}
}
}
+ if (active) await automateDay(active.date, settings, now);
+
await refresh(now);
}
@@ -110,14 +117,16 @@ async function reconcileDay(now) {
* Create a day and immediately open the transport task: per the workflow, the drive
* out from base *is* the first task, and "Arrivé sur site" is what ends it.
*/
-async function openDay(dateKey, startTs) {
+async function openDay(dateKey, startTs, manual = false) {
const { entries, days } = await getHistory();
const model = buildModel(entries, days);
await createDay({
date: dateKey,
status: 'active',
plannedStartTs: atLocalTime(dateKey, settings.dayStart),
- plannedEndTs: atLocalTime(dateKey, settings.dayEnd),
+ plannedEndTs: manual && settings.autoEndDay
+ ? Math.max(atLocalTime(dateKey, settings.dayEnd), startTs + HOUR)
+ : atLocalTime(dateKey, settings.dayEnd),
startedTs: startTs,
endedTs: null,
lunchType: settings.lunchType,
@@ -127,6 +136,7 @@ async function openDay(dateKey, startTs) {
kind: 'travel',
task: TRANSPORT_TASK,
client: model.lastClient,
+ automatic: true,
});
}
@@ -142,6 +152,7 @@ export async function refresh(now = Date.now()) {
const entriesFor = (day) => day ? entries.filter((e) => e.date === day.date).sort((a, b) => a.seq - b.seq) : [];
dayState.today = localDateKey(now);
dayState.todayRecord = days.find((d) => d.date === dayState.today) ?? null;
+ dayState.previousWork = previousContext(days, entries, active?.date ?? dayState.today).previousWork;
dayState.day = active ?? null;
dayState.entries = entriesFor(active);
dayState.lastClosed = lastClosed ?? null;
@@ -161,7 +172,7 @@ export async function refresh(now = Date.now()) {
/** Manual start, for a Saturday or an early departure. */
export async function startDayNow(now = Date.now()) {
if (dayState.importing) return;
- await openDay(localDateKey(now), now);
+ await openDay(localDateKey(now), now, true);
await refresh(now);
}
@@ -174,17 +185,13 @@ export async function startDayNow(now = Date.now()) {
*/
export async function advance({ kind = 'work', task, client }, now = Date.now()) {
if (!dayState.day || dayState.importing) return;
- await closeAndStart(dayState.day.date, now, { kind, task, client: client ?? null }, running()?.id ?? null);
+ await manualAdvance(dayState.day.date, now, { kind, task, client: client ?? null }, running()?.id ?? null);
await refresh(now);
}
export async function startLunch(now = Date.now()) {
if (!dayState.day || dayState.importing || running()?.kind === 'lunch') return;
- await closeAndStart(dayState.day.date, now, {
- kind: 'lunch',
- task: 'Pause déjeuner',
- client: null,
- }, running()?.id ?? null);
+ await automateDay(dayState.day.date, settings, now, { lunch: true, expectedId: running()?.id ?? null });
await refresh(now);
}
@@ -193,6 +200,7 @@ export async function setLunch({ lunchType, restaurant }) {
await patchActiveDay(dayState.day.date, () => ({
lunchType,
restaurant: lunchType === 'restaurant' ? (restaurant ?? null) : null,
+ lunchManual: true,
}));
await refresh();
}
@@ -249,7 +257,7 @@ export async function applyImport(payload, mode) {
export async function endDay(now = Date.now()) {
if (!dayState.day || dayState.importing) return;
const date = dayState.day.date;
- await closeDay(date, now);
+ await automateDay(date, settings, now, { end: true });
await refresh(now);
}
@@ -293,7 +301,9 @@ export function predictNext(now = Date.now()) {
const clients = suggestClients(model, now);
return {
- task: tasks[0]?.value ?? TRANSPORT_TASK,
+ task: settings.autoPreviousTask && current?.kind === 'travel' && current.seq === 0
+ ? dayState.previousWork?.task ?? 'Travaux sur site'
+ : tasks[0]?.value ?? TRANSPORT_TASK,
client: current?.client ?? clients[0]?.value ?? null,
tasks,
clients,
diff --git a/src/lib/db.js b/src/lib/db.js
index 1f25b8e..a324fd0 100644
--- a/src/lib/db.js
+++ b/src/lib/db.js
@@ -17,6 +17,9 @@ import { DAY, HOUR, localDateKey } from './time.js';
* @property {'panier'|'restaurant'|null} lunchType
* @property {string|null} restaurant
* @property {number|null} closePromptedTs
+ * @property {boolean} [autoLunchHandled]
+ * @property {number|null} [autoLunchEndTs]
+ * @property {boolean} [lunchManual]
*/
/**
@@ -32,6 +35,7 @@ import { DAY, HOUR, localDateKey } from './time.js';
* @property {string|null} client
* @property {number} startTs
* @property {number|null} endTs
+ * @property {boolean} [automatic] true only for unconfirmed automatic entries
*/
/**
@@ -167,7 +171,7 @@ export function getEntries(dateKey) {
*
* @param {string} dateKey
* @param {number} ts
- * @param {Pick|null} next
+ * @param {(Pick & {automatic?: boolean})|null} next
* Pass null to only close the running entry (end of day).
* @param {number|null} [expectedId] running entry observed by the action's caller
* @returns {Promise} id of the new entry
@@ -200,7 +204,7 @@ export function closeAndStart(dateKey, ts, next, expectedId) {
}
export function patchEntry(id, patch) {
- return db.entries.update(id, patch);
+ return db.entries.update(id, { ...patch, automatic: false });
}
/**
@@ -226,8 +230,8 @@ export function setEntryStart(id, ts, now = Date.now()) {
const upper = entry.endTs ?? now;
const stored = Math.min(Math.max(ts, lower), upper);
- await db.entries.update(entry.id, { startTs: stored });
- if (prev) await db.entries.update(prev.id, { endTs: stored });
+ await db.entries.update(entry.id, { startTs: stored, ...(entry.automatic ? { automatic: false } : {}) });
+ if (prev) await db.entries.update(prev.id, { endTs: stored, ...(prev.automatic ? { automatic: false } : {}) });
else await db.days.update(entry.date, { startedTs: stored });
return stored;
diff --git a/src/lib/editing.js b/src/lib/editing.js
index eb7620d..412f979 100644
--- a/src/lib/editing.js
+++ b/src/lib/editing.js
@@ -2,7 +2,7 @@ import { db, deleteEntry, getDay, getEntries } from './db.js';
import { localDateKey } from './time.js';
const conflict = () => new Error('Ce poste a changé. Fermez puis ouvrez à nouveau la correction.');
-const fields = ['id', 'date', 'seq', 'kind', 'task', 'client', 'startTs', 'endTs'];
+const fields = ['id', 'date', 'seq', 'kind', 'task', 'client', 'startTs', 'endTs', 'automatic'];
async function readOriginal(original) {
const entry = await db.entries.get(original.id);
@@ -32,6 +32,7 @@ async function saveLunch(date, values) {
await db.days.update(date, {
lunchType: values.lunchType,
restaurant: values.lunchType === 'restaurant' ? String(values.restaurant ?? '').trim() || null : null,
+ lunchManual: true, autoLunchHandled: true,
});
}
@@ -48,9 +49,10 @@ export function saveRecordedEntry(original, values, now = Date.now()) {
throw new Error('Ces horaires dépassent le poste voisin. Corrigez d’abord ce voisin.');
}
if (!prev && localDateKey(startTs) !== day.date) throw new Error('Le début doit rester à la date de cette journée.');
- await db.entries.update(entry.id, { ...content(values), startTs, endTs });
- if (prev && startTs !== entry.startTs) await db.entries.update(prev.id, { endTs: startTs });
- if (next && endTs !== entry.endTs) await db.entries.update(next.id, { startTs: endTs });
+ await db.entries.update(entry.id, { ...content(values), startTs, endTs, ...(entry.automatic ? { automatic: false } : {}) });
+ if (prev && startTs !== entry.startTs) await db.entries.update(prev.id, { endTs: startTs, ...(prev.automatic ? { automatic: false } : {}) });
+ if (next && endTs !== entry.endTs) await db.entries.update(next.id, { startTs: endTs, ...(next.automatic ? { automatic: false } : {}) });
+ if (entry.kind === 'lunch') await db.days.update(day.date, { autoLunchHandled: true });
if (!prev) await db.days.update(day.date, { startedTs: startTs });
if (!next && day.status === 'closed') await db.days.update(day.date, { endedTs: endTs });
await saveLunch(day.date, values);
@@ -80,7 +82,7 @@ export function insertRecordedEntry(original, values, now = Date.now()) {
}
let insertedId;
for (let i = 0; i < pieces.length; i++) {
- const piece = { ...pieces[i], seq: entry.seq + i };
+ const piece = { ...pieces[i], seq: entry.seq + i, ...(entry.automatic ? { automatic: false } : {}) };
const pieceId = i === keepIndex ? (await db.entries.put({ ...piece, id }), id) : await db.entries.add(piece);
if (i === insertedIndex) insertedId = pieceId;
}
@@ -95,6 +97,9 @@ export function removeRecordedEntry(original) {
if (entries.length < 2) throw new Error('Le dernier poste de la journée ne peut pas être supprimé.');
if (entry.endTs == null) throw new Error('Le poste en cours ne peut pas être supprimé.');
await deleteEntry(entry.id);
+ if (entry.kind === 'lunch') await db.days.update(entry.date, { autoLunchHandled: true });
+ for (const e of await getEntries(entry.date)) {
+ if (e.automatic && (e.seq === entry.seq || e.seq === entry.seq - 1)) await db.entries.update(e.id, { automatic: false });
+ }
});
}
-
diff --git a/src/lib/settings.svelte.js b/src/lib/settings.svelte.js
index 6f3b83c..861449a 100644
--- a/src/lib/settings.svelte.js
+++ b/src/lib/settings.svelte.js
@@ -1,7 +1,9 @@
import { readSettings, writeSettings } from './db.js';
import { parseSettings } from './backup.js';
+import { AUTOMATION_DEFAULTS } from './automation-options.js';
export const DEFAULT_SETTINGS = {
+ ...AUTOMATION_DEFAULTS,
/** JS weekday numbers, 0 = Sunday. Monday–Friday by default. */
workdays: [1, 2, 3, 4, 5],
dayStart: '07:30',
diff --git a/src/routes/Resume.svelte b/src/routes/Resume.svelte
index 44f6be7..f65ff8d 100644
--- a/src/routes/Resume.svelte
+++ b/src/routes/Resume.svelte
@@ -56,6 +56,7 @@
{#if error}{error}
{/if}
{#if dayState.closedDays.length > 1}
+ Journée à consulter
{ selectedDate = event.currentTarget.value; copied = false; editing = false; }}>
{#each dayState.closedDays as day (day.date)}
{fmtDayLabel(day.date)} {day.date.slice(0, 4)}
@@ -71,6 +72,10 @@
{:else}
+
+
{resume.label}
+
+
{#each resume.clients as client (client.name)}
diff --git a/src/routes/Settings.svelte b/src/routes/Settings.svelte
index c90d103..2ddd1db 100644
--- a/src/routes/Settings.svelte
+++ b/src/routes/Settings.svelte
@@ -1,6 +1,7 @@
+ Réglages
+
Statistiques
Heures par client et tâche, repas et évolution de l’activité.
@@ -110,7 +113,23 @@
>
Type par défaut ; modifiable chaque jour.
-
L’heure et la durée affichent des repères sur la journée. Le début et la fin de la pause restent manuels.
+
Utiliser ces horaires pour les rappels et, si activée ci-dessous, la pause automatique.
+
+
+
+ Automatismes
+ {#each AUTOMATION_OPTIONS as option (option.key)}
+
+
+ updateSettings({ [option.key]: event.currentTarget.checked })} />
+ {option.name}
+
+
{option.description}
+
+ {/each}
+ Conserver les saisies manuelles. Sans tâche précédente, utiliser « Travaux sur site ». Une pause lancée ou corrigée manuellement reste manuelle.
+ Exécuter les automatismes pendant l’utilisation et rattraper les étapes manquées à la réouverture. Ne pas créer les journées entièrement absentes de l’historique.
@@ -222,4 +241,7 @@
border-color: var(--danger);
font-weight: 700;
}
+ .automation-option + .automation-option { border-top: 2px solid var(--line); padding-top: 12px; }
+ label.automation-toggle { justify-content: flex-start; min-height: var(--tap); }
+ .automation-toggle input { width: 26px; height: 26px; accent-color: var(--accent); }
diff --git a/src/routes/Stats.svelte b/src/routes/Stats.svelte
index 06c0101..4018c82 100644
--- a/src/routes/Stats.svelte
+++ b/src/routes/Stats.svelte
@@ -89,6 +89,7 @@
{#if stats.tasks.length && stats.workedMs}
La tâche la plus longue au total est {stats.tasks[0].name} : {fmtDuration(stats.tasks[0].ms)}, soit {pct(stats.topTaskShare)} du travail.
{/if}
+ Ces observations décrivent les saisies de la période ; elles ne mesurent pas la productivité.
@@ -146,6 +147,7 @@
Répartition des repas
{stats.mealDays} journée(s) avec une pause repas enregistrée · {fmtDuration(stats.lunchMs)} au total.
+ Plusieurs pauses repas le même jour comptent pour une journée. Le type et le restaurant sont ceux renseignés pour la journée.
+ Un temps non renseigné n’est attribué ni au travail ni au repas. Ces points peuvent expliquer un écart avec l’amplitude entre début et fin.
{/if}