Added automation
This commit is contained in:
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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user