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 # 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. # 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. # Notified by Let's Encrypt before a certificate expires. Optional but advised.
ACME_EMAIL=you@example.com 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: | run: |
set -eu set -eu
npm ci npm ci
npm test npm run validate
npm run check npx playwright install --with-deps chromium
npm run build npm run test:e2e
deploy: deploy:
needs: validate needs: validate

4
.gitignore vendored
View File

@@ -25,3 +25,7 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.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 check # svelte-check
npm run build # production build into dist/ npm run build # production build into dist/
npm run validate # tests, Svelte check and full PWA build 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 ## 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 ```sh
cp .env.example .env 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 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 — - 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. 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. on every recreate and will hit the Let's Encrypt rate limit after a handful of restarts.
To update, pull and rebuild: 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 ### Behind an existing reverse proxy
If something upstream already terminates TLS, set `SITE_ADDRESS=:80`, drop the `443` The current Caddy configuration terminates TLS itself and accepts PROXY protocol
port mappings, and let the proxy forward to port 80. `ACME_EMAIL` is then unused but from the address or CIDR in `TRUST_PROXY`. Set this value to the real upstream
must still be non-empty — Caddy will not parse a blank value. 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 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 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 La checklist avant production et les validations restant à exécuter sont suivies
dans [PRODUCTION.md](PRODUCTION.md). 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} TRUST_PROXY: ${TRUST_PROXY:?set TRUST_PROXY in .env}
ports: ports:
- ${LISTEN_IP:?0.0.0.0}:80:80 - "${LISTEN_IP:-0.0.0.0}:80:80"
- ${LISTEN_IP:?0.0.0.0}:443:443 - "${LISTEN_IP:-0.0.0.0}:443:443"
- ${LISTEN_IP:?0.0.0.0}:443:443/udp - "${LISTEN_IP:-0.0.0.0}:443:443/udp"
volumes: volumes:
- ./caddy/data:/data - ./caddy/data:/data

54
package-lock.json generated
View File

