Added automation

This commit is contained in:
2026-09-20 14:38:17 +02:00
parent daa4bbd028
commit c9c524ff2a
13 changed files with 489 additions and 28 deletions

View File

@@ -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

View File

@@ -39,6 +39,7 @@
<span class="dur tnum">{fmtDuration(duration(entry))}</span>
<span class="what">
<strong>{entry.task}</strong>
{#if entry.automatic}<span class="dim">Auto</span>{/if}
{#if entry.client}<span class="dim">{entry.client}</span>{/if}
</span>
{#if onselect}<span class="edit" aria-hidden="true">✎</span>{/if}

View File

@@ -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.' },
];

105
src/lib/automation.js Normal file
View File

@@ -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;
});
}

235
src/lib/automation.test.js Normal file
View File

@@ -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 });
});
});

View File

@@ -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) =>

View File

@@ -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,

View File

@@ -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<EntryRecord, 'kind'|'task'|'client'>|null} next
* @param {(Pick<EntryRecord, 'kind'|'task'|'client'> & {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<number|null>} 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;

View File

@@ -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 });
}
});
}

View File

@@ -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',

View File

@@ -56,6 +56,7 @@
{#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>
@@ -71,6 +72,10 @@
</p>
</div>
{:else}
<div class="row-between">
<h1>{resume.label}</h1>
</div>
<div class="card stack">
{#each resume.clients as client (client.name)}
<div class="client">

View File

@@ -1,6 +1,7 @@
<script>
import BackupPanel from '../components/BackupPanel.svelte';
import { go } from '../lib/router.svelte.js';
import { AUTOMATION_OPTIONS } from '../lib/automation-options.js';
import { cleanupOldDays } from '../lib/db.js';
import { dayState, refresh } from '../lib/day.svelte.js';
import {
@@ -28,6 +29,8 @@
</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>
@@ -110,7 +113,23 @@
>
</div>
<p class="dim small">Type par défaut ; modifiable chaque jour.</p>
<p class="dim small">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.</p>
<p class="dim small">Utiliser ces horaires pour les rappels et, si activée ci-dessous, la pause automatique.</p>
</section>
<section class="card stack">
<h2>Automatismes</h2>
{#each AUTOMATION_OPTIONS as option (option.key)}
<div class="automation-option stack">
<label class="automation-toggle">
<input type="checkbox" checked={settings[option.key]} aria-describedby={`help-${option.key}`}
onchange={(event) => updateSettings({ [option.key]: event.currentTarget.checked })} />
{option.name}
</label>
<p class="small dim" id={`help-${option.key}`}>{option.description}</p>
</div>
{/each}
<p class="small dim">Conserver les saisies manuelles. Sans tâche précédente, utiliser « Travaux sur site ». Une pause lancée ou corrigée manuellement reste manuelle.</p>
<p class="small dim">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.</p>
</section>
<section class="card stack">
@@ -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); }
</style>

View File

@@ -89,6 +89,7 @@
{#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}
<p class="small dim">Ces observations décrivent les saisies de la période ; elles ne mesurent pas la productivité.</p>
</section>
<section class="card stack">
@@ -146,6 +147,7 @@
<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>
<p class="small dim">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.</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>
@@ -196,6 +198,7 @@
<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>
<p class="small dim">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.</p>
</details>
{/if}
</div>