diff --git a/.env.example b/.env.example index 23efff3..8480039 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 8da1b41..5776f7f 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -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 diff --git a/.gitignore b/.gitignore index 9d60660..76672d6 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,7 @@ dist-ssr *.njsproj *.sln *.sw? + +# Browser test artifacts +playwright-report/ +test-results/ diff --git a/README.md b/README.md index bc2db79..e16fd97 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docker-compose.yml b/docker-compose.yml index 829880f..53deaf2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,10 +12,10 @@ 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 - - ./caddy/config:/config \ No newline at end of file + - ./caddy/config:/config diff --git a/package-lock.json b/package-lock.json index 933e646..fec420c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index d925b18..f8364d8 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..8366e98 --- /dev/null +++ b/playwright.config.js @@ -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, + }, +}); diff --git a/src/App.svelte b/src/App.svelte index a761f57..ed17a07 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -1,5 +1,8 @@ { if (!busy) onclose(); }}> -

Clôture enregistrée : {fmtTime(day?.endedTs ?? now)}. Heure actuelle : {fmtTime(now)}.

+

Clôture enregistrée : {fmtTime(snapshot?.endedTs ?? now)}. Heure actuelle : {fmtTime(now)}.

- {#if now > (day?.endedTs ?? now)} + {#if now > (snapshot?.endedTs ?? now)}

Conserver la fin enregistrée, ajouter une pause et démarrer le travail à la reprise.

- +

{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.

{:else}

Aucun intervalle à ajouter. Aucune pause vide ne sera créée.

{/if} {#if error}

{error}

{/if} diff --git a/src/lib/backup-safety.js b/src/lib/backup-safety.js new file mode 100644 index 0000000..1d770e4 --- /dev/null +++ b/src/lib/backup-safety.js @@ -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; + }); +} diff --git a/src/lib/backup-safety.test.js b/src/lib/backup-safety.test.js new file mode 100644 index 0000000..bee9cf2 --- /dev/null +++ b/src/lib/backup-safety.test.js @@ -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(); +}); diff --git a/src/lib/backup-state.svelte.js b/src/lib/backup-state.svelte.js new file mode 100644 index 0000000..6bef837 --- /dev/null +++ b/src/lib/backup-state.svelte.js @@ -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; +} diff --git a/src/lib/break.test.js b/src/lib/break.test.js index 7c7b1fb..67ccf87 100644 --- a/src/lib/break.test.js +++ b/src/lib/break.test.js @@ -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é/); +}); diff --git a/src/lib/day.svelte.js b/src/lib/day.svelte.js index a4e15ed..6795588 100644 --- a/src/lib/day.svelte.js +++ b/src/lib/day.svelte.js @@ -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, diff --git a/src/lib/day.test.js b/src/lib/day.test.js index b6ce2af..43c4049 100644 --- a/src/lib/day.test.js +++ b/src/lib/day.test.js @@ -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'}); +}); diff --git a/src/lib/db.js b/src/lib/db.js index d19b3b1..55abd51 100644 --- a/src/lib/db.js +++ b/src/lib/db.js @@ -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)) { diff --git a/src/lib/dialog.js b/src/lib/dialog.js index bbf9044..5181969 100644 --- a/src/lib/dialog.js +++ b/src/lib/dialog.js @@ -1,13 +1,28 @@ -/** Focus ownership, keyboard trap and restoration for modal panels. */ +/** Modal focus ownership follows visual stacking, including nested pickers. */ +const hidden = new WeakMap(); +function topDialog() { + return [...document.querySelectorAll('[role="dialog"]')] + .filter(node => node.isConnected) + .sort((a, b) => (Number(getComputedStyle(a).zIndex) || 0) - (Number(getComputedStyle(b).zIndex) || 0)).at(-1); +} + export function modalFocus(node) { const origin = document.activeElement; const controls = () => [...node.querySelectorAll('button, input, select, textarea, a[href], [tabindex="0"]')] - .filter(el => !el.disabled && el.getClientRects().length); - queueMicrotask(() => (controls()[0] ?? node).focus()); + .filter(el => !el.matches(':disabled') && !el.closest('[inert]') && el.getClientRects().length); + const blocked = []; + // Hide every branch outside this dialog from keyboard and assistive technology. + for (let branch = node; branch.parentElement; branch = branch.parentElement) { + for (const sibling of branch.parentElement.children) { + if (sibling === branch || !(sibling instanceof HTMLElement)) continue; + const record = hidden.get(sibling) ?? { count: 0, inert: sibling.inert }; + record.count++; hidden.set(sibling, record); sibling.inert = true; blocked.push(sibling); + } + } + const focusFirst = () => (controls()[0] ?? node).focus(); + queueMicrotask(() => { if (node.isConnected && topDialog() === node) focusFirst(); }); function trap(event) { - if (event.key !== 'Tab') return; - const dialogs = [...document.querySelectorAll('[role="dialog"]')]; - if (dialogs.at(-1) !== node) return; + if (event.key !== 'Tab' || topDialog() !== node) return; const list = controls(), first = list[0], last = list.at(-1); if (!first) { event.preventDefault(); node.focus(); return; } if (event.shiftKey && (document.activeElement === first || !node.contains(document.activeElement))) { @@ -16,6 +31,18 @@ export function modalFocus(node) { event.preventDefault(); first.focus(); } } + function contain(event) { + if (topDialog() === node && !node.contains(event.target)) focusFirst(); + } document.addEventListener('keydown', trap); - return { destroy() { document.removeEventListener('keydown', trap); if (origin instanceof HTMLElement && origin.isConnected) origin.focus(); } }; + document.addEventListener('focusin', contain); + return { destroy() { + document.removeEventListener('keydown', trap); + document.removeEventListener('focusin', contain); + for (const element of blocked) { + const record = hidden.get(element); + if (--record.count === 0) { element.inert = record.inert; hidden.delete(element); } + } + if (origin instanceof HTMLElement && origin.isConnected && !origin.closest('[inert]')) origin.focus(); + } }; } diff --git a/src/lib/editing.js b/src/lib/editing.js index 4760209..a12f821 100644 --- a/src/lib/editing.js +++ b/src/lib/editing.js @@ -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; + }); +} diff --git a/src/lib/router.svelte.js b/src/lib/router.svelte.js index 36f0bcf..76be9bd 100644 --- a/src/lib/router.svelte.js +++ b/src/lib/router.svelte.js @@ -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() { diff --git a/src/lib/settings.svelte.js b/src/lib/settings.svelte.js index 2e22147..1ccfa7a 100644 --- a/src/lib/settings.svelte.js +++ b/src/lib/settings.svelte.js @@ -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. */ } + } +} diff --git a/src/routes/Resume.svelte b/src/routes/Resume.svelte index 09f08d4..71ec5ae 100644 --- a/src/routes/Resume.svelte +++ b/src/routes/Resume.svelte @@ -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}

@@ -100,7 +109,7 @@ updateSettings({ lunchStart: e.currentTarget.value })} + onchange={(e) => saveField(e, 'lunchStart')} />

@@ -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)} />

@@ -187,7 +196,7 @@

La purge nécessite une confirmation et conserve une sauvegarde locale récupérable.

- + {#if purged !== null}

{purged === 0 @@ -199,10 +208,10 @@

{ if (!purgeBusy) purgeOpen = false; }}> -

Journées concernées : {purgeDates.length ? purgeDates.join(', ') : 'aucune'}.

+

Journées concernées : {reviewedPurge.length ? reviewedPurge.map(item => item.day.date).join(', ') : 'aucune'}.

Une sauvegarde récupérable sera conservée sur cet appareil avant la suppression.

{#if purgeError}

{purgeError}

{/if} - +