diff --git a/README.md b/README.md index 20004dc..5fd7f3b 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,40 @@ il n'est pas publié avec l'application. Les tests couvrent notamment les clôtures oubliées, les reprises, les appels concurrents, les retours d'horloge, les échecs transactionnels et la conservation. +## Corriger les postes et les journées terminées + +Le **Total journée** mesure la durée entre le début et la fin, repas compris. +Le récapitulatif et le texte copié distinguent aussi le travail hors repas et le +déjeuner. Les totaux suivent le réglage d'arrondi existant ; les éventuels trous +importés restent inclus dans l'amplitude de la journée. + +Dans **Journée**, touchez un poste dans **Postes de la journée** pour modifier +son début, sa fin, sa tâche, son client ou son type (travail, transport, repas). +Les horaires sont saisis à la minute, avec leur date, et enregistrés ensemble +avec le bouton **Enregistrer**. Annuler ne modifie rien. Le début du premier +poste et la fin du dernier définissent les bornes de la journée. Modifier une +limite intermédiaire ajuste la limite du poste voisin, sans chevauchement. +La fin d'un poste encore en cours reste pilotée par les boutons de changement +de tâche ou de clôture. + +**Ajouter un poste oublié** permet de choisir une plage existante puis les +horaires du poste à insérer dans cette plage. Les portions avant et après sont +conservées avec leur tâche et leur client. Par exemple, un poste 08:00–12:00 +peut devenir 08:00–09:00, une tâche oubliée 09:00–10:00, puis 10:00–12:00. +Le type **Repas** permet aussi d'ajouter une pause oubliée et de renseigner +panier ou restaurant. Pour ajouter juste après une frontière existante, +choisissez le poste qui commence à cette frontière. + +Dans **Récap**, choisissez la date puis **Corriger cette journée** : les mêmes +actions sont disponibles sans réouverture, même si une autre journée est en +cours. Le récapitulatif et son texte à copier sont recalculés après chaque +enregistrement. La suppression d'un poste demande confirmation ; le voisin +absorbe sa durée, et le dernier poste d'une journée ne peut pas être supprimé. + +Chaque correction est transactionnelle. Les horaires inversés, les insertions +hors de la plage choisie et les formulaires devenus périmés après une autre +modification sont refusés sans écriture partielle. + 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. @@ -138,8 +172,8 @@ Two decisions shape everything else. **Raw timestamps are stored; rounding is applied only at render.** Changing the rounding setting therefore corrects past days too. Adjacent entries share one stored instant — entry *N*'s end *is* entry *N+1*'s start — so rounding can never open a gap between -them. Times a worker sets by hand are snapped to the grid on write, so a chosen value -reads back exactly as chosen. +them. The quick time picker follows the rounding grid. The detailed entry editor +stores minute-level corrections; summaries apply the selected rounding at render. **Suggestions are derived from history, not from counter tables.** There is nothing to keep in sync; the model is rebuilt in memory from the entries. The tradeoff is that a diff --git a/src/components/DayEditor.svelte b/src/components/DayEditor.svelte new file mode 100644 index 0000000..2b2767d --- /dev/null +++ b/src/components/DayEditor.svelte @@ -0,0 +1,122 @@ + + +
+

Postes de la journée

+

Touchez un poste pour corriger ses horaires, sa tâche ou son client.

+

Horaires à la minute ; le récapitulatif applique l’arrondi choisi dans les réglages.

