Added automation
This commit is contained in:
17
src/lib/automation-options.js
Normal file
17
src/lib/automation-options.js
Normal 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
105
src/lib/automation.js
Normal 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
235
src/lib/automation.test.js
Normal 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 });
|
||||
});
|
||||
});
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user