Added edit mode
This commit is contained in:
38
README.md
38
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
|
||||
|
||||
122
src/components/DayEditor.svelte
Normal file
122
src/components/DayEditor.svelte
Normal file
@@ -0,0 +1,122 @@
|
||||
<script>
|
||||
import { insertRecordedEntry, localInput, removeRecordedEntry, saveRecordedEntry } from '../lib/editing.js';
|
||||
import { dayState, refresh } from '../lib/day.svelte.js';
|
||||
import { fmtTime, MINUTE } from '../lib/time.js';
|
||||
import Sheet from './Sheet.svelte';
|
||||
import Timeline from './Timeline.svelte';
|
||||
|
||||
let { day, entries = [], now = Date.now(), onchange = () => {} } = $props();
|
||||
let mode = $state('');
|
||||
let original = $state(null);
|
||||
let draft = $state({ kind: 'work', task: '', client: '', start: '', end: '', lunchType: 'panier', restaurant: '' });
|
||||
let busy = $state(false);
|
||||
let error = $state('');
|
||||
let confirmingDelete = $state(false);
|
||||
const ordered = $derived([...entries].sort((a, b) => a.seq - b.seq));
|
||||
|
||||
function select(entry, operation = mode) {
|
||||
original = { ...entry };
|
||||
mode = operation;
|
||||
error = '';
|
||||
confirmingDelete = false;
|
||||
const insertion = operation === 'insert';
|
||||
draft = {
|
||||
kind: insertion ? 'work' : entry.kind,
|
||||
task: insertion ? '' : entry.task,
|
||||
client: entry.client ?? '',
|
||||
start: localInput(entry.startTs),
|
||||
end: localInput(insertion ? Math.min(entry.startTs + 30 * MINUTE, entry.endTs ?? now) : entry.endTs ?? now),
|
||||
lunchType: day.lunchType ?? 'panier', restaurant: day.restaurant ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function close() { if (!busy) { mode = ''; original = null; } }
|
||||
|
||||
async function commit(remove = false) {
|
||||
if (busy || dayState.importing) return;
|
||||
busy = true; error = '';
|
||||
try {
|
||||
// Preserve seconds when only text changes; typed times have minute precision.
|
||||
const startTs = draft.start === localInput(original.startTs) ? original.startTs : new Date(draft.start).getTime();
|
||||
const endTs = mode === 'edit' && original.endTs == null ? null :
|
||||
mode === 'edit' && draft.end === localInput(original.endTs) ? original.endTs : new Date(draft.end).getTime();
|
||||
const values = { ...draft, startTs, endTs };
|
||||
if (remove) await removeRecordedEntry(original);
|
||||
else if (mode === 'insert') await insertRecordedEntry(original, values);
|
||||
else await saveRecordedEntry(original, values);
|
||||
await refresh();
|
||||
onchange();
|
||||
mode = ''; original = null;
|
||||
} catch (cause) { error = cause.message; }
|
||||
finally { busy = false; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="card stack">
|
||||
<h3>Postes de la journée</h3>
|
||||
<p class="dim hint">Touchez un poste pour corriger ses horaires, sa tâche ou son client.</p>
|
||||
<p class="dim hint">Horaires à la minute ; le récapitulatif applique l’arrondi choisi dans les réglages.</p>
|
||||
<Timeline {entries} rounding={1} {now} onselect={(entry) => select(entry, 'edit')} />
|
||||
<button type="button" disabled={!ordered.length || dayState.importing} onclick={() => select(ordered[0], 'insert')}>Ajouter un poste oublié</button>
|
||||
</div>
|
||||
|
||||
<Sheet open={Boolean(mode)} title={mode === 'insert' ? 'Ajouter un poste' : 'Modifier le poste'} onclose={close}>
|
||||
{#if original}
|
||||
<form onsubmit={(event) => { event.preventDefault(); commit(); }}>
|
||||
<fieldset disabled={busy || dayState.importing} class="stack">
|
||||
{#if mode === 'insert'}
|
||||
<label class="stack">Plage à corriger
|
||||
<select value={original.id} onchange={(event) => select(ordered.find((e) => e.id === Number(event.currentTarget.value)), 'insert')}>
|
||||
{#each ordered as entry (entry.id)}
|
||||
<option value={entry.id}>{fmtTime(entry.startTs)}–{entry.endTs == null ? 'en cours' : fmtTime(entry.endTs)} · {entry.task}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<p class="dim hint">Le poste oublié sera inséré dans cette plage. Les portions avant et après seront conservées.</p>
|
||||
{:else}
|
||||
<p class="dim hint">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.</p>
|
||||
{/if}
|
||||
<label class="stack">Type de poste
|
||||
<select bind:value={draft.kind}>
|
||||
<option value="work">Travail</option><option value="travel">Transport</option><option value="lunch">Repas</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="stack">Début du poste<input type="datetime-local" required bind:value={draft.start} /></label>
|
||||
{#if mode === 'edit' && original.endTs == null}
|
||||
<p class="dim hint">Poste en cours : sa fin sera enregistrée au prochain changement de tâche.</p>
|
||||
{:else}
|
||||
<label class="stack">Fin du poste<input type="datetime-local" required bind:value={draft.end} /></label>
|
||||
{/if}
|
||||
{#if draft.kind === 'lunch'}
|
||||
<label class="stack">Type de repas<select bind:value={draft.lunchType}><option value="panier">Panier</option><option value="restaurant">Restaurant</option></select></label>
|
||||
{#if draft.lunchType === 'restaurant'}
|
||||
<label class="stack">Restaurant<input type="text" bind:value={draft.restaurant} /></label>
|
||||
{/if}
|
||||
{:else}
|
||||
<label class="stack">Tâche<input type="text" required bind:value={draft.task} /></label>
|
||||
<label class="stack">Client<input type="text" bind:value={draft.client} /></label>
|
||||
{/if}
|
||||
{#if day.status === 'closed'}<p class="dim hint">La journée restera terminée après la correction.</p>{/if}
|
||||
{#if error}<p role="alert">{error}</p>{/if}
|
||||
<button type="submit" class="btn-primary">{busy ? 'Enregistrement…' : 'Enregistrer'}</button>
|
||||
<button type="button" onclick={close}>Annuler</button>
|
||||
{#if mode === 'edit' && original.endTs != null && entries.length > 1}
|
||||
{#if confirmingDelete}
|
||||
<p class="hint">Le poste voisin absorbera cette durée. Le total de la journée restera inchangé.</p>
|
||||
<button type="button" class="btn-danger" onclick={() => commit(true)}>Confirmer la suppression</button>
|
||||
{:else}
|
||||
<button type="button" onclick={() => (confirmingDelete = true)}>Supprimer ce poste</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</fieldset>
|
||||
</form>
|
||||
{/if}
|
||||
</Sheet>
|
||||
|
||||
<style>
|
||||
fieldset { border: 0; padding: 0; margin: 0; min-width: 0; }
|
||||
.hint { font-size: 0.85rem; }
|
||||
input, select { width: 100%; min-width: 0; }
|
||||
input[type='datetime-local'] { font: inherit; color: var(--fg); background: var(--bg); border: var(--border) solid var(--line); border-radius: var(--radius); min-height: var(--tap); padding: 8px; }
|
||||
[role='alert'] { color: var(--danger); }
|
||||
</style>
|
||||
105
src/lib/editing.js
Normal file
105
src/lib/editing.js
Normal 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
173
src/lib/editing.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script>
|
||||
import DayEditor from '../components/DayEditor.svelte';
|
||||
import { dayState, reopenToday } from '../lib/day.svelte.js';
|
||||
import { buildResume, resumeToText } from '../lib/resume.js';
|
||||
import { go } from '../lib/router.svelte.js';
|
||||
@@ -9,12 +10,14 @@
|
||||
let selectedDate = $state('');
|
||||
let reopening = $state(false);
|
||||
let error = $state('');
|
||||
let editing = $state(false);
|
||||
const selectedDay = $derived(dayState.closedDays.find((d) => d.date === selectedDate) ?? dayState.lastClosed);
|
||||
const selectedEntries = $derived(dayState.historyEntries.filter((e) => e.date === selectedDay?.date));
|
||||
|
||||
const resume = $derived(
|
||||
buildResume(
|
||||
selectedDay,
|
||||
dayState.historyEntries.filter((e) => e.date === selectedDay?.date),
|
||||
selectedEntries,
|
||||
settings.roundingMinutes,
|
||||
),
|
||||
);
|
||||
@@ -54,7 +57,7 @@
|
||||
{#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; }}>
|
||||
<select value={selectedDay?.date} onchange={(event) => { selectedDate = event.currentTarget.value; copied = false; editing = false; }}>
|
||||
{#each dayState.closedDays as day (day.date)}
|
||||
<option value={day.date}>{fmtDayLabel(day.date)} {day.date.slice(0, 4)}</option>
|
||||
{/each}
|
||||
@@ -120,6 +123,12 @@
|
||||
<button type="button" class="btn-primary" onclick={copy}>
|
||||
{copied ? '✓ Copié' : 'Copier le récapitulatif'}
|
||||
</button>
|
||||
<button type="button" onclick={() => (editing = !editing)}>{editing ? 'Masquer les postes' : 'Corriger cette journée'}</button>
|
||||
{#if editing}
|
||||
{#key selectedDay.date}
|
||||
<DayEditor day={selectedDay} entries={selectedEntries} onchange={() => (copied = false)} />
|
||||
{/key}
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if closedToday}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<script>
|
||||
import EntrySheet from '../components/EntrySheet.svelte';
|
||||
import DayEditor from '../components/DayEditor.svelte';
|
||||
import SmartField from '../components/SmartField.svelte';
|
||||
import TimeField from '../components/TimeField.svelte';
|
||||
import Timeline from '../components/Timeline.svelte';
|
||||
import {
|
||||
advance,
|
||||
currentClient,
|
||||
@@ -13,14 +12,13 @@
|
||||
keepWorking,
|
||||
predictForCurrent,
|
||||
predictNext,
|
||||
removeEntry,
|
||||
reopenToday,
|
||||
running,
|
||||
setLunch,
|
||||
startDayNow,
|
||||
startLunch,
|
||||
} from '../lib/day.svelte.js';
|
||||
import { suggestClients, suggestRestaurants, suggestTasks } from '../lib/predict.js';
|
||||
import { suggestRestaurants } from '../lib/predict.js';
|
||||
import { go } from '../lib/router.svelte.js';
|
||||
import { lunchGuidance } from '../lib/lunch.js';
|
||||
import { settings } from '../lib/settings.svelte.js';
|
||||
@@ -28,8 +26,6 @@
|
||||
|
||||
let now = $state(Date.now());
|
||||
let confirmingEnd = $state(false);
|
||||
/** id of the timeline entry being corrected, if any */
|
||||
let editingId = $state(null);
|
||||
let busy = $state(false);
|
||||
let actionError = $state('');
|
||||
|
||||
@@ -65,29 +61,6 @@
|
||||
dayState.model ? suggestRestaurants(dayState.model, dayClient ?? '') : [],
|
||||
);
|
||||
|
||||
// Tracked by id rather than held as an object, so the sheet keeps showing live
|
||||
// data after an edit writes back.
|
||||
const editing = $derived(dayState.entries.find((e) => e.id === editingId) ?? null);
|
||||
const editingPrev = $derived(
|
||||
editing ? (dayState.entries.find((e) => e.seq === editing.seq - 1) ?? null) : null,
|
||||
);
|
||||
|
||||
/** Suggestions ranked for the entry being corrected, not for the next slot. */
|
||||
const editingSuggestions = $derived.by(() => {
|
||||
if (!editing || !dayState.model) return { tasks: [], clients: [] };
|
||||
return {
|
||||
tasks: suggestTasks(dayState.model, {
|
||||
prevTask: editingPrev?.task ?? null,
|
||||
prevElapsedMs:
|
||||
editingPrev?.endTs != null ? editingPrev.endTs - editingPrev.startTs : 0,
|
||||
seq: editing.seq,
|
||||
ts: editing.startTs,
|
||||
client: editing.client,
|
||||
}),
|
||||
clients: suggestClients(dayState.model, now),
|
||||
};
|
||||
});
|
||||
|
||||
const actionLabel = $derived(
|
||||
current?.kind === 'travel'
|
||||
? 'Arrivé sur site'
|
||||
@@ -223,18 +196,7 @@
|
||||
{/if}
|
||||
|
||||
<!-- Day so far -------------------------------------------------------- -->
|
||||
{#if dayState.entries.length > 1}
|
||||
<div class="card">
|
||||
<h3>Journée</h3>
|
||||
<p class="dim hint">Touchez un poste pour corriger l'heure, la tâche ou le client.</p>
|
||||
<Timeline
|
||||
entries={dayState.entries}
|
||||
rounding={settings.roundingMinutes}
|
||||
{now}
|
||||
onselect={(e) => (editingId = e.id)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<DayEditor day={dayState.day} entries={dayState.entries} {now} />
|
||||
|
||||
<!-- Separated from everything frequent, and confirmed. ----------------- -->
|
||||
<div class="end">
|
||||
@@ -253,19 +215,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EntrySheet
|
||||
entry={editing}
|
||||
prev={editingPrev}
|
||||
tasks={editingSuggestions.tasks}
|
||||
clients={editingSuggestions.clients}
|
||||
rounding={settings.roundingMinutes}
|
||||
{now}
|
||||
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" aria-labelledby="close-day-title">
|
||||
@@ -290,10 +239,6 @@
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.sel {
|
||||
background: var(--accent);
|
||||
|
||||
Reference in New Issue
Block a user