Improved import security, added browser tests
Some checks failed
Tests et déploiement / deploy (push) Has been cancelled
Tests et déploiement / validate (push) Has been cancelled

This commit is contained in:
2026-10-08 21:34:58 +02:00
parent 5cf19bb8ac
commit b0aa5acf4b
28 changed files with 793 additions and 99 deletions

View File

@@ -1,5 +1,8 @@
<script>
import { db } from './lib/db.js';
import { dayState, reconcile } from './lib/day.svelte.js';
import { backupState, loadBackupStatus } from './lib/backup-state.svelte.js';
import { backupReminderDue } from './lib/backup-safety.js';
import { go, router } from './lib/router.svelte.js';
import { loadSettings, settings } from './lib/settings.svelte.js';
import { needsRefresh, applyUpdate } from './lib/updates.svelte.js';
@@ -14,7 +17,8 @@
let ready = $state(false);
let bootError = $state('');
let retry = $state(0);
async function tick() { try { await reconcile(); } catch (error) { bootError = `Actualisation impossible : ${error.message}`; } }
let clockNow = $state(Date.now());
async function tick() { if (!ready) return; try { await db.open(); await reconcile(); await loadBackupStatus(); clockNow = Date.now(); bootError = ''; } catch (error) { bootError = `Actualisation impossible : ${error.message}`; } }
$effect(() => {
retry;
@@ -22,7 +26,9 @@
let alive = true;
(async () => {
try {
await db.open();
await loadSettings();
await loadBackupStatus();
if (import.meta.env.DEV) {
const { installDevSeed } = await import('./lib/seed.js');
installDevSeed();
@@ -52,6 +58,9 @@
{#if !ready}
<div class="boot">{#if bootError}<div class="stack"><p role="alert">{bootError}</p><button onclick={() => (retry += 1)}>Réessayer</button></div>{:else}<p>Chargement…</p>{/if}</div>
{:else}
{#if backupState.loaded && backupReminderDue(backupState.status, clockNow) && view !== 'settings'}
<div class="update"><span>Sauvegarde à vérifier</span><button disabled={dayState.importing} onclick={() => go('settings')}>Sauvegarder</button></div>
{/if}
{#if needsRefresh.value && !dayState.day}
<!-- Only offered while no day is running, so an update can never interrupt
a day in progress. -->

View File

@@ -13,7 +13,9 @@
parseBackup,
} from '../lib/backup.js';
import { applyImport, dayState } from '../lib/day.svelte.js';
import { db, exportBackup } from '../lib/db.js';
import { prepareVerifiedExport, verifyExportFile, backupReminderDue } from '../lib/backup-safety.js';
import { backupState, loadBackupStatus } from '../lib/backup-state.svelte.js';
import { db } from '../lib/db.js';
import { fmtDayLabel, fmtTime, localDateKey } from '../lib/time.js';
/** @type {HTMLInputElement} */
@@ -24,7 +26,7 @@
let result = $state(null);
let confirmingReplace = $state(false);
let busy = $state(false);
let lastExport = $state(localStorage.getItem('pardis-last-export') ?? '');
let verifying = $state(false);
let filename = $state('');
function reset() {
@@ -36,14 +38,14 @@
}
async function exportAll() {
if (busy) return;
if (busy || dayState.importing) return;
busy = true;
error = null;
result = null;
try {
const payload = await exportBackup();
const { payload, text } = await prepareVerifiedExport();
const url = URL.createObjectURL(
new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }),
new Blob([text], { type: 'application/json' }),
);
const link = document.createElement('a');
link.href = url;
@@ -56,7 +58,7 @@
// Give browsers (including standalone mobile apps) time to start reading.
setTimeout(() => URL.revokeObjectURL(url), 60_000);
}
lastExport = payload.exportedAt; localStorage.setItem('pardis-last-export', lastExport);
await loadBackupStatus();
result = `Export préparé : ${payload.days.length} journée${payload.days.length > 1 ? 's' : ''}, ${payload.entries.length} poste${payload.entries.length > 1 ? 's' : ''}.`;
} catch (cause) {
error = `Export impossible : ${cause.message}`;
@@ -66,15 +68,18 @@
}
async function recoverPurge() {
if (busy || dayState.importing) return;
busy = true; reset(); result = null;
try {
const record = await db.settings.get('before-purge');
if (!record) throw new Error('Aucune sauvegarde avant purge disponible.');
staged = parseBackup(JSON.stringify(record.value)); filename = 'Sauvegarde avant purge';
} catch (cause) { error = cause.message; }
finally { busy = false; }
}
async function onPick(event) {
if (busy) return;
if (busy || dayState.importing) return;
// Capture the File before reset() clears the input and its FileList.
const file = event.currentTarget.files?.[0];
reset();
@@ -85,7 +90,12 @@
if (file.size > MAX_BACKUP_BYTES) {
throw new BackupError('Fichier trop volumineux (20 Mo maximum).');
}
staged = parseBackup(await file.text());
const text = await file.text();
if (verifying) {
const verified = await verifyExportFile(text);
await loadBackupStatus();
result = `Sauvegarde vérifiée : fichier complet relu, ${verified.days} journée(s), ${verified.entries} poste(s), import possible.`;
} else staged = parseBackup(text);
filename = file.name;
} catch (cause) {
error =
@@ -96,7 +106,7 @@
}
async function run(mode) {
if (!staged || busy) return;
if (!staged || busy || dayState.importing) return;
busy = true;
error = null;
try {
@@ -120,7 +130,11 @@
<button type="button" disabled={busy} onclick={exportAll}>Exporter les données</button>
<p class="dim small">Dernier export préparé : {lastExport ? new Date(lastExport).toLocaleString('fr-FR') : 'aucun'}. Vérifiez que le fichier est bien enregistré et testez sa restauration. Pensez à exporter régulièrement.</p>
<p class="dim small">Dernier export préparé : {backupState.status.exportedAt ? new Date(backupState.status.exportedAt).toLocaleString('fr-FR') : 'aucun'}.</p>
<p class="dim small">Dernière sauvegarde vérifiée : {backupState.status.verifiedExportedAt ? new Date(backupState.status.verifiedExportedAt).toLocaleString('fr-FR') : 'aucune'}.</p>
{#if backupState.loaded && backupReminderDue(backupState.status)}<p role="status">Pensez à sauvegarder : exportez puis vérifiez le fichier enregistré. Un rappel apparaît après sept jours sans sauvegarde vérifiée.</p>{/if}
<button disabled={busy || !backupState.status.checksum} onclick={() => { verifying = true; picker.click(); }}>Vérifier le fichier exporté…</button>
<p class="dim small">Choisissez le fichier téléchargé : son contenu complet sera comparé à l’export. Aucune journée ne sera modifiée.</p>
<button disabled={busy} onclick={recoverPurge}>Récupérer la sauvegarde avant purge</button>
<!-- The real input is hidden: a styled button is a far bigger target. -->
@@ -132,7 +146,7 @@
disabled={busy}
hidden
/>
<button type="button" disabled={busy} onclick={() => picker.click()}>Importer un fichier…</button>
<button type="button" disabled={busy} onclick={() => { verifying = false; picker.click(); }}>Importer un fichier…</button>
{#if busy}<p role="status">Traitement en cours…</p>{/if}

View File

@@ -1,5 +1,5 @@
<script>
import { insertRecordedEntry, removeRecordedEntry, saveRecordedEntry } from '../lib/editing.js';
import { insertGapEntry, insertRecordedEntry, removeRecordedEntry, saveRecordedEntry } from '../lib/editing.js';
import { dayState, refresh } from '../lib/day.svelte.js';
import { fmtTime, startOfDay } from '../lib/time.js';
import { settings } from '../lib/settings.svelte.js';
@@ -18,6 +18,13 @@
let confirmingDelete = $state(false);
const ordered = $derived([...entries].sort((a, b) => a.seq - b.seq));
const locations = $derived(ordered.flatMap((entry, index) => {
const next = ordered[index + 1];
return next && entry.endTs != null && entry.endTs < next.startTs ? [entry, {
id: `gap:${entry.id}`, task: 'Intervalle sans poste', kind: 'gap', client: null,
startTs: entry.endTs, endTs: next.startTs, previous: entry, following: next,
}] : [entry];
}));
const index = $derived(ordered.findIndex((e) => e.id === original?.id));
const previous = $derived(ordered[index - 1]);
const following = $derived(ordered[index + 1]);
@@ -60,6 +67,7 @@
try {
const values = { ...draft, endTs: mode === 'edit' && original.endTs == null ? null : draft.endTs };
if (remove) await removeRecordedEntry(original);
else if (mode === 'insert' && original.kind === 'gap') await insertGapEntry(original.previous, original.following, values);
else if (mode === 'insert') await insertRecordedEntry(original, values);
else await saveRecordedEntry(original, values);
await refresh();
@@ -73,7 +81,7 @@
<div class="card stack">
<h3>Postes de la journée</h3>
{#if editable}<p class="dim hint">Touchez un poste pour corriger ses horaires, sa tâche ou son client.</p>{/if}
<Timeline {entries} rounding={settings.roundingMinutes} {now} onselect={editable ? (entry) => select(entry, 'edit') : null} />
<Timeline {entries} rounding={settings.roundingMinutes} {now} onselect={editable && !dayState.importing ? (entry) => select(entry, 'edit') : null} />
{#if editable}<button type="button" disabled={!ordered.length || dayState.importing} onclick={() => select(ordered[0], 'insert')}>Ajouter un poste oublié</button>{/if}
</div>
@@ -83,13 +91,13 @@
<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)}
<select value={original.id} onchange={(event) => select(locations.find((e) => String(e.id) === event.currentTarget.value), 'insert')}>
{#each locations as entry (entry.id)}
<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>
<p class="dim hint">Le poste oublié sera inséré dans cette plage. Les portions avant et après seront conservées.</p>
<p class="dim hint">Le poste oublié sera inséré dans cette plage. Les portions avant et après seront conservées. Dans un intervalle sans poste, elles restent vides.</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}

View File

@@ -1,4 +1,5 @@
<script>
import { untrack } from 'svelte';
import Sheet from './Sheet.svelte';
import { dayState, reopenToday } from '../lib/day.svelte.js';
import { go } from '../lib/router.svelte.js';
@@ -8,24 +9,40 @@
let busy = $state(false);
let error = $state('');
let now = $state(Date.now());
$effect(() => { if (open) { now = Date.now(); mode = 'break'; error = ''; } });
const day = $derived(dayState.todayRecord);
const last = $derived(dayState.historyEntries.filter(e => e.date === day?.date).sort((a,b) => a.seq-b.seq).at(-1));
let snapshot = $state(null);
$effect(() => {
if (!open) return;
untrack(() => {
now = Date.now(); error = '';
const day = dayState.todayRecord;
const last = dayState.historyEntries.filter(e => e.date === day?.date).sort((a,b) => a.seq-b.seq).at(-1);
snapshot = day && last ? { date: day.date, endedTs: day.endedTs, last: { ...last } } : null;
mode = now > (snapshot?.endedTs ?? now) ? 'break' : 'continue';
});
const timer = setInterval(() => (now = Date.now()), 1000);
return () => clearInterval(timer);
});
const last = $derived(snapshot?.last);
async function confirm() {
if (busy || dayState.importing) return;
busy = true;
try { await reopenToday(Date.now(), mode); onclose(); go('today'); }
try {
if (!snapshot) throw new Error('Cette journée ne peut plus être reprise.');
const reopened = await reopenToday(Date.now(), mode, $state.snapshot(snapshot));
if (!reopened) throw new Error('La journée a déjà été reprise ou n’est plus une journée terminée aujourd’hui.');
onclose(); go('today');
}
catch (cause) { error = cause.message; }
finally { busy = false; }
}
</script>
<Sheet {open} title="Reprendre la journée" onclose={() => { if (!busy) onclose(); }}>
<p>Clôture enregistrée : {fmtTime(day?.endedTs ?? now)}. Heure actuelle : {fmtTime(now)}.</p>
<p>Clôture enregistrée : {fmtTime(snapshot?.endedTs ?? now)}. Heure actuelle : {fmtTime(now)}.</p>
<fieldset disabled={busy || dayState.importing} class="stack">
{#if now > (day?.endedTs ?? now)}
{#if now > (snapshot?.endedTs ?? now)}
<label><input type="radio" bind:group={mode} value="break" /> Créer une pause jusqu’à maintenant</label>
<p class="dim">Conserver la fin enregistrée, ajouter une pause et démarrer le travail à la reprise.</p>
<label><input type="radio" bind:group={mode} value="continue" /> Continuer depuis la fin enregistrée</label>
<label><input type="radio" bind:group={mode} value="continue" /> {last?.kind === 'lunch' ? 'Prolonger le repas depuis la fin enregistrée' : last?.kind === 'break' ? 'Prolonger la pause depuis la fin enregistrée' : 'Continuer depuis la fin enregistrée'}</label>
<p class="dim">{last?.kind === 'lunch' ? 'Prolonger le repas' : last?.kind === 'break' ? 'Prolonger la pause' : 'Prolonger le dernier poste et compter cet intervalle comme travaillé'}, puis reprendre le travail. La tâche et le client restent modifiables.</p>
{:else}<p>Aucun intervalle à ajouter. Aucune pause vide ne sera créée.</p>{/if}
{#if error}<p role="alert">{error}</p>{/if}

84
src/lib/backup-safety.js Normal file
View File

@@ -0,0 +1,84 @@
import { db, exportBackup, readSettings } from './db.js';
import { parseBackup } from './backup.js';
import { DAY, localDateKey } from './time.js';
export const BACKUP_REMINDER_DAYS = 7;
export async function readBackupStatus() {
return (await db.settings.get('backup-status'))?.value ?? {};
}
/** Verify the complete downloaded file, not just its timestamp or row counts. */
async function digest(text) {
const bytes = new TextEncoder().encode(text);
const hash = await crypto.subtle.digest('SHA-256', bytes);
return [...new Uint8Array(hash)].map(byte => byte.toString(16).padStart(2, '0')).join('');
}
export async function prepareVerifiedExport(now = Date.now()) {
const payload = await exportBackup();
const text = JSON.stringify(payload, null, 2);
// A backup must pass the same reader used for restoration before we offer it.
parseBackup(text);
const checksum = await digest(text);
await db.transaction('rw', db.settings, async () => {
const current = await readBackupStatus();
await db.settings.put({ key: 'backup-status', value: {
...current, preparedAt: now, exportedAt: payload.exportedAt, checksum,
} });
});
return { payload, text };
}
export async function verifyExportFile(text, now = Date.now()) {
const payload = parseBackup(text);
const checksum = await digest(text);
return db.transaction('rw', db.settings, async () => {
const current = await readBackupStatus();
if (!current.checksum || checksum !== current.checksum) {
throw new Error('Ce fichier ne correspond pas au dernier export préparé sur cet appareil. Exportez les données puis choisissez ce nouveau fichier.');
}
const next = { ...current, verifiedAt: now, verifiedExportedAt: current.exportedAt };
await db.settings.put({ key: 'backup-status', value: next });
return { status: next, days: payload.dayCount, entries: payload.entryCount };
});
}
export function backupReminderDue(status, now = Date.now()) {
const verified = Date.parse(status.verifiedExportedAt ?? '');
return !Number.isFinite(verified) || now - verified >= BACKUP_REMINDER_DAYS * DAY;
}
export function purgeCandidates(days, retentionDays, now = Date.now()) {
const closed = days.filter(day => day.status === 'closed').sort((a, b) => b.date.localeCompare(a.date));
const cutoff = localDateKey(now - retentionDays * DAY);
return retentionDays > 0 ? closed.filter(day => day.date < cutoff && day.date !== closed[0]?.date) : [];
}
/** Delete only the reviewed, unchanged dates, preserving a snapshot under one lock. */
export function purgeReviewedDays(reviewed, retentionDays, now = Date.now()) {
return db.transaction('rw', db.days, db.entries, db.settings, async () => {
const stored = await readSettings();
if (stored?.retentionDays != null && stored.retentionDays !== retentionDays) {
throw new Error('La durée de conservation a changé. Fermez puis ouvrez la confirmation.');
}
const days = await db.days.toArray();
const entries = await db.entries.toArray();
const eligible = new Set(purgeCandidates(days, retentionDays, now).map(day => day.date));
for (const original of reviewed) {
const current = days.find(day => day.date === original.day.date);
const own = entries.filter(entry => entry.date === original.day.date).sort((a, b) => a.seq - b.seq);
if (!eligible.has(original.day.date) || JSON.stringify(current) !== JSON.stringify(original.day) || JSON.stringify(own) !== JSON.stringify(original.entries)) {
throw new Error('Une journée à purger a changé. Fermez puis ouvrez la confirmation.');
}
}
if (!reviewed.length) return 0;
const backup = await exportBackup();
parseBackup(JSON.stringify(backup));
await db.settings.put({ key: 'before-purge', value: backup });
const dates = reviewed.map(item => item.day.date);
await db.entries.where('date').anyOf(dates).delete();
await db.days.bulkDelete(dates);
return dates.length;
});
}

View File

@@ -0,0 +1,71 @@
import 'fake-indexeddb/auto';
import { beforeEach, expect, it, vi } from 'vitest';
import { db, getDay, getEntries, importBackup } from './db.js';
import { backupReminderDue, prepareVerifiedExport, purgeCandidates, purgeReviewedDays, readBackupStatus, verifyExportFile } from './backup-safety.js';
import { parseBackup } from './backup.js';
import { atLocalTime, DAY } from './time.js';
const now = atLocalTime('2026-10-08', '18:00');
const record = date => ({ date, status: 'closed', startedTs: atLocalTime(date,'08:00'), endedTs: atLocalTime(date,'17:00'), plannedStartTs: atLocalTime(date,'08:00'), plannedEndTs: atLocalTime(date,'17:00'), lunchType: 'panier' });
async function install(date) {
const day = record(date); await db.days.add(day);
await db.entries.add({ date, seq:0, kind:'work', task:'Chantier de test', client:null, startTs:day.startedTs,endTs:day.endedTs });
}
async function reviewed(date) { return { day: await getDay(date), entries: await getEntries(date) }; }
beforeEach(async () => { await db.days.clear(); await db.entries.clear(); await db.settings.clear(); });
it('only verifies the complete exported file, without altering the history', async () => {
await install('2026-10-08');
const prepared = await prepareVerifiedExport(now);
const before = await getEntries('2026-10-08');
await verifyExportFile(prepared.text,now);
expect((await readBackupStatus()).verifiedExportedAt).toBe(prepared.payload.exportedAt);
expect(await getEntries('2026-10-08')).toEqual(before);
const corrupted = JSON.parse(prepared.text); corrupted.entries[0].task = 'Changed';
await expect(verifyExportFile(JSON.stringify(corrupted))).rejects.toThrow(/ne correspond pas/);
});
it('reminds after seven days from the verified export, rather than the day it was checked', () => {
expect(backupReminderDue({},now)).toBe(true);
expect(backupReminderDue({verifiedExportedAt:new Date(now-6*DAY).toISOString()},now)).toBe(false);
expect(backupReminderDue({verifiedExportedAt:new Date(now-7*DAY).toISOString(),verifiedAt:now},now)).toBe(true);
});
it('preserves an importable snapshot and purges only the dates reviewed', async () => {
await install('2025-01-01'); await install('2026-10-08');
const old = await reviewed('2025-01-01');
await install('2025-02-01'); // another tab imports an eligible day after review
expect(await purgeReviewedDays([old],30,now)).toBe(1);
expect(await getDay('2025-01-01')).toBeUndefined();
expect(await getDay('2025-02-01')).toBeDefined();
const payload = parseBackup(JSON.stringify((await db.settings.get('before-purge')).value));
await importBackup(payload,'merge');
expect(await getEntries('2025-01-01')).toMatchObject(old.entries.map(({id,...rest})=>rest));
});
it('rejects a changed reviewed day, leaving its slots and previous backup intact', async () => {
await install('2025-01-01'); await install('2026-10-08');
const old = await reviewed('2025-01-01');
await db.entries.update(old.entries[0].id,{task:'Correction dans un autre onglet'});
await expect(purgeReviewedDays([old],30,now)).rejects.toThrow(/a changé/);
expect(await getDay('2025-01-01')).toBeDefined();
expect(await db.settings.get('before-purge')).toBeUndefined();
});
it('rolls back the saved snapshot and deletion on a database failure', async () => {
await install('2025-01-01'); await install('2026-10-08');
const old = await reviewed('2025-01-01');
await db.settings.put({key:'before-purge',value:'existing backup'});
const spy = vi.spyOn(db.days,'bulkDelete').mockRejectedValue(new Error('Storage failure'));
try { await expect(purgeReviewedDays([old],30,now)).rejects.toThrow('Storage failure'); }
finally { spy.mockRestore(); }
expect(await getEntries('2025-01-01')).toEqual(old.entries);
expect((await db.settings.get('before-purge')).value).toBe('existing backup');
});
it('always preserves the last closed day and active days', () => {
const old = record('2025-01-01');
expect(purgeCandidates([old],30,now)).toEqual([]);
expect(purgeCandidates([old,{...record('2026-10-08'),status:'active'}],30,now)).toEqual([]);
});
it('rejects a retention change from another tab before writing a backup', async () => {
await install('2025-01-01'); await install('2026-10-08');
await db.settings.put({key:'app',value:{retentionDays:720}});
await expect(purgeReviewedDays([await reviewed('2025-01-01')],30,now)).rejects.toThrow(/conservation a changé/);
expect(await getDay('2025-01-01')).toBeDefined();
});

View File

@@ -0,0 +1,6 @@
import { readBackupStatus } from './backup-safety.js';
export const backupState = $state({ status: {}, loaded: false });
export async function loadBackupStatus() {
backupState.status = await readBackupStatus();
backupState.loaded = true;
}

View File

@@ -6,7 +6,7 @@ import { buildStats } from './stats.js';
import { buildBackup, parseBackup } from './backup.js';
import { buildModel } from './predict.js';
import { automateDay } from './automation.js';
import { insertRecordedEntry, removeRecordedEntry, saveRecordedEntry } from './editing.js';
import { insertGapEntry, insertRecordedEntry, removeRecordedEntry, saveRecordedEntry } from './editing.js';
import { DEFAULT_SETTINGS } from './settings.svelte.js';
import { atLocalTime, HOUR, MINUTE } from './time.js';
const date = '2026-10-08';
@@ -113,3 +113,23 @@ describe('preserving imported gaps while editing', () => {
expect(rows[0].endTs).toBe(at('11:00')); expect(rows[1].startTs).toBe(at('13:00'));
});
});
it('refuses to resume a closure that was corrected after confirmation opened', async () => {
await install([slot(0,'work','08:00','17:00')]);
const original = (await getEntries(date))[0];
await saveRecordedEntry(original,{...original,endTs:at('16:30')},at('18:00'));
await expect(reopenDay(date,at('18:00'),'break',{date,endedTs:at('17:00'),last:original})).rejects.toThrow(/a changé/);
expect((await getDay(date)).status).toBe('closed');
expect(await getEntries(date)).toHaveLength(1);
});
it('records a pause within a gap without changing either work neighbour', async () => {
await install([slot(0,'work','08:00','11:00'),slot(1,'work','13:00','17:00')]);
const [previous, following] = await getEntries(date);
await insertGapEntry(previous, following, slot(0,'break','11:30','12:30'), at('18:00'));
const rows = await getEntries(date);
expect(rows.map(row => row.kind)).toEqual(['work','break','work']);
expect(rows[0].endTs).toBe(at('11:00')); expect(rows[2].startTs).toBe(at('13:00'));
expect(buildStats([day],rows).quality.uncoveredMs).toBe(HOUR);
await expect(insertGapEntry(previous,following,slot(0,'break','11:30','12:30'),at('18:00'))).rejects.toThrow(/a changé/);
});

View File

@@ -21,7 +21,8 @@ import {
} from './db.js';
import { buildModel, suggestClients, suggestTasks, TRANSPORT_TASK } from './predict.js';
import { automateDay, manualAdvance, previousContext } from './automation.js';
import { loadSettings, settings } from './settings.svelte.js';
import { loadBackupStatus } from './backup-state.svelte.js';
import { applyTheme, loadSettings, settings } from './settings.svelte.js';
import { atLocalTime, HOUR, localDateKey, roundTs, weekday } from './time.js';
export const dayState = $state({
@@ -36,6 +37,7 @@ export const dayState = $state({
historyEntries: [],
todayRecord: null,
previousWork: null,
lastKnownWork: null,
/** Prediction model, rebuilt whenever history changes. */
model: null,
/** True once the configured end time has passed and the worker has not answered. */
@@ -70,7 +72,7 @@ export function reconcile(now = Date.now()) {
async function reconcileDay(now) {
// A settings change in another tab must also stop this tab's clock actions.
const persisted = await readSettings();
if (persisted) Object.assign(settings, persisted);
if (persisted) { Object.assign(settings, persisted); applyTheme(); }
dayState.today = localDateKey(now);
const active = await getActiveDay();
@@ -139,7 +141,9 @@ export async function refresh(now = Date.now()) {
const entriesFor = (day) => day ? entries.filter((e) => e.date === day.date).sort((a, b) => a.seq - b.seq) : [];
dayState.today = localDateKey(now);
dayState.todayRecord = days.find((d) => d.date === dayState.today) ?? null;
dayState.previousWork = previousContext(days, entries, active?.date ?? dayState.today).previousWork;
const previous = previousContext(days, entries, active?.date ?? dayState.today);
dayState.previousWork = previous.previousWork;
dayState.lastKnownWork = previous.lastKnownWork;
dayState.day = active ?? null;
dayState.entries = entriesFor(active);
dayState.lastClosed = lastClosed ?? null;
@@ -229,7 +233,7 @@ export async function applyImport(payload, mode) {
// The guard above prevents subsequent ticks/focus events during the import.
await Promise.allSettled([...pendingReconciliations]);
const result = await importBackup(payload, mode);
if (mode === 'replace') await loadSettings();
if (mode === 'replace') { await loadSettings(); await loadBackupStatus(); }
// Do not immediately purge old imported days or create a new working day.
// Retention is explicitly confirmed in settings.
dayState.today = localDateKey();
@@ -259,10 +263,11 @@ export async function keepWorking(now = Date.now()) {
}
/** Undo a premature end-of-day, continuing the last task without a gap. */
export async function reopenToday(now = Date.now(), mode = 'continue') {
export async function reopenToday(now = Date.now(), mode = 'continue', expected = null) {
if (dayState.importing) return;
await reopenDay(localDateKey(now), now, mode);
const reopened = await reopenDay(expected?.date ?? localDateKey(now), now, mode, expected);
await refresh(now);
return reopened;
}
/* ---------------------------------------------------------------- predictions */
@@ -277,7 +282,7 @@ export function predictNext(now = Date.now()) {
if (!model) return { task: TRANSPORT_TASK, client: null, tasks: [], clients: [] };
const current = running();
const lastWork = [...dayState.entries].reverse().find((e) => e.kind === 'work') ?? dayState.previousWork;
const lastWork = [...dayState.entries].reverse().find((e) => e.kind === 'work') ?? dayState.lastKnownWork;
const tasks = suggestTasks(model, {
prevTask: current?.task ?? null,
prevElapsedMs: current ? now - current.startTs : 0,

View File

@@ -1,8 +1,8 @@
import 'fake-indexeddb/auto';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanupOldDays, closeAndStart, closeDay, createDay, db, getDay, getEntries, reopenDay } from './db.js';
import { advance, dayState, endDay, keepWorking, reconcile, refresh, reopenToday, setLunch, startDayNow, startLunch } from './day.svelte.js';
import { DEFAULT_SETTINGS, settings, settingsError, updateSettings } from './settings.svelte.js';
import { advance, dayState, endDay, keepWorking, predictNext, reconcile, refresh, reopenToday, setLunch, startDayNow, startLunch } from './day.svelte.js';
import { DEFAULT_SETTINGS, settings, settingsError, setWorkingDay, updateSettings } from './settings.svelte.js';
import { atLocalTime, HOUR } from './time.js';
import { buildResume } from './resume.js';
@@ -244,3 +244,22 @@ describe('settings persistence across tabs', () => {
expect(settingsError.message).toContain('write failed');
});
});
it('preserves weekdays changed in another tab when toggling one weekday', async () => {
settings.workdays = [1];
await db.settings.put({key:'app',value:{...DEFAULT_SETTINGS,workdays:[1,3]}});
await setWorkingDay(2,true);
expect((await db.settings.get('app')).value.workdays).toEqual([1,2,3]);
});
it('resumes a pause using the last known work even when the previous day only has transport', async () => {
const oldDate = '2026-09-04', recentDate = '2026-09-05';
await db.days.bulkAdd([oldDate,recentDate].map(date => ({...makeDay(date),status:'closed',endedTs:at('17:00',date)})));
await db.entries.bulkAdd([
{date:oldDate,seq:0,kind:'work',task:'Pose ancienne',client:'Client ancien',startTs:at('08:00',oldDate),endTs:at('17:00',oldDate)},
{date:recentDate,seq:0,...first,startTs:at('08:00',recentDate),endTs:at('17:00',recentDate)},
]);
await startDayNow(at('07:30'));
await advance({kind:'break',task:'Pause'},at('08:00'));
expect(predictNext(at('08:00'))).toMatchObject({task:'Pose ancienne',client:'Client ancien'});
});

View File

@@ -121,7 +121,7 @@ export function closeDay(date, ts, stale = false) {
}
/** Confirmed same-day reopening; status and all resumed slots commit together. */
export function reopenDay(date, now, mode = 'continue') {
export function reopenDay(date, now, mode = 'continue', expected = null) {
return db.transaction('rw', db.days, db.entries, async () => {
const day = await getDay(date);
if (!['continue', 'break'].includes(mode)) throw new Error('Choix de reprise invalide.');
@@ -129,6 +129,10 @@ export function reopenDay(date, now, mode = 'continue') {
const entries = await getEntries(date);
const last = entries.at(-1);
if (!last) return false;
if (expected && (day.endedTs !== expected.endedTs ||
['id', 'kind', 'task', 'client', 'startTs', 'endTs'].some(key => last[key] !== expected.last[key]))) {
throw new Error('La clôture ou le dernier poste a changé. Fermez puis ouvrez la confirmation de reprise.');
}
const boundary = day.endedTs;
if (!Number.isFinite(boundary) || now < boundary) return false;
if (mode === 'continue' && ['work', 'travel'].includes(last.kind)) {

View File

@@ -1,13 +1,28 @@
/** Focus ownership, keyboard trap and restoration for modal panels. */
/** Modal focus ownership follows visual stacking, including nested pickers. */
const hidden = new WeakMap();
function topDialog() {
return [...document.querySelectorAll('[role="dialog"]')]
.filter(node => node.isConnected)
.sort((a, b) => (Number(getComputedStyle(a).zIndex) || 0) - (Number(getComputedStyle(b).zIndex) || 0)).at(-1);
}
export function modalFocus(node) {
const origin = document.activeElement;
const controls = () => [...node.querySelectorAll('button, input, select, textarea, a[href], [tabindex="0"]')]
.filter(el => !el.disabled && el.getClientRects().length);
queueMicrotask(() => (controls()[0] ?? node).focus());
.filter(el => !el.matches(':disabled') && !el.closest('[inert]') && el.getClientRects().length);
const blocked = [];
// Hide every branch outside this dialog from keyboard and assistive technology.
for (let branch = node; branch.parentElement; branch = branch.parentElement) {
for (const sibling of branch.parentElement.children) {
if (sibling === branch || !(sibling instanceof HTMLElement)) continue;
const record = hidden.get(sibling) ?? { count: 0, inert: sibling.inert };
record.count++; hidden.set(sibling, record); sibling.inert = true; blocked.push(sibling);
}
}
const focusFirst = () => (controls()[0] ?? node).focus();
queueMicrotask(() => { if (node.isConnected && topDialog() === node) focusFirst(); });
function trap(event) {
if (event.key !== 'Tab') return;
const dialogs = [...document.querySelectorAll('[role="dialog"]')];
if (dialogs.at(-1) !== node) return;
if (event.key !== 'Tab' || topDialog() !== node) return;
const list = controls(), first = list[0], last = list.at(-1);
if (!first) { event.preventDefault(); node.focus(); return; }
if (event.shiftKey && (document.activeElement === first || !node.contains(document.activeElement))) {
@@ -16,6 +31,18 @@ export function modalFocus(node) {
event.preventDefault(); first.focus();
}
}
function contain(event) {
if (topDialog() === node && !node.contains(event.target)) focusFirst();
}
document.addEventListener('keydown', trap);
return { destroy() { document.removeEventListener('keydown', trap); if (origin instanceof HTMLElement && origin.isConnected) origin.focus(); } };
document.addEventListener('focusin', contain);
return { destroy() {
document.removeEventListener('keydown', trap);
document.removeEventListener('focusin', contain);
for (const element of blocked) {
const record = hidden.get(element);
if (--record.count === 0) { element.inert = record.inert; hidden.delete(element); }
}
if (origin instanceof HTMLElement && origin.isConnected && !origin.closest('[inert]')) origin.focus();
} };
}

View File

@@ -103,3 +103,23 @@ export function removeRecordedEntry(original) {
}
});
}
/** Record an explicit slot inside an imported empty interval without filling its remnants. */
export function insertGapEntry(previousOriginal, followingOriginal, values, now = Date.now()) {
return db.transaction('rw', db.days, db.entries, async () => {
const { entry: previous, entries } = await readOriginal(previousOriginal);
const { entry: following } = await readOriginal(followingOriginal);
const index = entries.findIndex(entry => entry.id === previous.id);
if (following.date !== previous.date || entries[index + 1]?.id !== following.id || previous.endTs == null) throw conflict();
validateTimes(values.startTs, values.endTs, now);
if (values.startTs < previous.endTs || values.endTs > following.startTs) {
throw new Error('Le poste ajouté doit rester dans l’intervalle sans poste.');
}
for (const later of entries.slice(index + 1)) await db.entries.update(later.id, { seq: later.seq + 1 });
const id = await db.entries.add({
date: previous.date, seq: previous.seq + 1, ...content(values), startTs: values.startTs, endTs: values.endTs,
});
await saveLunch(previous.date, values);
return id;
});
}

View File

@@ -9,18 +9,23 @@
const ROUTES = ['resume', 'today', 'settings', 'stats', 'details', 'automations', 'non-working'];
function fromHash() {
const h = location.hash.replace(/^#\/?/, '');
return ROUTES.includes(h) ? h : null;
const [path, query = ''] = location.hash.replace(/^#\/?/, '').split('?');
const date = new URLSearchParams(query).get('date');
return { route: ROUTES.includes(path) ? path : null,
selectedDate: date && /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : '' };
}
export const router = $state({ route: fromHash(), selectedDate: '' });
export const router = $state(fromHash());
window.addEventListener('hashchange', () => {
router.route = fromHash();
const next = fromHash();
router.route = next.route;
if (next.selectedDate) router.selectedDate = next.selectedDate;
});
export function go(route) {
location.hash = route ? `#/${route}` : '';
const date = ['resume', 'details'].includes(route) && router.selectedDate ? `?date=${router.selectedDate}` : '';
location.hash = route ? `#/${route}${date}` : '';
}
export function back() {

View File

@@ -101,3 +101,23 @@ export async function changeNonWorkingDate(original, replacement) {
});
settings.nonWorkingDates = nextDates;
}
/** Update one weekday against the persisted list, preserving other tabs' choices. */
export async function setWorkingDay(value, enabled) {
try {
parseSettings({ workdays: [value] });
const persisted = await db.transaction('rw', db.settings, async () => {
const { loaded, ...base } = $state.snapshot(settings);
const stored = { ...base, ...await readSettings() };
const days = new Set(stored.workdays);
if (enabled) days.add(value); else days.delete(value);
const next = { ...stored, workdays: [...days].sort() };
await writeSettings(next);
return next;
});
Object.assign(settings, persisted); applyTheme(); settingsError.message = null;
} catch (error) {
settingsError.message = `Enregistrement impossible : ${error.message}`;
try { await loadSettings(); } catch { /* Keep the last displayed state. */ }
}
}

View File

@@ -7,7 +7,7 @@
import { fmtDayLabel, fmtDuration, weekday } from '../lib/time.js';
let copied = $state(false);
let selectedDate = $state(router.selectedDate);
const selectedDate = $derived(router.selectedDate);
let reopening = $state(false);
let error = $state('');
const selectedDay = $derived(dayState.closedDays.find((d) => d.date === selectedDate) ?? dayState.lastClosed);
@@ -47,7 +47,7 @@
{#if dayState.closedDays.length > 1}
<label class="stack">
<span>Journée à consulter</span>
<select value={selectedDay?.date} onchange={(event) => { selectedDate = event.currentTarget.value; router.selectedDate = selectedDate; copied = false; }}>
<select value={selectedDay?.date} onchange={(event) => { router.selectedDate = event.currentTarget.value; copied = false; }}>
{#each dayState.closedDays as day (day.date)}
<option value={day.date}>{fmtDayLabel(day.date)} {day.date.slice(0, 4)}</option>
{/each}

View File

@@ -2,14 +2,14 @@
import BackupPanel from '../components/BackupPanel.svelte';
import { go } from '../lib/router.svelte.js';
import Sheet from '../components/Sheet.svelte';
import { DAY, localDateKey } from '../lib/time.js';
import { cleanupOldDays, exportBackup, db } from '../lib/db.js';
import { purgeCandidates, purgeReviewedDays } from '../lib/backup-safety.js';
import { dayState, refresh } from '../lib/day.svelte.js';
import {
ROUNDING_CHOICES,
settings,
settingsError,
updateSettings,
setWorkingDay,
WEEKDAY_LABELS,
} from '../lib/settings.svelte.js';
@@ -17,22 +17,31 @@
let purgeOpen = $state(false);
let purgeError = $state('');
let purgeBusy = $state(false);
const purgeDates = $derived(dayState.closedDays.filter(d => settings.retentionDays > 0 && d.date < localDateKey(Date.now() - settings.retentionDays * DAY) && d.date !== dayState.lastClosed?.date).map(d => d.date));
let reviewedPurge = $state([]);
let reviewedRetention = $state(0);
function reviewPurge() {
reviewedRetention = settings.retentionDays;
reviewedPurge = purgeCandidates(dayState.closedDays, reviewedRetention).map(day => ({
day: { ...day }, entries: dayState.historyEntries.filter(entry => entry.date === day.date).sort((a, b) => a.seq - b.seq).map(entry => ({ ...entry })),
}));
purgeError = ''; purgeOpen = true;
}
function toggleWorkday(value) {
const next = settings.workdays.includes(value)
? settings.workdays.filter((d) => d !== value)
: [...settings.workdays, value];
updateSettings({ workdays: next.sort() });
return setWorkingDay(value, !settings.workdays.includes(value));
}
async function saveField(event, key, numeric = false) {
const input = event.currentTarget;
await updateSettings({ [key]: numeric ? Number(input.value) : input.value });
input.value = String(settings[key]);
}
async function purge() {
if (purgeBusy || dayState.importing) return;
purgeBusy = true; purgeError = '';
try {
const backup = await exportBackup();
await db.settings.put({ key: 'before-purge', value: backup });
purged = await cleanupOldDays(settings.retentionDays);
purged = await purgeReviewedDays($state.snapshot(reviewedPurge), reviewedRetention);
await refresh(); purgeOpen = false;
} catch (error) { purgeError = error.message; }
finally { purgeBusy = false; }
@@ -77,7 +86,7 @@
<input
type="time"
value={settings.dayStart}
onchange={(e) => updateSettings({ dayStart: e.currentTarget.value })}
onchange={(e) => saveField(e, 'dayStart')}
/>
</label>
<label>
@@ -85,7 +94,7 @@
<input
type="time"
value={settings.dayEnd}
onchange={(e) => updateSettings({ dayEnd: e.currentTarget.value })}
onchange={(e) => saveField(e, 'dayEnd')}
/>
</label>
<p class="dim small">
@@ -100,7 +109,7 @@
<input
type="time"
value={settings.lunchStart}
onchange={(e) => updateSettings({ lunchStart: e.currentTarget.value })}
onchange={(e) => saveField(e, 'lunchStart')}
/>
</label>
<label>
@@ -110,7 +119,7 @@
min="0"
step="5"
value={settings.lunchMinutes}
onchange={(e) => updateSettings({ lunchMinutes: Number(e.currentTarget.value) })}
onchange={(e) => saveField(e, 'lunchMinutes', true)}
/>
</label>
<div class="btn-row">
@@ -177,7 +186,7 @@
min="30"
step="30"
value={settings.retentionDays}
onchange={(e) => updateSettings({ retentionDays: Number(e.currentTarget.value) })}
onchange={(e) => saveField(e, 'retentionDays', true)}
/>
</label>
<p class="dim small">
@@ -187,7 +196,7 @@
<p class="dim small">
La purge nécessite une confirmation et conserve une sauvegarde locale récupérable.
</p>
<button type="button" onclick={() => (purgeOpen = true)}>Purger maintenant</button>
<button type="button" onclick={reviewPurge}>Purger maintenant</button>
{#if purged !== null}
<p class="dim small">
{purged === 0
@@ -199,10 +208,10 @@
</fieldset>
<Sheet open={purgeOpen} title="Confirmer la purge" onclose={() => { if (!purgeBusy) purgeOpen = false; }}>
<p>Journées concernées : {purgeDates.length ? purgeDates.join(', ') : 'aucune'}.</p>
<p>Journées concernées : {reviewedPurge.length ? reviewedPurge.map(item => item.day.date).join(', ') : 'aucune'}.</p>
<p>Une sauvegarde récupérable sera conservée sur cet appareil avant la suppression.</p>
{#if purgeError}<p role="alert">{purgeError}</p>{/if}
<button disabled={purgeBusy || dayState.importing || !purgeDates.length} class="btn-danger" onclick={purge}>Confirmer la purge</button>
<button disabled={purgeBusy || dayState.importing || !reviewedPurge.length} class="btn-danger" onclick={purge}>Confirmer la purge</button>
<button disabled={purgeBusy} onclick={() => (purgeOpen = false)}>Annuler</button>
</Sheet>
<style>

View File

@@ -49,7 +49,7 @@
</div>
{/if}
<label class="check"><input type="checkbox" bind:checked={includeActive} />Inclure la journée en cours (provisoire)</label>
<p class="small dim">Travail hors repas, transport compris. Arrondi : {settings.roundingMinutes} min, comme dans le récapitulatif.</p>
<p class="small dim">Transport compris ; repas et pauses exclus. Arrondi : {settings.roundingMinutes} min, comme dans le récapitulatif.</p>
<p class="small dim">Seules les données encore conservées sont comptées. Une journée absente n’est pas considérée comme un jour non travaillé.</p>
{#if invalid}<p role="alert">La date de début doit précéder ou égaler la date de fin.</p>{/if}
</section>
@@ -79,7 +79,7 @@
<dl>
<div><dt>Moyenne travaillée / jour</dt><dd>{fmtDuration(stats.averageWorkMs)}</dd></div>
<div><dt>Médiane travaillée / jour</dt><dd>{fmtDuration(stats.medianWorkMs)}</dd></div>
<div><dt>Amplitude moyenne, repas compris</dt><dd>{fmtDuration(stats.averageSpanMs)}</dd></div>
<div><dt>Amplitude moyenne, repas et pauses compris</dt><dd>{fmtDuration(stats.averageSpanMs)}</dd></div>
<div><dt>Repas moyen, jours avec pause</dt><dd>{fmtDuration(stats.averageLunchMs)}</dd></div>
</dl>
<p class="small">Journée la plus longue en travail : <strong>{dateLabel(stats.longest.date)}, {fmtDuration(stats.longest.workedMs)}</strong>. La plus courte : {dateLabel(stats.shortest.date)}, {fmtDuration(stats.shortest.workedMs)}.</p>

View File

@@ -16,7 +16,6 @@
keepWorking,
predictForCurrent,
predictNext,
reopenToday,
running,
setLunch,
startDayNow,
@@ -34,7 +33,7 @@
let actionError = $state('');
async function act(action) {
if (busy) return;
if (busy || dayState.importing) return;
busy = true;
actionError = '';
try { await action(); }
@@ -115,7 +114,7 @@
Aujourd'hui n'est pas un jour travaillé, ou l'heure de début n'est pas atteinte.
{/if}
</p>
<button type="button" class="btn-primary" disabled={busy} onclick={() => closedToday ? (reopening = true) : act(startDayNow)}>
<button type="button" class="btn-primary" disabled={busy || dayState.importing} onclick={() => closedToday ? (reopening = true) : act(startDayNow)}>
{closedToday ? 'Reprendre la journée' : blockedToday ? 'Démarrer quand même' : 'Démarrer la journée'}
</button>
</div>
@@ -146,12 +145,12 @@
<button
type="button"
class:sel={dayState.day.lunchType !== 'restaurant'}
disabled={busy} onclick={() => act(() => chooseLunchType('panier'))}>Panier</button
disabled={busy || dayState.importing} onclick={() => act(() => chooseLunchType('panier'))}>Panier</button
>
<button
type="button"
class:sel={dayState.day.lunchType === 'restaurant'}
disabled={busy} onclick={() => act(() => chooseLunchType('restaurant'))}>Restaurant</button
disabled={busy || dayState.importing} onclick={() => act(() => chooseLunchType('restaurant'))}>Restaurant</button
>
</div>
{#if dayState.day.lunchType === 'restaurant'}
@@ -187,7 +186,7 @@
{/if}
<!-- Primary actions --------------------------------------------------- -->
<button type="button" class="btn-primary" disabled={busy} onclick={() => act(goNext)}>{actionLabel}</button>
<button type="button" class="btn-primary" disabled={busy || dayState.importing} onclick={() => act(goNext)}>{actionLabel}</button>
{#if lunchHint}
<p class="dim" role="status">
@@ -201,11 +200,11 @@
{/if}
{#if current?.kind !== 'lunch'}
<button type="button" disabled={busy} onclick={() => act(startLunch)}>Pause déjeuner</button>
<button type="button" disabled={busy || dayState.importing} onclick={() => act(startLunch)}>Pause déjeuner</button>
{/if}
{#if current?.kind !== 'break'}
<button type="button" disabled={busy} onclick={() => act(() => advance({ kind: 'break', task: 'Pause', client: null }))}>Commencer une pause</button>
<button type="button" disabled={busy || dayState.importing} onclick={() => act(() => advance({ kind: 'break', task: 'Pause', client: null }))}>Commencer une pause</button>
{/if}
<!-- Day so far -------------------------------------------------------- -->
<DayEditor day={dayState.day} entries={dayState.entries} {now} />
@@ -214,8 +213,8 @@
<div class="end"><button type="button" onclick={() => (confirmingEnd = true)}>Terminer la journée</button></div>
<Sheet open={confirmingEnd} title="Terminer la journée" onclose={() => { if (!busy) confirmingEnd = false; }}>
<p>Clôturer le poste en cours et terminer la journée maintenant ?</p>
<button disabled={busy} onclick={() => (confirmingEnd = false)}>Annuler</button>
<button class="btn-danger" disabled={busy} onclick={() => act(finishDay)}>Confirmer</button>
<button disabled={busy || dayState.importing} onclick={() => (confirmingEnd = false)}>Annuler</button>
<button class="btn-danger" disabled={busy || dayState.importing} onclick={() => act(finishDay)}>Confirmer</button>
</Sheet>
</div>
@@ -227,10 +226,10 @@
<p>
L'heure de fin ({fmtTime(dayState.day.plannedEndTs)}) est passée. Terminer la journée maintenant ?
</p>
<button type="button" class="btn-primary" disabled={busy} onclick={() => act(finishDay)}>
<button type="button" class="btn-primary" disabled={busy || dayState.importing} onclick={() => act(finishDay)}>
Terminer la journée
</button>
<button type="button" disabled={busy} onclick={() => act(keepWorking)}>Toujours en cours</button>
<button type="button" disabled={busy || dayState.importing} onclick={() => act(keepWorking)}>Toujours en cours</button>
</div>
</div>
{/if}