Simplified edit mode

This commit is contained in:
2026-09-19 22:24:52 +02:00
parent d46a5f10af
commit 6f64475050
10 changed files with 113 additions and 77 deletions

View File

@@ -83,22 +83,28 @@ concurrents, les retours d'horloge, les échecs transactionnels et la conservati
## Corriger les postes et les journées terminées ## 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 **Total travaillé** exclut le repas, qui reste affiché séparément dans le
Le récapitulatif et le texte copié distinguent aussi le travail hors repas et le récapitulatif et le texte copié. Les totaux suivent le réglage d'arrondi existant.
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 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). 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 Les sélecteurs d'heure ne proposent pas de date : ils conservent la date du
avec le bouton **Enregistrer**. Annuler ne modifie rien. Le début du premier poste. Les minutes et les boutons d'ajustement suivent l'arrondi choisi dans
les réglages (1, 5, 15 ou 30 minutes). Les tâches, clients et restaurants utilisent
les mêmes suggestions et la même saisie libre que l'écran de la journée.
Les corrections sont enregistrées ensemble avec **Enregistrer**. Annuler ne
modifie rien ; ouvrir puis valider un horaire inchangé conserve sa valeur
enregistrée. Le début du premier
poste et la fin du dernier définissent les bornes de la journée. Modifier une 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. 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 La fin d'un poste encore en cours reste pilotée par les boutons de changement
de tâche ou de clôture. de tâche ou de clôture.
**Ajouter un poste oublié** permet de choisir une plage existante puis les **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 horaires du poste à insérer dans cette plage. Le début et la fin sont préremplis
avec les deux bornes connues (jusqu'à l'heure actuelle pour un poste en cours).
Il suffit de changer une seule borne pour corriger le début ou la fin de la 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 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. 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 Le type **Repas** permet aussi d'ajouter une pause oubliée et de renseigner
@@ -110,6 +116,8 @@ 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 cours. Le récapitulatif et son texte à copier sont recalculés après chaque
enregistrement. La suppression d'un poste demande confirmation ; le voisin 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é. absorbe sa durée, et le dernier poste d'une journée ne peut pas être supprimé.
Supprimer un repas réaffecte sa durée au poste voisin et peut donc changer le
total travaillé.
Chaque correction est transactionnelle. Les horaires inversés, les insertions 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 hors de la plage choisie et les formulaires devenus périmés après une autre
@@ -172,8 +180,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. The quick time picker follows the rounding grid. The detailed entry editor them. Time pickers follow the selected rounding grid; opening an unchanged time
stores minute-level corrections; summaries apply the selected rounding at render. preserves its stored timestamp. Summaries apply the same 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

