Simplified edit mode
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')}`;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user