Fixed a few minor bugs

This commit is contained in:
2026-09-19 19:58:13 +02:00
parent 870457955b
commit 9bb9260c09
11 changed files with 520 additions and 135 deletions

View File

@@ -1,8 +1,8 @@
# TimeLog
Offline-first PWA for tracking hours on site. The day starts and ends on its own, each
task begins where the previous one ended, and the app's main output is a concise
summary of the last working day, ready to copy into a paper logbook.
Offline-first PWA for tracking hours on site. While the app is running, it can start
the day automatically and prompt for its end. Each task begins where the previous
one ended. Closed days have concise summaries ready to copy into a paper logbook.
Built for use outdoors: high contrast, large targets, usable with gloves on. All data
stays on the device (IndexedDB); there is no backend and nothing is uploaded.
@@ -12,7 +12,7 @@ stays on the device (IndexedDB); there is no backend and nothing is uploaded.
```sh
npm install
npm run dev # http://localhost:5173
npm test # unit tests for the time, prediction, resume and boundary logic
npm test # unit and IndexedDB regression tests
npm run check # svelte-check
npm run build # production build into dist/
```
@@ -58,6 +58,29 @@ les fichiers invalides, la fusion, le remplacement et l'annulation transactionne
sur une IndexedDB simulée. `src/lib/fixtures/backup.json` est le jeu d'essai fourni ;
il n'est pas publié avec l'application.
## Journées, reprise et historique
- Une journée oubliée est clôturée au retour dans l'application, à son heure de
fin prévue (prolongations comprises), sans jamais précéder la dernière activité
enregistrée. Cette heure est une estimation : l'application ne mesure pas le
travail lorsqu'elle est fermée. Le dernier poste conserve ainsi sa durée jusqu'à
cette limite, au lieu d'être ramené à zéro.
- Une journée terminée propose **Reprendre la journée**. Cela annule sa clôture et
continue le dernier poste, client et déjeuner conservés. Le temps écoulé depuis
l'arrêt est compté dans ce poste : la reprise corrige une clôture prématurée.
- La création, la clôture et la reprise sont transactionnelles. Les actions
simultanées sur le même poste ne créent pas de doublons ; une journée clôturée
ne peut pas recevoir un nouveau poste actif. La purge épargne les journées
actives et conserve toujours la dernière journée terminée.
- Les horaires de déjeuner affichent un rappel puis une heure indicative de
reprise, calculée depuis le début réel de la pause. Les transitions restent
manuelles : aucun temps de travail n'est transformé automatiquement en pause.
- Dans **Récap**, le sélecteur **Journée à consulter** permet d'afficher et de copier
chaque journée terminée encore conservée, y compris les journées importées.
Les tests couvrent notamment les clôtures oubliées, les reprises, les appels
concurrents, les retours d'horloge, les échecs transactionnels et la conservation.
In dev the console exposes `timelog.seed()` to generate 20 days of realistic history
(useful for exercising the suggestion engine) and `timelog.wipe()` to clear it.
@@ -132,6 +155,6 @@ src/lib/
day.svelte.js day lifecycle state machine
```
The four pure modules hold the logic worth testing and carry the unit tests. `db.js`
concentrates every write that touches a boundary into `closeAndStart` and
`setEntryStart`, so the timeline cannot drift out of order from anywhere else.
Pure helpers and database/lifecycle operations have regression tests. `db.js`
keeps day and entry changes in shared transactions, including creation, closure,
reopening and boundary corrections.

View File

@@ -27,7 +27,7 @@ const isDate = (v) => {
};
/** Only recognised settings enter the database; missing values use app defaults. */
function parseSettings(raw) {
export function parseSettings(raw) {
if (raw == null) return null;
if (!isObject(raw)) throw new BackupError('Réglages invalides dans le fichier.');
const clock = (v) => typeof v === 'string' && /^([01]\d|2[0-3]):[0-5]\d$/.test(v);
@@ -45,7 +45,7 @@ function parseSettings(raw) {
const out = {};
for (const [key, validate] of Object.entries(rules)) {
if (!Object.hasOwn(raw, key)) continue;
if (!validate(raw[key])) throw new BackupError(`Réglage invalide dans le fichier : ${key}.`);
if (!validate(raw[key])) throw new BackupError(`Réglage invalide : ${key}.`);
out[key] = key === 'workdays' ? [...new Set(raw[key])].sort() : raw[key];
}
return out;

View File

@@ -9,16 +9,16 @@
import {
cleanupOldDays,
closeAndStart,
closeDay,
createDay,
deleteEntry,
getActiveDay,
getDay,
getEntries,
getHistory,
getLastClosedDay,
importBackup,
patchDay,
patchActiveDay,
patchEntry,
putDay,
reopenDay,
setEntryStart,
} from './db.js';
import { buildModel, suggestClients, suggestTasks, TRANSPORT_TASK } from './predict.js';
@@ -33,6 +33,9 @@ export const dayState = $state({
/** Last closed day + its entries — what the resume screen renders. */
lastClosed: null,
lastClosedEntries: [],
closedDays: [],
historyEntries: [],
todayRecord: null,
/** Prediction model, rebuilt whenever history changes. */
model: null,
/** True once the configured end time has passed and the worker has not answered. */
@@ -56,6 +59,7 @@ const pendingReconciliations = new Set();
*/
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(
@@ -68,25 +72,24 @@ export function reconcile(now = Date.now()) {
async function reconcileDay(now) {
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 closeDay(active.date, now, 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);
await cleanupOldDays(settings.retentionDays, now);
} 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) {
@@ -103,21 +106,14 @@ async function reconcileDay(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({
const { entries, days } = await getHistory();
const model = buildModel(entries, days);
await createDay({
date: dateKey,
status: 'active',
plannedStartTs: atLocalTime(dateKey, settings.dayStart),
@@ -127,11 +123,7 @@ async function openDay(dateKey, startTs) {
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,
@@ -139,16 +131,23 @@ async function openDay(dateKey, startTs) {
}
/** Reload everything from the database and rebuild the model. */
let refreshVersion = 0;
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 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.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(
@@ -161,7 +160,8 @@ export async function refresh(now = Date.now()) {
/** Manual start, for a Saturday or an early departure. */
export async function startDayNow(now = Date.now()) {
await openDay(dayState.today, now);
if (dayState.importing) return;
await openDay(localDateKey(now), now);
await refresh(now);
}
@@ -173,27 +173,27 @@ export async function startDayNow(now = Date.now()) {
* @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 });
if (!dayState.day || dayState.importing) return;
await closeAndStart(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) return;
if (!dayState.day || dayState.importing || running()?.kind === 'lunch') return;
await closeAndStart(dayState.day.date, now, {
kind: 'lunch',
task: 'Pause déjeuner',
client: null,
});
}, running()?.id ?? null);
await refresh(now);
}
export async function setLunch({ lunchType, restaurant }) {
if (!dayState.day) return;
await patchDay(dayState.day.date, {
if (!dayState.day || dayState.importing) return;
await patchActiveDay(dayState.day.date, () => ({
lunchType,
restaurant: lunchType === 'restaurant' ? (restaurant ?? null) : null,
});
}));
await refresh();
}
@@ -247,46 +247,27 @@ export async function applyImport(payload, mode) {
}
export async function endDay(now = Date.now()) {
if (!dayState.day) return;
if (!dayState.day || dayState.importing) return;
const date = dayState.day.date;
await closeAndStart(date, now, null);
await patchDay(date, { status: 'closed', endedTs: now });
await closeDay(date, 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,
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: reopens today and starts a fresh task from now. */
/** Undo a premature end-of-day, continuing the last task without a gap. */
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,
});
if (dayState.importing) return;
await reopenDay(localDateKey(now), now);
await refresh(now);
}

227
src/lib/day.test.js Normal file
View File

@@ -0,0 +1,227 @@
import 'fake-indexeddb/auto';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanupOldDays, closeAndStart, closeDay, createDay, db, getDay, getEntries, reopenDay } from './db.js';
import { advance, dayState, endDay, keepWorking, reconcile, refresh, reopenToday, setLunch, startDayNow, startLunch } from './day.svelte.js';
import { DEFAULT_SETTINGS, settings, settingsError, updateSettings } from './settings.svelte.js';
import { atLocalTime, HOUR } from './time.js';
import { buildResume } from './resume.js';
const DATE = '2026-09-07';
const at = (time, date = DATE) => atLocalTime(date, time);
const first = { kind: 'travel', task: 'Transport', client: 'Client A' };
const makeDay = (date = DATE) => ({
date, status: 'active', plannedStartTs: at('07:30', date), plannedEndTs: at('17:30', date),
startedTs: at('07:30', date), endedTs: null, lunchType: 'restaurant', restaurant: 'Le Relais', closePromptedTs: null,
});
const records = async () => ({ days: await db.days.toArray(), entries: await db.entries.toArray() });
beforeEach(async () => {
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(at('08:00'));
vi.stubGlobal('document', { documentElement: { dataset: {} } });
await db.days.clear(); await db.entries.clear(); await db.settings.clear();
Object.assign(settings, DEFAULT_SETTINGS, { workdays: [], retentionDays: 0 });
dayState.importing = false;
await refresh();
});
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); vi.useRealTimers(); });
describe('day closure', () => {
it('closes forgotten work at the planned end rather than zeroing the last task', async () => {
await startDayNow(at('07:30'));
await advance({ task: 'Travaux', client: 'Client A' }, at('14:00'));
await reconcile(at('06:00', '2026-09-08'));
const day = await getDay(DATE), entries = await getEntries(DATE);
expect(day.status).toBe('closed');
expect(day.endedTs).toBe(at('17:30'));
expect(entries.at(-1).endTs - entries.at(-1).startTs).toBe(3.5 * HOUR);
expect(buildResume(day, entries).workedMs).toBe(10 * HOUR);
});
it('never places the day end before late recorded activity', async () => {
await startDayNow(at('07:30'));
await advance({ task: 'Travaux tardifs' }, at('18:00'));
await reconcile(at('06:00', '2026-09-08'));
const day = await getDay(DATE), entries = await getEntries(DATE);
expect(day.endedTs).toBe(at('18:00'));
expect(entries.at(-1).endTs).toBe(day.endedTs);
});
it('uses the stored extended end even if settings subsequently change', async () => {
await startDayNow(at('07:30'));
await advance({ task: 'Travaux' }, at('14:00'));
await keepWorking(at('17:30'));
settings.dayEnd = '16:00';
await reconcile(at('06:00', '2026-09-08'));
expect((await getDay(DATE)).endedTs).toBe(at('18:30'));
});
it('manual closure clamps the day and entry to the same end after a clock reversal', async () => {
await startDayNow(at('09:00'));
await endDay(at('08:00'));
expect((await getDay(DATE)).endedTs).toBe(at('09:00'));
expect((await getEntries(DATE))[0].endTs).toBe(at('09:00'));
});
it('repeated close requests do not move an already recorded end', async () => {
await createDay(makeDay(), first);
await Promise.all([closeDay(DATE, at('17:00')), closeDay(DATE, at('18:00'))]);
expect((await getDay(DATE)).endedTs).toBe(at('17:00'));
});
});
describe('start and resume', () => {
it('does not replace a closed day or lose its lunch when start is called again', async () => {
await createDay(makeDay(), first);
await closeDay(DATE, at('17:00'));
const before = await records();
await startDayNow(at('17:15'));
expect(await records()).toEqual(before);
expect(dayState.todayRecord.status).toBe('closed');
});
it('resumes the last task and keeps all metadata without a gap or new entry', async () => {
await createDay(makeDay(), first);
await closeAndStart(DATE, at('08:00'), { kind: 'work', task: 'Pose', client: 'Client B' });
await closeDay(DATE, at('17:00'));
const before = await getDay(DATE), entries = await getEntries(DATE);
await reopenToday(at('17:15'));
const reopened = await getDay(DATE), after = await getEntries(DATE);
expect(reopened).toMatchObject({ status: 'active', startedTs: before.startedTs, lunchType: before.lunchType, restaurant: before.restaurant });
expect(after).toEqual([entries[0], { ...entries[1], endTs: null }]);
await endDay(at('18:00'));
expect(buildResume(await getDay(DATE), await getEntries(DATE)).workedMs).toBe(10.5 * HOUR);
});
it('does not reopen yesterday when the UI still holds yesterday’s date', async () => {
await createDay(makeDay(), first); await closeDay(DATE, at('17:00'));
dayState.today = DATE;
await reopenToday(at('01:00', '2026-09-08'));
expect((await getDay(DATE)).status).toBe('closed');
});
it('does not automatically reopen a day already ended today', async () => {
await createDay(makeDay(), first); await closeDay(DATE, at('10:00'));
settings.workdays = [1];
await reconcile(at('12:00'));
expect((await getDay(DATE)).status).toBe('closed');
expect(await db.entries.count()).toBe(1);
});
});
describe('concurrent actions', () => {
it('concurrent clock reconciliations create one day and one transport entry', async () => {
settings.workdays = [1];
await Promise.all([reconcile(at('08:00')), reconcile(at('08:00'))]);
expect(await db.days.count()).toBe(1);
expect(await db.entries.count()).toBe(1);
});
it('separate callers creating the same date are idempotent at the database boundary', async () => {
const results = await Promise.all([createDay(makeDay(), first), createDay(makeDay(), first)]);
expect(results).toEqual([true, false]);
expect(await db.entries.count()).toBe(1);
});
it('cannot open two different dates simultaneously', async () => {
await Promise.all([createDay(makeDay(), first), createDay(makeDay('2026-09-08'), first)]);
expect(await db.days.where('status').equals('active').count()).toBe(1);
expect(await db.entries.count()).toBe(1);
});
it.each(['close-first', 'advance-first'])('keeps a concurrently closed day free of running entries: %s', async (order) => {
await startDayNow(at('07:30'));
const close = () => endDay(at('17:00'));
const next = () => advance({ task: 'Suivante' }, at('17:00'));
await Promise.all(order === 'close-first' ? [close(), next()] : [next(), close()]);
expect((await getDay(DATE)).status).toBe('closed');
expect((await getEntries(DATE)).every((e) => e.endTs !== null)).toBe(true);
});
it('two actions on the same observed entry advance only once', async () => {
await createDay(makeDay(), first);
const id = (await getEntries(DATE))[0].id;
const next = { kind: 'work', task: 'Pose', client: 'Client A' };
await Promise.all([closeAndStart(DATE, at('08:00'), next, id), closeAndStart(DATE, at('08:00'), next, id)]);
const entries = await getEntries(DATE);
expect(entries).toHaveLength(2);
expect(entries[0].endTs).toBe(entries[1].startTs);
});
it('repeated lunch taps do not create duplicate lunch entries', async () => {
await startDayNow(at('07:30'));
await Promise.all([startLunch(at('12:00')), startLunch(at('12:00'))]);
await startLunch(at('12:01'));
expect((await getEntries(DATE)).filter((e) => e.kind === 'lunch')).toHaveLength(1);
});
it('two resume requests reopen the same entry only once', async () => {
await createDay(makeDay(), first); await closeDay(DATE, at('17:00'));
await Promise.all([reopenDay(DATE, at('17:15')), reopenDay(DATE, at('17:15'))]);
expect(await db.entries.count()).toBe(1);
expect((await getEntries(DATE))[0].endTs).toBeNull();
});
it('does not update lunch from a stale screen after another caller closed the day', async () => {
await createDay(makeDay(), first); await refresh();
await closeDay(DATE, at('17:00'));
await setLunch({ lunchType: 'panier', restaurant: null });
expect((await getDay(DATE)).restaurant).toBe('Le Relais');
});
it('extends the close prompt only once for two simultaneous responses', async () => {
await startDayNow(at('07:30'));
await Promise.all([keepWorking(at('17:30')), keepWorking(at('17:30'))]);
expect((await getDay(DATE)).plannedEndTs).toBe(at('18:30'));
});
});
describe('transaction failure', () => {
it('rolls back the day when its first entry cannot be saved', async () => {
vi.spyOn(db.entries, 'add').mockRejectedValueOnce(new Error('Write failed'));
await expect(createDay(makeDay(), first)).rejects.toThrow('Write failed');
expect(await records()).toEqual({ days: [], entries: [] });
});
it.each(['close', 'resume'])('rolls back both stores when the %s operation fails', async (operation) => {
await createDay(makeDay(), first);
if (operation === 'resume') await closeDay(DATE, at('17:00'));
const before = await records();
vi.spyOn(db.days, 'update').mockRejectedValueOnce(new Error('Write failed'));
await expect(operation === 'close' ? closeDay(DATE, at('17:00')) : reopenDay(DATE, at('17:15'))).rejects.toThrow('Write failed');
expect(await records()).toEqual(before);
});
});
describe('history and retention', () => {
it('never purges a day that is still active', async () => {
await createDay(makeDay('2020-01-06'), first);
expect(await cleanupOldDays(30, at('08:00'))).toBe(0);
expect(await db.days.count()).toBe(1);
});
it('closes stale work before retention and retains the most recent closed day', async () => {
await createDay(makeDay('2020-01-06'), first);
settings.retentionDays = 30;
await reconcile(at('08:00', '2026-09-09'));
expect((await getDay('2020-01-06')).status).toBe('closed');
expect((await getEntries('2020-01-06'))[0].endTs).toBe(at('17:30', '2020-01-06'));
});
it('loads older closed days and their entries in the recap state', async () => {
for (const date of ['2026-09-03', '2026-09-04']) {
await createDay(makeDay(date), first); await closeDay(date, at('17:30', date));
}
await refresh();
expect(dayState.closedDays.map((d) => d.date)).toEqual(['2026-09-04', '2026-09-03']);
expect(dayState.historyEntries).toHaveLength(2);
expect(dayState.lastClosed.date).toBe('2026-09-04');
});
it('rejects empty time settings before they can disable the day clock', async () => {
await updateSettings({ dayEnd: '' });
expect(settings.dayEnd).toBe('17:30');
expect(settingsError.message).toMatch(/dayEnd/);
expect(await db.settings.count()).toBe(0);
});
});

View File

@@ -1,6 +1,6 @@
import Dexie from 'dexie';
import { BackupError, buildBackup, EXPORT_FORMAT, EXPORT_VERSION, parseBackup } from './backup.js';
import { DAY, localDateKey } from './time.js';
import { DAY, HOUR, localDateKey } from './time.js';
/**
* A single working day. `plannedStartTs` / `plannedEndTs` are snapshotted from the
@@ -88,6 +88,59 @@ export function patchDay(dateKey, patch) {
return db.days.update(dateKey, patch);
}
/** Create once, together with the first entry, even from competing tabs. */
export function createDay(day, first) {
return db.transaction('rw', db.days, db.entries, async () => {
if (await getDay(day.date) || await getActiveDay()) return false;
await db.days.add(day);
await closeAndStart(day.date, day.startedTs, first);
return true;
});
}
/** Close the day and its last entry at the same authoritative timestamp. */
export function closeDay(date, ts, stale = false) {
return db.transaction('rw', db.days, db.entries, async () => {
const day = await getDay(date);
if (!day || day.status !== 'active') return false;
const entries = await getEntries(date);
const lastActivity = entries.reduce((max, e) => Math.max(max, e.startTs, e.endTs ?? e.startTs), day.startedTs);
const endedTs = Math.max(stale ? day.plannedEndTs : ts, lastActivity);
await closeAndStart(date, endedTs, null);
await patchDay(date, { status: 'closed', endedTs });
return true;
});
}
/** Undo a premature close; no new entry or unaccounted interval is introduced. */
export function reopenDay(date, now) {
return db.transaction('rw', db.days, db.entries, async () => {
const day = await getDay(date);
if (!day || day.status !== 'closed' || await getActiveDay()) return false;
const entries = await getEntries(date);
const last = entries.at(-1);
if (!last) return false;
await db.entries.update(last.id, { endTs: null });
await patchDay(date, {
status: 'active', endedTs: null, closePromptedTs: null,
plannedEndTs: Math.max(day.plannedEndTs, now + HOUR),
});
return true;
});
}
/** Conditional day updates cannot resurrect or modify a concurrently closed day. */
export function patchActiveDay(date, makePatch) {
return db.transaction('rw', db.days, async () => {
const day = await getDay(date);
if (day?.status !== 'active') return false;
const patch = makePatch(day);
if (!patch) return false;
await patchDay(date, patch);
return true;
});
}
/** 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();
@@ -116,12 +169,16 @@ export function getEntries(dateKey) {
* @param {number} ts
* @param {Pick<EntryRecord, 'kind'|'task'|'client'>|null} next
* Pass null to only close the running entry (end of day).
* @param {number|null} [expectedId] running entry observed by the action's caller
* @returns {Promise<number|null>} id of the new entry
*/
export function closeAndStart(dateKey, ts, next) {
return db.transaction('rw', db.entries, async () => {
export function closeAndStart(dateKey, ts, next, expectedId) {
return db.transaction('rw', db.days, db.entries, async () => {
if ((await getDay(dateKey))?.status !== 'active') return null;
const list = await db.entries.where('date').equals(dateKey).sortBy('seq');
const running = list.find((e) => e.endTs == null);
// Two taps/tabs acting on the same entry may advance it only once.
if (expectedId !== undefined && (running?.id ?? null) !== expectedId) return null;
let boundary = ts;
if (running) {
@@ -211,33 +268,30 @@ export function deleteEntry(id) {
/** 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 };
return db.transaction('r', db.entries, db.days, async () => {
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) {
export async function cleanupOldDays(retentionDays, now = Date.now()) {
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 () => {
const cutoff = localDateKey(now - retentionDays * DAY);
return db.transaction('rw', db.days, db.entries, async () => {
const keep = await getLastClosedDay();
const stale = (await db.days.toArray()).filter(
(d) => d.status === 'closed' && d.date < cutoff && d.date !== keep?.date,
);
const keys = stale.map((d) => d.date);
if (!keys.length) return 0;
await db.days.bulkDelete(keys);
await db.entries.where('date').anyOf(keys).delete();
return keys.length;
});
return keys.length;
}
/**

13
src/lib/lunch.js Normal file
View File

@@ -0,0 +1,13 @@
import { atLocalTime, MINUTE } from './time.js';
/** Clock hints only: lunch boundaries always come from the worker's actions. */
export function lunchGuidance(day, entries, settings, now) {
if (!day || day.status !== 'active') return null;
const current = entries.find((e) => e.endTs == null);
if (current?.kind === 'lunch') {
const resumeTs = current.startTs + settings.lunchMinutes * MINUTE;
return { resumeTs, due: now >= resumeTs };
}
if (entries.some((e) => e.kind === 'lunch') || now >= day.plannedEndTs) return null;
return now >= atLocalTime(day.date, settings.lunchStart) ? { resumeTs: null, due: true } : null;
}

27
src/lib/lunch.test.js Normal file
View File

@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { lunchGuidance } from './lunch.js';
import { atLocalTime } from './time.js';
const at = (time) => atLocalTime('2026-09-07', time);
const day = { date: '2026-09-07', status: 'active', plannedEndTs: at('17:30') };
const settings = { lunchStart: '12:00', lunchMinutes: 45 };
describe('lunch clock hints', () => {
it('uses the configured lunch time without starting lunch automatically', () => {
const entries = [{ kind: 'work', startTs: at('08:00'), endTs: null }];
expect(lunchGuidance(day, entries, settings, at('11:59'))).toBeNull();
expect(lunchGuidance(day, entries, settings, at('12:00'))).toEqual({ resumeTs: null, due: true });
expect(entries[0].endTs).toBeNull();
});
it('uses the real lunch start and configured duration for the return hint', () => {
const entries = [{ kind: 'lunch', startTs: at('12:15'), endTs: null }];
expect(lunchGuidance(day, entries, settings, at('12:30'))).toEqual({ resumeTs: at('13:00'), due: false });
expect(lunchGuidance(day, entries, settings, at('13:00')).due).toBe(true);
expect(entries[0].endTs).toBeNull();
});
it('stops suggesting lunch after it was taken or after the day ends', () => {
const entries = [{ kind: 'lunch', startTs: at('12:00'), endTs: at('12:45') }];
expect(lunchGuidance(day, entries, settings, at('14:00'))).toBeNull();
expect(lunchGuidance(day, [], settings, at('18:00'))).toBeNull();
expect(lunchGuidance({ ...day, status: 'closed' }, [], settings, at('12:00'))).toBeNull();
});
});

View File

@@ -1,4 +1,5 @@
import { readSettings, writeSettings } from './db.js';
import { parseSettings } from './backup.js';
export const DEFAULT_SETTINGS = {
/** JS weekday numbers, 0 = Sunday. Monday–Friday by default. */
@@ -50,6 +51,8 @@ export async function loadSettings() {
}
export async function updateSettings(patch) {
try { parseSettings({ ...$state.snapshot(settings), ...patch }); }
catch (error) { settingsError.message = error.message; return; }
Object.assign(settings, patch);
applyTheme();
// `settings` is a $state proxy and its nested values (workdays) are proxies

View File

@@ -3,14 +3,18 @@
import { buildResume, resumeToText } from '../lib/resume.js';
import { go } from '../lib/router.svelte.js';
import { settings } from '../lib/settings.svelte.js';
import { fmtDuration, weekday } from '../lib/time.js';
import { fmtDayLabel, fmtDuration, weekday } from '../lib/time.js';
let copied = $state(false);
let selectedDate = $state('');
let reopening = $state(false);
let error = $state('');
const selectedDay = $derived(dayState.closedDays.find((d) => d.date === selectedDate) ?? dayState.lastClosed);
const resume = $derived(
buildResume(
dayState.lastClosed,
dayState.lastClosedEntries,
selectedDay,
dayState.historyEntries.filter((e) => e.date === selectedDay?.date),
settings.roundingMinutes,
),
);
@@ -22,7 +26,16 @@
settings.workdays.includes(weekday(dayState.today)) ? settings.dayStart : null,
);
const closedToday = $derived(dayState.lastClosed?.date === dayState.today);
const closedToday = $derived(selectedDay?.date === dayState.today && !dayState.day);
async function reopen() {
if (reopening) return;
reopening = true;
error = '';
try { await reopenToday(); go('today'); }
catch (cause) { error = `Reprise impossible : ${cause.message}`; }
finally { reopening = false; }
}
async function copy() {
try {
@@ -37,6 +50,17 @@
</script>
<div class="screen">
{#if error}<p role="alert">{error}</p>{/if}
{#if dayState.closedDays.length > 1}
<label class="stack">
<span>Journée à consulter</span>
<select value={selectedDay?.date} onchange={(event) => { selectedDate = event.currentTarget.value; copied = false; }}>
{#each dayState.closedDays as day (day.date)}
<option value={day.date}>{fmtDayLabel(day.date)} {day.date.slice(0, 4)}</option>
{/each}
</select>
</label>
{/if}
{#if !resume}
<div class="card center stack">
<h2>Aucune journée enregistrée</h2>
@@ -101,16 +125,15 @@
{#if closedToday}
<button
type="button"
onclick={async () => {
await reopenToday();
go('today');
}}>Reprendre la journée</button
disabled={reopening}
onclick={reopen}>Reprendre la journée</button
>
{:else if startsAt}
<p class="dim small">Annule la clôture et continue la dernière tâche depuis l’arrêt.</p>
{:else if !dayState.day && !dayState.todayRecord && startsAt}
<p class="dim center small">
La journée démarrera automatiquement à {startsAt}.
</p>
{:else}
{:else if !dayState.day && !dayState.todayRecord}
<p class="dim center small">Aujourd'hui n'est pas un jour travaillé.</p>
{/if}
</div>

View File

@@ -105,6 +105,7 @@
>
</div>
<p class="dim small">Type par défaut ; modifiable chaque jour.</p>
<p class="dim small">L’heure et la durée affichent des repères sur la journée. Le début et la fin de la pause restent manuels.</p>
</section>
<section class="card stack">

View File

@@ -14,6 +14,7 @@
predictForCurrent,
predictNext,
removeEntry,
reopenToday,
running,
setLunch,
startDayNow,
@@ -21,6 +22,7 @@
} from '../lib/day.svelte.js';
import { suggestClients, suggestRestaurants, suggestTasks } from '../lib/predict.js';
import { go } from '../lib/router.svelte.js';
import { lunchGuidance } from '../lib/lunch.js';
import { settings } from '../lib/settings.svelte.js';
import { fmtDuration, fmtTime } from '../lib/time.js';
@@ -28,6 +30,17 @@
let confirmingEnd = $state(false);
/** id of the timeline entry being corrected, if any */
let editingId = $state(null);
let busy = $state(false);
let actionError = $state('');
async function act(action) {
if (busy) return;
busy = true;
actionError = '';
try { await action(); }
catch (error) { actionError = `Action impossible : ${error.message}`; }
finally { busy = false; }
}
$effect(() => {
const id = setInterval(() => (now = Date.now()), 30_000);
@@ -35,6 +48,8 @@
});
const current = $derived(running());
const closedToday = $derived(dayState.todayRecord?.status === 'closed');
const lunchHint = $derived(lunchGuidance(dayState.day, dayState.entries, settings, now));
// Snapshotted against writes, not against the clock: the proposed next task must
// not shift under the worker's thumb while they reach for the button.
@@ -82,7 +97,7 @@
);
function goNext() {
advance({ kind: 'work', task: next.task, client: next.client });
return advance({ kind: 'work', task: next.task, client: next.client });
}
/**
@@ -97,7 +112,7 @@
}
function chooseLunchType(lunchType) {
setLunch({
return setLunch({
lunchType,
restaurant:
lunchType === 'restaurant'
@@ -107,15 +122,21 @@
}
</script>
{#if actionError}<p class="action-error card" role="alert">{actionError}</p>{/if}
{#if !dayState.day}
<div class="screen">
<div class="card center stack">
<h2>Journée non commencée</h2>
<h2>{closedToday ? 'Journée terminée' : 'Journée non commencée'}</h2>
<p class="dim">
Aujourd'hui n'est pas un jour travaillé, ou l'heure de début n'est pas atteinte.
{#if closedToday}
Reprendre annule la clôture et continue la dernière tâche, y compris le temps écoulé depuis l’arrêt.
{:else}
Aujourd'hui n'est pas un jour travaillé, ou l'heure de début n'est pas atteinte.
{/if}
</p>
<button type="button" class="btn-primary" onclick={() => startDayNow()}>
Démarrer la journée
<button type="button" class="btn-primary" disabled={busy} onclick={() => act(closedToday ? reopenToday : startDayNow)}>
{closedToday ? 'Reprendre la journée' : 'Démarrer la journée'}
</button>
</div>
</div>
@@ -137,7 +158,7 @@
Number.NEGATIVE_INFINITY}
max={now}
rounding={settings.roundingMinutes}
onchange={(ts) => editEntryStart(current.id, ts)}
onchange={(ts) => act(() => editEntryStart(current.id, ts))}
/>
{#if current.kind === 'lunch'}
@@ -145,12 +166,12 @@
<button
type="button"
class:sel={dayState.day.lunchType !== 'restaurant'}
onclick={() => chooseLunchType('panier')}>Panier</button
disabled={busy} onclick={() => act(() => chooseLunchType('panier'))}>Panier</button
>
<button
type="button"
class:sel={dayState.day.lunchType === 'restaurant'}
onclick={() => chooseLunchType('restaurant')}>Restaurant</button
disabled={busy} onclick={() => act(() => chooseLunchType('restaurant'))}>Restaurant</button
>
</div>
{#if dayState.day.lunchType === 'restaurant'}
@@ -160,7 +181,7 @@
suggestions={restaurants}
placeholder="Nom du restaurant"
emptyText="Aucun restaurant enregistré"
onchange={(v) => setLunch({ lunchType: 'restaurant', restaurant: v })}
onchange={(v) => act(() => setLunch({ lunchType: 'restaurant', restaurant: v }))}
/>
{/if}
{:else}
@@ -169,7 +190,7 @@
value={current.task}
suggestions={forCurrent.tasks}
placeholder="Nom de la tâche"
onchange={(v) => editEntry(current.id, { task: v })}
onchange={(v) => act(() => editEntry(current.id, { task: v }))}
/>
<SmartField
label="Client"
@@ -177,17 +198,28 @@
suggestions={forCurrent.clients}
placeholder="Nom du client"
emptyText="Aucun client enregistré"
onchange={(v) => editEntry(current.id, { client: v })}
onchange={(v) => act(() => editEntry(current.id, { client: v }))}
/>
{/if}
</div>
{/if}
<!-- Primary actions --------------------------------------------------- -->
<button type="button" class="btn-primary" onclick={goNext}>{actionLabel}</button>
<button type="button" class="btn-primary" disabled={busy} onclick={() => act(goNext)}>{actionLabel}</button>
{#if lunchHint}
<p class="dim" role="status">
{#if lunchHint.resumeTs != null}
Reprise indicative à {fmtTime(lunchHint.resumeTs)}.
{#if lunchHint.due}La durée habituelle de la pause est atteinte.{/if}
{:else}
L’heure habituelle du déjeuner ({settings.lunchStart}) est atteinte.
{/if}
</p>
{/if}
{#if current?.kind !== 'lunch'}
<button type="button" onclick={() => startLunch()}>Pause déjeuner</button>
<button type="button" disabled={busy} onclick={() => act(startLunch)}>Pause déjeuner</button>
{/if}
<!-- Day so far -------------------------------------------------------- -->
@@ -209,7 +241,7 @@
{#if confirmingEnd}
<div class="btn-row">
<button type="button" onclick={() => (confirmingEnd = false)}>Annuler</button>
<button type="button" class="btn-danger" onclick={finishDay}>
<button type="button" class="btn-danger" disabled={busy} onclick={() => act(finishDay)}>
Confirmer
</button>
</div>
@@ -228,30 +260,31 @@
clients={editingSuggestions.clients}
rounding={settings.roundingMinutes}
{now}
onstart={editEntryStart}
onedit={editEntry}
ondelete={removeEntry}
onstart={(id, ts) => act(() => editEntryStart(id, ts))}
onedit={(id, patch) => act(() => editEntry(id, patch))}
ondelete={(id) => act(() => removeEntry(id))}
onclose={() => (editingId = null)}
/>
<!-- End-of-day grace prompt --------------------------------------------- -->
{#if dayState.needsClosePrompt}
<div class="prompt" role="dialog" aria-modal="true">
<div class="prompt" role="dialog" aria-modal="true" aria-labelledby="close-day-title">
<div class="card stack">
<h2>Fin de journée</h2>
<h2 id="close-day-title">Fin de journée</h2>
<p>
L'heure de fin ({settings.dayEnd}) est passée. Terminer la journée maintenant ?
L'heure de fin ({fmtTime(dayState.day.plannedEndTs)}) est passée. Terminer la journée maintenant ?
</p>
<button type="button" class="btn-primary" onclick={finishDay}>
<button type="button" class="btn-primary" disabled={busy} onclick={() => act(finishDay)}>
Terminer la journée
</button>
<button type="button" onclick={() => keepWorking()}>Toujours en cours</button>
<button type="button" disabled={busy} onclick={() => act(keepWorking)}>Toujours en cours</button>
</div>
</div>
{/if}
{/if}
<style>
.action-error { margin: var(--pad); color: var(--danger); }
.big {
font-size: 1.6rem;
font-weight: 800;