@@ -1,19 +1,41 @@
<script> <script>
import { insertRecordedEntry, localInput, removeRecordedEntry, saveRecordedEntry } from '../lib/editing.js'; import { insertRecordedEntry, removeRecordedEntry, saveRecordedEntry } from '../lib/editing.js';
import { dayState, refresh } from '../lib/day.svelte.js'; import { dayState, refresh } from '../lib/day.svelte.js';
import { fmtTime, MINUTE } from '../lib/time.js'; import { fmtTime, startOfDay } from '../lib/time.js';
import { settings } from '../lib/settings.svelte.js';
import { suggestClients, suggestRestaurants, suggestTasks } from '../lib/predict.js';
import SmartField from './SmartField.svelte';
import TimeField from './TimeField.svelte';
import Sheet from './Sheet.svelte'; import Sheet from './Sheet.svelte';
import Timeline from './Timeline.svelte'; import Timeline from './Timeline.svelte';
let { day, entries = [], now = Date.now(), onchange = () => {} } = $props(); let { day, entries = [], now = Date.now(), onchange = () => {} } = $props();
let mode = $state(''); let mode = $state('');
let original = $state(null); let original = $state(null);
let draft = $state({ kind: 'work', task: '', client: '', start: '', end: '', lunchType: 'panier', restaurant: '' }); let draft = $state({ kind: 'work', task: '', client: '', startTs: 0, endTs: 0, lunchType: 'panier', restaurant: '' });
let busy = $state(false); let busy = $state(false);
let error = $state(''); let error = $state('');
let confirmingDelete = $state(false); let confirmingDelete = $state(false);
const ordered = $derived([...entries].sort((a, b) => a.seq - b.seq)); const ordered = $derived([...entries].sort((a, b) => a.seq - b.seq));
const index = $derived(ordered.findIndex((e) => e.id === original?.id));
const previous = $derived(ordered[index - 1]);
const following = $derived(ordered[index + 1]);
const suggestions = $derived.by(() => {
const model = dayState.model;
if (!model) return { tasks: [], clients: [], restaurants: [] };
const tasks = suggestTasks(model, {
prevTask: previous?.task ?? null,
prevElapsedMs: previous ? (previous.endTs ?? now) - previous.startTs : 0,
seq: original?.seq ?? 0, ts: draft.startTs || now, client: draft.client || null,
});
// Corrections may repeat a neighbouring task; keep every known value selectable.
for (const value of model.taskCount.keys()) {
if (!tasks.some((task) => task.value === value)) tasks.push({ value, hint: '', score: 0 });
}
return { tasks, clients: suggestClients(model), restaurants: suggestRestaurants(model, draft.client) };
});
function select(entry, operation = mode) { function select(entry, operation = mode) {
original = { ...entry }; original = { ...entry };
mode = operation; mode = operation;
@@ -24,8 +46,8 @@
kind: insertion ? 'work' : entry.kind, kind: insertion ? 'work' : entry.kind,
task: insertion ? '' : entry.task, task: insertion ? '' : entry.task,
client: entry.client ?? '', client: entry.client ?? '',
start: localInput(entry.startTs), startTs: entry.startTs,
end: localInput(insertion ? Math.min(entry.startTs + 30 * MINUTE, entry.endTs ?? now) : entry.endTs ?? now), endTs: entry.endTs ?? now,
lunchType: day.lunchType ?? 'panier', restaurant: day.restaurant ?? '', lunchType: day.lunchType ?? 'panier', restaurant: day.restaurant ?? '',
}; };
} }
@@ -36,11 +58,7 @@
if (busy || dayState.importing) return; if (busy || dayState.importing) return;
busy = true; error = ''; busy = true; error = '';
try { try {
// Preserve seconds when only text changes; typed times have minute precision. const values = { ...draft, endTs: mode === 'edit' && original.endTs == null ? null : draft.endTs };
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); if (remove) await removeRecordedEntry(original);
else if (mode === 'insert') await insertRecordedEntry(original, values); else if (mode === 'insert') await insertRecordedEntry(original, values);
else await saveRecordedEntry(original, values); else await saveRecordedEntry(original, values);
@@ -55,20 +73,19 @@
<div class="card stack"> <div class="card stack">
<h3>Postes de la journée</h3> <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">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={settings.roundingMinutes} {now} onselect={(entry) => select(entry, 'edit')} />
<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> <button type="button" disabled={!ordered.length || dayState.importing} onclick={() => select(ordered[0], 'insert')}>Ajouter un poste oublié</button>
</div> </div>
<Sheet open={Boolean(mode)} title={mode === 'insert' ? 'Ajouter un poste' : 'Modifier le poste'} onclose={close}> <Sheet open={Boolean(mode)} title={mode === 'insert' ? 'Ajouter un poste' : 'Modifier le poste'} onclose={close}>
{#if original} {#if original}
<form onsubmit={(event) => { event.preventDefault(); commit(); }}> <div>
<fieldset disabled={busy || dayState.importing} class="stack"> <fieldset disabled={busy || dayState.importing} class="stack">
{#if mode === 'insert'} {#if mode === 'insert'}
<label class="stack">Plage à corriger <label class="stack">Plage à corriger
<select value={original.id} onchange={(event) => select(ordered.find((e) => e.id === Number(event.currentTarget.value)), 'insert')}> <select value={original.id} onchange={(event) => select(ordered.find((e) => e.id === Number(event.currentTarget.value)), 'insert')}>
{#each ordered as entry (entry.id)} {#each ordered as entry (entry.id)}
<option value={entry.id}>{fmtTime(entry.startTs)}–{entry.endTs == null ? 'en cours' : fmtTime(entry.endTs)} · {entry.task}</option> <option value={entry.id}>{fmtTime(entry.startTs, settings.roundingMinutes)}–{entry.endTs == null ? 'en cours' : fmtTime(entry.endTs, settings.roundingMinutes)} · {entry.task}</option>
{/each} {/each}
</select> </select>
</label> </label>
@@ -81,42 +98,50 @@
<option value="work">Travail</option><option value="travel">Transport</option><option value="lunch">Repas</option> <option value="work">Travail</option><option value="travel">Transport</option><option value="lunch">Repas</option>
</select> </select>
</label> </label>
<label class="stack">Début du poste<input type="datetime-local" required bind:value={draft.start} /></label> <TimeField label="Début du poste" ts={draft.startTs} rounding={settings.roundingMinutes}
min={mode === 'insert' ? original.startTs : previous?.startTs ?? startOfDay(day.date)}
max={draft.endTs} onchange={(ts) => (draft.startTs = ts)} />
{#if mode === 'edit' && original.endTs == null} {#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> <p class="dim hint">Poste en cours : sa fin sera enregistrée au prochain changement de tâche.</p>
{:else} {:else}
<label class="stack">Fin du poste<input type="datetime-local" required bind:value={draft.end} /></label> <TimeField label="Fin du poste" ts={draft.endTs} rounding={settings.roundingMinutes}
min={draft.startTs} max={mode === 'insert' ? original.endTs ?? now : following?.endTs ?? now}
onchange={(ts) => (draft.endTs = ts)} />
{/if} {/if}
{#if draft.kind === 'lunch'} {#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> <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'} {#if draft.lunchType === 'restaurant'}
<label class="stack">Restaurant<input type="text" bind:value={draft.restaurant} /></label> <SmartField label="Restaurant" value={draft.restaurant} suggestions={suggestions.restaurants}
placeholder="Nom du restaurant" emptyText="Aucun restaurant enregistré" level={1}
onchange={(value) => (draft.restaurant = value)} />
{/if} {/if}
{:else} {:else}
<label class="stack">Tâche<input type="text" required bind:value={draft.task} /></label> <SmartField label="Tâche" value={draft.task} suggestions={suggestions.tasks}
<label class="stack">Client<input type="text" bind:value={draft.client} /></label> placeholder="Nom de la tâche" level={1} onchange={(value) => (draft.task = value)} />
<SmartField label="Client" value={draft.client} suggestions={suggestions.clients}
placeholder="Nom du client" emptyText="Aucun client enregistré" level={1}
onchange={(value) => (draft.client = value)} />
{/if} {/if}
{#if day.status === 'closed'}<p class="dim hint">La journée restera terminée après la correction.</p>{/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} {#if error}<p role="alert">{error}</p>{/if}
<button type="submit" class="btn-primary">{busy ? 'Enregistrement…' : 'Enregistrer'}</button> <button type="button" class="btn-primary" onclick={() => commit()}>{busy ? 'Enregistrement…' : 'Enregistrer'}</button>
<button type="button" onclick={close}>Annuler</button> <button type="button" onclick={close}>Annuler</button>
{#if mode === 'edit' && original.endTs != null && entries.length > 1} {#if mode === 'edit' && original.endTs != null && entries.length > 1}
{#if confirmingDelete} {#if confirmingDelete}
<p class="hint">Le poste voisin absorbera cette durée. Le total de la journée restera inchangé.</p> <p class="hint">Le poste voisin absorbera cette durée. Supprimer un repas peut modifier le total travaillé.</p>
<button type="button" class="btn-danger" onclick={() => commit(true)}>Confirmer la suppression</button> <button type="button" class="btn-danger" onclick={() => commit(true)}>Confirmer la suppression</button>
{:else} {:else}
<button type="button" onclick={() => (confirmingDelete = true)}>Supprimer ce poste</button> <button type="button" onclick={() => (confirmingDelete = true)}>Supprimer ce poste</button>
{/if} {/if}
{/if} {/if}
</fieldset> </fieldset>
</form> </div>
{/if} {/if}
</Sheet> </Sheet>
<style> <style>
fieldset { border: 0; padding: 0; margin: 0; min-width: 0; } fieldset { border: 0; padding: 0; margin: 0; min-width: 0; }
.hint { font-size: 0.85rem; } .hint { font-size: 0.85rem; }
input, select { width: 100%; min-width: 0; } 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); } [role='alert'] { color: var(--danger); }
</style> </style>

View File

@@ -8,7 +8,11 @@
let { open = false, title = '', level = 0, onclose, children } = $props(); let { open = false, title = '', level = 0, onclose, children } = $props();
function onkeydown(event) { function onkeydown(event) {
if (open && event.key === 'Escape') onclose?.(); if (!open || event.key !== 'Escape' || event.defaultPrevented) return;
const above = [...document.querySelectorAll('[role="dialog"]')].some(
(element) => Number(getComputedStyle(element).zIndex) > 41 + level * 10,
);
if (!above) { event.preventDefault(); onclose?.(); }
} }
</script> </script>

View File

@@ -41,7 +41,7 @@
<div class="field"> <div class="field">
<span class="label">{label}</span> <span class="label">{label}</span>
<button type="button" class="value" onclick={() => (open = true)}> <button type="button" class="value" aria-label={`${label} : ${value || placeholder}`} onclick={() => (open = true)}>
<span class="text" class:placeholder={!value}>{value || placeholder}</span> <span class="text" class:placeholder={!value}>{value || placeholder}</span>
<span aria-hidden="true">▾</span> <span aria-hidden="true">▾</span>
</button> </button>

View File

@@ -1,13 +1,13 @@
<script> <script>
/** /**
* Editable clock time. Collapsed it is a big button showing the time; tapping * Editable clock time. Collapsed it is a big button showing the time; tapping
* opens a sheet with coarse nudge buttons and a native picker. * opens a sheet with nudge buttons and clock choices on the configured grid.
* *
* The nudges carry the weight here: almost every correction in the field is * The nudges carry the weight here: almost every correction in the field is
* "I actually got here twenty minutes ago", and that should not require typing * "I actually got here twenty minutes ago", and that should not require typing
* on a phone with gloves on. * on a phone with gloves on.
*/ */
import { fmtTime, localDateKey, MINUTE, roundTs, startOfDay } from '../lib/time.js'; import { atLocalTime, fmtTime, localDateKey, MINUTE, roundTs } from '../lib/time.js';
import Sheet from './Sheet.svelte'; import Sheet from './Sheet.svelte';
let { let {
@@ -23,43 +23,45 @@
// Nudge steps follow the rounding setting: at 15-minute rounding a ±5 button // Nudge steps follow the rounding setting: at 15-minute rounding a ±5 button
// would round straight back to where it started and appear broken. // would round straight back to where it started and appear broken.
const step = $derived(Math.max(5, rounding)); const step = $derived(Math.max(1, rounding));
const NUDGES = $derived( const NUDGES = $derived(
[step, step === 5 ? 15 : step * 2].flatMap((m) => [-m, m]).sort((a, b) => a - b), [step, step === 5 ? 15 : step * 2].flatMap((m) => [-m, m]).sort((a, b) => a - b),
); );
let open = $state(false); let open = $state(false);
let draft = $state(0); let draft = $state(0);
let initialDraft = $state(0);
const minutes = $derived(Array.from({ length: 60 / step }, (_, i) => i * step));
const hours = Array.from({ length: 24 }, (_, i) => i);
const clock = $derived(fmtTime(draft, rounding).split(':').map(Number));
/** /**
* Clamp *and* snap. Everything the sheet shows — the big preview, the native * Snap choices to the grid, retaining an exact neighbouring boundary if the
* picker, the value finally stored — is the same grid-aligned instant, so the * rounded value would cross it. An untouched picker never rewrites raw data.
* worker never sees two different times for one edit.
*/ */
const clamp = (value) => roundTs(Math.min(Math.max(value, min), max), rounding); const clamp = (value) => Math.min(Math.max(roundTs(value, rounding), min), max);
const atLimit = (minutes) => clamp(draft + minutes * MINUTE) === draft; const atLimit = (minutes) => clamp(draft + minutes * MINUTE) === draft;
function start() { function start() {
draft = clamp(ts); draft = clamp(ts);
initialDraft = draft;
open = true; open = true;
} }
function onPick(event) { function onPick(hour, minute) {
const [h, m] = event.currentTarget.value.split(':').map(Number); draft = clamp(atLocalTime(localDateKey(draft), `${hour}:${minute}`));
if (Number.isNaN(h)) return;
draft = clamp(startOfDay(localDateKey(draft)) + (h * 60 + m) * MINUTE);
} }
function confirm() { function confirm() {
open = false; open = false;
if (draft !== ts) onchange?.(draft); if (draft !== initialDraft) onchange?.(draft);
} }
</script> </script>
<div class="field"> <div class="field">
<span class="label">{label}</span> <span class="label">{label}</span>
<button type="button" class="value tnum" onclick={start}> <button type="button" class="value tnum" aria-label={`${label} : ${fmtTime(ts, rounding)}`} onclick={start}>
<span>{fmtTime(ts, rounding)}</span> <span>{fmtTime(ts, rounding)}</span>
<span aria-hidden="true">✎</span> <span aria-hidden="true">✎</span>
</button> </button>
@@ -80,12 +82,18 @@
{/each} {/each}
</div> </div>
<input <div class="clock">
type="time" <label>Heures
value={fmtTime(draft, rounding)} <select aria-label="Heures" value={clock[0]} onchange={(event) => onPick(Number(event.currentTarget.value), clock[1])}>
onchange={onPick} {#each hours as hour}<option value={hour}>{String(hour).padStart(2, '0')}</option>{/each}
aria-label="Heure exacte" </select>
/> </label>
<label>Minutes
<select aria-label="Minutes" value={clock[1]} onchange={(event) => onPick(clock[0], Number(event.currentTarget.value))}>
{#each minutes as minute}<option value={minute}>{String(minute).padStart(2, '0')}</option>{/each}
</select>
</label>
</div>
<div class="btn-row"> <div class="btn-row">
<button type="button" onclick={() => (open = false)}>Annuler</button> <button type="button" onclick={() => (open = false)}>Annuler</button>
@@ -131,4 +139,7 @@
.confirm { .confirm {
min-height: var(--tap); min-height: var(--tap);
} }
.clock { display: grid; grid-template-columns: 1fr 1fr; gap: var(--gap); }
.clock label { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.clock select { width: 100%; }
</style> </style>

View File

@@ -98,8 +98,3 @@ export function removeRecordedEntry(original) {
}); });
} }
/** 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')}`;
}

View File

@@ -1,7 +1,7 @@
import 'fake-indexeddb/auto'; import 'fake-indexeddb/auto';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { closeAndStart, closeDay, createDay, db, exportBackup, getDay, getEntries } from './db.js'; import { closeAndStart, closeDay, createDay, db, exportBackup, getDay, getEntries } from './db.js';
import { insertRecordedEntry, localInput, removeRecordedEntry, saveRecordedEntry } from './editing.js'; import { insertRecordedEntry, removeRecordedEntry, saveRecordedEntry } from './editing.js';
import { buildResume, resumeToText } from './resume.js'; import { buildResume, resumeToText } from './resume.js';
import { atLocalTime, HOUR } from './time.js'; import { atLocalTime, HOUR } from './time.js';
@@ -51,8 +51,8 @@ describe('forgotten entries', () => {
const resume = buildResume(record, entries); const resume = buildResume(record, entries);
expect(record.status).toBe('closed'); expect(entries[1].client).toBeNull(); expect(record.status).toBe('closed'); expect(entries[1].client).toBeNull();
expect(resume.lunch).toEqual({ type: 'restaurant', restaurant: 'Le Relais', ms: HOUR }); expect(resume.lunch).toEqual({ type: 'restaurant', restaurant: 'Le Relais', ms: HOUR });
expect(resume.workedMs).toBe(8 * HOUR); expect(resume.totalMs).toBe(9 * HOUR); expect(resume.workedMs).toBe(8 * HOUR);
expect(resumeToText(resume)).toContain('Total journée (repas compris) : 9h00'); expect(resumeToText(resume)).toContain('Total travaillé : 8h00');
const backup = await exportBackup(); const backup = await exportBackup();
expect(backup.entries).toHaveLength(3); expect(backup.days[0].status).toBe('closed'); expect(backup.entries).toHaveLength(3); expect(backup.days[0].status).toBe('closed');
}); });
@@ -63,7 +63,7 @@ describe('forgotten entries', () => {
const original = (await getEntries(date))[1]; const original = (await getEntries(date))[1];
await insertRecordedEntry(original, values('12:15', '12:30')); await insertRecordedEntry(original, values('12:15', '12:30'));
const result = buildResume(await getDay(date), await getEntries(date), 1); const result = buildResume(await getDay(date), await getEntries(date), 1);
expect(result.lunch.ms).toBe(0.75 * HOUR); expect(result.totalMs).toBe(9 * HOUR); expect(result.lunch.ms).toBe(0.75 * HOUR); expect(result.workedMs).toBe(8.25 * HOUR);
}); });
it('keeps one running entry when inserting up to the present', async () => { it('keeps one running entry when inserting up to the present', async () => {
@@ -118,7 +118,7 @@ describe('recorded day corrections', () => {
await saveRecordedEntry(list.at(-1), values('13:00', '18:00')); await saveRecordedEntry(list.at(-1), values('13:00', '18:00'));
const record = await getDay(date); const record = await getDay(date);
expect(record).toMatchObject({ startedTs: at('07:30'), endedTs: at('18:00'), status: 'closed' }); 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(buildResume(record, await getEntries(date)).workedMs).toBe(10.5 * HOUR);
expect(await getDay(other.date)).toEqual(other); expect(await getDay(other.date)).toEqual(other);
expect((await getEntries(other.date))[0].endTs).toBeNull(); expect((await getEntries(other.date))[0].endTs).toBeNull();
}); });
@@ -127,7 +127,7 @@ describe('recorded day corrections', () => {
const list = await seed([entry(0, '08:00', '10:00'), entry(1, '11:00', '17:00')]); 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')); await saveRecordedEntry(list[1], values('11:00', '17:00'));
expect((await getEntries(date))[0].endTs).toBe(at('10:00')); expect((await getEntries(date))[0].endTs).toBe(at('10:00'));
expect(buildResume(await getDay(date), await getEntries(date)).totalMs).toBe(9 * HOUR); expect(buildResume(await getDay(date), await getEntries(date)).workedMs).toBe(8 * HOUR);
}); });
it('rejects overlaps beyond neighbours and future timestamps without writes', async () => { it('rejects overlaps beyond neighbours and future timestamps without writes', async () => {
@@ -167,7 +167,5 @@ describe('recorded day corrections', () => {
expect(await getEntries(date)).toHaveLength(1); 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,8 +67,6 @@ 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,
}; };
} }
@@ -101,8 +99,7 @@ export function resumeToText(resume) {
lines.push(''); lines.push('');
} }
lines.push(`Travail hors repas : ${fmtDuration(resume.workedMs)}`); lines.push(`Total travaillé : ${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

@@ -40,7 +40,6 @@ 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 });
}); });
@@ -93,8 +92,7 @@ describe('resumeToText', () => {
'', '',
'Déjeuner : restaurant (Le Relais), 1h00', 'Déjeuner : restaurant (Le Relais), 1h00',
'', '',
'Travail hors repas : 9h00', 'Total travaillé : 9h00',
'Total journée (repas compris) : 10h00',
].join('\n'), ].join('\n'),
); );
}); });

View File

@@ -122,8 +122,8 @@
{/each} {/each}
</div> </div>
<p class="dim small"> <p class="dim small">
S'applique à l'affichage seulement : les heures réelles restent enregistrées, un Définit l'affichage des durées et le pas des corrections d'horaire. Changer
changement d'arrondi corrige aussi les journées passées. l'arrondi actualise aussi les récapitulatifs passés sans modifier les heures enregistrées.
</p> </p>
</section> </section>