First working prototype
This commit is contained in:
@@ -1,5 +0,0 @@
|
||||
<script>
|
||||
let count = $state(0)
|
||||
</script>
|
||||
|
||||
<button type="button" class="counter" onclick={() => count++}>Count is {count}</button>
|
||||
65
src/lib/boundary.test.js
Normal file
65
src/lib/boundary.test.js
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Pure-logic cover for the boundary clamp used by `setEntryStart`. The database
|
||||
* call is a thin transaction around this arithmetic; getting the clamp wrong is
|
||||
* what would invert a timeline.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { atLocalTime, MINUTE, roundTs } from './time.js';
|
||||
|
||||
const DATE = '2026-09-04';
|
||||
const at = (hhmm) => atLocalTime(DATE, hhmm);
|
||||
|
||||
/** Mirrors the clamp in db.js `setEntryStart`. */
|
||||
function clampStart({ prevStart = null, ownEnd = null, now }, ts) {
|
||||
const lower = prevStart ?? Number.NEGATIVE_INFINITY;
|
||||
const upper = ownEnd ?? now;
|
||||
return Math.min(Math.max(ts, lower), upper);
|
||||
}
|
||||
|
||||
describe('entry start clamping', () => {
|
||||
it('accepts a time inside the neighbouring boundaries', () => {
|
||||
const out = clampStart({ prevStart: at('08:00'), ownEnd: at('12:00') }, at('09:30'));
|
||||
expect(out).toBe(at('09:30'));
|
||||
});
|
||||
|
||||
it('refuses to start before the previous entry began', () => {
|
||||
const out = clampStart({ prevStart: at('08:00'), ownEnd: at('12:00') }, at('07:00'));
|
||||
expect(out).toBe(at('08:00'));
|
||||
});
|
||||
|
||||
it('refuses to start after its own end', () => {
|
||||
const out = clampStart({ prevStart: at('08:00'), ownEnd: at('12:00') }, at('13:00'));
|
||||
expect(out).toBe(at('12:00'));
|
||||
});
|
||||
|
||||
it('caps a running entry at the present moment', () => {
|
||||
const now = at('10:00');
|
||||
expect(clampStart({ prevStart: at('08:00'), ownEnd: null, now }, at('11:00'))).toBe(now);
|
||||
});
|
||||
|
||||
it('lets the first entry of the day move freely backwards', () => {
|
||||
// No predecessor: this is the case that makes the auto-captured day start
|
||||
// correctable when the worker actually left base earlier.
|
||||
const out = clampStart({ prevStart: null, ownEnd: at('12:00') }, at('06:15'));
|
||||
expect(out).toBe(at('06:15'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('manual times snap to the rounding grid', () => {
|
||||
it.each([
|
||||
[15, at('09:37'), at('09:30')],
|
||||
[15, at('09:38'), at('09:45')],
|
||||
[30, at('09:44'), at('09:30')],
|
||||
[5, at('09:37'), at('09:35')],
|
||||
[1, at('09:37'), at('09:37')],
|
||||
])('rounding %i min stores the displayed value', (step, input, expected) => {
|
||||
// A time the worker typed must read back exactly as typed, so edits are
|
||||
// snapped on write rather than only at render.
|
||||
expect(roundTs(input, step)).toBe(expected);
|
||||
});
|
||||
|
||||
it('keeps a nudged time on the grid it started on', () => {
|
||||
const start = roundTs(at('09:37'), 15);
|
||||
expect(roundTs(start + 15 * MINUTE, 15)).toBe(at('09:45'));
|
||||
});
|
||||
});
|
||||
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;
|
||||
}
|
||||
251
src/lib/db.js
251
src/lib/db.js
@@ -1,45 +1,240 @@
|
||||
import Dexie from 'dexie';
|
||||
import { DAY, localDateKey } from './time.js';
|
||||
|
||||
export const db = new Dexie('TimeLogDB');
|
||||
/**
|
||||
* A single working day. `plannedStartTs` / `plannedEndTs` are snapshotted from the
|
||||
* settings in force when the day was created, so editing settings later never
|
||||
* rewrites history.
|
||||
*
|
||||
* @typedef {object} DayRecord
|
||||
* @property {string} date local 'YYYY-MM-DD', primary key
|
||||
* @property {'active'|'closed'} status
|
||||
* @property {number} plannedStartTs
|
||||
* @property {number} plannedEndTs
|
||||
* @property {number} startedTs
|
||||
* @property {number|null} endedTs
|
||||
* @property {'panier'|'restaurant'|null} lunchType
|
||||
* @property {string|null} restaurant
|
||||
* @property {number|null} closePromptedTs
|
||||
*/
|
||||
|
||||
/**
|
||||
* One stretch of the day. `endTs === null` marks the single running entry; every
|
||||
* other entry's `endTs` equals the next entry's `startTs`.
|
||||
*
|
||||
* @typedef {object} EntryRecord
|
||||
* @property {number} [id]
|
||||
* @property {string} date
|
||||
* @property {number} seq position within the day, dense from 0
|
||||
* @property {'travel'|'work'|'lunch'} kind
|
||||
* @property {string} task
|
||||
* @property {string|null} client
|
||||
* @property {number} startTs
|
||||
* @property {number|null} endTs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} SettingsRecord
|
||||
* @property {string} key
|
||||
* @property {any} value
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Dexie & {
|
||||
* days: import('dexie').Table<DayRecord, string>,
|
||||
* entries: import('dexie').Table<EntryRecord, number>,
|
||||
* settings: import('dexie').Table<SettingsRecord, string>,
|
||||
* }} TimeLogDB
|
||||
*/
|
||||
|
||||
export const db = /** @type {TimeLogDB} */ (new Dexie('TimeLogDB'));
|
||||
|
||||
// v1 was the throwaway prototype schema; declared so Dexie has an upgrade path.
|
||||
db.version(1).stores({
|
||||
timeLogs: '++id, date, startTime, endTime, taskName, clientName',
|
||||
settings: 'key' // for retention policy
|
||||
settings: 'key',
|
||||
});
|
||||
|
||||
// Helper functions
|
||||
export async function addLog(log) {
|
||||
return await db.timeLogs.add(log);
|
||||
db.version(2).stores({
|
||||
timeLogs: null, // drop the prototype table
|
||||
days: 'date, status',
|
||||
entries: '++id, date, [date+seq], startTs',
|
||||
settings: 'key',
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ settings */
|
||||
|
||||
export async function readSettings() {
|
||||
const rec = await db.settings.get('app');
|
||||
return rec?.value ?? null;
|
||||
}
|
||||
|
||||
export async function getLogsByDate(date) {
|
||||
return await db.timeLogs.where('date').equals(date).toArray();
|
||||
export async function writeSettings(value) {
|
||||
await db.settings.put({ key: 'app', value });
|
||||
}
|
||||
|
||||
export async function getRecentLogs(limit = 10) {
|
||||
return await db.timeLogs.orderBy('id').reverse().limit(limit).toArray();
|
||||
/* ---------------------------------------------------------------------- days */
|
||||
|
||||
export function getDay(dateKey) {
|
||||
return db.days.get(dateKey);
|
||||
}
|
||||
|
||||
export async function getLastLog() {
|
||||
return await db.timeLogs.orderBy('id').reverse().first();
|
||||
export function putDay(day) {
|
||||
return db.days.put(day);
|
||||
}
|
||||
|
||||
export async function setRetentionDays(days) {
|
||||
await db.settings.put({ key: 'retentionDays', value: days });
|
||||
export function patchDay(dateKey, patch) {
|
||||
return db.days.update(dateKey, patch);
|
||||
}
|
||||
|
||||
// Retention logic
|
||||
export async function cleanupOldLogs() {
|
||||
const retentionDays = await db.settings.get('retentionDays') || 30;
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
|
||||
|
||||
// Keep the latest day's report
|
||||
const latestDate = await db.timeLogs.orderBy('date').reverse().first();
|
||||
if (latestDate) {
|
||||
await db.timeLogs
|
||||
.where('date')
|
||||
.below(cutoffDate.toISOString().split('T')[0])
|
||||
.and(log => log.date !== latestDate.date)
|
||||
.delete();
|
||||
}
|
||||
/** The one day still running, if any — including a stale one from a past date. */
|
||||
export function getActiveDay() {
|
||||
return db.days.where('status').equals('active').first();
|
||||
}
|
||||
|
||||
/** Most recent closed day: the one the resume screen shows. */
|
||||
export async function getLastClosedDay() {
|
||||
const closed = await db.days.where('status').equals('closed').toArray();
|
||||
if (!closed.length) return null;
|
||||
closed.sort((a, b) => a.date.localeCompare(b.date));
|
||||
return closed[closed.length - 1];
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------- entries */
|
||||
|
||||
export function getEntries(dateKey) {
|
||||
return db.entries.where('date').equals(dateKey).sortBy('seq');
|
||||
}
|
||||
|
||||
/**
|
||||
* The single place the chain invariant is enforced: closing the running entry and
|
||||
* opening the next one share one timestamp, in one transaction, so entry N's end
|
||||
* can never drift from entry N+1's start.
|
||||
*
|
||||
* @param {string} dateKey
|
||||
* @param {number} ts
|
||||
* @param {Pick<EntryRecord, 'kind'|'task'|'client'>|null} next
|
||||
* Pass null to only close the running entry (end of day).
|
||||
* @returns {Promise<number|null>} id of the new entry
|
||||
*/
|
||||
export function closeAndStart(dateKey, ts, next) {
|
||||
return db.transaction('rw', db.entries, async () => {
|
||||
const list = await db.entries.where('date').equals(dateKey).sortBy('seq');
|
||||
const running = list.find((e) => e.endTs == null);
|
||||
|
||||
let boundary = ts;
|
||||
if (running) {
|
||||
// A tap can never produce an entry that ends before it started.
|
||||
boundary = Math.max(ts, running.startTs);
|
||||
await db.entries.update(running.id, { endTs: boundary });
|
||||
}
|
||||
if (!next) return null;
|
||||
|
||||
const seq = list.length ? list[list.length - 1].seq + 1 : 0;
|
||||
return db.entries.add({
|
||||
date: dateKey,
|
||||
seq,
|
||||
startTs: boundary,
|
||||
endTs: null,
|
||||
...next,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function patchEntry(id, patch) {
|
||||
return db.entries.update(id, patch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the boundary at the start of an entry. Because that instant is shared with
|
||||
* the previous entry's end, both sides move together and the chain stays gapless.
|
||||
* For the first entry of a day there is no predecessor, so the day's start moves
|
||||
* instead — that is what makes the auto-captured start time correctable.
|
||||
*
|
||||
* The value is clamped between the previous entry's start and this entry's own
|
||||
* end (or now, while it is still running), so no edit can invert the timeline.
|
||||
*
|
||||
* @returns {Promise<number|undefined>} the timestamp actually stored
|
||||
*/
|
||||
export function setEntryStart(id, ts, now = Date.now()) {
|
||||
return db.transaction('rw', db.entries, db.days, async () => {
|
||||
const entry = await db.entries.get(id);
|
||||
if (!entry) return undefined;
|
||||
|
||||
const list = await db.entries.where('date').equals(entry.date).sortBy('seq');
|
||||
const prev = list[list.findIndex((e) => e.id === id) - 1];
|
||||
|
||||
const lower = prev ? prev.startTs : Number.NEGATIVE_INFINITY;
|
||||
const upper = entry.endTs ?? now;
|
||||
const stored = Math.min(Math.max(ts, lower), upper);
|
||||
|
||||
await db.entries.update(entry.id, { startTs: stored });
|
||||
if (prev) await db.entries.update(prev.id, { endTs: stored });
|
||||
else await db.days.update(entry.date, { startedTs: stored });
|
||||
|
||||
return stored;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an entry and re-stitch the chain: the previous entry absorbs the gap by
|
||||
* extending to the next entry's start. Keeps the timeline contiguous after a
|
||||
* mis-tap, which is the whole point of allowing deletes.
|
||||
*/
|
||||
export function deleteEntry(id) {
|
||||
return db.transaction('rw', db.entries, async () => {
|
||||
const victim = await db.entries.get(id);
|
||||
if (!victim) return;
|
||||
const list = await db.entries.where('date').equals(victim.date).sortBy('seq');
|
||||
const i = list.findIndex((e) => e.id === id);
|
||||
const prev = list[i - 1];
|
||||
const next = list[i + 1];
|
||||
|
||||
await db.entries.delete(id);
|
||||
if (prev) {
|
||||
// Extend the previous entry over the hole; if nothing follows, it inherits
|
||||
// the victim's end (including `null`, i.e. it becomes the running entry).
|
||||
await db.entries.update(prev.id, { endTs: next ? next.startTs : victim.endTs });
|
||||
} else if (next) {
|
||||
await db.entries.update(next.id, { startTs: victim.startTs });
|
||||
}
|
||||
// Re-number so `seq` stays dense.
|
||||
const rest = list.filter((e) => e.id !== id);
|
||||
await Promise.all(
|
||||
rest.map((e, n) => (e.seq === n ? null : db.entries.update(e.id, { seq: n }))),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------- history */
|
||||
|
||||
/** Everything the prediction model is built from. */
|
||||
export async function getHistory() {
|
||||
const [entries, days] = await Promise.all([
|
||||
db.entries.toArray(),
|
||||
db.days.toArray(),
|
||||
]);
|
||||
return { entries, days };
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop days older than the retention window. The most recent closed day is always
|
||||
* kept — it is the app's primary output and must survive any setting.
|
||||
*/
|
||||
export async function cleanupOldDays(retentionDays) {
|
||||
if (!retentionDays || retentionDays <= 0) return 0;
|
||||
const cutoff = localDateKey(Date.now() - retentionDays * DAY);
|
||||
const keep = await getLastClosedDay();
|
||||
|
||||
const stale = (await db.days.toArray()).filter(
|
||||
(d) => d.date < cutoff && d.date !== keep?.date,
|
||||
);
|
||||
if (!stale.length) return 0;
|
||||
|
||||
const keys = stale.map((d) => d.date);
|
||||
await db.transaction('rw', db.days, db.entries, async () => {
|
||||
await db.days.bulkDelete(keys);
|
||||
await db.entries.where('date').anyOf(keys).delete();
|
||||
});
|
||||
return keys.length;
|
||||
}
|
||||
|
||||
319
src/lib/predict.js
Normal file
319
src/lib/predict.js
Normal file
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* Suggestion / prediction engine.
|
||||
*
|
||||
* Pure functions over the raw history — no counter tables to keep in sync. The
|
||||
* volume is a few hundred entries per year, so the model is just rebuilt whenever
|
||||
* history changes.
|
||||
*
|
||||
* Everything is a blend of smoothed conditional frequencies. No training, no
|
||||
* opaque state: a worker who does the same three things in the same order every
|
||||
* day gets the right answer on day two, and the reasoning stays inspectable.
|
||||
*/
|
||||
|
||||
import { HOUR, MINUTE, localDateKey, startOfDay } from './time.js';
|
||||
|
||||
/**
|
||||
* Used before there is any history to learn from, so day one is still usable.
|
||||
* Listed in the order a day actually runs — that order is the tiebreaker when
|
||||
* nothing has been learned yet, so the first suggestion after arriving on site is
|
||||
* the work, not the trip home.
|
||||
*/
|
||||
export const SEED_TASKS = [
|
||||
'Chargement / transport',
|
||||
'Travaux sur site',
|
||||
'Retour / déchargement',
|
||||
];
|
||||
|
||||
const seedRank = (task) => {
|
||||
const i = SEED_TASKS.indexOf(task);
|
||||
return i === -1 ? SEED_TASKS.length : i;
|
||||
};
|
||||
|
||||
export const TRANSPORT_TASK = SEED_TASKS[0];
|
||||
|
||||
/** Blend weights for the task ranking. Tunable in one place. */
|
||||
export const WEIGHTS = {
|
||||
bigram: 0.35, // what usually follows the current task
|
||||
seq: 0.25, // what usually sits at this position in the day
|
||||
clock: 0.2, // what usually happens around this hour
|
||||
client: 0.1, // what this client usually needs
|
||||
global: 0.1, // overall favourites
|
||||
};
|
||||
|
||||
/** Recency half-life for client ranking. */
|
||||
const CLIENT_HALF_LIFE_DAYS = 30;
|
||||
|
||||
/** Laplace smoothing constant. */
|
||||
const ALPHA = 0.5;
|
||||
|
||||
/* ------------------------------------------------------------------ counting */
|
||||
|
||||
const bump = (map, key, by = 1) => map.set(key, (map.get(key) ?? 0) + by);
|
||||
|
||||
function bump2(map, outer, inner) {
|
||||
let m = map.get(outer);
|
||||
if (!m) map.set(outer, (m = new Map()));
|
||||
bump(m, inner);
|
||||
}
|
||||
|
||||
/** 30-minute bucket of the day, 0..47. */
|
||||
export function clockBucket(ts) {
|
||||
return Math.floor((ts - startOfDay(localDateKey(ts))) / (30 * MINUTE)) % 48;
|
||||
}
|
||||
|
||||
const median = (xs) => {
|
||||
if (!xs.length) return 0;
|
||||
const s = [...xs].sort((a, b) => a - b);
|
||||
const mid = s.length >> 1;
|
||||
return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
|
||||
};
|
||||
|
||||
/**
|
||||
* How long the current task has run, relative to how long it usually runs.
|
||||
* Three buckets only — enough to separate "just started" from "dragging on"
|
||||
* without making the conditioned bigram sparse.
|
||||
*/
|
||||
export function elapsedBucket(elapsedMs, medianMs) {
|
||||
if (!medianMs) return 'typical';
|
||||
if (elapsedMs < medianMs * 0.6) return 'short';
|
||||
if (elapsedMs > medianMs * 1.4) return 'long';
|
||||
return 'typical';
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------- model */
|
||||
|
||||
/**
|
||||
* @param {Array} entries all historical entries
|
||||
* @param {Array} days all historical days
|
||||
*/
|
||||
export function buildModel(entries, days = []) {
|
||||
const model = {
|
||||
taskCount: new Map(),
|
||||
taskDurations: new Map(),
|
||||
taskLastTs: new Map(),
|
||||
bigram: new Map(), // prevTask -> task -> n
|
||||
bigramElapsed: new Map(), // `${prev}|${bucket}` -> task -> n
|
||||
bySeq: new Map(), // seq -> task -> n
|
||||
byClock: new Map(), // clock bucket -> task -> n
|
||||
byClient: new Map(), // client -> task -> n
|
||||
clientCount: new Map(),
|
||||
clientLastTs: new Map(),
|
||||
restByClient: new Map(), // client -> restaurant -> n
|
||||
restCount: new Map(),
|
||||
restLastTs: new Map(),
|
||||
lastClient: null,
|
||||
lastRestaurant: null,
|
||||
};
|
||||
|
||||
// Work entries only: lunch and its restaurant are modelled separately.
|
||||
const work = entries
|
||||
.filter((e) => e.kind !== 'lunch' && e.task)
|
||||
.sort((a, b) => a.startTs - b.startTs);
|
||||
|
||||
const byDate = new Map();
|
||||
for (const e of work) {
|
||||
if (!byDate.has(e.date)) byDate.set(e.date, []);
|
||||
byDate.get(e.date).push(e);
|
||||
}
|
||||
|
||||
for (const e of work) {
|
||||
bump(model.taskCount, e.task);
|
||||
bump2(model.bySeq, e.seq, e.task);
|
||||
bump2(model.byClock, clockBucket(e.startTs), e.task);
|
||||
model.taskLastTs.set(e.task, Math.max(model.taskLastTs.get(e.task) ?? 0, e.startTs));
|
||||
|
||||
if (e.endTs != null) {
|
||||
const arr = model.taskDurations.get(e.task) ?? [];
|
||||
arr.push(e.endTs - e.startTs);
|
||||
model.taskDurations.set(e.task, arr);
|
||||
}
|
||||
if (e.client) {
|
||||
bump(model.clientCount, e.client);
|
||||
bump2(model.byClient, e.client, e.task);
|
||||
model.clientLastTs.set(
|
||||
e.client,
|
||||
Math.max(model.clientLastTs.get(e.client) ?? 0, e.startTs),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
model.taskMedian = new Map(
|
||||
[...model.taskDurations].map(([task, ds]) => [task, median(ds)]),
|
||||
);
|
||||
|
||||
// Transitions, within a day only — the last task of Monday does not predict the
|
||||
// first task of Tuesday.
|
||||
for (const list of byDate.values()) {
|
||||
for (let i = 1; i < list.length; i++) {
|
||||
const prev = list[i - 1];
|
||||
const cur = list[i];
|
||||
bump2(model.bigram, prev.task, cur.task);
|
||||
if (prev.endTs != null) {
|
||||
const bucket = elapsedBucket(
|
||||
prev.endTs - prev.startTs,
|
||||
model.taskMedian.get(prev.task),
|
||||
);
|
||||
bump2(model.bigramElapsed, `${prev.task}|${bucket}`, cur.task);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Restaurants live on the day record, paired with that day's dominant client.
|
||||
const sortedDays = [...days].sort((a, b) => a.date.localeCompare(b.date));
|
||||
for (const d of sortedDays) {
|
||||
if (d.lunchType !== 'restaurant' || !d.restaurant) continue;
|
||||
bump(model.restCount, d.restaurant);
|
||||
model.restLastTs.set(d.restaurant, startOfDay(d.date));
|
||||
model.lastRestaurant = d.restaurant;
|
||||
const client = dominantClient(byDate.get(d.date) ?? []);
|
||||
if (client) bump2(model.restByClient, client, d.restaurant);
|
||||
}
|
||||
|
||||
const lastWithClient = [...work].reverse().find((e) => e.client);
|
||||
model.lastClient = lastWithClient?.client ?? null;
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
/** The client a day's time mostly went to — how a restaurant gets attributed. */
|
||||
function dominantClient(dayEntries) {
|
||||
const totals = new Map();
|
||||
for (const e of dayEntries) {
|
||||
if (!e.client || e.endTs == null) continue;
|
||||
bump(totals, e.client, e.endTs - e.startTs);
|
||||
}
|
||||
let best = null;
|
||||
for (const [client, ms] of totals) if (!best || ms > best[1]) best = [client, ms];
|
||||
return best?.[0] ?? null;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ ranking */
|
||||
|
||||
/** Smoothed P(x | table), over the union of candidates. */
|
||||
function dist(table, candidates) {
|
||||
const total = [...(table?.values() ?? [])].reduce((a, b) => a + b, 0);
|
||||
const denom = total + ALPHA * candidates.length;
|
||||
const out = new Map();
|
||||
for (const c of candidates) out.set(c, ((table?.get(c) ?? 0) + ALPHA) / denom);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rank tasks for the slot about to open.
|
||||
*
|
||||
* @param {object} model
|
||||
* @param {object} ctx
|
||||
* @param {string|null} ctx.prevTask task being closed
|
||||
* @param {number} ctx.prevElapsedMs how long it ran
|
||||
* @param {number} ctx.seq position of the new entry in the day
|
||||
* @param {number} ctx.ts when the new entry starts
|
||||
* @param {string|null} ctx.client
|
||||
* @returns {Array<{value: string, hint: string, score: number}>}
|
||||
*/
|
||||
export function suggestTasks(model, ctx) {
|
||||
const known = [...model.taskCount.keys()];
|
||||
const candidates = known.length ? known : [...SEED_TASKS];
|
||||
for (const t of SEED_TASKS) if (!candidates.includes(t)) candidates.push(t);
|
||||
|
||||
// Prefer the elapsed-conditioned transitions, fall back to the plain bigram when
|
||||
// that bucket has not been seen yet.
|
||||
let bigramTable = null;
|
||||
if (ctx.prevTask) {
|
||||
const bucket = elapsedBucket(
|
||||
ctx.prevElapsedMs ?? 0,
|
||||
model.taskMedian.get(ctx.prevTask),
|
||||
);
|
||||
bigramTable =
|
||||
model.bigramElapsed.get(`${ctx.prevTask}|${bucket}`) ??
|
||||
model.bigram.get(ctx.prevTask);
|
||||
}
|
||||
|
||||
const pBigram = dist(bigramTable, candidates);
|
||||
const pSeq = dist(model.bySeq.get(ctx.seq), candidates);
|
||||
const pClock = dist(model.byClock.get(clockBucket(ctx.ts)), candidates);
|
||||
const pClient = dist(ctx.client ? model.byClient.get(ctx.client) : null, candidates);
|
||||
const pGlobal = dist(model.taskCount, candidates);
|
||||
|
||||
return candidates
|
||||
.map((value) => ({
|
||||
value,
|
||||
score:
|
||||
WEIGHTS.bigram * pBigram.get(value) +
|
||||
WEIGHTS.seq * pSeq.get(value) +
|
||||
WEIGHTS.clock * pClock.get(value) +
|
||||
WEIGHTS.client * pClient.get(value) +
|
||||
WEIGHTS.global * pGlobal.get(value),
|
||||
hint: durationHint(model, value),
|
||||
}))
|
||||
// Never propose repeating the task just closed; the worker would have kept going.
|
||||
.filter((s) => s.value !== ctx.prevTask)
|
||||
// Seed order only ever breaks an exact tie, so it cannot perturb a ranking
|
||||
// that history has actually informed.
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.score - a.score ||
|
||||
seedRank(a.value) - seedRank(b.value) ||
|
||||
a.value.localeCompare(b.value),
|
||||
);
|
||||
}
|
||||
|
||||
function durationHint(model, task) {
|
||||
const ms = model.taskMedian.get(task);
|
||||
if (!ms || ms < 5 * MINUTE) return '';
|
||||
const h = Math.floor(ms / HOUR);
|
||||
const m = Math.round((ms % HOUR) / MINUTE);
|
||||
return h ? `~${h}h${String(m).padStart(2, '0')} d'habitude` : `~${m} min d'habitude`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rank clients. The most recently used one is pinned to the top — that is the
|
||||
* stated default — and the rest fall back on frequency decayed by recency.
|
||||
*/
|
||||
export function suggestClients(model, now = Date.now()) {
|
||||
const ranked = [...model.clientCount.keys()]
|
||||
.map((value) => {
|
||||
const last = model.clientLastTs.get(value) ?? 0;
|
||||
const days = Math.max(0, (now - last) / 86_400_000);
|
||||
return {
|
||||
value,
|
||||
score: model.clientCount.get(value) * 0.5 ** (days / CLIENT_HALF_LIFE_DAYS),
|
||||
hint: recencyHint(last, now),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.score - a.score || a.value.localeCompare(b.value));
|
||||
|
||||
const pinned = ranked.findIndex((c) => c.value === model.lastClient);
|
||||
if (pinned > 0) ranked.unshift(ranked.splice(pinned, 1)[0]);
|
||||
return ranked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rank restaurants for a client: what was eaten with *this* client first, then
|
||||
* everything else by overall frequency.
|
||||
*/
|
||||
export function suggestRestaurants(model, client, now = Date.now()) {
|
||||
const paired = model.restByClient.get(client) ?? new Map();
|
||||
return [...model.restCount.keys()]
|
||||
.map((value) => {
|
||||
const withClient = paired.get(value) ?? 0;
|
||||
return {
|
||||
value,
|
||||
// Any restaurant tied to this client outranks any that is not.
|
||||
score: withClient * 1000 + model.restCount.get(value),
|
||||
hint: withClient
|
||||
? `${withClient} × avec ${client}`
|
||||
: recencyHint(model.restLastTs.get(value) ?? 0, now),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.score - a.score || a.value.localeCompare(b.value));
|
||||
}
|
||||
|
||||
function recencyHint(ts, now) {
|
||||
if (!ts) return '';
|
||||
const days = Math.floor((now - ts) / 86_400_000);
|
||||
if (days <= 0) return "aujourd'hui";
|
||||
if (days === 1) return 'hier';
|
||||
if (days < 7) return `il y a ${days} jours`;
|
||||
if (days < 30) return `il y a ${Math.floor(days / 7)} sem.`;
|
||||
return `il y a ${Math.floor(days / 30)} mois`;
|
||||
}
|
||||
218
src/lib/predict.test.js
Normal file
218
src/lib/predict.test.js
Normal file
@@ -0,0 +1,218 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildModel,
|
||||
elapsedBucket,
|
||||
SEED_TASKS,
|
||||
suggestClients,
|
||||
suggestRestaurants,
|
||||
suggestTasks,
|
||||
} from './predict.js';
|
||||
import { atLocalTime, HOUR, MINUTE } from './time.js';
|
||||
|
||||
/** A day shaped like the real workflow: transport, work, lunch, work, return. */
|
||||
function makeDay(date, client, restaurant, tasks) {
|
||||
let ts = atLocalTime(date, '07:30');
|
||||
const entries = tasks.map(([task, minutes, kind = 'work'], seq) => {
|
||||
const startTs = ts;
|
||||
ts += minutes * MINUTE;
|
||||
return {
|
||||
id: `${date}-${seq}`,
|
||||
date,
|
||||
seq,
|
||||
kind,
|
||||
task,
|
||||
client: kind === 'lunch' ? null : client,
|
||||
startTs,
|
||||
endTs: ts,
|
||||
};
|
||||
});
|
||||
return {
|
||||
day: {
|
||||
date,
|
||||
status: 'closed',
|
||||
lunchType: restaurant ? 'restaurant' : 'panier',
|
||||
restaurant,
|
||||
},
|
||||
entries,
|
||||
};
|
||||
}
|
||||
|
||||
const STANDARD = [
|
||||
['Chargement / transport', 45, 'travel'],
|
||||
['Terrassement', 195],
|
||||
['Pause déjeuner', 60, 'lunch'],
|
||||
['Pose de câble', 210],
|
||||
['Retour / déchargement', 50],
|
||||
];
|
||||
|
||||
function history(specs) {
|
||||
const days = [];
|
||||
const entries = [];
|
||||
for (const [date, client, restaurant] of specs) {
|
||||
const made = makeDay(date, client, restaurant, STANDARD);
|
||||
days.push(made.day);
|
||||
entries.push(...made.entries);
|
||||
}
|
||||
return { days, entries };
|
||||
}
|
||||
|
||||
const FIXTURE = history([
|
||||
['2026-09-01', 'Dupont SA', 'Le Relais'],
|
||||
['2026-09-02', 'Dupont SA', 'Le Relais'],
|
||||
['2026-09-03', 'Martin & Fils', 'Chez Marcel'],
|
||||
['2026-09-04', 'Dupont SA', 'Le Relais'],
|
||||
]);
|
||||
|
||||
const NOW = atLocalTime('2026-09-07', '09:00');
|
||||
|
||||
describe('buildModel', () => {
|
||||
it('learns tasks, clients and their last use', () => {
|
||||
const m = buildModel(FIXTURE.entries, FIXTURE.days);
|
||||
expect(m.taskCount.get('Terrassement')).toBe(4);
|
||||
expect(m.clientCount.get('Dupont SA')).toBe(12); // 4 work entries × 3 days
|
||||
expect(m.lastClient).toBe('Dupont SA');
|
||||
});
|
||||
|
||||
it('excludes lunch from the task model', () => {
|
||||
const m = buildModel(FIXTURE.entries, FIXTURE.days);
|
||||
expect(m.taskCount.has('Pause déjeuner')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not chain the last task of one day to the first of the next', () => {
|
||||
const m = buildModel(FIXTURE.entries, FIXTURE.days);
|
||||
expect(m.bigram.get('Retour / déchargement')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('records a median duration per task', () => {
|
||||
const m = buildModel(FIXTURE.entries, FIXTURE.days);
|
||||
expect(m.taskMedian.get('Terrassement')).toBe(195 * MINUTE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('suggestTasks', () => {
|
||||
it('predicts the task that historically follows the current one', () => {
|
||||
const m = buildModel(FIXTURE.entries, FIXTURE.days);
|
||||
const [best] = suggestTasks(m, {
|
||||
prevTask: 'Chargement / transport',
|
||||
prevElapsedMs: 45 * MINUTE,
|
||||
seq: 1,
|
||||
ts: atLocalTime('2026-09-07', '08:15'),
|
||||
client: 'Dupont SA',
|
||||
});
|
||||
expect(best.value).toBe('Terrassement');
|
||||
});
|
||||
|
||||
it('predicts the return trip at the end of the chain', () => {
|
||||
const m = buildModel(FIXTURE.entries, FIXTURE.days);
|
||||
const [best] = suggestTasks(m, {
|
||||
prevTask: 'Pose de câble',
|
||||
prevElapsedMs: 210 * MINUTE,
|
||||
seq: 4,
|
||||
ts: atLocalTime('2026-09-07', '16:40'),
|
||||
client: 'Dupont SA',
|
||||
});
|
||||
expect(best.value).toBe('Retour / déchargement');
|
||||
});
|
||||
|
||||
it('never proposes repeating the task just closed', () => {
|
||||
const m = buildModel(FIXTURE.entries, FIXTURE.days);
|
||||
const out = suggestTasks(m, {
|
||||
prevTask: 'Terrassement',
|
||||
prevElapsedMs: 195 * MINUTE,
|
||||
seq: 2,
|
||||
ts: NOW,
|
||||
client: 'Dupont SA',
|
||||
});
|
||||
expect(out.map((s) => s.value)).not.toContain('Terrassement');
|
||||
});
|
||||
|
||||
it('falls back to the seed list with no history at all', () => {
|
||||
const m = buildModel([], []);
|
||||
const out = suggestTasks(m, { prevTask: null, prevElapsedMs: 0, seq: 0, ts: NOW });
|
||||
expect(out.length).toBe(SEED_TASKS.length);
|
||||
expect(out.map((s) => s.value)).toEqual(SEED_TASKS);
|
||||
});
|
||||
|
||||
it('proposes the work, not the trip home, on a cold start after transport', () => {
|
||||
// With zero history every candidate scores identically, so the tiebreak has to
|
||||
// follow the natural order of a day rather than the alphabet.
|
||||
const m = buildModel([], []);
|
||||
const out = suggestTasks(m, {
|
||||
prevTask: 'Chargement / transport',
|
||||
prevElapsedMs: 45 * MINUTE,
|
||||
seq: 1,
|
||||
ts: NOW,
|
||||
});
|
||||
expect(out[0].value).toBe('Travaux sur site');
|
||||
});
|
||||
|
||||
it('surfaces a duration hint for tasks it has timed', () => {
|
||||
const m = buildModel(FIXTURE.entries, FIXTURE.days);
|
||||
const hit = suggestTasks(m, {
|
||||
prevTask: 'Chargement / transport',
|
||||
prevElapsedMs: 45 * MINUTE,
|
||||
seq: 1,
|
||||
ts: NOW,
|
||||
client: 'Dupont SA',
|
||||
}).find((s) => s.value === 'Terrassement');
|
||||
expect(hit.hint).toBe("~3h15 d'habitude");
|
||||
});
|
||||
});
|
||||
|
||||
describe('elapsedBucket', () => {
|
||||
it.each([
|
||||
[30 * MINUTE, HOUR, 'short'],
|
||||
[HOUR, HOUR, 'typical'],
|
||||
[2 * HOUR, HOUR, 'long'],
|
||||
[5 * HOUR, 0, 'typical'], // unknown median
|
||||
])('buckets %i against %i as %s', (elapsed, median, expected) => {
|
||||
expect(elapsedBucket(elapsed, median)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('suggestClients', () => {
|
||||
it('pins the most recently used client to the top', () => {
|
||||
// Martin & Fils is used least, but most recently.
|
||||
const h = history([
|
||||
['2026-09-01', 'Dupont SA', null],
|
||||
['2026-09-02', 'Dupont SA', null],
|
||||
['2026-09-03', 'Dupont SA', null],
|
||||
['2026-09-04', 'Martin & Fils', null],
|
||||
]);
|
||||
const m = buildModel(h.entries, h.days);
|
||||
expect(suggestClients(m, NOW)[0].value).toBe('Martin & Fils');
|
||||
});
|
||||
|
||||
it('ranks the rest by frequency decayed with recency', () => {
|
||||
const m = buildModel(FIXTURE.entries, FIXTURE.days);
|
||||
expect(suggestClients(m, NOW).map((c) => c.value)).toEqual([
|
||||
'Dupont SA',
|
||||
'Martin & Fils',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('suggestRestaurants', () => {
|
||||
it('defaults to the restaurant most used with this client', () => {
|
||||
const m = buildModel(FIXTURE.entries, FIXTURE.days);
|
||||
expect(suggestRestaurants(m, 'Dupont SA', NOW)[0].value).toBe('Le Relais');
|
||||
expect(suggestRestaurants(m, 'Martin & Fils', NOW)[0].value).toBe('Chez Marcel');
|
||||
});
|
||||
|
||||
it('ranks any client-paired restaurant above a merely frequent one', () => {
|
||||
// Le Relais is globally more common, but Chez Marcel is Martin & Fils' place.
|
||||
const ranked = suggestRestaurants(FIXTURE_MODEL(), 'Martin & Fils', NOW);
|
||||
expect(ranked[0].value).toBe('Chez Marcel');
|
||||
expect(ranked[0].hint).toBe('1 × avec Martin & Fils');
|
||||
});
|
||||
|
||||
it('falls back to global frequency for an unknown client', () => {
|
||||
expect(suggestRestaurants(FIXTURE_MODEL(), 'Client inconnu', NOW)[0].value).toBe(
|
||||
'Le Relais',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function FIXTURE_MODEL() {
|
||||
return buildModel(FIXTURE.entries, FIXTURE.days);
|
||||
}
|
||||
105
src/lib/resume.js
Normal file
105
src/lib/resume.js
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Builds the last-day resume — the app's primary output, meant to be copied
|
||||
* straight into a paper logbook.
|
||||
*
|
||||
* Durations are computed from *rounded* boundaries so the printed times and the
|
||||
* printed totals always agree. Because adjacent entries share one stored
|
||||
* timestamp, rounding can never open a gap between them.
|
||||
*/
|
||||
|
||||
import { fmtDayLabel, fmtDuration, roundTs } from './time.js';
|
||||
|
||||
/**
|
||||
* @param {object} day
|
||||
* @param {Array} entries
|
||||
* @param {number} roundingMinutes
|
||||
*/
|
||||
export function buildResume(day, entries, roundingMinutes = 15) {
|
||||
if (!day) return null;
|
||||
|
||||
const fallbackEnd = day.endedTs ?? Date.now();
|
||||
/** @type {Map<string, {name: string, totalMs: number, tasks: {name: string, ms: number}[]}>} */
|
||||
const byClient = new Map();
|
||||
/** @type {{type: string, restaurant: string|null, ms: number}|null} */
|
||||
let lunch = null;
|
||||
let workedMs = 0;
|
||||
let omitted = 0;
|
||||
|
||||
for (const e of [...entries].sort((a, b) => a.seq - b.seq)) {
|
||||
const start = roundTs(e.startTs, roundingMinutes);
|
||||
const end = roundTs(e.endTs ?? fallbackEnd, roundingMinutes);
|
||||
const ms = Math.max(0, end - start);
|
||||
|
||||
if (ms === 0) {
|
||||
// Real but shorter than one rounding step. Kept in the data, left out of the
|
||||
// logbook, and reported so the worker knows something was dropped.
|
||||
omitted++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (e.kind === 'lunch') {
|
||||
lunch = {
|
||||
type: day.lunchType ?? 'panier',
|
||||
restaurant: day.restaurant ?? null,
|
||||
ms: (lunch?.ms ?? 0) + ms,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
workedMs += ms;
|
||||
const client = e.client || 'Sans client';
|
||||
if (!byClient.has(client)) byClient.set(client, { name: client, totalMs: 0, tasks: [] });
|
||||
const bucket = byClient.get(client);
|
||||
bucket.totalMs += ms;
|
||||
|
||||
// Same task twice in a day (before and after lunch) reads as one line.
|
||||
const existing = bucket.tasks.find((t) => t.name === e.task);
|
||||
if (existing) existing.ms += ms;
|
||||
else bucket.tasks.push({ name: e.task, ms });
|
||||
}
|
||||
|
||||
const clients = [...byClient.values()].sort((a, b) => b.totalMs - a.totalMs);
|
||||
for (const c of clients) c.tasks.sort((a, b) => b.ms - a.ms);
|
||||
|
||||
return {
|
||||
dateKey: day.date,
|
||||
label: fmtDayLabel(day.date),
|
||||
clients,
|
||||
lunch,
|
||||
workedMs,
|
||||
omitted,
|
||||
};
|
||||
}
|
||||
|
||||
/** The exact string the copy button puts on the clipboard. */
|
||||
export function resumeToText(resume) {
|
||||
if (!resume) return '';
|
||||
const lines = [resume.label, ''];
|
||||
|
||||
// Pad task names to a common width so the durations line up in the logbook.
|
||||
const width = Math.max(
|
||||
0,
|
||||
...resume.clients.flatMap((c) => c.tasks.map((t) => t.name.length)),
|
||||
);
|
||||
|
||||
for (const c of resume.clients) {
|
||||
lines.push(`${c.name} — ${fmtDuration(c.totalMs)}`);
|
||||
for (const t of c.tasks) {
|
||||
lines.push(` ${t.name.padEnd(width)} ${fmtDuration(t.ms)}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (resume.lunch) {
|
||||
const where =
|
||||
resume.lunch.type === 'restaurant'
|
||||
? `restaurant (${resume.lunch.restaurant || 'non précisé'})`
|
||||
: 'panier';
|
||||
lines.push(`Déjeuner : ${where}, ${fmtDuration(resume.lunch.ms)}`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
lines.push(`Total travaillé : ${fmtDuration(resume.workedMs)}`);
|
||||
|
||||
return lines.join('\n').replace(/\n{3,}/g, '\n\n').trim();
|
||||
}
|
||||
107
src/lib/resume.test.js
Normal file
107
src/lib/resume.test.js
Normal file
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildResume, resumeToText } from './resume.js';
|
||||
import { atLocalTime, MINUTE } from './time.js';
|
||||
|
||||
const DATE = '2026-09-04';
|
||||
const at = (hhmm) => atLocalTime(DATE, hhmm);
|
||||
|
||||
const DAY = {
|
||||
date: DATE,
|
||||
status: 'closed',
|
||||
lunchType: 'restaurant',
|
||||
restaurant: 'Le Relais',
|
||||
endedTs: at('17:30'),
|
||||
};
|
||||
|
||||
const ENTRIES = [
|
||||
{ id: 1, date: DATE, seq: 0, kind: 'travel', task: 'Chargement / transport', client: 'Dupont SA', startTs: at('07:30'), endTs: at('08:15') },
|
||||
{ id: 2, date: DATE, seq: 1, kind: 'work', task: 'Terrassement', client: 'Dupont SA', startTs: at('08:15'), endTs: at('12:00') },
|
||||
{ id: 3, date: DATE, seq: 2, kind: 'lunch', task: 'Pause déjeuner', client: null, startTs: at('12:00'), endTs: at('13:00') },
|
||||
{ id: 4, date: DATE, seq: 3, kind: 'work', task: 'Terrassement', client: 'Dupont SA', startTs: at('13:00'), endTs: at('16:45') },
|
||||
{ id: 5, date: DATE, seq: 4, kind: 'work', task: 'Retour / déchargement', client: 'Dupont SA', startTs: at('16:45'), endTs: at('17:30') },
|
||||
];
|
||||
|
||||
describe('buildResume', () => {
|
||||
it('groups by client and sums repeated tasks', () => {
|
||||
const r = buildResume(DAY, ENTRIES, 15);
|
||||
expect(r.clients).toHaveLength(1);
|
||||
const [dupont] = r.clients;
|
||||
expect(dupont.name).toBe('Dupont SA');
|
||||
// Terrassement appears twice, before and after lunch, and reads as one line.
|
||||
expect(dupont.tasks.map((t) => t.name)).toEqual([
|
||||
'Terrassement',
|
||||
'Chargement / transport',
|
||||
'Retour / déchargement',
|
||||
]);
|
||||
expect(dupont.tasks[0].ms).toBe(450 * MINUTE); // 3h45 + 3h45
|
||||
});
|
||||
|
||||
it('keeps lunch out of the worked total', () => {
|
||||
const r = buildResume(DAY, ENTRIES, 15);
|
||||
expect(r.workedMs).toBe(9 * 60 * MINUTE); // 09h00 on site, lunch excluded
|
||||
expect(r.lunch).toEqual({ type: 'restaurant', restaurant: 'Le Relais', ms: 60 * MINUTE });
|
||||
});
|
||||
|
||||
it('splits totals across several clients', () => {
|
||||
const mixed = [
|
||||
ENTRIES[0],
|
||||
{ ...ENTRIES[1], client: 'Martin & Fils' },
|
||||
...ENTRIES.slice(2),
|
||||
];
|
||||
const r = buildResume(DAY, mixed, 15);
|
||||
expect(r.clients.map((c) => c.name)).toEqual(['Dupont SA', 'Martin & Fils']);
|
||||
expect(r.workedMs).toBe(9 * 60 * MINUTE);
|
||||
});
|
||||
|
||||
it('omits entries shorter than one rounding step, and says so', () => {
|
||||
const withBlip = [
|
||||
...ENTRIES,
|
||||
{ id: 6, date: DATE, seq: 5, kind: 'work', task: 'Mauvais appui', client: 'Dupont SA', startTs: at('17:30'), endTs: at('17:32') },
|
||||
];
|
||||
const r = buildResume(DAY, withBlip, 15);
|
||||
expect(r.omitted).toBe(1);
|
||||
expect(r.clients[0].tasks.map((t) => t.name)).not.toContain('Mauvais appui');
|
||||
});
|
||||
|
||||
it('keeps a 2-minute entry when rounding is set to 1 minute', () => {
|
||||
const withBlip = [
|
||||
...ENTRIES,
|
||||
{ id: 6, date: DATE, seq: 5, kind: 'work', task: 'Mauvais appui', client: 'Dupont SA', startTs: at('17:30'), endTs: at('17:32') },
|
||||
];
|
||||
const r = buildResume(DAY, withBlip, 1);
|
||||
expect(r.omitted).toBe(0);
|
||||
expect(r.clients[0].tasks.map((t) => t.name)).toContain('Mauvais appui');
|
||||
});
|
||||
|
||||
it('returns null without a day', () => {
|
||||
expect(buildResume(null, [], 15)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resumeToText', () => {
|
||||
it('renders the approved logbook format', () => {
|
||||
expect(resumeToText(buildResume(DAY, ENTRIES, 15))).toBe(
|
||||
[
|
||||
'Vendredi 4 septembre',
|
||||
'',
|
||||
'Dupont SA — 9h00',
|
||||
' Terrassement 7h30',
|
||||
' Chargement / transport 0h45',
|
||||
' Retour / déchargement 0h45',
|
||||
'',
|
||||
'Déjeuner : restaurant (Le Relais), 1h00',
|
||||
'',
|
||||
'Total travaillé : 9h00',
|
||||
].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
it('names the panier case without a restaurant', () => {
|
||||
const r = buildResume({ ...DAY, lunchType: 'panier', restaurant: null }, ENTRIES, 15);
|
||||
expect(resumeToText(r)).toContain('Déjeuner : panier, 1h00');
|
||||
});
|
||||
|
||||
it('is empty for a null resume', () => {
|
||||
expect(resumeToText(null)).toBe('');
|
||||
});
|
||||
});
|
||||
29
src/lib/router.svelte.js
Normal file
29
src/lib/router.svelte.js
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Three screens, so no router dependency — just enough hash handling that the
|
||||
* Android back button behaves inside an installed PWA.
|
||||
*
|
||||
* `route === null` means "wherever the day state says", which is the default the
|
||||
* worker should land on.
|
||||
*/
|
||||
|
||||
const ROUTES = ['resume', 'today', 'settings'];
|
||||
|
||||
function fromHash() {
|
||||
const h = location.hash.replace(/^#\/?/, '');
|
||||
return ROUTES.includes(h) ? h : null;
|
||||
}
|
||||
|
||||
export const router = $state({ route: fromHash() });
|
||||
|
||||
window.addEventListener('hashchange', () => {
|
||||
router.route = fromHash();
|
||||
});
|
||||
|
||||
export function go(route) {
|
||||
location.hash = route ? `#/${route}` : '';
|
||||
}
|
||||
|
||||
export function back() {
|
||||
if (history.length > 1) history.back();
|
||||
else go(null);
|
||||
}
|
||||
99
src/lib/seed.js
Normal file
99
src/lib/seed.js
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* DEV-only demo history, so the prediction engine has something to chew on.
|
||||
*
|
||||
* Not run automatically — it would clobber whatever you were testing. Open the
|
||||
* console and call `timelog.seed()`.
|
||||
*/
|
||||
|
||||
import { db } from './db.js';
|
||||
import { addDays, atLocalTime, localDateKey, MINUTE, weekday } from './time.js';
|
||||
|
||||
const CLIENTS = ['Dupont SA', 'Martin & Fils', 'Commune de Vaugines'];
|
||||
|
||||
/** Each client eats at a consistent place — the pairing the resto prediction learns. */
|
||||
const RESTAURANT_BY_CLIENT = {
|
||||
'Dupont SA': 'Le Relais',
|
||||
'Martin & Fils': 'Chez Marcel',
|
||||
'Commune de Vaugines': 'La Bonne Fourchette',
|
||||
};
|
||||
|
||||
const SHAPE = [
|
||||
{ kind: 'travel', task: 'Chargement / transport', minutes: 45 },
|
||||
{ kind: 'work', task: 'Terrassement', minutes: 195 },
|
||||
{ kind: 'lunch', task: 'Pause déjeuner', minutes: 60 },
|
||||
{ kind: 'work', task: 'Pose de câble', minutes: 210 },
|
||||
{ kind: 'work', task: 'Retour / déchargement', minutes: 50 },
|
||||
];
|
||||
|
||||
function jitter(minutes) {
|
||||
return Math.round(minutes + (Math.random() * 2 - 1) * minutes * 0.15);
|
||||
}
|
||||
|
||||
async function seed(dayCount = 20) {
|
||||
await db.transaction('rw', db.days, db.entries, async () => {
|
||||
await db.days.clear();
|
||||
await db.entries.clear();
|
||||
|
||||
let cursor = localDateKey();
|
||||
let produced = 0;
|
||||
|
||||
while (produced < dayCount) {
|
||||
cursor = addDays(cursor, -1);
|
||||
const dow = weekday(cursor);
|
||||
if (dow === 0 || dow === 6) continue;
|
||||
|
||||
const client = CLIENTS[produced % CLIENTS.length];
|
||||
// Three quarters of days are restaurant days, so both branches have history.
|
||||
// The period must stay coprime with CLIENTS.length, or lunch type correlates
|
||||
// with the client and no client ever accumulates a restaurant.
|
||||
const lunchType = produced % 4 === 0 ? 'panier' : 'restaurant';
|
||||
|
||||
let ts = atLocalTime(cursor, '07:30');
|
||||
const plannedEndTs = atLocalTime(cursor, '17:30');
|
||||
const entries = [];
|
||||
|
||||
SHAPE.forEach((step, seq) => {
|
||||
const end = ts + jitter(step.minutes) * MINUTE;
|
||||
entries.push({
|
||||
date: cursor,
|
||||
seq,
|
||||
kind: step.kind,
|
||||
task: step.task,
|
||||
client: step.kind === 'lunch' ? null : client,
|
||||
startTs: ts,
|
||||
endTs: end,
|
||||
});
|
||||
ts = end;
|
||||
});
|
||||
|
||||
await db.days.put({
|
||||
date: cursor,
|
||||
status: 'closed',
|
||||
plannedStartTs: atLocalTime(cursor, '07:30'),
|
||||
plannedEndTs,
|
||||
startedTs: entries[0].startTs,
|
||||
endedTs: ts,
|
||||
lunchType,
|
||||
restaurant: lunchType === 'restaurant' ? RESTAURANT_BY_CLIENT[client] : null,
|
||||
closePromptedTs: null,
|
||||
});
|
||||
await db.entries.bulkAdd(entries);
|
||||
produced++;
|
||||
}
|
||||
});
|
||||
|
||||
location.reload();
|
||||
}
|
||||
|
||||
async function wipe() {
|
||||
await db.transaction('rw', db.days, db.entries, async () => {
|
||||
await db.days.clear();
|
||||
await db.entries.clear();
|
||||
});
|
||||
location.reload();
|
||||
}
|
||||
|
||||
export function installDevSeed() {
|
||||
window.timelog = { seed, wipe, db };
|
||||
console.info('[timelog] dev helpers: timelog.seed(), timelog.wipe()');
|
||||
}
|
||||
74
src/lib/settings.svelte.js
Normal file
74
src/lib/settings.svelte.js
Normal file
@@ -0,0 +1,74 @@
|
||||
import { readSettings, writeSettings } from './db.js';
|
||||
|
||||
export const DEFAULT_SETTINGS = {
|
||||
/** JS weekday numbers, 0 = Sunday. Monday–Friday by default. */
|
||||
workdays: [1, 2, 3, 4, 5],
|
||||
dayStart: '07:30',
|
||||
dayEnd: '17:30',
|
||||
lunchStart: '12:00',
|
||||
lunchMinutes: 60,
|
||||
/** 'panier' = worker-provided, 'restaurant' = eaten out. */
|
||||
lunchType: 'panier',
|
||||
/** 1 | 5 | 15 | 30 */
|
||||
roundingMinutes: 15,
|
||||
/** 'jour' | 'nuit' — deliberately manual: dark mode is worse in direct sun. */
|
||||
theme: 'jour',
|
||||
/**
|
||||
* Generous by default: old days are what the predictions are learned from.
|
||||
* Two years, kept a multiple of the settings input's 30-day step so the field
|
||||
* does not render as invalid.
|
||||
*/
|
||||
retentionDays: 720,
|
||||
};
|
||||
|
||||
export const ROUNDING_CHOICES = [1, 5, 15, 30];
|
||||
|
||||
export const WEEKDAY_LABELS = [
|
||||
{ value: 1, label: 'Lun' },
|
||||
{ value: 2, label: 'Mar' },
|
||||
{ value: 3, label: 'Mer' },
|
||||
{ value: 4, label: 'Jeu' },
|
||||
{ value: 5, label: 'Ven' },
|
||||
{ value: 6, label: 'Sam' },
|
||||
{ value: 0, label: 'Dim' },
|
||||
];
|
||||
|
||||
export const settings = $state({ ...DEFAULT_SETTINGS, loaded: false });
|
||||
|
||||
/**
|
||||
* Set when a write fails, so a broken save is visible on the settings screen
|
||||
* instead of vanishing. Every call site of `updateSettings` is a click handler
|
||||
* that ignores the returned promise, so a rejection here would otherwise be
|
||||
* completely silent — which is exactly how the settings stopped persisting.
|
||||
*/
|
||||
export const settingsError = $state({ message: null });
|
||||
|
||||
export async function loadSettings() {
|
||||
const stored = await readSettings();
|
||||
Object.assign(settings, DEFAULT_SETTINGS, stored ?? {}, { loaded: true });
|
||||
applyTheme();
|
||||
}
|
||||
|
||||
export async function updateSettings(patch) {
|
||||
Object.assign(settings, patch);
|
||||
applyTheme();
|
||||
// `settings` is a $state proxy and its nested values (workdays) are proxies
|
||||
// too. IndexedDB clones structurally and refuses proxies with a
|
||||
// DataCloneError, so the plain snapshot is required, not optional.
|
||||
const { loaded, ...persisted } = $state.snapshot(settings);
|
||||
try {
|
||||
await writeSettings(persisted);
|
||||
settingsError.message = null;
|
||||
} catch (error) {
|
||||
settingsError.message = `Enregistrement impossible : ${error.message}`;
|
||||
console.error('[timelog] failed to persist settings', error);
|
||||
}
|
||||
}
|
||||
|
||||
export function applyTheme() {
|
||||
document.documentElement.dataset.theme = settings.theme;
|
||||
}
|
||||
|
||||
export function isWorkday(weekdayNumber) {
|
||||
return settings.workdays.includes(weekdayNumber);
|
||||
}
|
||||
80
src/lib/time.js
Normal file
80
src/lib/time.js
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Time helpers. Everything here works in *local* time — the prototype derived day
|
||||
* keys from `toISOString()`, which is UTC and files late/early work under the wrong
|
||||
* day. Timestamps are epoch milliseconds; rounding is applied at render only.
|
||||
*/
|
||||
|
||||
export const MINUTE = 60_000;
|
||||
export const HOUR = 3_600_000;
|
||||
export const DAY = 86_400_000;
|
||||
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
|
||||
/** Local 'YYYY-MM-DD' for a timestamp. */
|
||||
export function localDateKey(ts = Date.now()) {
|
||||
const d = new Date(ts);
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
|
||||
/** Timestamp of local midnight starting `dateKey`. */
|
||||
export function startOfDay(dateKey) {
|
||||
const [y, m, d] = dateKey.split('-').map(Number);
|
||||
return new Date(y, m - 1, d).getTime();
|
||||
}
|
||||
|
||||
/** Timestamp of 'HH:MM' local time on `dateKey`. */
|
||||
export function atLocalTime(dateKey, hhmm) {
|
||||
const [y, m, d] = dateKey.split('-').map(Number);
|
||||
const [h, min] = hhmm.split(':').map(Number);
|
||||
return new Date(y, m - 1, d, h, min, 0, 0).getTime();
|
||||
}
|
||||
|
||||
/** `dateKey` shifted by `n` days, DST-safe. */
|
||||
export function addDays(dateKey, n) {
|
||||
const [y, m, d] = dateKey.split('-').map(Number);
|
||||
return localDateKey(new Date(y, m - 1, d + n).getTime());
|
||||
}
|
||||
|
||||
/** JS weekday for a date key: 0 = Sunday … 6 = Saturday. */
|
||||
export function weekday(dateKey) {
|
||||
return new Date(startOfDay(dateKey)).getDay();
|
||||
}
|
||||
|
||||
/**
|
||||
* Round to the nearest `minutes`, half up, relative to *local midnight* rather
|
||||
* than the epoch — so zones with a :30 or :45 offset still land on whole
|
||||
* wall-clock steps. Always drops seconds.
|
||||
*/
|
||||
export function roundTs(ts, minutes) {
|
||||
const step = Math.max(1, minutes || 1) * MINUTE;
|
||||
const midnight = startOfDay(localDateKey(ts));
|
||||
return midnight + Math.round((ts - midnight) / step) * step;
|
||||
}
|
||||
|
||||
/** 'HH:MM' in local time, rounded to `roundMinutes`. */
|
||||
export function fmtTime(ts, roundMinutes = 1) {
|
||||
const d = new Date(roundTs(ts, roundMinutes));
|
||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/** A duration as '8h15' / '0h45'. */
|
||||
export function fmtDuration(ms) {
|
||||
const total = Math.max(0, Math.round(ms / MINUTE));
|
||||
return `${Math.floor(total / 60)}h${pad(total % 60)}`;
|
||||
}
|
||||
|
||||
/** 'vendredi 5 septembre' — the resume heading. */
|
||||
export function fmtDayLabel(dateKey) {
|
||||
const s = new Intl.DateTimeFormat('fr-FR', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
}).format(new Date(startOfDay(dateKey)));
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
/** 'HH:MM' → minutes since midnight. */
|
||||
export function hhmmToMinutes(hhmm) {
|
||||
const [h, m] = hhmm.split(':').map(Number);
|
||||
return h * 60 + m;
|
||||
}
|
||||
102
src/lib/time.test.js
Normal file
102
src/lib/time.test.js
Normal file
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
addDays,
|
||||
atLocalTime,
|
||||
fmtDuration,
|
||||
fmtTime,
|
||||
HOUR,
|
||||
localDateKey,
|
||||
MINUTE,
|
||||
roundTs,
|
||||
weekday,
|
||||
} from './time.js';
|
||||
|
||||
describe('localDateKey', () => {
|
||||
it('uses local components, not UTC', () => {
|
||||
// The prototype bug: 00:30 local in a positive-offset zone serialises to the
|
||||
// previous day under toISOString().
|
||||
const ts = new Date(2026, 8, 7, 0, 30).getTime();
|
||||
expect(localDateKey(ts)).toBe('2026-09-07');
|
||||
});
|
||||
|
||||
it('holds at both ends of the day', () => {
|
||||
expect(localDateKey(new Date(2026, 8, 6, 0, 0, 0).getTime())).toBe('2026-09-06');
|
||||
expect(localDateKey(new Date(2026, 8, 6, 23, 59, 59).getTime())).toBe('2026-09-06');
|
||||
});
|
||||
});
|
||||
|
||||
describe('roundTs', () => {
|
||||
const at = (h, m, s = 0) => new Date(2026, 8, 6, h, m, s).getTime();
|
||||
|
||||
it('drops seconds even at 1-minute granularity', () => {
|
||||
expect(fmtTime(at(8, 14, 59), 1)).toBe('08:15');
|
||||
expect(fmtTime(at(8, 14, 29), 1)).toBe('08:14');
|
||||
});
|
||||
|
||||
it.each([
|
||||
[1, at(8, 7, 0), '08:07'],
|
||||
[5, at(8, 7, 0), '08:05'],
|
||||
[15, at(8, 7, 0), '08:00'],
|
||||
[30, at(8, 7, 0), '08:00'],
|
||||
[15, at(8, 38, 0), '08:45'],
|
||||
[30, at(8, 38, 0), '08:30'],
|
||||
])('rounds to the nearest %i min', (step, ts, expected) => {
|
||||
expect(fmtTime(ts, step)).toBe(expected);
|
||||
});
|
||||
|
||||
it('rounds halves up', () => {
|
||||
expect(fmtTime(at(8, 7, 30), 15)).toBe('08:15');
|
||||
expect(fmtTime(at(8, 15, 0), 30)).toBe('08:30');
|
||||
});
|
||||
|
||||
it('never leaves a gap between adjacent entries', () => {
|
||||
// Adjacent entries share one stored timestamp, so whatever it rounds to, the
|
||||
// rendered end of one is the rendered start of the next.
|
||||
const boundary = at(12, 7, 33);
|
||||
for (const step of [1, 5, 15, 30]) {
|
||||
expect(roundTs(boundary, step)).toBe(roundTs(boundary, step));
|
||||
expect(fmtTime(boundary, step)).toBe(fmtTime(boundary, step));
|
||||
}
|
||||
});
|
||||
|
||||
it('lands on wall-clock steps regardless of zone offset', () => {
|
||||
const rounded = roundTs(at(9, 22), 15);
|
||||
const d = new Date(rounded);
|
||||
expect(d.getMinutes() % 15).toBe(0);
|
||||
expect(d.getSeconds()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fmtDuration', () => {
|
||||
it.each([
|
||||
[0, '0h00'],
|
||||
[45 * MINUTE, '0h45'],
|
||||
[8 * HOUR + 15 * MINUTE, '8h15'],
|
||||
[HOUR, '1h00'],
|
||||
])('formats %i as %s', (ms, expected) => {
|
||||
expect(fmtDuration(ms)).toBe(expected);
|
||||
});
|
||||
|
||||
it('clamps negatives rather than printing nonsense', () => {
|
||||
expect(fmtDuration(-5000)).toBe('0h00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('date arithmetic', () => {
|
||||
it('adds days across a month boundary', () => {
|
||||
expect(addDays('2026-08-31', 1)).toBe('2026-09-01');
|
||||
expect(addDays('2026-09-01', -1)).toBe('2026-08-31');
|
||||
});
|
||||
|
||||
it('reads weekdays in local time', () => {
|
||||
expect(weekday('2026-09-06')).toBe(0); // Sunday
|
||||
expect(weekday('2026-09-07')).toBe(1); // Monday
|
||||
});
|
||||
|
||||
it('builds local timestamps from HH:MM', () => {
|
||||
const ts = atLocalTime('2026-09-07', '07:30');
|
||||
const d = new Date(ts);
|
||||
expect([d.getHours(), d.getMinutes()]).toEqual([7, 30]);
|
||||
expect(localDateKey(ts)).toBe('2026-09-07');
|
||||
});
|
||||
});
|
||||
38
src/lib/updates.svelte.js
Normal file
38
src/lib/updates.svelte.js
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Service worker update policy.
|
||||
*
|
||||
* The app checks for a new build hourly, but never applies one on its own: the
|
||||
* waiting worker is only activated when the worker taps "Installer", and that
|
||||
* banner is only rendered while no day is running (see `App.svelte`). A reload
|
||||
* mid-day would interrupt input, which is exactly what "non-breaking updates"
|
||||
* has to rule out.
|
||||
*/
|
||||
|
||||
import { registerSW } from 'virtual:pwa-register';
|
||||
|
||||
const CHECK_INTERVAL = 60 * 60 * 1000;
|
||||
|
||||
export const needsRefresh = $state({ value: false });
|
||||
|
||||
/** @type {(reloadPage?: boolean) => Promise<void>} */
|
||||
let update = async () => {};
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
update = registerSW({
|
||||
immediate: true,
|
||||
onNeedRefresh() {
|
||||
needsRefresh.value = true;
|
||||
},
|
||||
onRegisteredSW(_url, registration) {
|
||||
if (!registration) return;
|
||||
setInterval(() => {
|
||||
if (navigator.onLine) registration.update().catch(() => {});
|
||||
}, CHECK_INTERVAL);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function applyUpdate() {
|
||||
needsRefresh.value = false;
|
||||
await update(true);
|
||||
}
|
||||
Reference in New Issue
Block a user