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

@@ -5,7 +5,14 @@
#
# The domain must already resolve to this server, and ports 80 and 443 must be
# reachable from the internet — port 80 is what the ACME challenge uses.
SITE_ADDRESS=timelog.example.com
SITE_ADDRESS=pardis.example.com
# Notified by Let's Encrypt before a certificate expires. Optional but advised.
ACME_EMAIL=you@example.com
# Adresse locale d’écoute ; laisser 0.0.0.0 pour toutes les interfaces.
LISTEN_IP=0.0.0.0
# Adresse/CIDR du proxy qui transmet le protocole PROXY à Caddy.
# Remplacer cette adresse d’exemple par celle du proxy réellement utilisé.
TRUST_PROXY=192.0.2.10/32

View File

@@ -23,9 +23,9 @@ jobs:
run: |
set -eu
npm ci
npm test
npm run check
npm run build
npm run validate
npx playwright install --with-deps chromium
npm run test:e2e
deploy:
needs: validate

4
.gitignore vendored
View File

@@ -25,3 +25,7 @@ dist-ssr
*.njsproj
*.sln
*.sw?
# Browser test artifacts
playwright-report/
test-results/

View File

@@ -16,6 +16,9 @@ npm test # unit and IndexedDB regression tests
npm run check # svelte-check
npm run build # production build into dist/
npm run validate # tests, Svelte check and full PWA build
npx playwright install chromium # install the browser for UI tests
npm run test:e2e # fresh profiles, synthetic fixtures and offline PWA test
npm run validate:all # include browser tests in full validation
```
## Exporter et importer les données
@@ -229,7 +232,7 @@ the static output, obtaining and renewing a Let's Encrypt certificate on its own
```sh
cp .env.example .env
$EDITOR .env # set SITE_ADDRESS and ACME_EMAIL
$EDITOR .env # set SITE_ADDRESS, ACME_EMAIL and TRUST_PROXY
docker compose up -d --build
```
@@ -239,7 +242,7 @@ Requirements:
- Ports **80 and 443** must be reachable from the internet. Port 80 is not optional —
it is what the ACME challenge uses to prove domain ownership.
Certificates are stored in the `caddy_data` volume. Keep it: without it Caddy re-issues
Certificates are stored in the bind-mounted `./caddy/data` directory. Keep it: without it Caddy re-issues
on every recreate and will hit the Let's Encrypt rate limit after a handful of restarts.
To update, pull and rebuild:
@@ -261,9 +264,12 @@ the only exception, which is why `npm run dev` works without certificates.
### Behind an existing reverse proxy
If something upstream already terminates TLS, set `SITE_ADDRESS=:80`, drop the `443`
port mappings, and let the proxy forward to port 80. `ACME_EMAIL` is then unused but
must still be non-empty — Caddy will not parse a blank value.
The current Caddy configuration terminates TLS itself and accepts PROXY protocol
from the address or CIDR in `TRUST_PROXY`. Set this value to the real upstream
proxy address; `192.0.2.10/32` in `.env.example` is a documentation placeholder.
`LISTEN_IP` selects the local interface for the published ports and defaults to
`0.0.0.0`. A deployment that terminates TLS elsewhere needs its own Caddy and
port configuration; changing `SITE_ADDRESS` alone does not configure that mode.
The app must be served from the root of its domain. `start_url` and the service worker
scope are both `/`, so hosting it under a subpath will not work without changing the
@@ -315,3 +321,23 @@ actualisation sont affichées avec une action Réessayer.
La checklist avant production et les validations restant à exécuter sont suivies
dans [PRODUCTION.md](PRODUCTION.md).
## Sauvegarde vérifiée et validation navigateur
**Exporter les données** prépare le fichier et enregistre son empreinte complète.
**Vérifier le fichier exporté…** relit le fichier téléchargé, contrôle qu’il est
importable et compare son contenu à l’export. La date de la dernière sauvegarde
vérifiée est distincte de celle du dernier export préparé. Après sept jours sans
export vérifié, Pardis affiche un rappel pendant son utilisation. Aucune donnée
n’est envoyée à un serveur ; conservez le fichier hors de cet appareil.
La purge affiche une liste figée de journées. Sauvegarde et suppression partagent
une transaction ; une correction concurrente bloque la purge et demande une
nouvelle consultation. Les autres journées récemment importées restent intactes.
Les tests Playwright utilisent des contextes neufs et des données synthétiques,
à 320 px. Ils vérifient consultation/correction, focus, Retour, reprise à deux
onglets, export vérifié, purge/restauration et rechargement hors ligne. Traces,
vidéos et captures sont désactivées. Pour un Chrome déjà installé, définir
`PARDIS_CHROME_PATH` avec son chemin ; sinon installer Chromium avec Playwright.
La CI installe Chromium et ses dépendances avant de lancer ces tests.

View File

@@ -12,9 +12,9 @@ services:
TRUST_PROXY: ${TRUST_PROXY:?set TRUST_PROXY in .env}
ports:
- ${LISTEN_IP:?0.0.0.0}:80:80
- ${LISTEN_IP:?0.0.0.0}:443:443
- ${LISTEN_IP:?0.0.0.0}:443:443/udp
- "${LISTEN_IP:-0.0.0.0}:80:80"
- "${LISTEN_IP:-0.0.0.0}:443:443"
- "${LISTEN_IP:-0.0.0.0}:443:443/udp"
volumes:
- ./caddy/data:/data

54
package-lock.json generated
View File

@@ -1,16 +1,17 @@
{
"name": "app",
"version": "0.0.0",
"name": "pardis",
"version": "0.8.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "app",
"version": "0.0.0",
"name": "pardis",
"version": "0.8.0",
"dependencies": {
"dexie": "^4.4.4"
},
"devDependencies": {
"@playwright/test": "^1.64.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@vite-pwa/assets-generator": "^1.0.2",
"fake-indexeddb": "^6.2.5",
@@ -2126,6 +2127,22 @@
"url": "https://github.com/sponsors/oxc-project"
}
},
"node_modules/@playwright/test": {
"version": "1.64.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.0.tgz",
"integrity": "sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.64.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@quansync/fs": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@quansync/fs/-/fs-1.0.0.tgz",
@@ -5583,6 +5600,35 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.64.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz",
"integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.64.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.64.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz",
"integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",

View File

@@ -10,9 +10,12 @@
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"check": "svelte-check --tsconfig ./jsconfig.json"
"check": "svelte-check --tsconfig ./jsconfig.json",
"test:e2e": "playwright test",
"validate:all": "npm run validate && npm run test:e2e"
},
"devDependencies": {
"@playwright/test": "^1.64.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@vite-pwa/assets-generator": "^1.0.2",
"fake-indexeddb": "^6.2.5",

22
playwright.config.js Normal file
View File

@@ -0,0 +1,22 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: false,
workers: 1,
timeout: 30_000,
reporter: 'list',
use: {
baseURL: 'http://127.0.0.1:4183',
timezoneId: 'Europe/Paris',
viewport: { width: 320, height: 640 },
// These tests use fresh contexts and synthetic fixtures; no user profile.
trace: 'off', screenshot: 'off', video: 'off',
launchOptions: process.env.PARDIS_CHROME_PATH ? { executablePath: process.env.PARDIS_CHROME_PATH } : {},
},
webServer: {
command: 'npm run preview -- --host 127.0.0.1 --port 4183 --strictPort',
url: 'http://127.0.0.1:4183',
reuseExistingServer: false,
},
});

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();
}
}
document.addEventListener('keydown', trap);
return { destroy() { document.removeEventListener('keydown', trap); if (origin instanceof HTMLElement && origin.isConnected) origin.focus(); } };
function contain(event) {
if (topDialog() === node && !node.contains(event.target)) focusFirst();
}
document.addEventListener('keydown', trap);
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}

249
tests/e2e/parcours.spec.js Normal file
View File

@@ -0,0 +1,249 @@
import { test, expect } from '@playwright/test';
const date = '2026-10-08';
const at = (time, key = date) => Date.parse(`${key}T${time}:00+02:00`);
const now = at('18:00');
function fixture({ pause = true, dates = [date], lastKind = 'work' } = {}) {
const days = dates.map(key => ({ date:key, status:'closed', startedTs:at('08:00',key), endedTs:at('17:00',key), plannedStartTs:at('08:00',key), plannedEndTs:at('17:00',key), lunchType:'panier' }));
const entry = (key, seq, kind, start, end) => ({ date:key, seq, kind, task:kind === 'break' ? 'Pause' : kind === 'lunch' ? 'Pause déjeuner' : 'Tâche de test', client:kind === 'work' ? 'Client de test' : null, startTs:at(start,key),endTs:at(end,key) });
const entries = dates.flatMap(key => pause ? [entry(key,0,'work','08:00','12:00'),entry(key,1,'break','12:00','12:30'),entry(key,2,'work','12:30','17:00')] : lastKind === 'work' ? [entry(key,0,'work','08:00','17:00')] : [entry(key,0,'work','08:00','16:00'),entry(key,1,lastKind,'16:00','17:00')]);
return { format:'timelog-export',version:2,exportedAt:new Date(now).toISOString(),settings:{autoStartDay:false,autoEndDay:false,autoLunch:false,retentionDays:30,roundingMinutes:15},days,entries };
}
async function importFixture(page, payload = fixture()) {
await page.clock.install({ time:now });
await page.goto('/#/settings');
await expect(page.getByRole('heading',{name:'Réglages',exact:true})).toBeVisible();
await page.locator('input[type=file]').setInputFiles({ name:'fixture-synthetique.json',mimeType:'application/json',buffer:Buffer.from(JSON.stringify(payload)) });
await page.getByRole('button',{name:'Remplacer tout',exact:true}).click();
await page.getByRole('dialog',{name:'Remplacer les données'}).getByRole('button',{name:'Confirmer',exact:true}).click();
await expect(page.getByRole('status').filter({hasText:'Données remplacées'})).toBeVisible();
await page.getByRole('button',{name:'Récap',exact:true}).click();
}
async function entries(page) {
return page.evaluate(() => new Promise((resolve,reject) => {
const request = indexedDB.open('TimeLogDB');
request.onerror = () => reject(new Error('Cannot open test database'));
request.onsuccess = () => {
const db = request.result;
const query = db.transaction('entries').objectStore('entries').getAll();
query.onsuccess = () => { resolve(query.result.sort((a,b)=>a.date.localeCompare(b.date)||a.seq-b.seq)); db.close(); };
query.onerror = () => { reject(new Error('Cannot read test entries')); db.close(); };
};
}));
}
test('consultation, correction, recalcul, copie et statistiques à 320 px', async ({page,context}) => {
await context.grantPermissions(['clipboard-read','clipboard-write']);
await importFixture(page);
await expect(page.locator('.total')).toContainText('8h30');
await expect(page.locator('.screen')).not.toContainText('0h30');
await page.getByRole('button',{name:'Copier le récapitulatif'}).click();
const copied = await page.evaluate(() => navigator.clipboard.readText());
expect(copied).toContain('8h30'); expect(copied).not.toContain('Pause');
await page.getByRole('button',{name:'Voir les détails'}).click();
await expect(page.locator('.timeline button')).toHaveCount(3);
await expect(page.locator('.timeline button').nth(1)).toBeDisabled();
await expect(page.getByRole('dialog')).toHaveCount(0);
await page.getByRole('button',{name:'Corriger cette journée'}).click();
await page.locator('.timeline button').nth(1).click();
const dialog = page.getByRole('dialog',{name:'Modifier le poste'});
await expect(dialog.getByRole('button',{name:'Fermer',exact:true})).toBeFocused();
await page.keyboard.press('Shift+Tab');
await expect(dialog.getByRole('button',{name:'Supprimer ce poste'})).toBeFocused();
await page.keyboard.press('Tab');
await expect(dialog.getByRole('button',{name:'Fermer',exact:true})).toBeFocused();
await page.keyboard.press('Escape');
await expect(page.locator('.timeline button').nth(1)).toBeFocused();
await page.locator('.timeline button').nth(1).click();
await dialog.getByRole('button',{name:'Supprimer ce poste'}).click();
await dialog.getByRole('button',{name:'Confirmer la suppression'}).click();
await expect(page.locator('.timeline button')).toHaveCount(2);
await page.getByRole('button',{name:'Retour au récapitulatif'}).click();
await expect(page.locator('.total')).toContainText('9h00');
await page.getByRole('button',{name:'Réglages',exact:true}).click();
await page.getByRole('button',{name:'Consulter les statistiques'}).click();
await expect(page.getByTestId('stats-worked')).toHaveText('9h00');
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
});
test('date conservée, rechargement et Retour depuis les trois nouvelles pages', async ({page}) => {
await importFixture(page,fixture({dates:['2026-10-07',date]}));
await page.getByLabel('Journée à consulter').selectOption('2026-10-07');
await page.getByRole('button',{name:'Voir les détails'}).click();
await page.reload();
await expect(page.getByRole('heading',{name:/7 octobre/})).toBeVisible();
await page.getByRole('button',{name:'Retour au récapitulatif'}).click();
await expect(page.getByLabel('Journée à consulter')).toHaveValue('2026-10-07');
await page.getByRole('button',{name:'Réglages',exact:true}).click();
await page.getByRole('button',{name:'Gérer les automatismes'}).click();
await expect(page.getByRole('checkbox')).toHaveCount(6);
await page.goBack();
await expect(page.getByRole('heading',{name:'Réglages',exact:true})).toBeVisible();
await page.getByRole('button',{name:'Gérer les jours non travaillés'}).click();
await expect(page.getByLabel('Date à bloquer')).toBeVisible();
await page.goBack();
await expect(page.getByRole('heading',{name:'Réglages',exact:true})).toBeVisible();
});
test('reprise annulée puis concurrente entre deux onglets, choix pause par défaut', async ({page,context}) => {
await importFixture(page,fixture({pause:false}));
const before = await entries(page);
await page.getByRole('button',{name:'Reprendre la journée',exact:true}).click();
let dialog = page.getByRole('dialog',{name:'Reprendre la journée',exact:true});
await expect(dialog.getByRole('radio',{name:'Créer une pause jusqu’à maintenant'})).toBeChecked();
await dialog.getByRole('button',{name:'Annuler'}).click();
expect(await entries(page)).toEqual(before);
await expect(page.getByRole('button',{name:'Reprendre la journée',exact:true})).toBeFocused();
const other = await context.newPage();
await other.clock.install({time:now}); await other.goto('/#/today');
await Promise.all([page.getByRole('button',{name:'Reprendre la journée',exact:true}).click(),other.getByRole('button',{name:'Reprendre la journée',exact:true}).click()]);
await Promise.all([page.getByRole('button',{name:'Confirmer la reprise'}).dblclick(),other.getByRole('button',{name:'Confirmer la reprise'}).click()]);
await expect(page.getByRole('heading',{name:'En cours',exact:true})).toBeVisible();
const rows = await entries(page);
expect(rows.map(row=>row.kind)).toEqual(['work','break','work']);
expect(rows[0].endTs).toBe(at('17:00')); expect(rows[1].startTs).toBe(at('17:00'));
expect(Math.abs(rows[1].endTs-now)).toBeLessThan(60_000);
});
test('choix continu et reprise immédiate d’une pause sur Journée', async ({page}) => {
await importFixture(page,fixture({pause:false}));
await page.getByRole('button',{name:'Journée',exact:true}).click();
await page.getByRole('button',{name:'Reprendre la journée',exact:true}).click();
await page.getByRole('radio',{name:'Continuer depuis la fin enregistrée'}).check();
await page.getByRole('button',{name:'Confirmer la reprise'}).click();
await expect(page.getByRole('heading',{name:'En cours',exact:true})).toBeVisible();
expect(await entries(page)).toHaveLength(1);
await page.getByRole('button',{name:'Commencer une pause'}).click();
await expect(page.getByRole('heading',{name:'Pause en cours'})).toBeVisible();
await page.getByRole('button',{name:'Reprendre le travail'}).click();
const rows = await entries(page);
expect(rows.map(row=>row.kind)).toEqual(['work','break','work']);
expect(rows.at(-1)).toMatchObject({task:'Tâche de test',client:'Client de test',endTs:null});
});
for (const kind of ['lunch','break']) test(`reprise après ${kind} garde sa nature`, async ({page}) => {
await importFixture(page,fixture({pause:false,lastKind:kind}));
await page.getByRole('button',{name:'Reprendre la journée',exact:true}).click();
await page.getByRole('radio',{name:kind === 'lunch' ? 'Prolonger le repas depuis la fin enregistrée' : 'Prolonger la pause depuis la fin enregistrée'}).check();
await page.getByRole('button',{name:'Confirmer la reprise'}).click();
await expect(page.getByRole('heading',{name:'En cours',exact:true})).toBeVisible();
const rows = await entries(page); expect(rows[1].kind).toBe(kind); expect(rows.at(-1).kind).toBe('work');
});
test('export relu et vérifié, PWA relancée hors ligne', async ({page,context}) => {
await importFixture(page);
await page.getByRole('button',{name:'Réglages',exact:true}).click();
const downloading = page.waitForEvent('download');
await page.getByRole('button',{name:'Exporter les données',exact:true}).click();
const download = await downloading;
await page.getByRole('button',{name:'Vérifier le fichier exporté…'}).click();
await page.locator('input[type=file]').setInputFiles(await download.path());
await expect(page.getByRole('status').filter({hasText:'Sauvegarde vérifiée'})).toBeVisible();
await page.evaluate(async () => { await navigator.serviceWorker.ready; });
await page.reload();
await expect.poll(() => page.evaluate(() => Boolean(navigator.serviceWorker.controller))).toBe(true);
await context.setOffline(true);
await page.reload();
await expect(page.getByRole('heading',{name:'Réglages',exact:true})).toBeVisible();
await page.getByRole('button',{name:'Récap',exact:true}).click();
await expect(page.locator('.total')).toContainText('8h30');
});
test('échec IndexedDB visible et reprise avec Réessayer', async ({page}) => {
await page.addInitScript(() => {
const original = indexedDB.open.bind(indexedDB);
indexedDB.open = () => { throw new Error('IndexedDB indisponible pour le test'); };
window.enableTestStorage = () => (indexedDB.open = original);
});
await page.goto('/#/settings');
await expect(page.getByRole('alert')).toContainText('Accès aux données impossible');
await page.evaluate(() => window.enableTestStorage());
await page.getByRole('button',{name:'Réessayer',exact:true}).click();
await expect(page.getByRole('heading',{name:'Réglages',exact:true})).toBeVisible();
});
test('purge confirmée et restauration de sa sauvegarde récupérable', async ({page}) => {
await importFixture(page,fixture({dates:['2025-01-01',date]}));
await page.getByRole('button',{name:'Réglages',exact:true}).click();
await page.getByRole('button',{name:'Purger maintenant'}).click();
const dialog = page.getByRole('dialog',{name:'Confirmer la purge'});
await expect(dialog).toContainText('2025-01-01');
await dialog.getByRole('button',{name:'Annuler'}).click();
expect(await entries(page)).toHaveLength(6);
await page.getByRole('button',{name:'Purger maintenant'}).click();
await dialog.getByRole('button',{name:'Confirmer la purge'}).click();
await expect(dialog).toHaveCount(0);
expect(await entries(page)).toHaveLength(3);
await page.getByRole('button',{name:'Récupérer la sauvegarde avant purge'}).click();
await page.getByRole('button',{name:'Fusionner',exact:true}).click();
await expect(page.getByRole('status').filter({hasText:'1 journée ajoutée'})).toBeVisible();
expect(await entries(page)).toHaveLength(6);
});
test('dialogue horaire imbriqué garde le focus et le rend à la correction', async ({page}) => {
await importFixture(page);
await page.getByRole('button',{name:'Voir les détails'}).click();
await page.getByRole('button',{name:'Corriger cette journée'}).click();
await page.locator('.timeline button').nth(1).click();
const correction = page.getByRole('dialog',{name:'Modifier le poste'});
await correction.getByRole('button',{name:'Début du poste : 12:00'}).click();
const clock = page.getByRole('dialog',{name:'Début du poste',exact:true});
await expect(clock.getByRole('button',{name:'Fermer',exact:true})).toBeFocused();
await page.keyboard.press('Shift+Tab');
await expect(clock.getByRole('button',{name:'Valider',exact:true})).toBeFocused();
await page.keyboard.press('Escape');
await expect(clock).toHaveCount(0);
await expect(correction.getByRole('button',{name:'Début du poste : 12:00'})).toBeFocused();
await page.keyboard.press('Escape');
await expect(correction).toHaveCount(0);
await expect(page.locator('.timeline button').nth(1)).toBeFocused();
});
test('pause ajoutée dans un intervalle vide sans combler ses portions restantes', async ({page}) => {
const payload = fixture({pause:false});
payload.entries = [
{...payload.entries[0],endTs:at('11:00')},
{...payload.entries[0],seq:1,startTs:at('13:00')},
];
await importFixture(page,payload);
await page.getByRole('button',{name:'Voir les détails'}).click();
await page.getByRole('button',{name:'Corriger cette journée'}).click();
await page.getByRole('button',{name:'Ajouter un poste oublié'}).click();
const dialog = page.getByRole('dialog',{name:'Ajouter un poste',exact:true});
await dialog.getByLabel('Plage à corriger').selectOption({label:'11:00–13:00 · Intervalle sans poste'});
await dialog.getByRole('button',{name:'Pause',exact:true}).click();
await dialog.getByRole('button',{name:'Début du poste : 11:00'}).click();
let clock = page.getByRole('dialog',{name:'Début du poste',exact:true});
await clock.getByLabel('Heures',{exact:true}).selectOption('12');
await clock.getByRole('button',{name:'Valider'}).click();
await dialog.getByRole('button',{name:'Fin du poste : 13:00'}).click();
clock = page.getByRole('dialog',{name:'Fin du poste',exact:true});
await clock.getByLabel('Heures',{exact:true}).selectOption('12');
await clock.getByLabel('Minutes',{exact:true}).selectOption('30');
await clock.getByRole('button',{name:'Valider'}).click();
await dialog.getByRole('button',{name:'Enregistrer',exact:true}).click();
await expect(dialog).toHaveCount(0);
const rows = await entries(page);
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(rows[1]).toMatchObject({startTs:at('12:00'),endTs:at('12:30'),client:null});
});
test('réglage horaire restauré dans le champ après échec de sauvegarde', async ({page}) => {
await importFixture(page);
await page.getByRole('button',{name:'Réglages',exact:true}).click();
const input = page.getByLabel('Début de journée',{exact:true});
const original = await input.inputValue();
await page.evaluate(() => {
const originalPut = IDBObjectStore.prototype.put;
IDBObjectStore.prototype.put = function(record,...args) {
if (this.name === 'settings' && record.key === 'app') {
IDBObjectStore.prototype.put = originalPut;
throw new Error('Échec d’écriture simulé');
}
return originalPut.call(this,record,...args);
};
});
await input.fill('09:00'); await input.press('Tab');
await expect(page.locator('.error.card')).toContainText('Enregistrement impossible');
await expect(input).toHaveValue(original);
});