First working prototype
This commit is contained in:
306
src/lib/day.svelte.js
Normal file
306
src/lib/day.svelte.js
Normal file
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* 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,
|
||||
closeAndStart,
|
||||
deleteEntry,
|
||||
getActiveDay,
|
||||
getDay,
|
||||
getEntries,
|
||||
getHistory,
|
||||
getLastClosedDay,
|
||||
patchDay,
|
||||
patchEntry,
|
||||
putDay,
|
||||
setEntryStart,
|
||||
} from './db.js';
|
||||
import { buildModel, suggestClients, suggestTasks, TRANSPORT_TASK } from './predict.js';
|
||||
import { 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: [],
|
||||
/** 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,
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* 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 async function reconcile(now = Date.now()) {
|
||||
dayState.today = localDateKey(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);
|
||||
} catch (error) {
|
||||
console.error('[timelog] retention cleanup failed', error);
|
||||
}
|
||||
}
|
||||
|
||||
const active = await getActiveDay();
|
||||
|
||||
// A day left open from an earlier date never stays open.
|
||||
if (active && active.date < dayState.today) {
|
||||
await forceClose(active);
|
||||
return reconcile(now);
|
||||
}
|
||||
|
||||
// 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.workdays.includes(weekday(dayState.today))) {
|
||||
const plannedStart = atLocalTime(dayState.today, settings.dayStart);
|
||||
if (now >= plannedStart) {
|
||||
await openDay(dayState.today, plannedStart);
|
||||
return refresh(now);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await refresh(now);
|
||||
}
|
||||
|
||||
/** Close a stale day at the earlier of its planned end and its last recorded activity. */
|
||||
async function forceClose(day) {
|
||||
const entries = await getEntries(day.date);
|
||||
const lastEnd = entries.reduce((max, e) => Math.max(max, e.endTs ?? e.startTs), 0);
|
||||
const endedTs = Math.min(day.plannedEndTs, lastEnd || day.plannedEndTs);
|
||||
await closeAndStart(day.date, endedTs, null);
|
||||
await patchDay(day.date, { status: 'closed', endedTs });
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
await putDay({
|
||||
date: dateKey,
|
||||
status: 'active',
|
||||
plannedStartTs: atLocalTime(dateKey, settings.dayStart),
|
||||
plannedEndTs: atLocalTime(dateKey, settings.dayEnd),
|
||||
startedTs: startTs,
|
||||
endedTs: null,
|
||||
lunchType: settings.lunchType,
|
||||
restaurant: null,
|
||||
closePromptedTs: null,
|
||||
});
|
||||
|
||||
const { entries, days } = await getHistory();
|
||||
const model = buildModel(entries, days);
|
||||
await closeAndStart(dateKey, startTs, {
|
||||
kind: 'travel',
|
||||
task: TRANSPORT_TASK,
|
||||
client: model.lastClient,
|
||||
});
|
||||
}
|
||||
|
||||
/** Reload everything from the database and rebuild the model. */
|
||||
export async function refresh(now = Date.now()) {
|
||||
const active = await getActiveDay();
|
||||
dayState.day = active ?? null;
|
||||
dayState.entries = active ? await getEntries(active.date) : [];
|
||||
|
||||
const lastClosed = await getLastClosedDay();
|
||||
dayState.lastClosed = lastClosed ?? null;
|
||||
dayState.lastClosedEntries = lastClosed ? await getEntries(lastClosed.date) : [];
|
||||
|
||||
const { entries, days } = await getHistory();
|
||||
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()) {
|
||||
await openDay(dayState.today, now);
|
||||
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) return;
|
||||
await closeAndStart(dayState.day.date, now, { kind, task, client: client ?? null });
|
||||
await refresh(now);
|
||||
}
|
||||
|
||||
export async function startLunch(now = Date.now()) {
|
||||
if (!dayState.day) return;
|
||||
await closeAndStart(dayState.day.date, now, {
|
||||
kind: 'lunch',
|
||||
task: 'Pause déjeuner',
|
||||
client: null,
|
||||
});
|
||||
await refresh(now);
|
||||
}
|
||||
|
||||
export async function setLunch({ lunchType, restaurant }) {
|
||||
if (!dayState.day) return;
|
||||
await patchDay(dayState.day.date, {
|
||||
lunchType,
|
||||
restaurant: lunchType === 'restaurant' ? (restaurant ?? null) : null,
|
||||
});
|
||||
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();
|
||||
}
|
||||
|
||||
export async function endDay(now = Date.now()) {
|
||||
if (!dayState.day) return;
|
||||
const date = dayState.day.date;
|
||||
await closeAndStart(date, now, null);
|
||||
await patchDay(date, { status: 'closed', endedTs: now });
|
||||
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) return;
|
||||
await patchDay(dayState.day.date, {
|
||||
plannedEndTs: Math.max(dayState.day.plannedEndTs, now) + HOUR,
|
||||
closePromptedTs: now,
|
||||
});
|
||||
await refresh(now);
|
||||
}
|
||||
|
||||
/** Undo a premature end-of-day: reopens today and starts a fresh task from now. */
|
||||
export async function reopenToday(now = Date.now()) {
|
||||
const day = await getDay(dayState.today);
|
||||
if (!day || day.status !== 'closed') return;
|
||||
await patchDay(dayState.today, {
|
||||
status: 'active',
|
||||
endedTs: null,
|
||||
plannedEndTs: Math.max(day.plannedEndTs, now + HOUR),
|
||||
});
|
||||
const { entries, days } = await getHistory();
|
||||
const model = buildModel(entries, days);
|
||||
const [best] = suggestTasks(model, {
|
||||
prevTask: null,
|
||||
prevElapsedMs: 0,
|
||||
seq: (await getEntries(dayState.today)).length,
|
||||
ts: now,
|
||||
client: model.lastClient,
|
||||
});
|
||||
await closeAndStart(dayState.today, now, {
|
||||
kind: 'work',
|
||||
task: best?.value ?? TRANSPORT_TASK,
|
||||
client: model.lastClient,
|
||||
});
|
||||
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: 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;
|
||||
}
|
||||
Reference in New Issue
Block a user