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

@@ -1,19 +1,41 @@
<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 { 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 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 draft = $state({ kind: 'work', task: '', client: '', startTs: 0, endTs: 0, 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));
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) {
original = { ...entry };
mode = operation;
@@ -24,8 +46,8 @@
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),
startTs: entry.startTs,
endTs: entry.endTs ?? now,
lunchType: day.lunchType ?? 'panier', restaurant: day.restaurant ?? '',
};
}
@@ -36,11 +58,7 @@
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 };
const values = { ...draft, endTs: mode === 'edit' && original.endTs == null ? null : draft.endTs };
if (remove) await removeRecordedEntry(original);
else if (mode === 'insert') await insertRecordedEntry(original, values);
else await saveRecordedEntry(original, values);
@@ -55,20 +73,19 @@
<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')} />
<Timeline {entries} rounding={settings.roundingMinutes} {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(); }}>
<div>
<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>
<option value={entry.id}>{fmtTime(entry.startTs, settings.roundingMinutes)}–{entry.endTs == null ? 'en cours' : fmtTime(entry.endTs, settings.roundingMinutes)} · {entry.task}</option>
{/each}
</select>
</label>
@@ -81,42 +98,50 @@
<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>
<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}
<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>
<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 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>
<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}
{: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>
<SmartField label="Tâche" value={draft.task} suggestions={suggestions.tasks}
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 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" class="btn-primary" onclick={() => commit()}>{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>
<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>
{:else}
<button type="button" onclick={() => (confirmingDelete = true)}>Supprimer ce poste</button>
{/if}
{/if}
</fieldset>
</form>
</div>
{/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; }
select { width: 100%; min-width: 0; }
[role='alert'] { color: var(--danger); }
</style>

View File

@@ -8,7 +8,11 @@
let { open = false, title = '', level = 0, onclose, children } = $props();
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>

View File

@@ -41,7 +41,7 @@
<div class="field">
<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 aria-hidden="true">▾</span>
</button>

View File

@@ -1,13 +1,13 @@
<script>
/**
* 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
* "I actually got here twenty minutes ago", and that should not require typing
* 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';
let {
@@ -23,43 +23,45 @@
// Nudge steps follow the rounding setting: at 15-minute rounding a ±5 button
// 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(
[step, step === 5 ? 15 : step * 2].flatMap((m) => [-m, m]).sort((a, b) => a - b),
);
let open = $state(false);
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
* picker, the value finally stored — is the same grid-aligned instant, so the
* worker never sees two different times for one edit.
* Snap choices to the grid, retaining an exact neighbouring boundary if the
* rounded value would cross it. An untouched picker never rewrites raw data.
*/
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;
function start() {
draft = clamp(ts);
initialDraft = draft;
open = true;
}
function onPick(event) {
const [h, m] = event.currentTarget.value.split(':').map(Number);
if (Number.isNaN(h)) return;
draft = clamp(startOfDay(localDateKey(draft)) + (h * 60 + m) * MINUTE);
function onPick(hour, minute) {
draft = clamp(atLocalTime(localDateKey(draft), `${hour}:${minute}`));
}
function confirm() {
open = false;
if (draft !== ts) onchange?.(draft);
if (draft !== initialDraft) onchange?.(draft);
}
</script>
<div class="field">
<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 aria-hidden="true">✎</span>
</button>
@@ -80,12 +82,18 @@
{/each}
</div>
<input
type="time"
value={fmtTime(draft, rounding)}
onchange={onPick}
aria-label="Heure exacte"
/>
<div class="clock">
<label>Heures
<select aria-label="Heures" value={clock[0]} onchange={(event) => onPick(Number(event.currentTarget.value), clock[1])}>
{#each hours as hour}<option value={hour}>{String(hour).padStart(2, '0')}</option>{/each}
</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">
<button type="button" onclick={() => (open = false)}>Annuler</button>
@@ -131,4 +139,7 @@
.confirm {
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>

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 { 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 { insertRecordedEntry, removeRecordedEntry, saveRecordedEntry } from './editing.js';
import { buildResume, resumeToText } from './resume.js';
import { atLocalTime, HOUR } from './time.js';
@@ -51,8 +51,8 @@ describe('forgotten entries', () => {
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');
expect(resume.workedMs).toBe(8 * HOUR);
expect(resumeToText(resume)).toContain('Total travaillé : 8h00');
const backup = await exportBackup();
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];
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);
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 () => {
@@ -118,7 +118,7 @@ describe('recorded day corrections', () => {
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(buildResume(record, await getEntries(date)).workedMs).toBe(10.5 * HOUR);
expect(await getDay(other.date)).toEqual(other);
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')]);
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);
expect(buildResume(await getDay(date), await getEntries(date)).workedMs).toBe(8 * HOUR);
});
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);
});
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,
lunch,
workedMs,
totalMs: Math.max(0, roundTs(fallbackEnd, roundingMinutes) -
roundTs(day.startedTs ?? Math.min(...entries.map((e) => e.startTs), fallbackEnd), roundingMinutes)),
omitted,
};
}
@@ -101,8 +99,7 @@ export function resumeToText(resume) {
lines.push('');
}
lines.push(`Travail hors repas : ${fmtDuration(resume.workedMs)}`);
lines.push(`Total journée (repas compris) : ${fmtDuration(resume.totalMs)}`);
lines.push(`Total travaillé : ${fmtDuration(resume.workedMs)}`);
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', () => {
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 });
});
@@ -93,8 +92,7 @@ describe('resumeToText', () => {
'',
'Déjeuner : restaurant (Le Relais), 1h00',
'',
'Travail hors repas : 9h00',
'Total journée (repas compris) : 10h00',
'Total travaillé : 9h00',
].join('\n'),
);
});

View File

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