diff --git a/README.md b/README.md
index e20683e..99fccd9 100644
--- a/README.md
+++ b/README.md
@@ -17,6 +17,47 @@ npm run check # svelte-check
npm run build # production build into dist/
```
+## Exporter et importer les données
+
+Dans **Réglages → Données**, **Exporter les données** télécharge un fichier
+`timelog-AAAA-MM-JJ.json`. Il contient toutes les journées conservées, leurs postes
+et les réglages enregistrés. L'export fonctionne hors ligne et ne modifie pas les
+données de l'appareil. Les identifiants internes des postes sont recréés à l'import.
+
+Pour restaurer une sauvegarde, choisissez **Importer un fichier…** puis vérifiez
+le nom du fichier, les compteurs et les éventuels ajustements signalés. Deux modes
+sont disponibles :
+
+- **Fusionner** ajoute seulement les dates absentes. Une date déjà présente est
+ conservée intégralement, même si le fichier contient une autre version de ses
+ postes. Les réglages locaux sont conservés. Importer deux fois le même fichier
+ ne crée pas de doublons.
+- **Remplacer tout** demande une confirmation, puis remplace toutes les journées,
+ tous les postes et les réglages, y compris une journée locale en cours. Si le
+ fichier ne contient pas de réglages, les valeurs par défaut sont rétablies.
+
+Les journées importées sont enregistrées comme terminées. Pour une journée
+exportée en cours, le dernier poste est arrêté à l'heure de l'export : le temps
+écoulé est conservé. Les intervalles sans travail déjà présents sont conservés.
+Une sauvegarde vide est valide et peut servir à restaurer un appareil vide.
+
+Le format JSON est identifié par `format: "timelog-export"` et `version: 1`.
+Les fichiers étrangers, versions non prises en charge, dates en double et réglages
+invalides sont refusés. Les postes inutilisables et les corrections de plages
+horaires sont signalés avant confirmation. La limite de taille est de 20 Mo.
+L'écriture est transactionnelle : un échec annule le remplacement ou la fusion
+dans son intégralité. Le récapitulatif, le thème et les suggestions sont ensuite
+actualisés sans recharger la page.
+
+L'import ne déclenche pas immédiatement de purge. La durée de conservation
+s'applique de nouveau à la prochaine ouverture ou au changement de jour ; le
+bouton **Purger maintenant** reste disponible.
+
+Les tests d'import/export couvrent le format, les réglages, la fidélité des durées,
+les fichiers invalides, la fusion, le remplacement et l'annulation transactionnelle
+sur une IndexedDB simulée. `src/lib/fixtures/backup.json` est le jeu d'essai fourni ;
+il n'est pas publié avec l'application.
+
In dev the console exposes `timelog.seed()` to generate 20 days of realistic history
(useful for exercising the suggestion engine) and `timelog.wipe()` to clear it.
diff --git a/package-lock.json b/package-lock.json
index f23c884..933e646 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,6 +13,7 @@
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@vite-pwa/assets-generator": "^1.0.2",
+ "fake-indexeddb": "^6.2.5",
"svelte": "^5.56.4",
"svelte-check": "^4.7.6",
"vite": "^8.1.1",
@@ -68,7 +69,6 @@
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
@@ -2999,7 +2999,6 @@
"integrity": "sha512-MCbrb508JZHqe7bUibmZj/lyojdhLRnfkmyXnkrCM2zVrjTgL89U8UEfInpKTvPeTnxsw2hmyZxnhsdNR6yhwg==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"cac": "^6.7.14",
"colorette": "^2.0.20",
@@ -3062,7 +3061,6 @@
"integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3076,7 +3074,6 @@
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -3308,7 +3305,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.11.20",
"caniuse-lite": "^1.0.30001810",
@@ -4033,6 +4029,16 @@
"node": ">=12.0.0"
}
},
+ "node_modules/fake-indexeddb": {
+ "version": "6.2.5",
+ "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz",
+ "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -5834,7 +5840,6 @@
"integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/estree": "1.0.9"
},
@@ -6407,7 +6412,6 @@
"integrity": "sha512-NdbDn7fl4be1ViUG0oq/lvG6OZy3oENolV2ONjiqqsfVoeAfzaQAKUcEX3MrQod/Bebv1PgwET9rfXhgn9s4Kg==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0",
@@ -6835,7 +6839,6 @@
"integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"lightningcss": "^1.33.0",
"picomatch": "^4.0.5",
diff --git a/package.json b/package.json
index 42a7c18..89cd148 100644
--- a/package.json
+++ b/package.json
@@ -14,6 +14,7 @@
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@vite-pwa/assets-generator": "^1.0.2",
+ "fake-indexeddb": "^6.2.5",
"svelte": "^5.56.4",
"svelte-check": "^4.7.6",
"vite": "^8.1.1",
diff --git a/public/backup-fixture.json b/public/backup-fixture.json
new file mode 100644
index 0000000..1eb9a46
--- /dev/null
+++ b/public/backup-fixture.json
@@ -0,0 +1,1130 @@
+{
+ "format": "timelog-export",
+ "version": 1,
+ "exportedAt": "2026-09-06T15:25:43.430Z",
+ "settings": null,
+ "days": [
+ {
+ "date": "2026-08-10",
+ "status": "closed",
+ "plannedStartTs": 1786339800000,
+ "plannedEndTs": 1786375800000,
+ "startedTs": 1786339800000,
+ "endedTs": 1786372440000,
+ "lunchType": "restaurant",
+ "restaurant": "Chez Marcel",
+ "closePromptedTs": null
+ },
+ {
+ "date": "2026-08-11",
+ "status": "closed",
+ "plannedStartTs": 1786426200000,
+ "plannedEndTs": 1786462200000,
+ "startedTs": 1786426200000,
+ "endedTs": 1786459440000,
+ "lunchType": "restaurant",
+ "restaurant": "Le Relais",
+ "closePromptedTs": null
+ },
+ {
+ "date": "2026-08-12",
+ "status": "closed",
+ "plannedStartTs": 1786512600000,
+ "plannedEndTs": 1786548600000,
+ "startedTs": 1786512600000,
+ "endedTs": 1786548120000,
+ "lunchType": "restaurant",
+ "restaurant": "La Bonne Fourchette",
+ "closePromptedTs": null
+ },
+ {
+ "date": "2026-08-13",
+ "status": "closed",
+ "plannedStartTs": 1786599000000,
+ "plannedEndTs": 1786635000000,
+ "startedTs": 1786599000000,
+ "endedTs": 1786634700000,
+ "lunchType": "panier",
+ "restaurant": null,
+ "closePromptedTs": null
+ },
+ {
+ "date": "2026-08-14",
+ "status": "closed",
+ "plannedStartTs": 1786685400000,
+ "plannedEndTs": 1786721400000,
+ "startedTs": 1786685400000,
+ "endedTs": 1786719780000,
+ "lunchType": "restaurant",
+ "restaurant": "Le Relais",
+ "closePromptedTs": null
+ },
+ {
+ "date": "2026-08-17",
+ "status": "closed",
+ "plannedStartTs": 1786944600000,
+ "plannedEndTs": 1786980600000,
+ "startedTs": 1786944600000,
+ "endedTs": 1786977660000,
+ "lunchType": "restaurant",
+ "restaurant": "La Bonne Fourchette",
+ "closePromptedTs": null
+ },
+ {
+ "date": "2026-08-18",
+ "status": "closed",
+ "plannedStartTs": 1787031000000,
+ "plannedEndTs": 1787067000000,
+ "startedTs": 1787031000000,
+ "endedTs": 1787064480000,
+ "lunchType": "restaurant",
+ "restaurant": "Chez Marcel",
+ "closePromptedTs": null
+ },
+ {
+ "date": "2026-08-19",
+ "status": "closed",
+ "plannedStartTs": 1787117400000,
+ "plannedEndTs": 1787153400000,
+ "startedTs": 1787117400000,
+ "endedTs": 1787150400000,
+ "lunchType": "panier",
+ "restaurant": null,
+ "closePromptedTs": null
+ },
+ {
+ "date": "2026-08-20",
+ "status": "closed",
+ "plannedStartTs": 1787203800000,
+ "plannedEndTs": 1787239800000,
+ "startedTs": 1787203800000,
+ "endedTs": 1787237400000,
+ "lunchType": "restaurant",
+ "restaurant": "La Bonne Fourchette",
+ "closePromptedTs": null
+ },
+ {
+ "date": "2026-08-21",
+ "status": "closed",
+ "plannedStartTs": 1787290200000,
+ "plannedEndTs": 1787326200000,
+ "startedTs": 1787290200000,
+ "endedTs": 1787324760000,
+ "lunchType": "restaurant",
+ "restaurant": "Chez Marcel",
+ "closePromptedTs": null
+ },
+ {
+ "date": "2026-08-24",
+ "status": "closed",
+ "plannedStartTs": 1787549400000,
+ "plannedEndTs": 1787585400000,
+ "startedTs": 1787549400000,
+ "endedTs": 1787583540000,
+ "lunchType": "restaurant",
+ "restaurant": "Le Relais",
+ "closePromptedTs": null
+ },
+ {
+ "date": "2026-08-25",
+ "status": "closed",
+ "plannedStartTs": 1787635800000,
+ "plannedEndTs": 1787671800000,
+ "startedTs": 1787635800000,
+ "endedTs": 1787669820000,
+ "lunchType": "panier",
+ "restaurant": null,
+ "closePromptedTs": null
+ },
+ {
+ "date": "2026-08-26",
+ "status": "closed",
+ "plannedStartTs": 1787722200000,
+ "plannedEndTs": 1787758200000,
+ "startedTs": 1787722200000,
+ "endedTs": 1787757420000,
+ "lunchType": "restaurant",
+ "restaurant": "Chez Marcel",
+ "closePromptedTs": null
+ },
+ {
+ "date": "2026-08-27",
+ "status": "closed",
+ "plannedStartTs": 1787808600000,
+ "plannedEndTs": 1787844600000,
+ "startedTs": 1787808600000,
+ "endedTs": 1787840940000,
+ "lunchType": "restaurant",
+ "restaurant": "Le Relais",
+ "closePromptedTs": null
+ },
+ {
+ "date": "2026-08-28",
+ "status": "closed",
+ "plannedStartTs": 1787895000000,
+ "plannedEndTs": 1787931000000,
+ "startedTs": 1787895000000,
+ "endedTs": 1787928060000,
+ "lunchType": "restaurant",
+ "restaurant": "La Bonne Fourchette",
+ "closePromptedTs": null
+ },
+ {
+ "date": "2026-08-31",
+ "status": "closed",
+ "plannedStartTs": 1788154200000,
+ "plannedEndTs": 1788190200000,
+ "startedTs": 1788154200000,
+ "endedTs": 1788189180000,
+ "lunchType": "panier",
+ "restaurant": null,
+ "closePromptedTs": null
+ },
+ {
+ "date": "2026-09-01",
+ "status": "closed",
+ "plannedStartTs": 1788240600000,
+ "plannedEndTs": 1788276600000,
+ "startedTs": 1788240600000,
+ "endedTs": 1788277320000,
+ "lunchType": "restaurant",
+ "restaurant": "Le Relais",
+ "closePromptedTs": null
+ },
+ {
+ "date": "2026-09-02",
+ "status": "closed",
+ "plannedStartTs": 1788327000000,
+ "plannedEndTs": 1788363000000,
+ "startedTs": 1788327000000,
+ "endedTs": 1788361140000,
+ "lunchType": "restaurant",
+ "restaurant": "La Bonne Fourchette",
+ "closePromptedTs": null
+ },
+ {
+ "date": "2026-09-03",
+ "status": "closed",
+ "plannedStartTs": 1788413400000,
+ "plannedEndTs": 1788449400000,
+ "startedTs": 1788413400000,
+ "endedTs": 1788446460000,
+ "lunchType": "restaurant",
+ "restaurant": "Chez Marcel",
+ "closePromptedTs": null
+ },
+ {
+ "date": "2026-09-04",
+ "status": "closed",
+ "plannedStartTs": 1788499800000,
+ "plannedEndTs": 1788535800000,
+ "startedTs": 1788499800000,
+ "endedTs": 1788532380000,
+ "lunchType": "panier",
+ "restaurant": null,
+ "closePromptedTs": null
+ }
+ ],
+ "entries": [
+ {
+ "date": "2026-08-10",
+ "seq": 0,
+ "kind": "travel",
+ "task": "Chargement / transport",
+ "client": "Martin & Fils",
+ "startTs": 1786339800000,
+ "endTs": 1786342200000
+ },
+ {
+ "date": "2026-08-10",
+ "seq": 1,
+ "kind": "work",
+ "task": "Terrassement",
+ "client": "Martin & Fils",
+ "startTs": 1786342200000,
+ "endTs": 1786352640000
+ },
+ {
+ "date": "2026-08-10",
+ "seq": 2,
+ "kind": "lunch",
+ "task": "Pause déjeuner",
+ "client": null,
+ "startTs": 1786352640000,
+ "endTs": 1786356000000
+ },
+ {
+ "date": "2026-08-10",
+ "seq": 3,
+ "kind": "work",
+ "task": "Pose de câble",
+ "client": "Martin & Fils",
+ "startTs": 1786356000000,
+ "endTs": 1786369020000
+ },
+ {
+ "date": "2026-08-10",
+ "seq": 4,
+ "kind": "work",
+ "task": "Retour / déchargement",
+ "client": "Martin & Fils",
+ "startTs": 1786369020000,
+ "endTs": 1786372440000
+ },
+ {
+ "date": "2026-08-11",
+ "seq": 0,
+ "kind": "travel",
+ "task": "Chargement / transport",
+ "client": "Dupont SA",
+ "startTs": 1786426200000,
+ "endTs": 1786428660000
+ },
+ {
+ "date": "2026-08-11",
+ "seq": 1,
+ "kind": "work",
+ "task": "Terrassement",
+ "client": "Dupont SA",
+ "startTs": 1786428660000,
+ "endTs": 1786439160000
+ },
+ {
+ "date": "2026-08-11",
+ "seq": 2,
+ "kind": "lunch",
+ "task": "Pause déjeuner",
+ "client": null,
+ "startTs": 1786439160000,
+ "endTs": 1786442640000
+ },
+ {
+ "date": "2026-08-11",
+ "seq": 3,
+ "kind": "work",
+ "task": "Pose de câble",
+ "client": "Dupont SA",
+ "startTs": 1786442640000,
+ "endTs": 1786456200000
+ },
+ {
+ "date": "2026-08-11",
+ "seq": 4,
+ "kind": "work",
+ "task": "Retour / déchargement",
+ "client": "Dupont SA",
+ "startTs": 1786456200000,
+ "endTs": 1786459440000
+ },
+ {
+ "date": "2026-08-12",
+ "seq": 0,
+ "kind": "travel",
+ "task": "Chargement / transport",
+ "client": "Commune de Vaugines",
+ "startTs": 1786512600000,
+ "endTs": 1786515060000
+ },
+ {
+ "date": "2026-08-12",
+ "seq": 1,
+ "kind": "work",
+ "task": "Terrassement",
+ "client": "Commune de Vaugines",
+ "startTs": 1786515060000,
+ "endTs": 1786528440000
+ },
+ {
+ "date": "2026-08-12",
+ "seq": 2,
+ "kind": "lunch",
+ "task": "Pause déjeuner",
+ "client": null,
+ "startTs": 1786528440000,
+ "endTs": 1786532340000
+ },
+ {
+ "date": "2026-08-12",
+ "seq": 3,
+ "kind": "work",
+ "task": "Pose de câble",
+ "client": "Commune de Vaugines",
+ "startTs": 1786532340000,
+ "endTs": 1786545060000
+ },
+ {
+ "date": "2026-08-12",
+ "seq": 4,
+ "kind": "work",
+ "task": "Retour / déchargement",
+ "client": "Commune de Vaugines",
+ "startTs": 1786545060000,
+ "endTs": 1786548120000
+ },
+ {
+ "date": "2026-08-13",
+ "seq": 0,
+ "kind": "travel",
+ "task": "Chargement / transport",
+ "client": "Martin & Fils",
+ "startTs": 1786599000000,
+ "endTs": 1786601640000
+ },
+ {
+ "date": "2026-08-13",
+ "seq": 1,
+ "kind": "work",
+ "task": "Terrassement",
+ "client": "Martin & Fils",
+ "startTs": 1786601640000,
+ "endTs": 1786614900000
+ },
+ {
+ "date": "2026-08-13",
+ "seq": 2,
+ "kind": "lunch",
+ "task": "Pause déjeuner",
+ "client": null,
+ "startTs": 1786614900000,
+ "endTs": 1786618200000
+ },
+ {
+ "date": "2026-08-13",
+ "seq": 3,
+ "kind": "work",
+ "task": "Pose de câble",
+ "client": "Martin & Fils",
+ "startTs": 1786618200000,
+ "endTs": 1786631820000
+ },
+ {
+ "date": "2026-08-13",
+ "seq": 4,
+ "kind": "work",
+ "task": "Retour / déchargement",
+ "client": "Martin & Fils",
+ "startTs": 1786631820000,
+ "endTs": 1786634700000
+ },
+ {
+ "date": "2026-08-14",
+ "seq": 0,
+ "kind": "travel",
+ "task": "Chargement / transport",
+ "client": "Dupont SA",
+ "startTs": 1786685400000,
+ "endTs": 1786688460000
+ },
+ {
+ "date": "2026-08-14",
+ "seq": 1,
+ "kind": "work",
+ "task": "Terrassement",
+ "client": "Dupont SA",
+ "startTs": 1786688460000,
+ "endTs": 1786698780000
+ },
+ {
+ "date": "2026-08-14",
+ "seq": 2,
+ "kind": "lunch",
+ "task": "Pause déjeuner",
+ "client": null,
+ "startTs": 1786698780000,
+ "endTs": 1786702740000
+ },
+ {
+ "date": "2026-08-14",
+ "seq": 3,
+ "kind": "work",
+ "task": "Pose de câble",
+ "client": "Dupont SA",
+ "startTs": 1786702740000,
+ "endTs": 1786716420000
+ },
+ {
+ "date": "2026-08-14",
+ "seq": 4,
+ "kind": "work",
+ "task": "Retour / déchargement",
+ "client": "Dupont SA",
+ "startTs": 1786716420000,
+ "endTs": 1786719780000
+ },
+ {
+ "date": "2026-08-17",
+ "seq": 0,
+ "kind": "travel",
+ "task": "Chargement / transport",
+ "client": "Commune de Vaugines",
+ "startTs": 1786944600000,
+ "endTs": 1786947420000
+ },
+ {
+ "date": "2026-08-17",
+ "seq": 1,
+ "kind": "work",
+ "task": "Terrassement",
+ "client": "Commune de Vaugines",
+ "startTs": 1786947420000,
+ "endTs": 1786957380000
+ },
+ {
+ "date": "2026-08-17",
+ "seq": 2,
+ "kind": "lunch",
+ "task": "Pause déjeuner",
+ "client": null,
+ "startTs": 1786957380000,
+ "endTs": 1786960920000
+ },
+ {
+ "date": "2026-08-17",
+ "seq": 3,
+ "kind": "work",
+ "task": "Pose de câble",
+ "client": "Commune de Vaugines",
+ "startTs": 1786960920000,
+ "endTs": 1786974780000
+ },
+ {
+ "date": "2026-08-17",
+ "seq": 4,
+ "kind": "work",
+ "task": "Retour / déchargement",
+ "client": "Commune de Vaugines",
+ "startTs": 1786974780000,
+ "endTs": 1786977660000
+ },
+ {
+ "date": "2026-08-18",
+ "seq": 0,
+ "kind": "travel",
+ "task": "Chargement / transport",
+ "client": "Martin & Fils",
+ "startTs": 1787031000000,
+ "endTs": 1787033700000
+ },
+ {
+ "date": "2026-08-18",
+ "seq": 1,
+ "kind": "work",
+ "task": "Terrassement",
+ "client": "Martin & Fils",
+ "startTs": 1787033700000,
+ "endTs": 1787043780000
+ },
+ {
+ "date": "2026-08-18",
+ "seq": 2,
+ "kind": "lunch",
+ "task": "Pause déjeuner",
+ "client": null,
+ "startTs": 1787043780000,
+ "endTs": 1787047380000
+ },
+ {
+ "date": "2026-08-18",
+ "seq": 3,
+ "kind": "work",
+ "task": "Pose de câble",
+ "client": "Martin & Fils",
+ "startTs": 1787047380000,
+ "endTs": 1787061600000
+ },
+ {
+ "date": "2026-08-18",
+ "seq": 4,
+ "kind": "work",
+ "task": "Retour / déchargement",
+ "client": "Martin & Fils",
+ "startTs": 1787061600000,
+ "endTs": 1787064480000
+ },
+ {
+ "date": "2026-08-19",
+ "seq": 0,
+ "kind": "travel",
+ "task": "Chargement / transport",
+ "client": "Dupont SA",
+ "startTs": 1787117400000,
+ "endTs": 1787120220000
+ },
+ {
+ "date": "2026-08-19",
+ "seq": 1,
+ "kind": "work",
+ "task": "Terrassement",
+ "client": "Dupont SA",
+ "startTs": 1787120220000,
+ "endTs": 1787131800000
+ },
+ {
+ "date": "2026-08-19",
+ "seq": 2,
+ "kind": "lunch",
+ "task": "Pause déjeuner",
+ "client": null,
+ "startTs": 1787131800000,
+ "endTs": 1787134920000
+ },
+ {
+ "date": "2026-08-19",
+ "seq": 3,
+ "kind": "work",
+ "task": "Pose de câble",
+ "client": "Dupont SA",
+ "startTs": 1787134920000,
+ "endTs": 1787147580000
+ },
+ {
+ "date": "2026-08-19",
+ "seq": 4,
+ "kind": "work",
+ "task": "Retour / déchargement",
+ "client": "Dupont SA",
+ "startTs": 1787147580000,
+ "endTs": 1787150400000
+ },
+ {
+ "date": "2026-08-20",
+ "seq": 0,
+ "kind": "travel",
+ "task": "Chargement / transport",
+ "client": "Commune de Vaugines",
+ "startTs": 1787203800000,
+ "endTs": 1787206800000
+ },
+ {
+ "date": "2026-08-20",
+ "seq": 1,
+ "kind": "work",
+ "task": "Terrassement",
+ "client": "Commune de Vaugines",
+ "startTs": 1787206800000,
+ "endTs": 1787218680000
+ },
+ {
+ "date": "2026-08-20",
+ "seq": 2,
+ "kind": "lunch",
+ "task": "Pause déjeuner",
+ "client": null,
+ "startTs": 1787218680000,
+ "endTs": 1787221980000
+ },
+ {
+ "date": "2026-08-20",
+ "seq": 3,
+ "kind": "work",
+ "task": "Pose de câble",
+ "client": "Commune de Vaugines",
+ "startTs": 1787221980000,
+ "endTs": 1787234520000
+ },
+ {
+ "date": "2026-08-20",
+ "seq": 4,
+ "kind": "work",
+ "task": "Retour / déchargement",
+ "client": "Commune de Vaugines",
+ "startTs": 1787234520000,
+ "endTs": 1787237400000
+ },
+ {
+ "date": "2026-08-21",
+ "seq": 0,
+ "kind": "travel",
+ "task": "Chargement / transport",
+ "client": "Martin & Fils",
+ "startTs": 1787290200000,
+ "endTs": 1787293080000
+ },
+ {
+ "date": "2026-08-21",
+ "seq": 1,
+ "kind": "work",
+ "task": "Terrassement",
+ "client": "Martin & Fils",
+ "startTs": 1787293080000,
+ "endTs": 1787304660000
+ },
+ {
+ "date": "2026-08-21",
+ "seq": 2,
+ "kind": "lunch",
+ "task": "Pause déjeuner",
+ "client": null,
+ "startTs": 1787304660000,
+ "endTs": 1787308620000
+ },
+ {
+ "date": "2026-08-21",
+ "seq": 3,
+ "kind": "work",
+ "task": "Pose de câble",
+ "client": "Martin & Fils",
+ "startTs": 1787308620000,
+ "endTs": 1787321400000
+ },
+ {
+ "date": "2026-08-21",
+ "seq": 4,
+ "kind": "work",
+ "task": "Retour / déchargement",
+ "client": "Martin & Fils",
+ "startTs": 1787321400000,
+ "endTs": 1787324760000
+ },
+ {
+ "date": "2026-08-24",
+ "seq": 0,
+ "kind": "travel",
+ "task": "Chargement / transport",
+ "client": "Dupont SA",
+ "startTs": 1787549400000,
+ "endTs": 1787552160000
+ },
+ {
+ "date": "2026-08-24",
+ "seq": 1,
+ "kind": "work",
+ "task": "Terrassement",
+ "client": "Dupont SA",
+ "startTs": 1787552160000,
+ "endTs": 1787564220000
+ },
+ {
+ "date": "2026-08-24",
+ "seq": 2,
+ "kind": "lunch",
+ "task": "Pause déjeuner",
+ "client": null,
+ "startTs": 1787564220000,
+ "endTs": 1787567880000
+ },
+ {
+ "date": "2026-08-24",
+ "seq": 3,
+ "kind": "work",
+ "task": "Pose de câble",
+ "client": "Dupont SA",
+ "startTs": 1787567880000,
+ "endTs": 1787580240000
+ },
+ {
+ "date": "2026-08-24",
+ "seq": 4,
+ "kind": "work",
+ "task": "Retour / déchargement",
+ "client": "Dupont SA",
+ "startTs": 1787580240000,
+ "endTs": 1787583540000
+ },
+ {
+ "date": "2026-08-25",
+ "seq": 0,
+ "kind": "travel",
+ "task": "Chargement / transport",
+ "client": "Commune de Vaugines",
+ "startTs": 1787635800000,
+ "endTs": 1787638140000
+ },
+ {
+ "date": "2026-08-25",
+ "seq": 1,
+ "kind": "work",
+ "task": "Terrassement",
+ "client": "Commune de Vaugines",
+ "startTs": 1787638140000,
+ "endTs": 1787650260000
+ },
+ {
+ "date": "2026-08-25",
+ "seq": 2,
+ "kind": "lunch",
+ "task": "Pause déjeuner",
+ "client": null,
+ "startTs": 1787650260000,
+ "endTs": 1787654040000
+ },
+ {
+ "date": "2026-08-25",
+ "seq": 3,
+ "kind": "work",
+ "task": "Pose de câble",
+ "client": "Commune de Vaugines",
+ "startTs": 1787654040000,
+ "endTs": 1787666520000
+ },
+ {
+ "date": "2026-08-25",
+ "seq": 4,
+ "kind": "work",
+ "task": "Retour / déchargement",
+ "client": "Commune de Vaugines",
+ "startTs": 1787666520000,
+ "endTs": 1787669820000
+ },
+ {
+ "date": "2026-08-26",
+ "seq": 0,
+ "kind": "travel",
+ "task": "Chargement / transport",
+ "client": "Martin & Fils",
+ "startTs": 1787722200000,
+ "endTs": 1787725140000
+ },
+ {
+ "date": "2026-08-26",
+ "seq": 1,
+ "kind": "work",
+ "task": "Terrassement",
+ "client": "Martin & Fils",
+ "startTs": 1787725140000,
+ "endTs": 1787736720000
+ },
+ {
+ "date": "2026-08-26",
+ "seq": 2,
+ "kind": "lunch",
+ "task": "Pause déjeuner",
+ "client": null,
+ "startTs": 1787736720000,
+ "endTs": 1787740140000
+ },
+ {
+ "date": "2026-08-26",
+ "seq": 3,
+ "kind": "work",
+ "task": "Pose de câble",
+ "client": "Martin & Fils",
+ "startTs": 1787740140000,
+ "endTs": 1787754600000
+ },
+ {
+ "date": "2026-08-26",
+ "seq": 4,
+ "kind": "work",
+ "task": "Retour / déchargement",
+ "client": "Martin & Fils",
+ "startTs": 1787754600000,
+ "endTs": 1787757420000
+ },
+ {
+ "date": "2026-08-27",
+ "seq": 0,
+ "kind": "travel",
+ "task": "Chargement / transport",
+ "client": "Dupont SA",
+ "startTs": 1787808600000,
+ "endTs": 1787811240000
+ },
+ {
+ "date": "2026-08-27",
+ "seq": 1,
+ "kind": "work",
+ "task": "Terrassement",
+ "client": "Dupont SA",
+ "startTs": 1787811240000,
+ "endTs": 1787821500000
+ },
+ {
+ "date": "2026-08-27",
+ "seq": 2,
+ "kind": "lunch",
+ "task": "Pause déjeuner",
+ "client": null,
+ "startTs": 1787821500000,
+ "endTs": 1787825220000
+ },
+ {
+ "date": "2026-08-27",
+ "seq": 3,
+ "kind": "work",
+ "task": "Pose de câble",
+ "client": "Dupont SA",
+ "startTs": 1787825220000,
+ "endTs": 1787837820000
+ },
+ {
+ "date": "2026-08-27",
+ "seq": 4,
+ "kind": "work",
+ "task": "Retour / déchargement",
+ "client": "Dupont SA",
+ "startTs": 1787837820000,
+ "endTs": 1787840940000
+ },
+ {
+ "date": "2026-08-28",
+ "seq": 0,
+ "kind": "travel",
+ "task": "Chargement / transport",
+ "client": "Commune de Vaugines",
+ "startTs": 1787895000000,
+ "endTs": 1787897820000
+ },
+ {
+ "date": "2026-08-28",
+ "seq": 1,
+ "kind": "work",
+ "task": "Terrassement",
+ "client": "Commune de Vaugines",
+ "startTs": 1787897820000,
+ "endTs": 1787910300000
+ },
+ {
+ "date": "2026-08-28",
+ "seq": 2,
+ "kind": "lunch",
+ "task": "Pause déjeuner",
+ "client": null,
+ "startTs": 1787910300000,
+ "endTs": 1787914260000
+ },
+ {
+ "date": "2026-08-28",
+ "seq": 3,
+ "kind": "work",
+ "task": "Pose de câble",
+ "client": "Commune de Vaugines",
+ "startTs": 1787914260000,
+ "endTs": 1787925300000
+ },
+ {
+ "date": "2026-08-28",
+ "seq": 4,
+ "kind": "work",
+ "task": "Retour / déchargement",
+ "client": "Commune de Vaugines",
+ "startTs": 1787925300000,
+ "endTs": 1787928060000
+ },
+ {
+ "date": "2026-08-31",
+ "seq": 0,
+ "kind": "travel",
+ "task": "Chargement / transport",
+ "client": "Martin & Fils",
+ "startTs": 1788154200000,
+ "endTs": 1788156540000
+ },
+ {
+ "date": "2026-08-31",
+ "seq": 1,
+ "kind": "work",
+ "task": "Terrassement",
+ "client": "Martin & Fils",
+ "startTs": 1788156540000,
+ "endTs": 1788169260000
+ },
+ {
+ "date": "2026-08-31",
+ "seq": 2,
+ "kind": "lunch",
+ "task": "Pause déjeuner",
+ "client": null,
+ "startTs": 1788169260000,
+ "endTs": 1788173280000
+ },
+ {
+ "date": "2026-08-31",
+ "seq": 3,
+ "kind": "work",
+ "task": "Pose de câble",
+ "client": "Martin & Fils",
+ "startTs": 1788173280000,
+ "endTs": 1788186540000
+ },
+ {
+ "date": "2026-08-31",
+ "seq": 4,
+ "kind": "work",
+ "task": "Retour / déchargement",
+ "client": "Martin & Fils",
+ "startTs": 1788186540000,
+ "endTs": 1788189180000
+ },
+ {
+ "date": "2026-09-01",
+ "seq": 0,
+ "kind": "travel",
+ "task": "Chargement / transport",
+ "client": "Dupont SA",
+ "startTs": 1788240600000,
+ "endTs": 1788243600000
+ },
+ {
+ "date": "2026-09-01",
+ "seq": 1,
+ "kind": "work",
+ "task": "Terrassement",
+ "client": "Dupont SA",
+ "startTs": 1788243600000,
+ "endTs": 1788256440000
+ },
+ {
+ "date": "2026-09-01",
+ "seq": 2,
+ "kind": "lunch",
+ "task": "Pause déjeuner",
+ "client": null,
+ "startTs": 1788256440000,
+ "endTs": 1788259920000
+ },
+ {
+ "date": "2026-09-01",
+ "seq": 3,
+ "kind": "work",
+ "task": "Pose de câble",
+ "client": "Dupont SA",
+ "startTs": 1788259920000,
+ "endTs": 1788274080000
+ },
+ {
+ "date": "2026-09-01",
+ "seq": 4,
+ "kind": "work",
+ "task": "Retour / déchargement",
+ "client": "Dupont SA",
+ "startTs": 1788274080000,
+ "endTs": 1788277320000
+ },
+ {
+ "date": "2026-09-02",
+ "seq": 0,
+ "kind": "travel",
+ "task": "Chargement / transport",
+ "client": "Commune de Vaugines",
+ "startTs": 1788327000000,
+ "endTs": 1788329880000
+ },
+ {
+ "date": "2026-09-02",
+ "seq": 1,
+ "kind": "work",
+ "task": "Terrassement",
+ "client": "Commune de Vaugines",
+ "startTs": 1788329880000,
+ "endTs": 1788340620000
+ },
+ {
+ "date": "2026-09-02",
+ "seq": 2,
+ "kind": "lunch",
+ "task": "Pause déjeuner",
+ "client": null,
+ "startTs": 1788340620000,
+ "endTs": 1788343740000
+ },
+ {
+ "date": "2026-09-02",
+ "seq": 3,
+ "kind": "work",
+ "task": "Pose de câble",
+ "client": "Commune de Vaugines",
+ "startTs": 1788343740000,
+ "endTs": 1788358140000
+ },
+ {
+ "date": "2026-09-02",
+ "seq": 4,
+ "kind": "work",
+ "task": "Retour / déchargement",
+ "client": "Commune de Vaugines",
+ "startTs": 1788358140000,
+ "endTs": 1788361140000
+ },
+ {
+ "date": "2026-09-03",
+ "seq": 0,
+ "kind": "travel",
+ "task": "Chargement / transport",
+ "client": "Martin & Fils",
+ "startTs": 1788413400000,
+ "endTs": 1788415980000
+ },
+ {
+ "date": "2026-09-03",
+ "seq": 1,
+ "kind": "work",
+ "task": "Terrassement",
+ "client": "Martin & Fils",
+ "startTs": 1788415980000,
+ "endTs": 1788426960000
+ },
+ {
+ "date": "2026-09-03",
+ "seq": 2,
+ "kind": "lunch",
+ "task": "Pause déjeuner",
+ "client": null,
+ "startTs": 1788426960000,
+ "endTs": 1788430320000
+ },
+ {
+ "date": "2026-09-03",
+ "seq": 3,
+ "kind": "work",
+ "task": "Pose de câble",
+ "client": "Martin & Fils",
+ "startTs": 1788430320000,
+ "endTs": 1788443400000
+ },
+ {
+ "date": "2026-09-03",
+ "seq": 4,
+ "kind": "work",
+ "task": "Retour / déchargement",
+ "client": "Martin & Fils",
+ "startTs": 1788443400000,
+ "endTs": 1788446460000
+ },
+ {
+ "date": "2026-09-04",
+ "seq": 0,
+ "kind": "travel",
+ "task": "Chargement / transport",
+ "client": "Dupont SA",
+ "startTs": 1788499800000,
+ "endTs": 1788502500000
+ },
+ {
+ "date": "2026-09-04",
+ "seq": 1,
+ "kind": "work",
+ "task": "Terrassement",
+ "client": "Dupont SA",
+ "startTs": 1788502500000,
+ "endTs": 1788514620000
+ },
+ {
+ "date": "2026-09-04",
+ "seq": 2,
+ "kind": "lunch",
+ "task": "Pause déjeuner",
+ "client": null,
+ "startTs": 1788514620000,
+ "endTs": 1788518160000
+ },
+ {
+ "date": "2026-09-04",
+ "seq": 3,
+ "kind": "work",
+ "task": "Pose de câble",
+ "client": "Dupont SA",
+ "startTs": 1788518160000,
+ "endTs": 1788529320000
+ },
+ {
+ "date": "2026-09-04",
+ "seq": 4,
+ "kind": "work",
+ "task": "Retour / déchargement",
+ "client": "Dupont SA",
+ "startTs": 1788529320000,
+ "endTs": 1788532380000
+ }
+ ]
+}
\ No newline at end of file
diff --git a/src/App.svelte b/src/App.svelte
index 1446aa4..2ab90e0 100644
--- a/src/App.svelte
+++ b/src/App.svelte
@@ -46,7 +46,7 @@
a day in progress. -->
Mise à jour disponible
- Installer
+ Installer
{/if}
@@ -62,18 +62,21 @@
go('resume')}>Récap
go('today')}>Journée
go('settings')}>Réglages
diff --git a/src/components/BackupPanel.svelte b/src/components/BackupPanel.svelte
new file mode 100644
index 0000000..04b2922
--- /dev/null
+++ b/src/components/BackupPanel.svelte
@@ -0,0 +1,223 @@
+
+
+
+ Données
+
+ Exporter les données
+
+
+
+ picker.click()}>Importer un fichier…
+
+ {#if busy}Traitement en cours…
{/if}
+
+ {#if error}
+ {error}
+ {/if}
+
+ {#if result}
+ {result}
+ {/if}
+
+ {#if staged}
+
+
{filename}
+
+ {staged.dayCount} journée{staged.dayCount > 1 ? 's' : ''} ·
+ {staged.entryCount} poste{staged.entryCount > 1 ? 's' : ''}
+ {#if staged.exportedAt}
+ Exporté le {fmtDayLabel(localDateKey(Date.parse(staged.exportedAt)))} à {fmtTime(Date.parse(staged.exportedAt))}
+ {/if}
+
+ {#if staged.dropped}
+
{staged.dropped} entrée(s) illisible(s) ignorée(s).
+ {/if}
+ {#if staged.repaired}
+
{staged.repaired} poste(s) ajusté(s) pour rendre les données exploitables.
+ {/if}
+ {#if staged.closedActive}
+
+ {staged.closedActive} journée(s) en cours seront enregistrées comme terminées à l’heure de l’export.
+
+ {/if}
+
+ {#if confirmingReplace}
+
+ Toutes les journées et tous les réglages de cet appareil seront remplacés par ceux du fichier.
+ {#if dayState.day}La journée en cours sera également remplacée.{/if}
+
+
+ (confirmingReplace = false)}>Annuler
+ run('replace')}>
+ Confirmer
+
+
+ {:else}
+
run('merge')}>
+ Fusionner
+
+
+ Ajoute les journées absentes. Celles déjà enregistrées ici ne sont pas touchées,
+ et les réglages sont conservés.
+
+
(confirmingReplace = true)}>
+ Remplacer tout
+
+
+ Efface les données locales et adopte le fichier, réglages compris.
+
+
Abandonner
+ {/if}
+
+ {/if}
+
+
+ Un seul fichier JSON contient journées, postes et réglages. Les journées importées
+ sont enregistrées comme terminées.
+
+
+
+
diff --git a/src/lib/backup-db.test.js b/src/lib/backup-db.test.js
new file mode 100644
index 0000000..6e0997d
--- /dev/null
+++ b/src/lib/backup-db.test.js
@@ -0,0 +1,119 @@
+import 'fake-indexeddb/auto';
+import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
+import { db, exportBackup, getEntries, importBackup, readSettings } from './db.js';
+import { buildBackup, parseBackup } from './backup.js';
+import { atLocalTime } from './time.js';
+
+function fixture(date = '2026-09-04', task = 'Travaux') {
+ const start = atLocalTime(date, '07:30');
+ const end = atLocalTime(date, '17:30');
+ return {
+ days: [{ date, status: 'closed', plannedStartTs: start, plannedEndTs: end,
+ startedTs: start, endedTs: end, lunchType: 'panier', restaurant: null, closePromptedTs: null }],
+ entries: [{ id: 7, date, seq: 0, kind: 'work', task, client: 'Client A', startTs: start, endTs: end }],
+ settings: { theme: 'nuit', roundingMinutes: 5 },
+ };
+}
+
+const payload = (data) => parseBackup(JSON.stringify(buildBackup(data)));
+const stores = async () => ({
+ days: await db.days.toArray(), entries: await db.entries.toArray(), settings: await db.settings.toArray(),
+});
+
+beforeEach(async () => {
+ await db.days.clear();
+ await db.entries.clear();
+ await db.settings.clear();
+});
+
+afterEach(() => vi.restoreAllMocks());
+
+describe('database backup integration', () => {
+ it('exports and restores a whole dataset with settings', async () => {
+ const original = fixture();
+ await db.days.bulkAdd(original.days);
+ await db.entries.bulkAdd(original.entries);
+ await db.settings.put({ key: 'app', value: original.settings });
+ const file = await exportBackup();
+ expect(file.entries[0]).not.toHaveProperty('id');
+ const counts = await importBackup(parseBackup(JSON.stringify(file)), 'replace');
+ expect(counts).toEqual({ added: 1, skipped: 0, entries: 1 });
+ expect(await db.days.toArray()).toEqual(original.days);
+ expect((await getEntries(original.days[0].date))[0]).toMatchObject(file.entries[0]);
+ expect(await readSettings()).toEqual(original.settings);
+ });
+
+ it('merges by whole date, retaining local edits and settings, and is idempotent', async () => {
+ await importBackup(payload(fixture('2026-09-04', 'Local')), 'replace');
+ const remote = fixture('2026-09-04', 'Remote');
+ const extra = fixture('2026-09-05', 'New');
+ remote.days.push(...extra.days);
+ remote.entries.push(...extra.entries);
+ remote.settings.theme = 'jour';
+ expect(await importBackup(payload(remote), 'merge')).toEqual({ added: 1, skipped: 1, entries: 1 });
+ expect((await getEntries('2026-09-04'))[0].task).toBe('Local');
+ expect((await getEntries('2026-09-05'))[0].task).toBe('New');
+ expect((await readSettings()).theme).toBe('nuit');
+ const before = await stores();
+ expect(await importBackup(payload(remote), 'merge')).toEqual({ added: 0, skipped: 2, entries: 0 });
+ expect(await stores()).toEqual(before);
+ });
+
+ it('preserves a local running day when merging the same date', async () => {
+ const local = fixture();
+ local.days[0].status = 'active'; local.days[0].endedTs = null;
+ local.entries[0].endTs = null;
+ await db.days.bulkAdd(local.days); await db.entries.bulkAdd(local.entries);
+ const before = await stores();
+ await importBackup(payload(fixture()), 'merge');
+ expect(await stores()).toEqual(before);
+ });
+
+ it('replaces absent settings with defaults instead of leaking old device settings', async () => {
+ await importBackup(payload(fixture()), 'replace');
+ await importBackup(payload({ ...fixture('2026-09-05'), settings: null }), 'replace');
+ expect(await readSettings()).toBeNull();
+ expect(await db.days.toCollection().primaryKeys()).toEqual(['2026-09-05']);
+ expect(await getEntries('2026-09-04')).toEqual([]);
+ });
+
+ it('can restore a valid empty backup', async () => {
+ await importBackup(payload(fixture()), 'replace');
+ expect(await importBackup(payload({ days: [], entries: [], settings: null }), 'replace'))
+ .toEqual({ added: 0, skipped: 0, entries: 0 });
+ expect(await stores()).toEqual({ days: [], entries: [], settings: [] });
+ });
+
+ it('rolls back all stores if writing settings fails after inserting entries', async () => {
+ await importBackup(payload(fixture()), 'replace');
+ const before = await stores();
+ vi.spyOn(db.settings, 'put').mockRejectedValueOnce(new Error('Disk full'));
+ await expect(importBackup(payload(fixture('2026-09-05')), 'replace')).rejects.toThrow('Disk full');
+ expect(await stores()).toEqual(before);
+ });
+
+ it('does not partly merge days when entry insertion fails', async () => {
+ await importBackup(payload(fixture()), 'replace');
+ const before = await stores();
+ vi.spyOn(db.entries, 'bulkAdd').mockRejectedValueOnce(new Error('Write failed'));
+ await expect(importBackup(payload(fixture('2026-09-05')), 'merge')).rejects.toThrow('Write failed');
+ expect(await stores()).toEqual(before);
+ });
+
+ it('rejects invalid settings, duplicate dates and an unknown mode before clearing data', async () => {
+ await importBackup(payload(fixture()), 'replace');
+ const before = await stores();
+ expect(() => importBackup({ ...fixture(), settings: [] }, 'replace')).toThrow();
+ const duplicated = fixture(); duplicated.days.push({ ...duplicated.days[0] });
+ expect(() => importBackup(duplicated, 'replace')).toThrow(/double/);
+ expect(() => importBackup(payload(fixture()), 'typo')).toThrow(/Mode/);
+ expect(await stores()).toEqual(before);
+ });
+
+ it('accepts proxied UI records without a DataCloneError', async () => {
+ const data = payload(fixture());
+ data.days = new Proxy(data.days.map((day) => new Proxy(day, {})), {});
+ data.entries = new Proxy(data.entries.map((entry) => new Proxy(entry, {})), {});
+ await expect(importBackup(new Proxy(data, {}), 'replace')).resolves.toMatchObject({ added: 1 });
+ });
+});
diff --git a/src/lib/backup-import.test.js b/src/lib/backup-import.test.js
new file mode 100644
index 0000000..98d9169
--- /dev/null
+++ b/src/lib/backup-import.test.js
@@ -0,0 +1,65 @@
+import 'fake-indexeddb/auto';
+import { beforeEach, afterEach, expect, it, vi } from 'vitest';
+import { db, getActiveDay } from './db.js';
+import { buildBackup, parseBackup } from './backup.js';
+import { applyImport, dayState, reconcile } from './day.svelte.js';
+import { DEFAULT_SETTINGS, settings } from './settings.svelte.js';
+import { atLocalTime, localDateKey } from './time.js';
+
+beforeEach(async () => {
+ await db.days.clear(); await db.entries.clear(); await db.settings.clear();
+ Object.assign(settings, DEFAULT_SETTINGS, { workdays: [] });
+ vi.stubGlobal('document', { documentElement: { dataset: {} } });
+});
+afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); });
+
+it('reloads imported settings, theme, resume and suggestions without immediately purging old days', async () => {
+ const data = {
+ days: ['2020-01-06', '2020-01-07'].map((date) => ({ date, status: 'closed' })),
+ entries: ['2020-01-06', '2020-01-07'].map((date) => ({
+ date, seq: 0, kind: 'work', task: 'Imported task', client: 'Imported client',
+ startTs: atLocalTime(date, '08:00'), endTs: atLocalTime(date, '10:00'),
+ })),
+ settings: { workdays: [], theme: 'nuit', roundingMinutes: 5, retentionDays: 30 },
+ };
+ const parsed = parseBackup(JSON.stringify(buildBackup(data)));
+ await applyImport(parsed, 'replace');
+ expect(await db.days.count()).toBe(2);
+ expect(settings.theme).toBe('nuit');
+ expect(document.documentElement.dataset.theme).toBe('nuit');
+ expect(dayState.lastClosed.date).toBe('2020-01-07');
+ expect(dayState.model.lastClient).toBe('Imported client');
+ expect(dayState.importing).toBe(false);
+ await reconcile();
+ expect(await db.days.count()).toBe(2);
+});
+
+it('pauses clock reconciliation for the duration of an import', async () => {
+ Object.assign(settings, { workdays: [0, 1, 2, 3, 4, 5, 6], dayStart: '00:00' });
+ const payload = parseBackup(JSON.stringify(buildBackup({ days: [], entries: [], settings: { workdays: [] } })));
+ const pending = applyImport(payload, 'replace');
+ expect(dayState.importing).toBe(true);
+ await reconcile(atLocalTime(localDateKey(), '12:00'));
+ await pending;
+ expect(await getActiveDay()).toBeUndefined();
+ expect(dayState.importing).toBe(false);
+});
+
+it('waits for a clock operation already in progress before replacing its data', async () => {
+ Object.assign(settings, { workdays: [0, 1, 2, 3, 4, 5, 6], dayStart: '00:00' });
+ const ticking = reconcile(atLocalTime(localDateKey(), '12:00'));
+ const payload = parseBackup(JSON.stringify(buildBackup({ days: [], entries: [], settings: { workdays: [] } })));
+ await applyImport(payload, 'replace');
+ await ticking;
+ expect(await db.days.count()).toBe(0);
+ expect(await db.entries.count()).toBe(0);
+});
+
+it('releases the import guard on failure and keeps the previous settings in memory', async () => {
+ settings.theme = 'nuit';
+ const payload = parseBackup(JSON.stringify(buildBackup({ days: [], entries: [], settings: { theme: 'jour' } })));
+ vi.spyOn(db.settings, 'put').mockRejectedValueOnce(new Error('Disk full'));
+ await expect(applyImport(payload, 'replace')).rejects.toThrow('Disk full');
+ expect(dayState.importing).toBe(false);
+ expect(settings.theme).toBe('nuit');
+});
diff --git a/src/lib/backup.js b/src/lib/backup.js
new file mode 100644
index 0000000..a126a41
--- /dev/null
+++ b/src/lib/backup.js
@@ -0,0 +1,241 @@
+/**
+ * Export / import of the whole local dataset as one JSON file.
+ *
+ * Pure functions: no database access, so the validation and repair rules are
+ * unit-testable. The parse side is deliberately strict about *recognising* a file
+ * and forgiving about its contents — a foreign file must be rejected outright,
+ * but our own file with a slightly odd entry should still import.
+ */
+
+import { localDateKey } from './time.js';
+
+export const EXPORT_FORMAT = 'timelog-export';
+export const EXPORT_VERSION = 1;
+export const MAX_BACKUP_BYTES = 20 * 1024 * 1024;
+
+/** Thrown for anything the user needs to read; message is shown verbatim. */
+export class BackupError extends Error {}
+
+const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
+const isNum = (v) => typeof v === 'number' && Number.isFinite(v);
+const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
+const isTimestamp = (v) => isNum(v) && Number.isFinite(new Date(v).getTime());
+const isDate = (v) => {
+ if (typeof v !== 'string' || !DATE_RE.test(v)) return false;
+ const ts = Date.parse(`${v}T00:00:00Z`);
+ return Number.isFinite(ts) && new Date(ts).toISOString().slice(0, 10) === v;
+};
+
+/** Only recognised settings enter the database; missing values use app defaults. */
+function parseSettings(raw) {
+ if (raw == null) return null;
+ if (!isObject(raw)) throw new BackupError('Réglages invalides dans le fichier.');
+ const clock = (v) => typeof v === 'string' && /^([01]\d|2[0-3]):[0-5]\d$/.test(v);
+ const rules = {
+ workdays: (v) => Array.isArray(v) && v.every((n) => Number.isInteger(n) && n >= 0 && n <= 6),
+ dayStart: clock,
+ dayEnd: clock,
+ lunchStart: clock,
+ lunchMinutes: (v) => Number.isInteger(v) && v >= 0 && v <= 1440,
+ lunchType: (v) => ['panier', 'restaurant'].includes(v),
+ roundingMinutes: (v) => [1, 5, 15, 30].includes(v),
+ theme: (v) => ['jour', 'nuit'].includes(v),
+ retentionDays: (v) => Number.isSafeInteger(v) && v >= 0,
+ };
+ const out = {};
+ for (const [key, validate] of Object.entries(rules)) {
+ if (!Object.hasOwn(raw, key)) continue;
+ if (!validate(raw[key])) throw new BackupError(`Réglage invalide dans le fichier : ${key}.`);
+ out[key] = key === 'workdays' ? [...new Set(raw[key])].sort() : raw[key];
+ }
+ return out;
+}
+
+/* ------------------------------------------------------------------- export */
+
+/**
+ * @param {{days: Array, entries: Array, settings: object|null}} data
+ * @param {number} [now]
+ */
+export function buildBackup({ days, entries, settings }, now = Date.now()) {
+ return {
+ format: EXPORT_FORMAT,
+ version: EXPORT_VERSION,
+ exportedAt: new Date(now).toISOString(),
+ settings: settings ?? null,
+ days: [...days].sort((a, b) => a.date.localeCompare(b.date)),
+ // Row ids are internal to Dexie and would collide on import; entries are
+ // addressed by date + seq instead.
+ entries: [...entries]
+ .map(({ id, ...rest }) => rest)
+ .sort((a, b) => a.date.localeCompare(b.date) || a.seq - b.seq),
+ };
+}
+
+export function backupFilename(now = Date.now()) {
+ return `timelog-${localDateKey(now)}.json`;
+}
+
+/* ------------------------------------------------------------------- import */
+
+/**
+ * Validate and normalise a backup file.
+ *
+ * @param {string} text raw file contents
+ * @returns {{days: Array, entries: Array, settings: object|null, exportedAt: string|null,
+ * dayCount: number, entryCount: number, dropped: number,
+ * repaired: number, closedActive: number}}
+ * @throws {BackupError}
+ */
+export function parseBackup(text) {
+ if (text.length > MAX_BACKUP_BYTES) {
+ throw new BackupError('Fichier trop volumineux (20 Mo maximum).');
+ }
+ let raw;
+ try {
+ raw = JSON.parse(text.replace(/^\uFEFF/, ''));
+ } catch {
+ throw new BackupError("Fichier illisible : ce n'est pas du JSON.");
+ }
+
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
+ throw new BackupError("Ce fichier n'est pas un export TimeLog.");
+ }
+ if (raw.format !== EXPORT_FORMAT) {
+ throw new BackupError("Ce fichier n'est pas un export TimeLog.");
+ }
+ if (isNum(raw.version) && raw.version > EXPORT_VERSION) {
+ throw new BackupError(
+ 'Fichier créé par une version plus récente de TimeLog. Mettez à jour avant de l’importer.',
+ );
+ }
+ if (raw.version !== EXPORT_VERSION) {
+ throw new BackupError('Version du format TimeLog non prise en charge.');
+ }
+ if (!Array.isArray(raw.days) || !Array.isArray(raw.entries)) {
+ throw new BackupError('Export incomplet : journées ou postes manquants.');
+ }
+
+ const exportTs = typeof raw.exportedAt === 'string' ? Date.parse(raw.exportedAt) : NaN;
+ const { days, entries, dropped, repaired, closedActive } = normalise(raw.days, raw.entries, exportTs);
+ // An export from a new device is a valid (empty) backup. An entirely damaged
+ // non-empty file must never become a successful "replace with nothing".
+ if (!days.length && (raw.days.length || raw.entries.length)) {
+ throw new BackupError('Aucune journée exploitable dans ce fichier.');
+ }
+
+ return {
+ days,
+ entries,
+ settings: parseSettings(raw.settings),
+ exportedAt: Number.isFinite(exportTs) ? new Date(exportTs).toISOString() : null,
+ dayCount: days.length,
+ entryCount: entries.length,
+ dropped,
+ repaired,
+ closedActive,
+ };
+}
+
+/**
+ * Rebuild a clean, self-consistent dataset from whatever the file contained.
+ *
+ * Every imported day is stored as *closed*. A day in progress is not something
+ * that transfers between devices, and admitting one risks a second simultaneously
+ * active day, which the day lifecycle assumes cannot happen.
+ */
+function normalise(rawDays, rawEntries, exportTs) {
+ let dropped = 0;
+ let repairedCount = 0;
+ let closedActive = 0;
+
+ const byDate = new Map();
+ for (const entry of rawEntries) {
+ if (!isObject(entry) || !isDate(entry.date) || !isTimestamp(entry.startTs)) {
+ dropped++;
+ continue;
+ }
+ if (!byDate.has(entry.date)) byDate.set(entry.date, []);
+ byDate.get(entry.date).push(entry);
+ }
+
+ const days = [];
+ const entries = [];
+ const seenDates = new Set();
+
+ for (const rawDay of [...rawDays].sort((a, b) => String(a?.date).localeCompare(String(b?.date)))) {
+ if (!isObject(rawDay) || !isDate(rawDay.date)) {
+ dropped++;
+ continue;
+ }
+ if (seenDates.has(rawDay.date)) {
+ throw new BackupError(`Journée en double dans le fichier : ${rawDay.date}.`);
+ }
+ seenDates.add(rawDay.date);
+ const own = (byDate.get(rawDay.date) ?? []).sort(
+ (a, b) => a.startTs - b.startTs || (a.seq ?? 0) - (b.seq ?? 0),
+ );
+ // A day with nothing in it produces an empty resume; leave it out.
+ if (!own.length) {
+ dropped++;
+ continue;
+ }
+ byDate.delete(rawDay.date);
+
+ // Repair overlaps and missing boundaries, but preserve legitimate gaps (e.g.
+ // a day stopped and resumed later). An import must not invent worked time.
+ const repaired = own.map((entry, seq) => ({
+ date: rawDay.date,
+ seq,
+ kind: ['travel', 'work', 'lunch'].includes(entry.kind) ? entry.kind : 'work',
+ task: typeof entry.task === 'string' && entry.task ? entry.task : 'Poste',
+ client: typeof entry.client === 'string' && entry.client ? entry.client : null,
+ startTs: entry.startTs,
+ endTs: isTimestamp(entry.endTs) ? entry.endTs : null,
+ }));
+
+ for (let i = 0; i < repaired.length - 1; i++) {
+ const entry = repaired[i];
+ if (entry.endTs == null || entry.endTs < entry.startTs || entry.endTs > repaired[i + 1].startTs) {
+ entry.endTs = repaired[i + 1].startTs;
+ }
+ }
+ const last = repaired[repaired.length - 1];
+ if (last.endTs == null) {
+ const end = rawDay.status === 'active' ? exportTs : rawDay.endedTs;
+ if (rawDay.status === 'active' && !isTimestamp(end)) {
+ throw new BackupError('Date d’export manquante ou invalide pour une journée en cours.');
+ }
+ last.endTs = isTimestamp(end) ? Math.max(last.startTs, end) : last.startTs;
+ } else if (last.endTs < last.startTs) last.endTs = last.startTs;
+ if (rawDay.status === 'active') closedActive++;
+
+ const endedTs = last.endTs;
+ const lunchType = rawDay.lunchType === 'restaurant' ? 'restaurant' : 'panier';
+
+ days.push({
+ date: rawDay.date,
+ status: 'closed',
+ plannedStartTs: isTimestamp(rawDay.plannedStartTs) ? rawDay.plannedStartTs : repaired[0].startTs,
+ plannedEndTs: isTimestamp(rawDay.plannedEndTs) ? rawDay.plannedEndTs : endedTs,
+ startedTs: repaired[0].startTs,
+ endedTs,
+ lunchType,
+ restaurant:
+ lunchType === 'restaurant' && typeof rawDay.restaurant === 'string'
+ ? rawDay.restaurant
+ : null,
+ closePromptedTs: rawDay.status !== 'active' && isTimestamp(rawDay.closePromptedTs)
+ ? rawDay.closePromptedTs : null,
+ });
+ // Compare the fields we retain, not database ids or unrelated file metadata.
+ const changed = repaired.filter((entry, i) =>
+ Object.keys(entry).some((key) => entry[key] !== own[i][key]),
+ ).length;
+ for (const entry of repaired) entries.push(entry);
+ repairedCount += changed;
+ }
+
+ for (const orphaned of byDate.values()) dropped += orphaned.length;
+ return { days, entries, dropped, repaired: repairedCount, closedActive };
+}
diff --git a/src/lib/backup.test.js b/src/lib/backup.test.js
new file mode 100644
index 0000000..38c6c31
--- /dev/null
+++ b/src/lib/backup.test.js
@@ -0,0 +1,278 @@
+import { describe, expect, it } from 'vitest';
+import {
+ BackupError,
+ backupFilename,
+ buildBackup,
+ EXPORT_FORMAT,
+ EXPORT_VERSION,
+ parseBackup,
+} from './backup.js';
+import { atLocalTime, MINUTE } from './time.js';
+import { buildResume } from './resume.js';
+import sample from './fixtures/backup.json';
+
+const at = (date, hhmm) => atLocalTime(date, hhmm);
+
+function fixture() {
+ const days = [
+ {
+ date: '2026-09-04',
+ status: 'closed',
+ plannedStartTs: at('2026-09-04', '07:30'),
+ plannedEndTs: at('2026-09-04', '17:30'),
+ startedTs: at('2026-09-04', '07:30'),
+ endedTs: at('2026-09-04', '17:30'),
+ lunchType: 'restaurant',
+ restaurant: 'Le Relais',
+ closePromptedTs: null,
+ },
+ ];
+ const entries = [
+ { id: 7, date: '2026-09-04', seq: 0, kind: 'travel', task: 'Chargement / transport', client: 'Dupont SA', startTs: at('2026-09-04', '07:30'), endTs: at('2026-09-04', '08:15') },
+ { id: 8, date: '2026-09-04', seq: 1, kind: 'work', task: 'Terrassement', client: 'Dupont SA', startTs: at('2026-09-04', '08:15'), endTs: at('2026-09-04', '17:30') },
+ ];
+ return { days, entries, settings: { roundingMinutes: 15, theme: 'jour' } };
+}
+
+const roundTrip = (data) => parseBackup(JSON.stringify(buildBackup(data)));
+
+describe('buildBackup', () => {
+ it('stamps the format so a foreign file can be recognised', () => {
+ const out = buildBackup(fixture());
+ expect(out.format).toBe(EXPORT_FORMAT);
+ expect(out.version).toBe(EXPORT_VERSION);
+ expect(typeof out.exportedAt).toBe('string');
+ });
+
+ it('strips row ids, which are local to one database', () => {
+ expect(buildBackup(fixture()).entries.every((e) => !('id' in e))).toBe(true);
+ });
+
+ it('names the file by date', () => {
+ expect(backupFilename(at('2026-09-06', '12:00'))).toBe('timelog-2026-09-06.json');
+ });
+});
+
+describe('round trip', () => {
+ it('preserves days, entries and settings', () => {
+ const out = roundTrip(fixture());
+ expect(out.dayCount).toBe(1);
+ expect(out.entryCount).toBe(2);
+ expect(out.settings).toEqual({ roundingMinutes: 15, theme: 'jour' });
+ expect(out.days[0].restaurant).toBe('Le Relais');
+ expect(out.entries.map((e) => e.task)).toEqual([
+ 'Chargement / transport',
+ 'Terrassement',
+ ]);
+ });
+
+ it('keeps the chain gapless', () => {
+ const { entries } = roundTrip(fixture());
+ expect(entries.every((e, i) => i === 0 || entries[i - 1].endTs === e.startTs)).toBe(true);
+ });
+});
+
+describe('parseBackup rejects what it should', () => {
+ it('refuses non-JSON', () => {
+ expect(() => parseBackup('not json at all')).toThrow(BackupError);
+ });
+
+ it('refuses a JSON file that is not ours', () => {
+ expect(() => parseBackup('{"hello":"world"}')).toThrow(/pas un export TimeLog/);
+ });
+
+ it('refuses a bare array', () => {
+ expect(() => parseBackup('[]')).toThrow(BackupError);
+ });
+
+ it('refuses a newer format version', () => {
+ const raw = { ...buildBackup(fixture()), version: EXPORT_VERSION + 1 };
+ expect(() => parseBackup(JSON.stringify(raw))).toThrow(/version plus récente/);
+ });
+
+ it('refuses a damaged non-empty file with no usable day', () => {
+ const raw = { ...buildBackup(fixture()), entries: [] };
+ expect(() => parseBackup(JSON.stringify(raw))).toThrow(/Aucune journée/);
+ });
+
+ it('reports missing tables rather than importing nothing', () => {
+ const raw = { format: EXPORT_FORMAT, version: 1 };
+ expect(() => parseBackup(JSON.stringify(raw))).toThrow(/Export incomplet/);
+ });
+
+ it.each([0, -1, 0.5, '1', null])('refuses unsupported version %s', (version) => {
+ expect(() => parseBackup(JSON.stringify({ ...buildBackup(fixture()), version }))).toThrow(BackupError);
+ });
+
+ it('rejects duplicate dates before any database write', () => {
+ const data = fixture();
+ data.days.push({ ...data.days[0] });
+ expect(() => roundTrip(data)).toThrow(/Journée en double/);
+ });
+
+ it('rejects impossible calendar dates and out-of-range timestamps', () => {
+ const data = fixture();
+ data.days[0].date = data.entries[0].date = data.entries[1].date = '2026-02-30';
+ expect(() => roundTrip(data)).toThrow(/Aucune journée/);
+ const huge = fixture();
+ huge.entries.forEach((e) => (e.startTs = 1e30));
+ expect(() => roundTrip(huge)).toThrow(/Aucune journée/);
+ });
+
+ it.each([
+ { workdays: 'monday' }, { dayStart: '' }, { dayEnd: '25:00' },
+ { roundingMinutes: 0 }, { theme: 'other' }, { retentionDays: -1 }, [],
+ ])('rejects settings that would break the application: %j', (settings) => {
+ expect(() => roundTrip({ ...fixture(), settings })).toThrow(/Réglage/);
+ });
+});
+
+describe('backup fidelity', () => {
+ it('accepts an empty database and a settings-only backup', () => {
+ const settings = { theme: 'nuit', workdays: [] };
+ expect(roundTrip({ days: [], entries: [], settings })).toMatchObject({
+ dayCount: 0, entryCount: 0, settings,
+ });
+ });
+
+ it('preserves every worked total in the provided 20-day fixture', () => {
+ const out = parseBackup(JSON.stringify(sample));
+ expect(out.dayCount).toBe(sample.days.length);
+ expect(out.entryCount).toBe(sample.entries.length);
+ expect(out.dropped).toBe(0);
+ for (const day of sample.days) {
+ const original = buildResume(day, sample.entries.filter((e) => e.date === day.date));
+ const restored = buildResume(out.days.find((d) => d.date === day.date), out.entries.filter((e) => e.date === day.date));
+ expect(restored).toEqual(original);
+ }
+ });
+
+ it('preserves a gap after a day was stopped and resumed', () => {
+ const data = fixture();
+ data.entries[1].startTs += 30 * MINUTE;
+ const out = roundTrip(data);
+ expect(out.entries[1].startTs - out.entries[0].endTs).toBe(30 * MINUTE);
+ expect(buildResume(out.days[0], out.entries)).toEqual(buildResume(data.days[0], data.entries));
+ });
+
+ it('closes a running entry at export time, preserving its elapsed work', () => {
+ const data = fixture();
+ data.days[0].status = 'active';
+ data.days[0].endedTs = null;
+ data.entries[1].endTs = null;
+ const now = at('2026-09-04', '10:00');
+ const out = parseBackup(JSON.stringify(buildBackup(data, now)));
+ expect(out.entries[1].endTs).toBe(now);
+ expect(out.days[0].endedTs).toBe(now);
+ expect(out.closedActive).toBe(1);
+ expect(buildResume(out.days[0], out.entries).workedMs).toBe(150 * MINUTE);
+ });
+
+ it('requires a usable export timestamp to finish an open entry', () => {
+ const data = fixture();
+ data.days[0].status = 'active';
+ data.entries[1].endTs = null;
+ const raw = { ...buildBackup(data), exportedAt: 'broken' };
+ expect(() => parseBackup(JSON.stringify(raw))).toThrow(/Date d’export/);
+ });
+
+ it('ignores an invalid optional export date without breaking the preview', () => {
+ const raw = { ...buildBackup(fixture()), exportedAt: 'broken' };
+ expect(parseBackup(JSON.stringify(raw)).exportedAt).toBeNull();
+ });
+
+ it('counts orphan entries and malformed records instead of silently dropping them', () => {
+ const data = buildBackup(fixture());
+ data.entries.push(null, { ...data.entries[0], date: '2026-09-05' });
+ expect(parseBackup(JSON.stringify(data)).dropped).toBe(2);
+ });
+
+ it('accepts a UTF-8 BOM and filters unknown settings keys', () => {
+ const raw = buildBackup(fixture());
+ raw.settings.loaded = true;
+ raw.settings.workdays = [5, 1, 1];
+ const out = parseBackup('\uFEFF' + JSON.stringify(raw));
+ expect(out.settings).toEqual({ roundingMinutes: 15, theme: 'jour', workdays: [1, 5] });
+ });
+});
+
+describe('parseBackup repairs its own files', () => {
+ const withDays = (days, entries) =>
+ JSON.stringify({ format: EXPORT_FORMAT, version: 1, days, entries });
+
+ it('forces an imported day closed, so there is never a second active day', () => {
+ const data = fixture();
+ data.days[0].status = 'active';
+ data.days[0].endedTs = null;
+ data.entries[1].endTs = null;
+ const out = roundTrip(data);
+ expect(out.days[0].status).toBe('closed');
+ expect(out.days[0].endedTs).toBe(out.entries[1].endTs);
+ });
+
+ it('renumbers seq densely and orders by start time', () => {
+ const data = fixture();
+ data.entries[0].seq = 5;
+ data.entries[1].seq = 99;
+ const out = roundTrip(data);
+ expect(out.entries.map((e) => e.seq)).toEqual([0, 1]);
+ expect(out.entries[0].task).toBe('Chargement / transport');
+ });
+
+ it('drops an entry with no usable start time and counts it', () => {
+ const data = fixture();
+ data.entries.push({ date: '2026-09-04', seq: 2, task: 'Cassé', startTs: 'nope' });
+ const out = roundTrip(data);
+ expect(out.dropped).toBe(1);
+ expect(out.entryCount).toBe(2);
+ });
+
+ it('drops a day that carries no entries', () => {
+ const raw = withDays(
+ [
+ { date: '2026-09-04', status: 'closed' },
+ { date: '2026-09-05', status: 'closed' },
+ ],
+ [{ date: '2026-09-04', seq: 0, kind: 'work', task: 'A', startTs: at('2026-09-04', '08:00'), endTs: at('2026-09-04', '09:00') }],
+ );
+ const out = parseBackup(raw);
+ expect(out.dayCount).toBe(1);
+ expect(out.dropped).toBe(1);
+ });
+
+ it('derives day bounds when the file omits them', () => {
+ const raw = withDays(
+ [{ date: '2026-09-04', status: 'closed' }],
+ [
+ { date: '2026-09-04', seq: 0, kind: 'work', task: 'A', startTs: at('2026-09-04', '08:00'), endTs: at('2026-09-04', '09:00') },
+ { date: '2026-09-04', seq: 1, kind: 'work', task: 'B', startTs: at('2026-09-04', '09:00'), endTs: at('2026-09-04', '11:30') },
+ ],
+ );
+ const out = parseBackup(raw);
+ expect(out.days[0].startedTs).toBe(at('2026-09-04', '08:00'));
+ expect(out.days[0].endedTs).toBe(at('2026-09-04', '11:30'));
+ });
+
+ it('substitutes a placeholder for a nameless task', () => {
+ const raw = withDays(
+ [{ date: '2026-09-04', status: 'closed' }],
+ [{ date: '2026-09-04', seq: 0, startTs: at('2026-09-04', '08:00'), endTs: at('2026-09-04', '09:00') }],
+ );
+ expect(parseBackup(raw).entries[0].task).toBe('Poste');
+ });
+
+ it('discards a restaurant name on a panier day', () => {
+ const data = fixture();
+ data.days[0].lunchType = 'panier';
+ expect(roundTrip(data).days[0].restaurant).toBeNull();
+ });
+
+ it('repairs an end time that precedes its own start', () => {
+ const raw = withDays(
+ [{ date: '2026-09-04', status: 'closed' }],
+ [{ date: '2026-09-04', seq: 0, kind: 'work', task: 'A', startTs: at('2026-09-04', '10:00'), endTs: at('2026-09-04', '08:00') }],
+ );
+ const out = parseBackup(raw);
+ expect(out.entries[0].endTs).toBe(out.entries[0].startTs);
+ });
+});
diff --git a/src/lib/day.svelte.js b/src/lib/day.svelte.js
index 37b50d9..7f1a53c 100644
--- a/src/lib/day.svelte.js
+++ b/src/lib/day.svelte.js
@@ -15,13 +15,14 @@ import {
getEntries,
getHistory,
getLastClosedDay,
+ importBackup,
patchDay,
patchEntry,
putDay,
setEntryStart,
} from './db.js';
import { buildModel, suggestClients, suggestTasks, TRANSPORT_TASK } from './predict.js';
-import { settings } from './settings.svelte.js';
+import { loadSettings, settings } from './settings.svelte.js';
import { atLocalTime, HOUR, localDateKey, roundTs, weekday } from './time.js';
export const dayState = $state({
@@ -37,6 +38,8 @@ export const dayState = $state({
/** True once the configured end time has passed and the worker has not answered. */
needsClosePrompt: false,
loaded: false,
+ /** Blocks clock reconciliation while an imported snapshot is installed. */
+ importing: false,
});
export const running = () => dayState.entries.find((e) => e.endTs == null) ?? null;
@@ -45,12 +48,24 @@ export const running = () => dayState.entries.find((e) => e.endTs == null) ?? nu
/** Retention runs once per session, not on every 30 s tick. */
let cleanedUpFor = null;
+const pendingReconciliations = new Set();
/**
* Bring stored state in line with the clock. Safe to call often — it is invoked on
* load, on tab focus, and from a 30 s tick.
*/
-export async function reconcile(now = Date.now()) {
+export function reconcile(now = Date.now()) {
+ if (dayState.importing) return Promise.resolve();
+ const pending = reconcileDay(now);
+ pendingReconciliations.add(pending);
+ pending.then(
+ () => pendingReconciliations.delete(pending),
+ () => pendingReconciliations.delete(pending),
+ );
+ return pending;
+}
+
+async function reconcileDay(now) {
dayState.today = localDateKey(now);
// Applying retention here is what makes the setting mean anything; leaving it to
@@ -203,6 +218,34 @@ export async function removeEntry(id) {
await refresh();
}
+/**
+ * Apply an imported dataset, then rebuild everything that was derived from the old
+ * one: the settings rune (replace mode adopts the file's settings), the prediction
+ * model, and the resume.
+ *
+ * @param {{days: Array, entries: Array, settings: object|null}} payload
+ * @param {'merge'|'replace'} mode
+ */
+export async function applyImport(payload, mode) {
+ if (dayState.importing) throw new Error('Un import est déjà en cours.');
+ dayState.importing = true;
+ try {
+ // Let an already-running clock operation finish before replacing its data.
+ // The guard above prevents subsequent ticks/focus events during the import.
+ await Promise.allSettled([...pendingReconciliations]);
+ const result = await importBackup(payload, mode);
+ if (mode === 'replace') await loadSettings();
+ // Do not immediately purge old imported days or create a new working day.
+ // Normal retention resumes next session/day; manual purge remains available.
+ dayState.today = localDateKey();
+ cleanedUpFor = dayState.today;
+ await refresh();
+ return result;
+ } finally {
+ dayState.importing = false;
+ }
+}
+
export async function endDay(now = Date.now()) {
if (!dayState.day) return;
const date = dayState.day.date;
diff --git a/src/lib/db.js b/src/lib/db.js
index eb791dd..f3ed24b 100644
--- a/src/lib/db.js
+++ b/src/lib/db.js
@@ -1,4 +1,5 @@
import Dexie from 'dexie';
+import { BackupError, buildBackup, EXPORT_FORMAT, EXPORT_VERSION, parseBackup } from './backup.js';
import { DAY, localDateKey } from './time.js';
/**
@@ -238,3 +239,65 @@ export async function cleanupOldDays(retentionDays) {
});
return keys.length;
}
+
+/**
+ * Write an imported dataset.
+ *
+ * `replace` wipes the local days and entries first and adopts the file's settings.
+ * `merge` adds only days the device does not already have and leaves settings
+ * alone — merging two devices' settings is meaningless, and keeping the local copy
+ * of a day the device already knows about never loses work that was done here.
+ *
+ * @param {{days: Array, entries: Array, settings: object|null}} payload
+ * @param {'merge'|'replace'} mode
+ * @returns {Promise<{added: number, skipped: number, entries: number}>}
+ */
+export function importBackup(payload, mode) {
+ if (mode !== 'merge' && mode !== 'replace') {
+ throw new BackupError('Mode d’import inconnu.');
+ }
+ // Validate again at the write boundary and turn Svelte state proxies into plain
+ // records. IndexedDB cannot structured-clone a proxy (DataCloneError).
+ const { days, entries, settings } = parseBackup(JSON.stringify({
+ ...payload,
+ format: EXPORT_FORMAT,
+ version: EXPORT_VERSION,
+ }));
+ return db.transaction('rw', db.days, db.entries, db.settings, async () => {
+ if (mode === 'replace') {
+ await db.days.clear();
+ await db.entries.clear();
+ await db.settings.clear();
+ }
+
+ const present = new Set((await db.days.toArray()).map((d) => d.date));
+ const incoming = days.filter((d) => !present.has(d.date));
+ const wanted = new Set(incoming.map((d) => d.date));
+ const incomingEntries = entries
+ .filter((e) => wanted.has(e.date))
+ .map(({ id, ...rest }) => rest);
+
+ if (incoming.length) await db.days.bulkAdd(incoming);
+ if (incomingEntries.length) await db.entries.bulkAdd(incomingEntries);
+
+ if (mode === 'replace' && settings) {
+ await db.settings.put({ key: 'app', value: settings });
+ }
+
+ return {
+ added: incoming.length,
+ skipped: days.length - incoming.length,
+ entries: incomingEntries.length,
+ };
+ });
+}
+
+/** A consistent snapshot of all three stores, even while another tab writes. */
+export function exportBackup() {
+ return db.transaction('r', db.days, db.entries, db.settings, async () => {
+ const [days, entries, settings] = await Promise.all([
+ db.days.toArray(), db.entries.toArray(), readSettings(),
+ ]);
+ return buildBackup({ days, entries, settings });
+ });
+}
diff --git a/src/lib/fixtures/backup.json b/src/lib/fixtures/backup.json
new file mode 100644
index 0000000..1eb9a46
--- /dev/null
+++ b/src/lib/fixtures/backup.json
@@ -0,0 +1,1130 @@
+{
+ "format": "timelog-export",
+ "version": 1,
+ "exportedAt": "2026-09-06T15:25:43.430Z",
+ "settings": null,
+ "days": [
+ {
+ "date": "2026-08-10",
+ "status": "closed",
+ "plannedStartTs": 1786339800000,
+ "plannedEndTs": 1786375800000,
+ "startedTs": 1786339800000,
+ "endedTs": 1786372440000,
+ "lunchType": "restaurant",
+ "restaurant": "Chez Marcel",
+ "closePromptedTs": null
+ },
+ {
+ "date": "2026-08-11",
+ "status": "closed",
+ "plannedStartTs": 1786426200000,
+ "plannedEndTs": 1786462200000,
+ "startedTs": 1786426200000,
+ "endedTs": 1786459440000,
+ "lunchType": "restaurant",
+ "restaurant": "Le Relais",
+ "closePromptedTs": null
+ },
+ {
+ "date": "2026-08-12",
+ "status": "closed",
+ "plannedStartTs": 1786512600000,
+ "plannedEndTs": 1786548600000,
+ "startedTs": 1786512600000,
+ "endedTs": 1786548120000,
+ "lunchType": "restaurant",
+ "restaurant": "La Bonne Fourchette",
+ "closePromptedTs": null
+ },
+ {
+ "date": "2026-08-13",
+ "status": "closed",
+ "plannedStartTs": 1786599000000,
+ "plannedEndTs": 1786635000000,
+ "startedTs": 1786599000000,
+ "endedTs": 1786634700000,
+ "lunchType": "panier",
+ "restaurant": null,
+ "closePromptedTs": null
+ },
+ {
+ "date": "2026-08-14",
+ "status": "closed",
+ "plannedStartTs": 1786685400000,
+ "plannedEndTs": 1786721400000,
+ "startedTs": 1786685400000,
+ "endedTs": 1786719780000,
+ "lunchType": "restaurant",
+ "restaurant": "Le Relais",
+ "closePromptedTs": null
+ },
+ {
+ "date": "2026-08-17",
+ "status": "closed",
+ "plannedStartTs": 1786944600000,
+ "plannedEndTs": 1786980600000,
+ "startedTs": 1786944600000,
+ "endedTs": 1786977660000,
+ "lunchType": "restaurant",
+ "restaurant": "La Bonne Fourchette",
+ "closePromptedTs": null
+ },
+ {
+ "date": "2026-08-18",
+ "status": "closed",
+ "plannedStartTs": 1787031000000,
+ "plannedEndTs": 1787067000000,
+ "startedTs": 1787031000000,
+ "endedTs": 1787064480000,
+ "lunchType": "restaurant",
+ "restaurant": "Chez Marcel",
+ "closePromptedTs": null
+ },
+ {
+ "date": "2026-08-19",
+ "status": "closed",
+ "plannedStartTs": 1787117400000,
+ "plannedEndTs": 1787153400000,
+ "startedTs": 1787117400000,
+ "endedTs": 1787150400000,
+ "lunchType": "panier",
+ "restaurant": null,
+ "closePromptedTs": null
+ },
+ {
+ "date": "2026-08-20",
+ "status": "closed",
+ "plannedStartTs": 1787203800000,
+ "plannedEndTs": 1787239800000,
+ "startedTs": 1787203800000,
+ "endedTs": 1787237400000,
+ "lunchType": "restaurant",
+ "restaurant": "La Bonne Fourchette",
+ "closePromptedTs": null
+ },
+ {
+ "date": "2026-08-21",
+ "status": "closed",
+ "plannedStartTs": 1787290200000,
+ "plannedEndTs": 1787326200000,
+ "startedTs": 1787290200000,
+ "endedTs": 1787324760000,
+ "lunchType": "restaurant",
+ "restaurant": "Chez Marcel",
+ "closePromptedTs": null
+ },
+ {
+ "date": "2026-08-24",
+ "status": "closed",
+ "plannedStartTs": 1787549400000,
+ "plannedEndTs": 1787585400000,
+ "startedTs": 1787549400000,
+ "endedTs": 1787583540000,
+ "lunchType": "restaurant",
+ "restaurant": "Le Relais",
+ "closePromptedTs": null
+ },
+ {
+ "date": "2026-08-25",
+ "status": "closed",
+ "plannedStartTs": 1787635800000,
+ "plannedEndTs": 1787671800000,
+ "startedTs": 1787635800000,
+ "endedTs": 1787669820000,
+ "lunchType": "panier",
+ "restaurant": null,
+ "closePromptedTs": null
+ },
+ {
+ "date": "2026-08-26",
+ "status": "closed",
+ "plannedStartTs": 1787722200000,
+ "plannedEndTs": 1787758200000,
+ "startedTs": 1787722200000,
+ "endedTs": 1787757420000,
+ "lunchType": "restaurant",
+ "restaurant": "Chez Marcel",
+ "closePromptedTs": null
+ },
+ {
+ "date": "2026-08-27",
+ "status": "closed",
+ "plannedStartTs": 1787808600000,
+ "plannedEndTs": 1787844600000,
+ "startedTs": 1787808600000,
+ "endedTs": 1787840940000,
+ "lunchType": "restaurant",
+ "restaurant": "Le Relais",
+ "closePromptedTs": null
+ },
+ {
+ "date": "2026-08-28",
+ "status": "closed",
+ "plannedStartTs": 1787895000000,
+ "plannedEndTs": 1787931000000,
+ "startedTs": 1787895000000,
+ "endedTs": 1787928060000,
+ "lunchType": "restaurant",
+ "restaurant": "La Bonne Fourchette",
+ "closePromptedTs": null
+ },
+ {
+ "date": "2026-08-31",
+ "status": "closed",
+ "plannedStartTs": 1788154200000,
+ "plannedEndTs": 1788190200000,
+ "startedTs": 1788154200000,
+ "endedTs": 1788189180000,
+ "lunchType": "panier",
+ "restaurant": null,
+ "closePromptedTs": null
+ },
+ {
+ "date": "2026-09-01",
+ "status": "closed",
+ "plannedStartTs": 1788240600000,
+ "plannedEndTs": 1788276600000,
+ "startedTs": 1788240600000,
+ "endedTs": 1788277320000,
+ "lunchType": "restaurant",
+ "restaurant": "Le Relais",
+ "closePromptedTs": null
+ },
+ {
+ "date": "2026-09-02",
+ "status": "closed",
+ "plannedStartTs": 1788327000000,
+ "plannedEndTs": 1788363000000,
+ "startedTs": 1788327000000,
+ "endedTs": 1788361140000,
+ "lunchType": "restaurant",
+ "restaurant": "La Bonne Fourchette",
+ "closePromptedTs": null
+ },
+ {
+ "date": "2026-09-03",
+ "status": "closed",
+ "plannedStartTs": 1788413400000,
+ "plannedEndTs": 1788449400000,
+ "startedTs": 1788413400000,
+ "endedTs": 1788446460000,
+ "lunchType": "restaurant",
+ "restaurant": "Chez Marcel",
+ "closePromptedTs": null
+ },
+ {
+ "date": "2026-09-04",
+ "status": "closed",
+ "plannedStartTs": 1788499800000,
+ "plannedEndTs": 1788535800000,
+ "startedTs": 1788499800000,
+ "endedTs": 1788532380000,
+ "lunchType": "panier",
+ "restaurant": null,
+ "closePromptedTs": null
+ }
+ ],
+ "entries": [
+ {
+ "date": "2026-08-10",
+ "seq": 0,
+ "kind": "travel",
+ "task": "Chargement / transport",
+ "client": "Martin & Fils",
+ "startTs": 1786339800000,
+ "endTs": 1786342200000
+ },
+ {
+ "date": "2026-08-10",
+ "seq": 1,
+ "kind": "work",
+ "task": "Terrassement",
+ "client": "Martin & Fils",
+ "startTs": 1786342200000,
+ "endTs": 1786352640000
+ },
+ {
+ "date": "2026-08-10",
+ "seq": 2,
+ "kind": "lunch",
+ "task": "Pause déjeuner",
+ "client": null,
+ "startTs": 1786352640000,
+ "endTs": 1786356000000
+ },
+ {
+ "date": "2026-08-10",
+ "seq": 3,
+ "kind": "work",
+ "task": "Pose de câble",
+ "client": "Martin & Fils",
+ "startTs": 1786356000000,
+ "endTs": 1786369020000
+ },
+ {
+ "date": "2026-08-10",
+ "seq": 4,
+ "kind": "work",
+ "task": "Retour / déchargement",
+ "client": "Martin & Fils",
+ "startTs": 1786369020000,
+ "endTs": 1786372440000
+ },
+ {
+ "date": "2026-08-11",
+ "seq": 0,
+ "kind": "travel",
+ "task": "Chargement / transport",
+ "client": "Dupont SA",
+ "startTs": 1786426200000,
+ "endTs": 1786428660000
+ },
+ {
+ "date": "2026-08-11",
+ "seq": 1,
+ "kind": "work",
+ "task": "Terrassement",
+ "client": "Dupont SA",
+ "startTs": 1786428660000,
+ "endTs": 1786439160000
+ },
+ {
+ "date": "2026-08-11",
+ "seq": 2,
+ "kind": "lunch",
+ "task": "Pause déjeuner",
+ "client": null,
+ "startTs": 1786439160000,
+ "endTs": 1786442640000
+ },
+ {
+ "date": "2026-08-11",
+ "seq": 3,
+ "kind": "work",
+ "task": "Pose de câble",
+ "client": "Dupont SA",
+ "startTs": 1786442640000,
+ "endTs": 1786456200000
+ },
+ {
+ "date": "2026-08-11",
+ "seq": 4,
+ "kind": "work",
+ "task": "Retour / déchargement",
+ "client": "Dupont SA",
+ "startTs": 1786456200000,
+ "endTs": 1786459440000
+ },
+ {
+ "date": "2026-08-12",
+ "seq": 0,
+ "kind": "travel",
+ "task": "Chargement / transport",
+ "client": "Commune de Vaugines",
+ "startTs": 1786512600000,
+ "endTs": 1786515060000
+ },
+ {
+ "date": "2026-08-12",
+ "seq": 1,
+ "kind": "work",
+ "task": "Terrassement",
+ "client": "Commune de Vaugines",
+ "startTs": 1786515060000,
+ "endTs": 1786528440000
+ },
+ {
+ "date": "2026-08-12",
+ "seq": 2,
+ "kind": "lunch",
+ "task": "Pause déjeuner",
+ "client": null,
+ "startTs": 1786528440000,
+ "endTs": 1786532340000
+ },
+ {
+ "date": "2026-08-12",
+ "seq": 3,
+ "kind": "work",
+ "task": "Pose de câble",
+ "client": "Commune de Vaugines",
+ "startTs": 1786532340000,
+ "endTs": 1786545060000
+ },
+ {
+ "date": "2026-08-12",
+ "seq": 4,
+ "kind": "work",
+ "task": "Retour / déchargement",
+ "client": "Commune de Vaugines",
+ "startTs": 1786545060000,
+ "endTs": 1786548120000
+ },
+ {
+ "date": "2026-08-13",
+ "seq": 0,
+ "kind": "travel",
+ "task": "Chargement / transport",
+ "client": "Martin & Fils",
+ "startTs": 1786599000000,
+ "endTs": 1786601640000
+ },
+ {
+ "date": "2026-08-13",
+ "seq": 1,
+ "kind": "work",
+ "task": "Terrassement",
+ "client": "Martin & Fils",
+ "startTs": 1786601640000,
+ "endTs": 1786614900000
+ },
+ {
+ "date": "2026-08-13",
+ "seq": 2,
+ "kind": "lunch",
+ "task": "Pause déjeuner",
+ "client": null,
+ "startTs": 1786614900000,
+ "endTs": 1786618200000
+ },
+ {
+ "date": "2026-08-13",
+ "seq": 3,
+ "kind": "work",
+ "task": "Pose de câble",
+ "client": "Martin & Fils",
+ "startTs": 1786618200000,
+ "endTs": 1786631820000
+ },
+ {
+ "date": "2026-08-13",
+ "seq": 4,
+ "kind": "work",
+ "task": "Retour / déchargement",
+ "client": "Martin & Fils",
+ "startTs": 1786631820000,
+ "endTs": 1786634700000
+ },
+ {
+ "date": "2026-08-14",
+ "seq": 0,
+ "kind": "travel",
+ "task": "Chargement / transport",
+ "client": "Dupont SA",
+ "startTs": 1786685400000,
+ "endTs": 1786688460000
+ },
+ {
+ "date": "2026-08-14",
+ "seq": 1,
+ "kind": "work",
+ "task": "Terrassement",
+ "client": "Dupont SA",
+ "startTs": 1786688460000,
+ "endTs": 1786698780000
+ },
+ {
+ "date": "2026-08-14",
+ "seq": 2,
+ "kind": "lunch",
+ "task": "Pause déjeuner",
+ "client": null,
+ "startTs": 1786698780000,
+ "endTs": 1786702740000
+ },
+ {
+ "date": "2026-08-14",
+ "seq": 3,
+ "kind": "work",
+ "task": "Pose de câble",
+ "client": "Dupont SA",
+ "startTs": 1786702740000,
+ "endTs": 1786716420000
+ },
+ {
+ "date": "2026-08-14",
+ "seq": 4,
+ "kind": "work",
+ "task": "Retour / déchargement",
+ "client": "Dupont SA",
+ "startTs": 1786716420000,
+ "endTs": 1786719780000
+ },
+ {
+ "date": "2026-08-17",
+ "seq": 0,
+ "kind": "travel",
+ "task": "Chargement / transport",
+ "client": "Commune de Vaugines",
+ "startTs": 1786944600000,
+ "endTs": 1786947420000
+ },
+ {
+ "date": "2026-08-17",
+ "seq": 1,
+ "kind": "work",
+ "task": "Terrassement",
+ "client": "Commune de Vaugines",
+ "startTs": 1786947420000,
+ "endTs": 1786957380000
+ },
+ {
+ "date": "2026-08-17",
+ "seq": 2,
+ "kind": "lunch",
+ "task": "Pause déjeuner",
+ "client": null,
+ "startTs": 1786957380000,
+ "endTs": 1786960920000
+ },
+ {
+ "date": "2026-08-17",
+ "seq": 3,
+ "kind": "work",
+ "task": "Pose de câble",
+ "client": "Commune de Vaugines",
+ "startTs": 1786960920000,
+ "endTs": 1786974780000
+ },
+ {
+ "date": "2026-08-17",
+ "seq": 4,
+ "kind": "work",
+ "task": "Retour / déchargement",
+ "client": "Commune de Vaugines",
+ "startTs": 1786974780000,
+ "endTs": 1786977660000
+ },
+ {
+ "date": "2026-08-18",
+ "seq": 0,
+ "kind": "travel",
+ "task": "Chargement / transport",
+ "client": "Martin & Fils",
+ "startTs": 1787031000000,
+ "endTs": 1787033700000
+ },
+ {
+ "date": "2026-08-18",
+ "seq": 1,
+ "kind": "work",
+ "task": "Terrassement",
+ "client": "Martin & Fils",
+ "startTs": 1787033700000,
+ "endTs": 1787043780000
+ },
+ {
+ "date": "2026-08-18",
+ "seq": 2,
+ "kind": "lunch",
+ "task": "Pause déjeuner",
+ "client": null,
+ "startTs": 1787043780000,
+ "endTs": 1787047380000
+ },
+ {
+ "date": "2026-08-18",
+ "seq": 3,
+ "kind": "work",
+ "task": "Pose de câble",
+ "client": "Martin & Fils",
+ "startTs": 1787047380000,
+ "endTs": 1787061600000
+ },
+ {
+ "date": "2026-08-18",
+ "seq": 4,
+ "kind": "work",
+ "task": "Retour / déchargement",
+ "client": "Martin & Fils",
+ "startTs": 1787061600000,
+ "endTs": 1787064480000
+ },
+ {
+ "date": "2026-08-19",
+ "seq": 0,
+ "kind": "travel",
+ "task": "Chargement / transport",
+ "client": "Dupont SA",
+ "startTs": 1787117400000,
+ "endTs": 1787120220000
+ },
+ {
+ "date": "2026-08-19",
+ "seq": 1,
+ "kind": "work",
+ "task": "Terrassement",
+ "client": "Dupont SA",
+ "startTs": 1787120220000,
+ "endTs": 1787131800000
+ },
+ {
+ "date": "2026-08-19",
+ "seq": 2,
+ "kind": "lunch",
+ "task": "Pause déjeuner",
+ "client": null,
+ "startTs": 1787131800000,
+ "endTs": 1787134920000
+ },
+ {
+ "date": "2026-08-19",
+ "seq": 3,
+ "kind": "work",
+ "task": "Pose de câble",
+ "client": "Dupont SA",
+ "startTs": 1787134920000,
+ "endTs": 1787147580000
+ },
+ {
+ "date": "2026-08-19",
+ "seq": 4,
+ "kind": "work",
+ "task": "Retour / déchargement",
+ "client": "Dupont SA",
+ "startTs": 1787147580000,
+ "endTs": 1787150400000
+ },
+ {
+ "date": "2026-08-20",
+ "seq": 0,
+ "kind": "travel",
+ "task": "Chargement / transport",
+ "client": "Commune de Vaugines",
+ "startTs": 1787203800000,
+ "endTs": 1787206800000
+ },
+ {
+ "date": "2026-08-20",
+ "seq": 1,
+ "kind": "work",
+ "task": "Terrassement",
+ "client": "Commune de Vaugines",
+ "startTs": 1787206800000,
+ "endTs": 1787218680000
+ },
+ {
+ "date": "2026-08-20",
+ "seq": 2,
+ "kind": "lunch",
+ "task": "Pause déjeuner",
+ "client": null,
+ "startTs": 1787218680000,
+ "endTs": 1787221980000
+ },
+ {
+ "date": "2026-08-20",
+ "seq": 3,
+ "kind": "work",
+ "task": "Pose de câble",
+ "client": "Commune de Vaugines",
+ "startTs": 1787221980000,
+ "endTs": 1787234520000
+ },
+ {
+ "date": "2026-08-20",
+ "seq": 4,
+ "kind": "work",
+ "task": "Retour / déchargement",
+ "client": "Commune de Vaugines",
+ "startTs": 1787234520000,
+ "endTs": 1787237400000
+ },
+ {
+ "date": "2026-08-21",
+ "seq": 0,
+ "kind": "travel",
+ "task": "Chargement / transport",
+ "client": "Martin & Fils",
+ "startTs": 1787290200000,
+ "endTs": 1787293080000
+ },
+ {
+ "date": "2026-08-21",
+ "seq": 1,
+ "kind": "work",
+ "task": "Terrassement",
+ "client": "Martin & Fils",
+ "startTs": 1787293080000,
+ "endTs": 1787304660000
+ },
+ {
+ "date": "2026-08-21",
+ "seq": 2,
+ "kind": "lunch",
+ "task": "Pause déjeuner",
+ "client": null,
+ "startTs": 1787304660000,
+ "endTs": 1787308620000
+ },
+ {
+ "date": "2026-08-21",
+ "seq": 3,
+ "kind": "work",
+ "task": "Pose de câble",
+ "client": "Martin & Fils",
+ "startTs": 1787308620000,
+ "endTs": 1787321400000
+ },
+ {
+ "date": "2026-08-21",
+ "seq": 4,
+ "kind": "work",
+ "task": "Retour / déchargement",
+ "client": "Martin & Fils",
+ "startTs": 1787321400000,
+ "endTs": 1787324760000
+ },
+ {
+ "date": "2026-08-24",
+ "seq": 0,
+ "kind": "travel",
+ "task": "Chargement / transport",
+ "client": "Dupont SA",
+ "startTs": 1787549400000,
+ "endTs": 1787552160000
+ },
+ {
+ "date": "2026-08-24",
+ "seq": 1,
+ "kind": "work",
+ "task": "Terrassement",
+ "client": "Dupont SA",
+ "startTs": 1787552160000,
+ "endTs": 1787564220000
+ },
+ {
+ "date": "2026-08-24",
+ "seq": 2,
+ "kind": "lunch",
+ "task": "Pause déjeuner",
+ "client": null,
+ "startTs": 1787564220000,
+ "endTs": 1787567880000
+ },
+ {
+ "date": "2026-08-24",
+ "seq": 3,
+ "kind": "work",
+ "task": "Pose de câble",
+ "client": "Dupont SA",
+ "startTs": 1787567880000,
+ "endTs": 1787580240000
+ },
+ {
+ "date": "2026-08-24",
+ "seq": 4,
+ "kind": "work",
+ "task": "Retour / déchargement",
+ "client": "Dupont SA",
+ "startTs": 1787580240000,
+ "endTs": 1787583540000
+ },
+ {
+ "date": "2026-08-25",
+ "seq": 0,
+ "kind": "travel",
+ "task": "Chargement / transport",
+ "client": "Commune de Vaugines",
+ "startTs": 1787635800000,
+ "endTs": 1787638140000
+ },
+ {
+ "date": "2026-08-25",
+ "seq": 1,
+ "kind": "work",
+ "task": "Terrassement",
+ "client": "Commune de Vaugines",
+ "startTs": 1787638140000,
+ "endTs": 1787650260000
+ },
+ {
+ "date": "2026-08-25",
+ "seq": 2,
+ "kind": "lunch",
+ "task": "Pause déjeuner",
+ "client": null,
+ "startTs": 1787650260000,
+ "endTs": 1787654040000
+ },
+ {
+ "date": "2026-08-25",
+ "seq": 3,
+ "kind": "work",
+ "task": "Pose de câble",
+ "client": "Commune de Vaugines",
+ "startTs": 1787654040000,
+ "endTs": 1787666520000
+ },
+ {
+ "date": "2026-08-25",
+ "seq": 4,
+ "kind": "work",
+ "task": "Retour / déchargement",
+ "client": "Commune de Vaugines",
+ "startTs": 1787666520000,
+ "endTs": 1787669820000
+ },
+ {
+ "date": "2026-08-26",
+ "seq": 0,
+ "kind": "travel",
+ "task": "Chargement / transport",
+ "client": "Martin & Fils",
+ "startTs": 1787722200000,
+ "endTs": 1787725140000
+ },
+ {
+ "date": "2026-08-26",
+ "seq": 1,
+ "kind": "work",
+ "task": "Terrassement",
+ "client": "Martin & Fils",
+ "startTs": 1787725140000,
+ "endTs": 1787736720000
+ },
+ {
+ "date": "2026-08-26",
+ "seq": 2,
+ "kind": "lunch",
+ "task": "Pause déjeuner",
+ "client": null,
+ "startTs": 1787736720000,
+ "endTs": 1787740140000
+ },
+ {
+ "date": "2026-08-26",
+ "seq": 3,
+ "kind": "work",
+ "task": "Pose de câble",
+ "client": "Martin & Fils",
+ "startTs": 1787740140000,
+ "endTs": 1787754600000
+ },
+ {
+ "date": "2026-08-26",
+ "seq": 4,
+ "kind": "work",
+ "task": "Retour / déchargement",
+ "client": "Martin & Fils",
+ "startTs": 1787754600000,
+ "endTs": 1787757420000
+ },
+ {
+ "date": "2026-08-27",
+ "seq": 0,
+ "kind": "travel",
+ "task": "Chargement / transport",
+ "client": "Dupont SA",
+ "startTs": 1787808600000,
+ "endTs": 1787811240000
+ },
+ {
+ "date": "2026-08-27",
+ "seq": 1,
+ "kind": "work",
+ "task": "Terrassement",
+ "client": "Dupont SA",
+ "startTs": 1787811240000,
+ "endTs": 1787821500000
+ },
+ {
+ "date": "2026-08-27",
+ "seq": 2,
+ "kind": "lunch",
+ "task": "Pause déjeuner",
+ "client": null,
+ "startTs": 1787821500000,
+ "endTs": 1787825220000
+ },
+ {
+ "date": "2026-08-27",
+ "seq": 3,
+ "kind": "work",
+ "task": "Pose de câble",
+ "client": "Dupont SA",
+ "startTs": 1787825220000,
+ "endTs": 1787837820000
+ },
+ {
+ "date": "2026-08-27",
+ "seq": 4,
+ "kind": "work",
+ "task": "Retour / déchargement",
+ "client": "Dupont SA",
+ "startTs": 1787837820000,
+ "endTs": 1787840940000
+ },
+ {
+ "date": "2026-08-28",
+ "seq": 0,
+ "kind": "travel",
+ "task": "Chargement / transport",
+ "client": "Commune de Vaugines",
+ "startTs": 1787895000000,
+ "endTs": 1787897820000
+ },
+ {
+ "date": "2026-08-28",
+ "seq": 1,
+ "kind": "work",
+ "task": "Terrassement",
+ "client": "Commune de Vaugines",
+ "startTs": 1787897820000,
+ "endTs": 1787910300000
+ },
+ {
+ "date": "2026-08-28",
+ "seq": 2,
+ "kind": "lunch",
+ "task": "Pause déjeuner",
+ "client": null,
+ "startTs": 1787910300000,
+ "endTs": 1787914260000
+ },
+ {
+ "date": "2026-08-28",
+ "seq": 3,
+ "kind": "work",
+ "task": "Pose de câble",
+ "client": "Commune de Vaugines",
+ "startTs": 1787914260000,
+ "endTs": 1787925300000
+ },
+ {
+ "date": "2026-08-28",
+ "seq": 4,
+ "kind": "work",
+ "task": "Retour / déchargement",
+ "client": "Commune de Vaugines",
+ "startTs": 1787925300000,
+ "endTs": 1787928060000
+ },
+ {
+ "date": "2026-08-31",
+ "seq": 0,
+ "kind": "travel",
+ "task": "Chargement / transport",
+ "client": "Martin & Fils",
+ "startTs": 1788154200000,
+ "endTs": 1788156540000
+ },
+ {
+ "date": "2026-08-31",
+ "seq": 1,
+ "kind": "work",
+ "task": "Terrassement",
+ "client": "Martin & Fils",
+ "startTs": 1788156540000,
+ "endTs": 1788169260000
+ },
+ {
+ "date": "2026-08-31",
+ "seq": 2,
+ "kind": "lunch",
+ "task": "Pause déjeuner",
+ "client": null,
+ "startTs": 1788169260000,
+ "endTs": 1788173280000
+ },
+ {
+ "date": "2026-08-31",
+ "seq": 3,
+ "kind": "work",
+ "task": "Pose de câble",
+ "client": "Martin & Fils",
+ "startTs": 1788173280000,
+ "endTs": 1788186540000
+ },
+ {
+ "date": "2026-08-31",
+ "seq": 4,
+ "kind": "work",
+ "task": "Retour / déchargement",
+ "client": "Martin & Fils",
+ "startTs": 1788186540000,
+ "endTs": 1788189180000
+ },
+ {
+ "date": "2026-09-01",
+ "seq": 0,
+ "kind": "travel",
+ "task": "Chargement / transport",
+ "client": "Dupont SA",
+ "startTs": 1788240600000,
+ "endTs": 1788243600000
+ },
+ {
+ "date": "2026-09-01",
+ "seq": 1,
+ "kind": "work",
+ "task": "Terrassement",
+ "client": "Dupont SA",
+ "startTs": 1788243600000,
+ "endTs": 1788256440000
+ },
+ {
+ "date": "2026-09-01",
+ "seq": 2,
+ "kind": "lunch",
+ "task": "Pause déjeuner",
+ "client": null,
+ "startTs": 1788256440000,
+ "endTs": 1788259920000
+ },
+ {
+ "date": "2026-09-01",
+ "seq": 3,
+ "kind": "work",
+ "task": "Pose de câble",
+ "client": "Dupont SA",
+ "startTs": 1788259920000,
+ "endTs": 1788274080000
+ },
+ {
+ "date": "2026-09-01",
+ "seq": 4,
+ "kind": "work",
+ "task": "Retour / déchargement",
+ "client": "Dupont SA",
+ "startTs": 1788274080000,
+ "endTs": 1788277320000
+ },
+ {
+ "date": "2026-09-02",
+ "seq": 0,
+ "kind": "travel",
+ "task": "Chargement / transport",
+ "client": "Commune de Vaugines",
+ "startTs": 1788327000000,
+ "endTs": 1788329880000
+ },
+ {
+ "date": "2026-09-02",
+ "seq": 1,
+ "kind": "work",
+ "task": "Terrassement",
+ "client": "Commune de Vaugines",
+ "startTs": 1788329880000,
+ "endTs": 1788340620000
+ },
+ {
+ "date": "2026-09-02",
+ "seq": 2,
+ "kind": "lunch",
+ "task": "Pause déjeuner",
+ "client": null,
+ "startTs": 1788340620000,
+ "endTs": 1788343740000
+ },
+ {
+ "date": "2026-09-02",
+ "seq": 3,
+ "kind": "work",
+ "task": "Pose de câble",
+ "client": "Commune de Vaugines",
+ "startTs": 1788343740000,
+ "endTs": 1788358140000
+ },
+ {
+ "date": "2026-09-02",
+ "seq": 4,
+ "kind": "work",
+ "task": "Retour / déchargement",
+ "client": "Commune de Vaugines",
+ "startTs": 1788358140000,
+ "endTs": 1788361140000
+ },
+ {
+ "date": "2026-09-03",
+ "seq": 0,
+ "kind": "travel",
+ "task": "Chargement / transport",
+ "client": "Martin & Fils",
+ "startTs": 1788413400000,
+ "endTs": 1788415980000
+ },
+ {
+ "date": "2026-09-03",
+ "seq": 1,
+ "kind": "work",
+ "task": "Terrassement",
+ "client": "Martin & Fils",
+ "startTs": 1788415980000,
+ "endTs": 1788426960000
+ },
+ {
+ "date": "2026-09-03",
+ "seq": 2,
+ "kind": "lunch",
+ "task": "Pause déjeuner",
+ "client": null,
+ "startTs": 1788426960000,
+ "endTs": 1788430320000
+ },
+ {
+ "date": "2026-09-03",
+ "seq": 3,
+ "kind": "work",
+ "task": "Pose de câble",
+ "client": "Martin & Fils",
+ "startTs": 1788430320000,
+ "endTs": 1788443400000
+ },
+ {
+ "date": "2026-09-03",
+ "seq": 4,
+ "kind": "work",
+ "task": "Retour / déchargement",
+ "client": "Martin & Fils",
+ "startTs": 1788443400000,
+ "endTs": 1788446460000
+ },
+ {
+ "date": "2026-09-04",
+ "seq": 0,
+ "kind": "travel",
+ "task": "Chargement / transport",
+ "client": "Dupont SA",
+ "startTs": 1788499800000,
+ "endTs": 1788502500000
+ },
+ {
+ "date": "2026-09-04",
+ "seq": 1,
+ "kind": "work",
+ "task": "Terrassement",
+ "client": "Dupont SA",
+ "startTs": 1788502500000,
+ "endTs": 1788514620000
+ },
+ {
+ "date": "2026-09-04",
+ "seq": 2,
+ "kind": "lunch",
+ "task": "Pause déjeuner",
+ "client": null,
+ "startTs": 1788514620000,
+ "endTs": 1788518160000
+ },
+ {
+ "date": "2026-09-04",
+ "seq": 3,
+ "kind": "work",
+ "task": "Pose de câble",
+ "client": "Dupont SA",
+ "startTs": 1788518160000,
+ "endTs": 1788529320000
+ },
+ {
+ "date": "2026-09-04",
+ "seq": 4,
+ "kind": "work",
+ "task": "Retour / déchargement",
+ "client": "Dupont SA",
+ "startTs": 1788529320000,
+ "endTs": 1788532380000
+ }
+ ]
+}
\ No newline at end of file
diff --git a/src/routes/Settings.svelte b/src/routes/Settings.svelte
index b6127e1..ee1ac1b 100644
--- a/src/routes/Settings.svelte
+++ b/src/routes/Settings.svelte
@@ -1,6 +1,7 @@
-
+
Réglages
{#if settingsError.message}
@@ -141,6 +142,8 @@
+
+
Historique
@@ -169,9 +172,15 @@
{/if}
-
+