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

View File

@@ -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 Les tests couvrent notamment les clôtures oubliées, les reprises, les appels
concurrents, les retours d'horloge, les échecs transactionnels et la conservation. 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 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. (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 **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 — 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 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 them. The quick time picker follows the rounding grid. The detailed entry editor
reads back exactly as chosen. stores minute-level corrections; summaries apply the selected rounding at render.
**Suggestions are derived from history, not from counter tables.** There is nothing to **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 keep in sync; the model is rebuilt in memory from the entries. The tradeoff is that a

View 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
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, clients,
lunch, lunch,
workedMs, workedMs,
totalMs: Math.max(0, roundTs(fallbackEnd, roundingMinutes) -
roundTs(day.startedTs ?? Math.min(...entries.map((e) => e.startTs), fallbackEnd), roundingMinutes)),
omitted, omitted,
}; };
} }
@@ -99,7 +101,8 @@ export function resumeToText(resume) {
lines.push(''); 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(); return lines.join('\n').replace(/\n{3,}/g, '\n\n').trim();
} }

View File

@@ -10,6 +10,7 @@ const DAY = {
status: 'closed', status: 'closed',
lunchType: 'restaurant', lunchType: 'restaurant',
restaurant: 'Le Relais', restaurant: 'Le Relais',
startedTs: at('07:30'),
endedTs: at('17:30'), endedTs: at('17:30'),
}; };
@@ -39,6 +40,7 @@ describe('buildResume', () => {
it('keeps lunch out of the worked total', () => { it('keeps lunch out of the worked total', () => {
const r = buildResume(DAY, ENTRIES, 15); const r = buildResume(DAY, ENTRIES, 15);
expect(r.workedMs).toBe(9 * 60 * MINUTE); // 09h00 on site, lunch excluded 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 }); expect(r.lunch).toEqual({ type: 'restaurant', restaurant: 'Le Relais', ms: 60 * MINUTE });
}); });
@@ -91,7 +93,8 @@ describe('resumeToText', () => {
'', '',
'Déjeuner : restaurant (Le Relais), 1h00', 'Déjeuner : restaurant (Le Relais), 1h00',
'', '',
'Total travaillé : 9h00', 'Travail hors repas : 9h00',
'Total journée (repas compris) : 10h00',
].join('\n'), ].join('\n'),
); );
}); });

View File

@@ -1,4 +1,5 @@
<script> <script>
import DayEditor from '../components/DayEditor.svelte';
import { dayState, reopenToday } from '../lib/day.svelte.js'; import { dayState, reopenToday } from '../lib/day.svelte.js';
import { buildResume, resumeToText } from '../lib/resume.js'; import { buildResume, resumeToText } from '../lib/resume.js';
import { go } from '../lib/router.svelte.js'; import { go } from '../lib/router.svelte.js';
@@ -9,12 +10,14 @@
let selectedDate = $state(''); let selectedDate = $state('');
let reopening = $state(false); let reopening = $state(false);
let error = $state(''); let error = $state('');
let editing = $state(false);
const selectedDay = $derived(dayState.closedDays.find((d) => d.date === selectedDate) ?? dayState.lastClosed); 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( const resume = $derived(
buildResume( buildResume(
selectedDay, selectedDay,
dayState.historyEntries.filter((e) => e.date === selectedDay?.date), selectedEntries,
settings.roundingMinutes, settings.roundingMinutes,
), ),
); );
@@ -54,7 +57,7 @@
{#if dayState.closedDays.length > 1} {#if dayState.closedDays.length > 1}
<label class="stack"> <label class="stack">
<span>Journée à consulter</span> <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)} {#each dayState.closedDays as day (day.date)}
<option value={day.date}>{fmtDayLabel(day.date)} {day.date.slice(0, 4)}</option> <option value={day.date}>{fmtDayLabel(day.date)} {day.date.slice(0, 4)}</option>
{/each} {/each}
@@ -120,6 +123,12 @@
<button type="button" class="btn-primary" onclick={copy}> <button type="button" class="btn-primary" onclick={copy}>
{copied ? '✓ Copié' : 'Copier le récapitulatif'} {copied ? '✓ Copié' : 'Copier le récapitulatif'}
</button> </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}
{#if closedToday} {#if closedToday}

View File

@@ -1,8 +1,7 @@
<script> <script>
import EntrySheet from '../components/EntrySheet.svelte'; import DayEditor from '../components/DayEditor.svelte';
import SmartField from '../components/SmartField.svelte'; import SmartField from '../components/SmartField.svelte';
import TimeField from '../components/TimeField.svelte'; import TimeField from '../components/TimeField.svelte';
import Timeline from '../components/Timeline.svelte';
import { import {
advance, advance,
currentClient, currentClient,
@@ -13,14 +12,13 @@
keepWorking, keepWorking,
predictForCurrent, predictForCurrent,
predictNext, predictNext,
removeEntry,
reopenToday, reopenToday,
running, running,
setLunch, setLunch,
startDayNow, startDayNow,
startLunch, startLunch,
} from '../lib/day.svelte.js'; } 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 { go } from '../lib/router.svelte.js';
import { lunchGuidance } from '../lib/lunch.js'; import { lunchGuidance } from '../lib/lunch.js';
import { settings } from '../lib/settings.svelte.js'; import { settings } from '../lib/settings.svelte.js';
@@ -28,8 +26,6 @@
let now = $state(Date.now()); let now = $state(Date.now());
let confirmingEnd = $state(false); let confirmingEnd = $state(false);
/** id of the timeline entry being corrected, if any */
let editingId = $state(null);
let busy = $state(false); let busy = $state(false);
let actionError = $state(''); let actionError = $state('');
@@ -65,29 +61,6 @@
dayState.model ? suggestRestaurants(dayState.model, dayClient ?? '') : [], 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( const actionLabel = $derived(
current?.kind === 'travel' current?.kind === 'travel'
? 'Arrivé sur site' ? 'Arrivé sur site'
@@ -223,18 +196,7 @@
{/if} {/if}
<!-- Day so far -------------------------------------------------------- --> <!-- Day so far -------------------------------------------------------- -->
{#if dayState.entries.length > 1} <DayEditor day={dayState.day} entries={dayState.entries} {now} />
<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}
<!-- Separated from everything frequent, and confirmed. ----------------- --> <!-- Separated from everything frequent, and confirmed. ----------------- -->
<div class="end"> <div class="end">
@@ -253,19 +215,6 @@
</div> </div>
</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 --------------------------------------------- --> <!-- End-of-day grace prompt --------------------------------------------- -->
{#if dayState.needsClosePrompt} {#if dayState.needsClosePrompt}
<div class="prompt" role="dialog" aria-modal="true" aria-labelledby="close-day-title"> <div class="prompt" role="dialog" aria-modal="true" aria-labelledby="close-day-title">
@@ -290,10 +239,6 @@
font-weight: 800; font-weight: 800;
} }
.hint {
font-size: 0.85rem;
margin-bottom: 4px;
}
.sel { .sel {
background: var(--accent); background: var(--accent);