Added edit mode

This commit is contained in:
2026-09-19 21:53:10 +02:00
parent 9bb9260c09
commit d46a5f10af
8 changed files with 458 additions and 64 deletions

105
src/lib/editing.js Normal file
View File

@@ -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')}`;
}

173
src/lib/editing.test.js Normal file
View File

@@ -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');
});
});

View File

@@ -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();
}

View File

@@ -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'),
);
});