@@ -1,16 +1,17 @@
{ {
"name": "app", "name": "pardis",
"version": "0.0.0", "version": "0.8.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "app", "name": "pardis",
"version": "0.0.0", "version": "0.8.0",
"dependencies": { "dependencies": {
"dexie": "^4.4.4" "dexie": "^4.4.4"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.64.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2", "@sveltejs/vite-plugin-svelte": "^7.1.2",
"@vite-pwa/assets-generator": "^1.0.2", "@vite-pwa/assets-generator": "^1.0.2",
"fake-indexeddb": "^6.2.5", "fake-indexeddb": "^6.2.5",
@@ -2126,6 +2127,22 @@
"url": "https://github.com/sponsors/oxc-project" "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": { "node_modules/@quansync/fs": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@quansync/fs/-/fs-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@quansync/fs/-/fs-1.0.0.tgz",
@@ -5583,6 +5600,35 @@
"url": "https://github.com/sponsors/jonschlinkert" "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": { "node_modules/possible-typed-array-names": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", "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", "preview": "vite preview",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "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": { "devDependencies": {
"@playwright/test": "^1.64.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2", "@sveltejs/vite-plugin-svelte": "^7.1.2",
"@vite-pwa/assets-generator": "^1.0.2", "@vite-pwa/assets-generator": "^1.0.2",
"fake-indexeddb": "^6.2.5", "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> <script>
import { db } from './lib/db.js';
import { dayState, reconcile } from './lib/day.svelte.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 { go, router } from './lib/router.svelte.js';
import { loadSettings, settings } from './lib/settings.svelte.js'; import { loadSettings, settings } from './lib/settings.svelte.js';
import { needsRefresh, applyUpdate } from './lib/updates.svelte.js'; import { needsRefresh, applyUpdate } from './lib/updates.svelte.js';
@@ -14,7 +17,8 @@
let ready = $state(false); let ready = $state(false);
let bootError = $state(''); let bootError = $state('');
let retry = $state(0); 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(() => { $effect(() => {
retry; retry;
@@ -22,7 +26,9 @@
let alive = true; let alive = true;
(async () => { (async () => {
try { try {
await db.open();
await loadSettings(); await loadSettings();
await loadBackupStatus();
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
const { installDevSeed } = await import('./lib/seed.js'); const { installDevSeed } = await import('./lib/seed.js');
installDevSeed(); installDevSeed();
@@ -52,6 +58,9 @@
{#if !ready} {#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> <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} {: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} {#if needsRefresh.value && !dayState.day}
<!-- Only offered while no day is running, so an update can never interrupt <!-- Only offered while no day is running, so an update can never interrupt
a day in progress. --> a day in progress. -->

View File

@@ -13,7 +13,9 @@
parseBackup, parseBackup,
} from '../lib/backup.js'; } from '../lib/backup.js';
import { applyImport, dayState } from '../lib/day.svelte.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'; import { fmtDayLabel, fmtTime, localDateKey } from '../lib/time.js';
/** @type {HTMLInputElement} */ /** @type {HTMLInputElement} */
@@ -24,7 +26,7 @@
let result = $state(null); let result = $state(null);
let confirmingReplace = $state(false); let confirmingReplace = $state(false);
let busy = $state(false); let busy = $state(false);
let lastExport = $state(localStorage.getItem('pardis-last-export') ?? ''); let verifying = $state(false);
let filename = $state(''); let filename = $state('');
function reset() { function reset() {
@@ -36,14 +38,14 @@
} }
async function exportAll() { async function exportAll() {
if (busy) return; if (busy || dayState.importing) return;
busy = true; busy = true;
error = null; error = null;
result = null; result = null;
try { try {
const payload = await exportBackup(); const { payload, text } = await prepareVerifiedExport();
const url = URL.createObjectURL( 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'); const link = document.createElement('a');
link.href = url; link.href = url;
@@ -56,7 +58,7 @@
// Give browsers (including standalone mobile apps) time to start reading. // Give browsers (including standalone mobile apps) time to start reading.
setTimeout(() => URL.revokeObjectURL(url), 60_000); 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' : ''}.`; 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) { } catch (cause) {
error = `Export impossible : ${cause.message}`; error = `Export impossible : ${cause.message}`;
@@ -66,15 +68,18 @@
} }
async function recoverPurge() { async function recoverPurge() {
if (busy || dayState.importing) return;
busy = true; reset(); result = null;
try { try {
const record = await db.settings.get('before-purge'); const record = await db.settings.get('before-purge');
if (!record) throw new Error('Aucune sauvegarde avant purge disponible.'); if (!record) throw new Error('Aucune sauvegarde avant purge disponible.');
staged = parseBackup(JSON.stringify(record.value)); filename = 'Sauvegarde avant purge'; staged = parseBackup(JSON.stringify(record.value)); filename = 'Sauvegarde avant purge';
} catch (cause) { error = cause.message; } } catch (cause) { error = cause.message; }
finally { busy = false; }
} }
async function onPick(event) { async function onPick(event) {
if (busy) return; if (busy || dayState.importing) return;
// Capture the File before reset() clears the input and its FileList. // Capture the File before reset() clears the input and its FileList.
const file = event.currentTarget.files?.[0]; const file = event.currentTarget.files?.[0];
reset(); reset();
@@ -85,7 +90,12 @@
if (file.size > MAX_BACKUP_BYTES) { if (file.size > MAX_BACKUP_BYTES) {
throw new BackupError('Fichier trop volumineux (20 Mo maximum).'); 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; filename = file.name;
} catch (cause) { } catch (cause) {
error = error =
@@ -96,7 +106,7 @@
} }
async function run(mode) { async function run(mode) {
if (!staged || busy) return; if (!staged || busy || dayState.importing) return;
busy = true; busy = true;
error = null; error = null;
try { try {
@@ -120,7 +130,11 @@
<button type="button" disabled={busy} onclick={exportAll}>Exporter les données</button> <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> <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. --> <!-- The real input is hidden: a styled button is a far bigger target. -->
@@ -132,7 +146,7 @@
disabled={busy} disabled={busy}
hidden 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} {#if busy}<p role="status">Traitement en cours…</p>{/if}

View File

@@ -1,5 +1,5 @@
<script> <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 { dayState, refresh } from '../lib/day.svelte.js';
import { fmtTime, startOfDay } from '../lib/time.js'; import { fmtTime, startOfDay } from '../lib/time.js';
import { settings } from '../lib/settings.svelte.js'; import { settings } from '../lib/settings.svelte.js';
@@ -18,6 +18,13 @@
let confirmingDelete = $state(false); let confirmingDelete = $state(false);
const ordered = $derived([...entries].sort((a, b) => a.seq - b.seq)); const ordered = $derived([...entries].sort((a, b) => a.seq - b.seq));
const 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 index = $derived(ordered.findIndex((e) => e.id === original?.id));
const previous = $derived(ordered[index - 1]); const previous = $derived(ordered[index - 1]);
const following = $derived(ordered[index + 1]); const following = $derived(ordered[index + 1]);
@@ -60,6 +67,7 @@
try { try {
const values = { ...draft, endTs: mode === 'edit' && original.endTs == null ? null : draft.endTs }; const values = { ...draft, endTs: mode === 'edit' && original.endTs == null ? null : draft.endTs };
if (remove) await removeRecordedEntry(original); 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 if (mode === 'insert') await insertRecordedEntry(original, values);
else await saveRecordedEntry(original, values); else await saveRecordedEntry(original, values);
await refresh(); await refresh();
@@ -73,7 +81,7 @@
<div class="card stack"> <div class="card stack">
<h3>Postes de la journée</h3> <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} {#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} {#if editable}<button type="button" disabled={!ordered.length || dayState.importing} onclick={() => select(ordered[0], 'insert')}>Ajouter un poste oublié</button>{/if}
</div> </div>
@@ -83,13 +91,13 @@
<fieldset disabled={busy || dayState.importing} class="stack"> <fieldset disabled={busy || dayState.importing} class="stack">
{#if mode === 'insert'} {#if mode === 'insert'}
<label class="stack">Plage à corriger <label class="stack">Plage à corriger
<select value={original.id} onchange={(event) => select(ordered.find((e) => e.id === Number(event.currentTarget.value)), 'insert')}> <select value={original.id} onchange={(event) => select(locations.find((e) => String(e.id) === event.currentTarget.value), 'insert')}>
{#each ordered as entry (entry.id)} {#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> <option value={entry.id}>{fmtTime(entry.startTs, settings.roundingMinutes)}–{entry.endTs == null ? 'en cours' : fmtTime(entry.endTs, settings.roundingMinutes)} · {entry.task}</option>
{/each} {/each}
</select> </select>
</label> </label>
<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} {: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> <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} {/if}

View File

@@ -1,4 +1,5 @@
<script> <script>
import { untrack } from 'svelte';
import Sheet from './Sheet.svelte'; import Sheet from './Sheet.svelte';
import { dayState, reopenToday } from '../lib/day.svelte.js'; import { dayState, reopenToday } from '../lib/day.svelte.js';
import { go } from '../lib/router.svelte.js'; import { go } from '../lib/router.svelte.js';
@@ -8,24 +9,40 @@
let busy = $state(false); let busy = $state(false);
let error = $state(''); let error = $state('');
let now = $state(Date.now()); let now = $state(Date.now());
$effect(() => { if (open) { now = Date.now(); mode = 'break'; error = ''; } }); let snapshot = $state(null);
const day = $derived(dayState.todayRecord); $effect(() => {
const last = $derived(dayState.historyEntries.filter(e => e.date === day?.date).sort((a,b) => a.seq-b.seq).at(-1)); 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() { async function confirm() {
if (busy || dayState.importing) return; if (busy || dayState.importing) return;
busy = true; 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; } catch (cause) { error = cause.message; }
finally { busy = false; } finally { busy = false; }
} }
</script> </script>
<Sheet {open} title="Reprendre la journée" onclose={() => { if (!busy) onclose(); }}> <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"> <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> <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> <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> <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} {:else}<p>Aucun intervalle à ajouter. Aucune pause vide ne sera créée.</p>{/if}
{#if error}<p role="alert">{error}</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 { buildBackup, parseBackup } from './backup.js';
import { buildModel } from './predict.js'; import { buildModel } from './predict.js';
import { automateDay } from './automation.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 { DEFAULT_SETTINGS } from './settings.svelte.js';
import { atLocalTime, HOUR, MINUTE } from './time.js'; import { atLocalTime, HOUR, MINUTE } from './time.js';
const date = '2026-10-08'; 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')); 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'; } from './db.js';
import { buildModel, suggestClients, suggestTasks, TRANSPORT_TASK } from './predict.js'; import { buildModel, suggestClients, suggestTasks, TRANSPORT_TASK } from './predict.js';
import { automateDay, manualAdvance, previousContext } from './automation.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'; import { atLocalTime, HOUR, localDateKey, roundTs, weekday } from './time.js';
export const dayState = $state({ export const dayState = $state({
@@ -36,6 +37,7 @@ export const dayState = $state({
historyEntries: [], historyEntries: [],
todayRecord: null, todayRecord: null,
previousWork: null, previousWork: null,
lastKnownWork: null,
/** Prediction model, rebuilt whenever history changes. */ /** Prediction model, rebuilt whenever history changes. */
model: null, model: null,
/** True once the configured end time has passed and the worker has not answered. */ /** 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) { async function reconcileDay(now) {
// A settings change in another tab must also stop this tab's clock actions. // A settings change in another tab must also stop this tab's clock actions.
const persisted = await readSettings(); const persisted = await readSettings();
if (persisted) Object.assign(settings, persisted); if (persisted) { Object.assign(settings, persisted); applyTheme(); }
dayState.today = localDateKey(now); dayState.today = localDateKey(now);
const active = await getActiveDay(); 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) : []; const entriesFor = (day) => day ? entries.filter((e) => e.date === day.date).sort((a, b) => a.seq - b.seq) : [];
dayState.today = localDateKey(now); dayState.today = localDateKey(now);
dayState.todayRecord = days.find((d) => d.date === dayState.today) ?? null; 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.day = active ?? null;
dayState.entries = entriesFor(active); dayState.entries = entriesFor(active);
dayState.lastClosed = lastClosed ?? null; 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. // The guard above prevents subsequent ticks/focus events during the import.
await Promise.allSettled([...pendingReconciliations]); await Promise.allSettled([...pendingReconciliations]);
const result = await importBackup(payload, mode); 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. // Do not immediately purge old imported days or create a new working day.
// Retention is explicitly confirmed in settings. // Retention is explicitly confirmed in settings.
dayState.today = localDateKey(); 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. */ /** 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; if (dayState.importing) return;
await reopenDay(localDateKey(now), now, mode); const reopened = await reopenDay(expected?.date ?? localDateKey(now), now, mode, expected);
await refresh(now); await refresh(now);
return reopened;
} }
/* ---------------------------------------------------------------- predictions */ /* ---------------------------------------------------------------- predictions */
@@ -277,7 +282,7 @@ export function predictNext(now = Date.now()) {
if (!model) return { task: TRANSPORT_TASK, client: null, tasks: [], clients: [] }; if (!model) return { task: TRANSPORT_TASK, client: null, tasks: [], clients: [] };
const current = running(); 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, { const tasks = suggestTasks(model, {
prevTask: current?.task ?? null, prevTask: current?.task ?? null,
prevElapsedMs: current ? now - current.startTs : 0, prevElapsedMs: current ? now - current.startTs : 0,

View File

@@ -1,8 +1,8 @@
import 'fake-indexeddb/auto'; import 'fake-indexeddb/auto';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanupOldDays, closeAndStart, closeDay, createDay, db, getDay, getEntries, reopenDay } from './db.js'; 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 { advance, dayState, endDay, keepWorking, predictNext, reconcile, refresh, reopenToday, setLunch, startDayNow, startLunch } from './day.svelte.js';
import { DEFAULT_SETTINGS, settings, settingsError, updateSettings } from './settings.svelte.js'; import { DEFAULT_SETTINGS, settings, settingsError, setWorkingDay, updateSettings } from './settings.svelte.js';
import { atLocalTime, HOUR } from './time.js'; import { atLocalTime, HOUR } from './time.js';
import { buildResume } from './resume.js'; import { buildResume } from './resume.js';
@@ -244,3 +244,22 @@ describe('settings persistence across tabs', () => {
expect(settingsError.message).toContain('write failed'); 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. */ /** 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 () => { return db.transaction('rw', db.days, db.entries, async () => {
const day = await getDay(date); const day = await getDay(date);
if (!['continue', 'break'].includes(mode)) throw new Error('Choix de reprise invalide.'); 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 entries = await getEntries(date);
const last = entries.at(-1); const last = entries.at(-1);
if (!last) return false; 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; const boundary = day.endedTs;
if (!Number.isFinite(boundary) || now < boundary) return false; if (!Number.isFinite(boundary) || now < boundary) return false;
if (mode === 'continue' && ['work', 'travel'].includes(last.kind)) { 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) { export function modalFocus(node) {
const origin = document.activeElement; const origin = document.activeElement;
const controls = () => [...node.querySelectorAll('button, input, select, textarea, a[href], [tabindex="0"]')] const controls = () => [...node.querySelectorAll('button, input, select, textarea, a[href], [tabindex="0"]')]
.filter(el => !el.disabled && el.getClientRects().length); .filter(el => !el.matches(':disabled') && !el.closest('[inert]') && el.getClientRects().length);
queueMicrotask(() => (controls()[0] ?? node).focus()); 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) { function trap(event) {
if (event.key !== 'Tab') return; if (event.key !== 'Tab' || topDialog() !== node) return;
const dialogs = [...document.querySelectorAll('[role="dialog"]')];
if (dialogs.at(-1) !== node) return;
const list = controls(), first = list[0], last = list.at(-1); const list = controls(), first = list[0], last = list.at(-1);
if (!first) { event.preventDefault(); node.focus(); return; } if (!first) { event.preventDefault(); node.focus(); return; }
if (event.shiftKey && (document.activeElement === first || !node.contains(document.activeElement))) { if (event.shiftKey && (document.activeElement === first || !node.contains(document.activeElement))) {
@@ -16,6 +31,18 @@ export function modalFocus(node) {
event.preventDefault(); first.focus(); event.preventDefault(); first.focus();
} }
} }
document.addEventListener('keydown', trap); function contain(event) {
return { destroy() { document.removeEventListener('keydown', trap); if (origin instanceof HTMLElement && origin.isConnected) origin.focus(); } }; 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']; const ROUTES = ['resume', 'today', 'settings', 'stats', 'details', 'automations', 'non-working'];
function fromHash() { function fromHash() {
const h = location.hash.replace(/^#\/?/, ''); const [path, query = ''] = location.hash.replace(/^#\/?/, '').split('?');
return ROUTES.includes(h) ? h : null; 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', () => { window.addEventListener('hashchange', () => {
router.route = fromHash(); const next = fromHash();
router.route = next.route;
if (next.selectedDate) router.selectedDate = next.selectedDate;
}); });
export function go(route) { 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() { export function back() {

View File

@@ -101,3 +101,23 @@ export async function changeNonWorkingDate(original, replacement) {
}); });
settings.nonWorkingDates = nextDates; 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'; import { fmtDayLabel, fmtDuration, weekday } from '../lib/time.js';
let copied = $state(false); let copied = $state(false);
let selectedDate = $state(router.selectedDate); const selectedDate = $derived(router.selectedDate);
let reopening = $state(false); let reopening = $state(false);
let error = $state(''); let error = $state('');
const selectedDay = $derived(dayState.closedDays.find((d) => d.date === selectedDate) ?? dayState.lastClosed); const selectedDay = $derived(dayState.closedDays.find((d) => d.date === selectedDate) ?? dayState.lastClosed);
@@ -47,7 +47,7 @@
{#if dayState.closedDays.length > 1} {#if dayState.closedDays.length > 1}
<label class="stack"> <label class="stack">
<span>Journée à consulter</span> <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)} {#each dayState.closedDays as day (day.date)}
<option value={day.date}>{fmtDayLabel(day.date)} {day.date.slice(0, 4)}</option> <option value={day.date}>{fmtDayLabel(day.date)} {day.date.slice(0, 4)}</option>
{/each} {/each}

View File

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

View File

@@ -49,7 +49,7 @@
</div> </div>
{/if} {/if}
<label class="check"><input type="checkbox" bind:checked={includeActive} />Inclure la journée en cours (provisoire)</label> <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> <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} {#if invalid}<p role="alert">La date de début doit précéder ou égaler la date de fin.</p>{/if}
</section> </section>
@@ -79,7 +79,7 @@
<dl> <dl>
<div><dt>Moyenne travaillée / jour</dt><dd>{fmtDuration(stats.averageWorkMs)}</dd></div> <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>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> <div><dt>Repas moyen, jours avec pause</dt><dd>{fmtDuration(stats.averageLunchMs)}</dd></div>
</dl> </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> <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, keepWorking,
predictForCurrent, predictForCurrent,
predictNext, predictNext,
reopenToday,
running, running,
setLunch, setLunch,
startDayNow, startDayNow,
@@ -34,7 +33,7 @@
let actionError = $state(''); let actionError = $state('');
async function act(action) { async function act(action) {
if (busy) return; if (busy || dayState.importing) return;
busy = true; busy = true;
actionError = ''; actionError = '';
try { await action(); } 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. Aujourd'hui n'est pas un jour travaillé, ou l'heure de début n'est pas atteinte.
{/if} {/if}
</p> </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'} {closedToday ? 'Reprendre la journée' : blockedToday ? 'Démarrer quand même' : 'Démarrer la journée'}
</button> </button>
</div> </div>
@@ -146,12 +145,12 @@
<button <button
type="button" type="button"
class:sel={dayState.day.lunchType !== 'restaurant'} 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 <button
type="button" type="button"
class:sel={dayState.day.lunchType === 'restaurant'} 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> </div>
{#if dayState.day.lunchType === 'restaurant'} {#if dayState.day.lunchType === 'restaurant'}
@@ -187,7 +186,7 @@
{/if} {/if}
<!-- Primary actions --------------------------------------------------- --> <!-- 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} {#if lunchHint}
<p class="dim" role="status"> <p class="dim" role="status">
@@ -201,11 +200,11 @@
{/if} {/if}
{#if current?.kind !== 'lunch'} {#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}
{#if current?.kind !== 'break'} {#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} {/if}
<!-- Day so far -------------------------------------------------------- --> <!-- Day so far -------------------------------------------------------- -->
<DayEditor day={dayState.day} entries={dayState.entries} {now} /> <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> <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; }}> <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> <p>Clôturer le poste en cours et terminer la journée maintenant ?</p>
<button disabled={busy} onclick={() => (confirmingEnd = false)}>Annuler</button> <button disabled={busy || dayState.importing} onclick={() => (confirmingEnd = false)}>Annuler</button>
<button class="btn-danger" disabled={busy} onclick={() => act(finishDay)}>Confirmer</button> <button class="btn-danger" disabled={busy || dayState.importing} onclick={() => act(finishDay)}>Confirmer</button>
</Sheet> </Sheet>
</div> </div>
@@ -227,10 +226,10 @@
<p> <p>
L'heure de fin ({fmtTime(dayState.day.plannedEndTs)}) est passée. Terminer la journée maintenant ? L'heure de fin ({fmtTime(dayState.day.plannedEndTs)}) est passée. Terminer la journée maintenant ?
</p> </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 Terminer la journée
</button> </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>
</div> </div>
{/if} {/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);
});