Files
pardis/src/lib/automation.js
2026-09-20 14:38:17 +02:00

106 lines
5.9 KiB
JavaScript

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