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

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