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} +

{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.

    {#each stats.meals as meal (meal.key)}
  • {meal.name}{meal.days} jour(s)
    @@ -196,6 +198,7 @@
  • Temps sans poste à l’intérieur des journées : {fmtDuration(stats.quality.uncoveredMs)}.
  • Chevauchements de postes : {fmtDuration(stats.quality.overlapMs)}.
+

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}