Improved import security, added browser tests
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
4
.gitignore
vendored
@@ -25,3 +25,7 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
# Browser test artifacts
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
|
|||||||
36
README.md
36
README.md
@@ -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.
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ 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
|
||||||
- ./caddy/config:/config
|
- ./caddy/config:/config
|
||||||
|
|||||||
54
package-lock.json
generated
54
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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
22
playwright.config.js
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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. -->
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
84
src/lib/backup-safety.js
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
71
src/lib/backup-safety.test.js
Normal file
71
src/lib/backup-safety.test.js
Normal 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();
|
||||||
|
});
|
||||||
6
src/lib/backup-state.svelte.js
Normal file
6
src/lib/backup-state.svelte.js
Normal 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;
|
||||||
|
}
|
||||||
@@ -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é/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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'});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function contain(event) {
|
||||||
|
if (topDialog() === node && !node.contains(event.target)) focusFirst();
|
||||||
|
}
|
||||||
document.addEventListener('keydown', trap);
|
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();
|
||||||
|
} };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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. */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
249
tests/e2e/parcours.spec.js
Normal 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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user