342 lines
12 KiB
JavaScript
342 lines
12 KiB
JavaScript
/**
|
|
* 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;
|
|
}
|