From 9bb9260c092ec1ad753497458134a87b217187c6 Mon Sep 17 00:00:00 2001 From: "Adrien W." Date: Sat, 19 Sep 2026 19:58:13 +0200 Subject: [PATCH] Fixed a few minor bugs --- README.md | 37 ++++-- src/lib/backup.js | 4 +- src/lib/day.svelte.js | 127 +++++++++------------ src/lib/day.test.js | 227 +++++++++++++++++++++++++++++++++++++ src/lib/db.js | 94 +++++++++++---- src/lib/lunch.js | 13 +++ src/lib/lunch.test.js | 27 +++++ src/lib/settings.svelte.js | 3 + src/routes/Resume.svelte | 43 +++++-- src/routes/Settings.svelte | 1 + src/routes/Today.svelte | 79 +++++++++---- 11 files changed, 520 insertions(+), 135 deletions(-) create mode 100644 src/lib/day.test.js create mode 100644 src/lib/lunch.js create mode 100644 src/lib/lunch.test.js diff --git a/README.md b/README.md index 99fccd9..20004dc 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/lib/backup.js b/src/lib/backup.js index a126a41..a595809 100644 --- a/src/lib/backup.js +++ b/src/lib/backup.js @@ -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; diff --git a/src/lib/day.svelte.js b/src/lib/day.svelte.js index 7f1a53c..510471c 100644 --- a/src/lib/day.svelte.js +++ b/src/lib/day.svelte.js @@ -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); } diff --git a/src/lib/day.test.js b/src/lib/day.test.js new file mode 100644 index 0000000..19e0271 --- /dev/null +++ b/src/lib/day.test.js @@ -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); + }); +}); diff --git a/src/lib/db.js b/src/lib/db.js index f3ed24b..1f25b8e 100644 --- a/src/lib/db.js +++ b/src/lib/db.js @@ -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|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} 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; } /** diff --git a/src/lib/lunch.js b/src/lib/lunch.js new file mode 100644 index 0000000..e569fe4 --- /dev/null +++ b/src/lib/lunch.js @@ -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; +} diff --git a/src/lib/lunch.test.js b/src/lib/lunch.test.js new file mode 100644 index 0000000..9949adc --- /dev/null +++ b/src/lib/lunch.test.js @@ -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(); + }); +}); diff --git a/src/lib/settings.svelte.js b/src/lib/settings.svelte.js index 893cc98..6f3b83c 100644 --- a/src/lib/settings.svelte.js +++ b/src/lib/settings.svelte.js @@ -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 diff --git a/src/routes/Resume.svelte b/src/routes/Resume.svelte index 9dd26ef..a0a8296 100644 --- a/src/routes/Resume.svelte +++ b/src/routes/Resume.svelte @@ -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 @@
+ {#if error}

{error}

{/if} + {#if dayState.closedDays.length > 1} + + {/if} {#if !resume}

Aucune journée enregistrée

@@ -101,16 +125,15 @@ {#if closedToday} Reprendre la journée - {:else if startsAt} +

Annule la clôture et continue la dernière tâche depuis l’arrêt.

+ {:else if !dayState.day && !dayState.todayRecord && startsAt}

La journée démarrera automatiquement à {startsAt}.

- {:else} + {:else if !dayState.day && !dayState.todayRecord}

Aujourd'hui n'est pas un jour travaillé.

{/if}
diff --git a/src/routes/Settings.svelte b/src/routes/Settings.svelte index ee1ac1b..08b0327 100644 --- a/src/routes/Settings.svelte +++ b/src/routes/Settings.svelte @@ -105,6 +105,7 @@ >

Type par défaut ; modifiable chaque jour.

+

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.

diff --git a/src/routes/Today.svelte b/src/routes/Today.svelte index b4bed39..d70e2f4 100644 --- a/src/routes/Today.svelte +++ b/src/routes/Today.svelte @@ -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 @@ } +{#if actionError}{/if} + {#if !dayState.day}
-

Journée non commencée

+

{closedToday ? 'Journée terminée' : 'Journée non commencée'}

- 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}

-
@@ -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 @@ act(() => chooseLunchType('panier'))}>Panier act(() => chooseLunchType('restaurant'))}>Restaurant {#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 }))} /> editEntry(current.id, { client: v })} + onchange={(v) => act(() => editEntry(current.id, { client: v }))} /> {/if} {/if} - + + + {#if lunchHint} +

+ {#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} +

+ {/if} {#if current?.kind !== 'lunch'} - + {/if} @@ -209,7 +241,7 @@ {#if confirmingEnd}
-
@@ -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)} /> {#if dayState.needsClosePrompt} -