/** * The day lifecycle: one state machine, one `reconcile()` entry point. * * Everything time-driven happens here — auto-start at the configured hour, * auto-close of a day left open from an earlier date, and the end-of-day grace * prompt. Components never decide when a day begins or ends. */ import { cleanupOldDays, createDay, deleteEntry, getActiveDay, getDay, getHistory, 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'; export const dayState = $state({ today: localDateKey(), /** The running day, or null when nothing is in progress. */ day: null, entries: [], /** Last closed day + its entries — what the resume screen renders. */ lastClosed: null, lastClosedEntries: [], 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. */ needsClosePrompt: false, loaded: false, /** Blocks clock reconciliation while an imported snapshot is installed. */ importing: false, }); export const running = () => dayState.entries.find((e) => e.endTs == null) ?? null; /* --------------------------------------------------------------- reconciling */ /** Retention runs once per session, not on every 30 s tick. */ let cleanedUpFor = null; const pendingReconciliations = new Set(); /** * Bring stored state in line with the clock. Safe to call often — it is invoked on * load, on tab focus, and from a 30 s tick. */ export function reconcile(now = Date.now()) { if (dayState.importing) return Promise.resolve(); if (pendingReconciliations.size) return [...pendingReconciliations][0]; const pending = reconcileDay(now); pendingReconciliations.add(pending); pending.then( () => pendingReconciliations.delete(pending), () => pendingReconciliations.delete(pending), ); return pending; } 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 automateDay(active.date, settings, now, { stale: true }); return reconcileDay(now); } // Applying retention here is what makes the setting mean anything; leaving it to // the manual button in settings meant it effectively never ran. if (cleanedUpFor !== dayState.today) { cleanedUpFor = dayState.today; try { await cleanupOldDays(settings.retentionDays, now); } catch (error) { console.error('[timelog] retention cleanup failed', error); } } // Auto-start. Only when no record exists for today at all: a day the worker has // already ended must not spring back to life. if (!active) { const existing = await getDay(dayState.today); if (!existing && settings.autoStartDay && settings.workdays.includes(weekday(dayState.today)) && !settings.nonWorkingDates.includes(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); } /** * 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, manual = false) { const { entries, days } = await getHistory(); const model = buildModel(entries, days); await createDay({ date: dateKey, status: 'active', plannedStartTs: atLocalTime(dateKey, settings.dayStart), plannedEndTs: manual && settings.autoEndDay ? Math.max(atLocalTime(dateKey, settings.dayEnd), startTs + HOUR) : atLocalTime(dateKey, settings.dayEnd), startedTs: startTs, endedTs: null, lunchType: settings.lunchType, restaurant: null, closePromptedTs: null, }, { kind: 'travel', task: TRANSPORT_TASK, client: model.lastClient, automatic: true, }, manual ? null : settings.nonWorkingDates); } /** Reload everything from the database and rebuild the model. */ let refreshVersion = 0; export async function refresh(now = Date.now()) { const version = ++refreshVersion; const { entries, days } = await getHistory(); if (version !== refreshVersion) return; const active = days.find((d) => d.status === 'active'); const closed = days.filter((d) => d.status === 'closed').sort((a, b) => b.date.localeCompare(a.date)); const lastClosed = closed[0]; 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; dayState.lastClosedEntries = entriesFor(lastClosed); dayState.closedDays = closed; dayState.historyEntries = entries; dayState.model = buildModel(entries, days); dayState.needsClosePrompt = Boolean( active && active.date === dayState.today && now >= active.plannedEndTs, ); dayState.loaded = true; } /* -------------------------------------------------------------------- actions */ /** 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, true); await refresh(now); } /** * Close the running entry and open the next one. The single mutation every button * on the Today screen goes through. * * @param {{kind?: 'travel'|'work'|'lunch', task: string, client?: string|null}} slot * @param {number} [now] */ export async function advance({ kind = 'work', task, client }, now = Date.now()) { if (!dayState.day || dayState.importing) return; 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 automateDay(dayState.day.date, settings, now, { lunch: true, expectedId: running()?.id ?? null }); await refresh(now); } export async function setLunch({ lunchType, restaurant }) { if (!dayState.day || dayState.importing) return; await patchActiveDay(dayState.day.date, () => ({ lunchType, restaurant: lunchType === 'restaurant' ? (restaurant ?? null) : null, lunchManual: true, })); await refresh(); } /** Correct the task or client of an entry already recorded. */ export async function editEntry(id, patch) { await patchEntry(id, patch); await refresh(); } /** * Correct when an entry started. Manually chosen times are snapped to the rounding * grid so the value the worker picked is exactly the value they see afterwards — * auto-captured times stay raw, as designed. */ export async function editEntryStart(id, ts) { await setEntryStart(id, roundTs(ts, settings.roundingMinutes)); await refresh(); } export async function removeEntry(id) { await deleteEntry(id); await refresh(); } /** * Apply an imported dataset, then rebuild everything that was derived from the old * one: the settings rune (replace mode adopts the file's settings), the prediction * model, and the resume. * * @param {{days: Array, entries: Array, settings: object|null}} payload * @param {'merge'|'replace'} mode */ export async function applyImport(payload, mode) { if (dayState.importing) throw new Error('Un import est déjà en cours.'); dayState.importing = true; try { // Let an already-running clock operation finish before replacing its data. // The guard above prevents subsequent ticks/focus events during the import. await Promise.allSettled([...pendingReconciliations]); const result = await importBackup(payload, mode); if (mode === 'replace') await loadSettings(); // Do not immediately purge old imported days or create a new working day. // Normal retention resumes next session/day; manual purge remains available. dayState.today = localDateKey(); cleanedUpFor = dayState.today; await refresh(); return result; } finally { dayState.importing = false; } } export async function endDay(now = Date.now()) { if (!dayState.day || dayState.importing) return; const date = dayState.day.date; await automateDay(date, settings, now, { end: true }); await refresh(now); } /** "Toujours en cours" — push the end out an hour so the prompt returns later. */ export async function keepWorking(now = Date.now()) { if (!dayState.day || dayState.importing) return; const expectedEnd = dayState.day.plannedEndTs; await patchActiveDay(dayState.day.date, (day) => day.plannedEndTs !== expectedEnd ? null : ({ plannedEndTs: Math.max(day.plannedEndTs, now) + HOUR, closePromptedTs: now, })); await refresh(now); } /** Undo a premature end-of-day, continuing the last task without a gap. */ export async function reopenToday(now = Date.now()) { if (dayState.importing) return; await reopenDay(localDateKey(now), now); await refresh(now); } /* ---------------------------------------------------------------- predictions */ /** * Best guess for the task and client of the slot that would open right now. * Callers snapshot this when a screen opens rather than recomputing per tick, so * the proposed value does not shift under the worker's thumb. */ export function predictNext(now = Date.now()) { const model = dayState.model; if (!model) return { task: TRANSPORT_TASK, client: null, tasks: [], clients: [] }; const current = running(); const tasks = suggestTasks(model, { prevTask: current?.task ?? null, prevElapsedMs: current ? now - current.startTs : 0, seq: dayState.entries.length, ts: now, client: current?.client ?? model.lastClient, }); const clients = suggestClients(model, now); return { 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, }; } /** * Suggestions ranked for the entry *currently* running, used when correcting it — * a different context from `predictNext`, which ranks the slot about to open. */ export function predictForCurrent(now = Date.now()) { const model = dayState.model; const current = running(); if (!model || !current) return { tasks: [], clients: [] }; const index = dayState.entries.findIndex((e) => e.id === current.id); const prev = index > 0 ? dayState.entries[index - 1] : null; return { tasks: suggestTasks(model, { prevTask: prev?.task ?? null, prevElapsedMs: prev?.endTs != null ? prev.endTs - prev.startTs : 0, seq: current.seq, ts: current.startTs, client: current.client, }), clients: suggestClients(model, now), }; } /** The client the day has been booked to so far — used to pick a restaurant. */ export function currentClient() { return [...dayState.entries].reverse().find((e) => e.client)?.client ?? null; }