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