+ select(entry, 'edit')} /> + +
+ + + {#if original} +
{ event.preventDefault(); commit(); }}> +
+ {#if mode === 'insert'} + +

Le poste oublié sera inséré dans cette plage. Les portions avant et après seront conservées.

+ {:else} +

Changer une limite ajuste aussi le poste voisin. Le premier début et la dernière fin définissent la durée de la journée.

+ {/if} + + + {#if mode === 'edit' && original.endTs == null} +

Poste en cours : sa fin sera enregistrée au prochain changement de tâche.

+ {:else} + + {/if} + {#if draft.kind === 'lunch'} + + {#if draft.lunchType === 'restaurant'} + + {/if} + {:else} + + + {/if} + {#if day.status === 'closed'}

La journée restera terminée après la correction.

{/if} + {#if error}

{error}

{/if} + + + {#if mode === 'edit' && original.endTs != null && entries.length > 1} + {#if confirmingDelete} +

Le poste voisin absorbera cette durée. Le total de la journée restera inchangé.

+ + {:else} + + {/if} + {/if} +
+
+ {/if} +
+ + diff --git a/src/lib/editing.js b/src/lib/editing.js new file mode 100644 index 0000000..9923684 --- /dev/null +++ b/src/lib/editing.js @@ -0,0 +1,105 @@ +import { db, deleteEntry, getDay, getEntries } from './db.js'; +import { localDateKey } from './time.js'; + +const conflict = () => new Error('Ce poste a changé. Fermez puis ouvrez à nouveau la correction.'); +const fields = ['id', 'date', 'seq', 'kind', 'task', 'client', 'startTs', 'endTs']; + +async function readOriginal(original) { + const entry = await db.entries.get(original.id); + if (!entry || fields.some((key) => entry[key] !== original[key])) throw conflict(); + const day = await getDay(entry.date); + if (!day) throw conflict(); + return { entry, day, entries: await getEntries(entry.date) }; +} + +function content(values) { + if (!['work', 'travel', 'lunch'].includes(values.kind)) throw new Error('Choisissez un type de poste.'); + const task = values.kind === 'lunch' ? 'Pause déjeuner' : String(values.task ?? '').trim(); + if (!task) throw new Error('Renseignez le nom de la tâche.'); + return { kind: values.kind, task, client: values.kind === 'lunch' ? null : String(values.client ?? '').trim() || null }; +} + +function validateTimes(start, end, now) { + if (!Number.isFinite(start) || !Number.isFinite(end) || start >= end) { + throw new Error('La fin doit être postérieure au début.'); + } + if (end > now) throw new Error('Les horaires ne peuvent pas être dans le futur.'); +} + +async function saveLunch(date, values) { + if (values.kind !== 'lunch') return; + if (!['panier', 'restaurant'].includes(values.lunchType)) throw new Error('Choisissez le type de repas.'); + await db.days.update(date, { + lunchType: values.lunchType, + restaurant: values.lunchType === 'restaurant' ? String(values.restaurant ?? '').trim() || null : null, + }); +} + +/** Correct both boundaries and their neighbours without changing day status. */ +export function saveRecordedEntry(original, values, now = Date.now()) { + return db.transaction('rw', db.days, db.entries, async () => { + const { entry, day, entries } = await readOriginal(original); + const index = entries.findIndex((e) => e.id === entry.id); + const prev = entries[index - 1], next = entries[index + 1]; + const { startTs, endTs } = values; + if ((entry.endTs == null) !== (endTs == null)) throw conflict(); + validateTimes(startTs, endTs ?? now, now); + if (prev && startTs < prev.startTs || next && endTs > (next.endTs ?? now)) { + throw new Error('Ces horaires dépassent le poste voisin. Corrigez d’abord ce voisin.'); + } + if (!prev && localDateKey(startTs) !== day.date) throw new Error('Le début doit rester à la date de cette journée.'); + await db.entries.update(entry.id, { ...content(values), startTs, endTs }); + if (prev && startTs !== entry.startTs) await db.entries.update(prev.id, { endTs: startTs }); + if (next && endTs !== entry.endTs) await db.entries.update(next.id, { startTs: endTs }); + if (!prev) await db.days.update(day.date, { startedTs: startTs }); + if (!next && day.status === 'closed') await db.days.update(day.date, { endedTs: endTs }); + await saveLunch(day.date, values); + }); +} + +/** Carve a forgotten interval out of a selected entry, preserving both remnants. */ +export function insertRecordedEntry(original, values, now = Date.now()) { + return db.transaction('rw', db.days, db.entries, async () => { + const { entry, entries } = await readOriginal(original); + const { startTs, endTs } = values; + validateTimes(startTs, endTs, now); + if (startTs < entry.startTs || endTs > (entry.endTs ?? now)) { + throw new Error('Le poste ajouté doit rester dans la plage choisie.'); + } + const { id, ...rest } = entry; + const pieces = []; + if (startTs > entry.startTs) pieces.push({ ...rest, endTs: startTs }); + const insertedIndex = pieces.length; + pieces.push({ ...rest, ...content(values), startTs, endTs }); + if (entry.endTs == null || endTs < entry.endTs) pieces.push({ ...rest, startTs: endTs }); + // Keep the original id on a surviving portion, or on the replacement when full. + const keepIndex = startTs > entry.startTs ? 0 : pieces.length - 1; + const shift = pieces.length - 1; + for (const later of entries.filter((e) => e.seq > entry.seq)) { + await db.entries.update(later.id, { seq: later.seq + shift }); + } + let insertedId; + for (let i = 0; i < pieces.length; i++) { + const piece = { ...pieces[i], seq: entry.seq + i }; + const pieceId = i === keepIndex ? (await db.entries.put({ ...piece, id }), id) : await db.entries.add(piece); + if (i === insertedIndex) insertedId = pieceId; + } + await saveLunch(entry.date, values); + return insertedId; + }); +} + +export function removeRecordedEntry(original) { + return db.transaction('rw', db.days, db.entries, async () => { + const { entry, entries } = await readOriginal(original); + if (entries.length < 2) throw new Error('Le dernier poste de la journée ne peut pas être supprimé.'); + if (entry.endTs == null) throw new Error('Le poste en cours ne peut pas être supprimé.'); + await deleteEntry(entry.id); + }); +} + +/** Native datetime-local inputs use wall time, never UTC. */ +export function localInput(ts) { + const d = new Date(ts); + return `${localDateKey(ts)}T${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; +} diff --git a/src/lib/editing.test.js b/src/lib/editing.test.js new file mode 100644 index 0000000..b623e54 --- /dev/null +++ b/src/lib/editing.test.js @@ -0,0 +1,173 @@ +import 'fake-indexeddb/auto'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { closeAndStart, closeDay, createDay, db, exportBackup, getDay, getEntries } from './db.js'; +import { insertRecordedEntry, localInput, removeRecordedEntry, saveRecordedEntry } from './editing.js'; +import { buildResume, resumeToText } from './resume.js'; +import { atLocalTime, HOUR } from './time.js'; + +const date = '2026-09-07'; +const at = (time) => atLocalTime(date, time); +const day = { date, status: 'closed', startedTs: at('08:00'), endedTs: at('17:00'), plannedStartTs: at('08:00'), plannedEndTs: at('17:00'), lunchType: 'panier', restaurant: null, closePromptedTs: null }; +const entry = (seq, start, end, task = 'Travaux') => ({ date, seq, kind: 'work', task, client: 'Client A', startTs: at(start), endTs: end ? at(end) : null }); +const values = (start, end, extra = {}) => ({ kind: 'work', task: 'Oubli', client: 'Client B', startTs: at(start), endTs: end ? at(end) : null, ...extra }); +const snapshot = async () => ({ days: await db.days.toArray(), entries: await db.entries.toArray() }); +async function seed(entries = [entry(0, '08:00', '12:00'), entry(1, '12:00', '13:00', 'Repas'), entry(2, '13:00', '17:00')]) { + await db.days.put(day); await db.entries.bulkAdd(entries); return getEntries(date); +} + +beforeEach(async () => { + vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(atLocalTime('2026-09-08', '18:00')); + await db.days.clear(); await db.entries.clear(); await db.settings.clear(); +}); +afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers(); }); + +describe('forgotten entries', () => { + it('splits one entry into three, preserves both remnants and renumbers the rest', async () => { + const original = await seed(); + await insertRecordedEntry(original[0], values('09:00', '10:00')); + const list = await getEntries(date); + expect(list.map((e) => e.seq)).toEqual([0, 1, 2, 3, 4]); + expect(list.map((e) => [e.startTs, e.endTs])).toEqual([ + [at('08:00'), at('09:00')], [at('09:00'), at('10:00')], [at('10:00'), at('12:00')], + [at('12:00'), at('13:00')], [at('13:00'), at('17:00')], + ]); + expect(list[0].id).toBe(original[0].id); + expect(list[2].task).toBe(original[0].task); expect(list[2].client).toBe('Client A'); + expect(await getDay(date)).toEqual(day); + }); + + it.each([['08:00', '09:00', 4], ['11:00', '12:00', 4], ['08:00', '12:00', 3]])('handles insertion %s–%s without zero-length fragments', async (start, end, length) => { + const [original] = await seed(); + await insertRecordedEntry(original, values(start, end)); + const list = await getEntries(date); + expect(list).toHaveLength(length); expect(list.every((e) => e.endTs > e.startTs)).toBe(true); + expect(new Set(list.map((e) => e.id)).size).toBe(length); + }); + + it('inserts a meal and updates the summary without reopening the day', async () => { + const [original] = await seed([entry(0, '08:00', '17:00')]); + await insertRecordedEntry(original, values('12:00', '13:00', { kind: 'lunch', lunchType: 'restaurant', restaurant: 'Le Relais' })); + const record = await getDay(date), entries = await getEntries(date); + const resume = buildResume(record, entries); + expect(record.status).toBe('closed'); expect(entries[1].client).toBeNull(); + expect(resume.lunch).toEqual({ type: 'restaurant', restaurant: 'Le Relais', ms: HOUR }); + expect(resume.workedMs).toBe(8 * HOUR); expect(resume.totalMs).toBe(9 * HOUR); + expect(resumeToText(resume)).toContain('Total journée (repas compris) : 9h00'); + const backup = await exportBackup(); + expect(backup.entries).toHaveLength(3); expect(backup.days[0].status).toBe('closed'); + }); + + it('can insert work inside a recorded meal without losing its remaining time', async () => { + const list = await seed(); + await db.entries.update(list[1].id, { kind: 'lunch', client: null }); + const original = (await getEntries(date))[1]; + await insertRecordedEntry(original, values('12:15', '12:30')); + const result = buildResume(await getDay(date), await getEntries(date), 1); + expect(result.lunch.ms).toBe(0.75 * HOUR); expect(result.totalMs).toBe(9 * HOUR); + }); + + it('keeps one running entry when inserting up to the present', async () => { + await db.days.put({ ...day, status: 'active', endedTs: null }); + await db.entries.add(entry(0, '08:00', null)); + const [original] = await getEntries(date); + await insertRecordedEntry(original, values('09:00', '10:00'), at('10:00')); + const list = await getEntries(date); + expect(list.filter((e) => e.endTs == null)).toHaveLength(1); + expect(list.at(-1).startTs).toBe(at('10:00')); + await closeAndStart(date, at('11:00'), { kind: 'work', task: 'Suite', client: 'Client A' }); + expect((await getEntries(date)).at(-1).startTs).toBe(at('11:00')); + }); + + it.each([['07:00', '09:00'], ['11:00', '13:00'], ['10:00', '09:00'], ['09:00', '09:00']])('rejects invalid insertion %s–%s atomically', async (start, end) => { + const [original] = await seed(), before = await snapshot(); + await expect(insertRecordedEntry(original, values(start, end))).rejects.toThrow(); + expect(await snapshot()).toEqual(before); + }); + + it('rolls back fragments and sequence changes when metadata fails', async () => { + const [original] = await seed(), before = await snapshot(); + vi.spyOn(db.days, 'update').mockRejectedValueOnce(new Error('disk')); + await expect(insertRecordedEntry(original, values('09:00', '10:00', { kind: 'lunch', lunchType: 'panier' }))).rejects.toThrow('disk'); + expect(await snapshot()).toEqual(before); + }); + + it('rejects concurrent double insertion on the same original', async () => { + const [original] = await seed(); + const results = await Promise.allSettled([insertRecordedEntry(original, values('09:00', '10:00')), insertRecordedEntry(original, values('09:00', '10:00'))]); + expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1); + expect(await getEntries(date)).toHaveLength(5); + }); +}); + +describe('recorded day corrections', () => { + it('edits both boundaries, task and client together with neighbours', async () => { + const list = await seed(); + await saveRecordedEntry(list[1], values('11:45', '13:15')); + const result = await getEntries(date); + expect(result[0].endTs).toBe(at('11:45')); expect(result[2].startTs).toBe(at('13:15')); + expect(result[1]).toMatchObject({ task: 'Oubli', client: 'Client B', startTs: at('11:45'), endTs: at('13:15') }); + expect(await getDay(date)).toEqual(day); + }); + + it('updates first start and last end, leaves the day closed and another day active', async () => { + let list = await seed(); + const other = { ...day, date: '2026-09-08', status: 'active', endedTs: null, startedTs: atLocalTime('2026-09-08', '08:00') }; + await createDay(other, { kind: 'travel', task: 'Transport', client: null }); + await saveRecordedEntry(list[0], values('07:30', '12:00')); + list = await getEntries(date); + await saveRecordedEntry(list.at(-1), values('13:00', '18:00')); + const record = await getDay(date); + expect(record).toMatchObject({ startedTs: at('07:30'), endedTs: at('18:00'), status: 'closed' }); + expect(buildResume(record, await getEntries(date)).totalMs).toBe(10.5 * HOUR); + expect(await getDay(other.date)).toEqual(other); + expect((await getEntries(other.date))[0].endTs).toBeNull(); + }); + + it('preserves imported gaps when only text is edited', async () => { + const list = await seed([entry(0, '08:00', '10:00'), entry(1, '11:00', '17:00')]); + await saveRecordedEntry(list[1], values('11:00', '17:00')); + expect((await getEntries(date))[0].endTs).toBe(at('10:00')); + expect(buildResume(await getDay(date), await getEntries(date)).totalMs).toBe(9 * HOUR); + }); + + it('rejects overlaps beyond neighbours and future timestamps without writes', async () => { + const list = await seed(), before = await snapshot(); + await expect(saveRecordedEntry(list[1], values('07:00', '13:00'))).rejects.toThrow('voisin'); + await expect(saveRecordedEntry(list[1], values('12:00', '18:00'))).rejects.toThrow('voisin'); + await expect(saveRecordedEntry(list[2], values('13:00', '18:00'), at('17:30'))).rejects.toThrow('futur'); + expect(await snapshot()).toEqual(before); + }); + + it('rejects stale forms after a concurrent close', async () => { + await db.days.put({ ...day, status: 'active', endedTs: null }); await db.entries.add(entry(0, '08:00', null)); + const [original] = await getEntries(date); + await closeDay(date, at('17:00')); + await expect(saveRecordedEntry(original, values('08:00', null))).rejects.toThrow('changé'); + expect((await getDay(date)).status).toBe('closed'); expect((await getEntries(date))[0].endTs).toBe(at('17:00')); + }); + + it('rolls back neighbour and entry edits when day metadata fails', async () => { + const [original] = await seed(), before = await snapshot(); + vi.spyOn(db.days, 'update').mockRejectedValueOnce(new Error('disk')); + await expect(saveRecordedEntry(original, values('07:30', '11:30'))).rejects.toThrow('disk'); + expect(await snapshot()).toEqual(before); + }); + + it.each([0, 1, 2])('deletes entry %i while keeping day boundaries and status', async (index) => { + const list = await seed(); await removeRecordedEntry(list[index]); + const result = await getEntries(date); + expect(result).toHaveLength(2); expect(result[0].startTs).toBe(day.startedTs); + expect(result[0].endTs).toBe(result[1].startTs); expect(result[1].endTs).toBe(day.endedTs); + expect(await getDay(date)).toEqual(day); + }); + + it('does not delete the only entry', async () => { + const [original] = await seed([entry(0, '08:00', '17:00')]); + await expect(removeRecordedEntry(original)).rejects.toThrow('dernier poste'); + expect(await getEntries(date)).toHaveLength(1); + }); + + it('formats local input without a UTC conversion', () => { + expect(localInput(at('07:42'))).toBe('2026-09-07T07:42'); + }); +}); diff --git a/src/lib/resume.js b/src/lib/resume.js index cf02a94..8ae888c 100644 --- a/src/lib/resume.js +++ b/src/lib/resume.js @@ -67,6 +67,8 @@ export function buildResume(day, entries, roundingMinutes = 15) { clients, lunch, workedMs, + totalMs: Math.max(0, roundTs(fallbackEnd, roundingMinutes) - + roundTs(day.startedTs ?? Math.min(...entries.map((e) => e.startTs), fallbackEnd), roundingMinutes)), omitted, }; } @@ -99,7 +101,8 @@ export function resumeToText(resume) { lines.push(''); } - lines.push(`Total travaillé : ${fmtDuration(resume.workedMs)}`); + lines.push(`Travail hors repas : ${fmtDuration(resume.workedMs)}`); + lines.push(`Total journée (repas compris) : ${fmtDuration(resume.totalMs)}`); return lines.join('\n').replace(/\n{3,}/g, '\n\n').trim(); } diff --git a/src/lib/resume.test.js b/src/lib/resume.test.js index 7069f12..cdc182c 100644 --- a/src/lib/resume.test.js +++ b/src/lib/resume.test.js @@ -10,6 +10,7 @@ const DAY = { status: 'closed', lunchType: 'restaurant', restaurant: 'Le Relais', + startedTs: at('07:30'), endedTs: at('17:30'), }; @@ -39,6 +40,7 @@ describe('buildResume', () => { 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.totalMs).toBe(10 * 60 * MINUTE); expect(r.lunch).toEqual({ type: 'restaurant', restaurant: 'Le Relais', ms: 60 * MINUTE }); }); @@ -91,7 +93,8 @@ describe('resumeToText', () => { '', 'Déjeuner : restaurant (Le Relais), 1h00', '', - 'Total travaillé : 9h00', + 'Travail hors repas : 9h00', + 'Total journée (repas compris) : 10h00', ].join('\n'), ); }); diff --git a/src/routes/Resume.svelte b/src/routes/Resume.svelte index a0a8296..f65ff8d 100644 --- a/src/routes/Resume.svelte +++ b/src/routes/Resume.svelte @@ -1,4 +1,5 @@