From 870457955bb8ff9350d778dcf3274396d5e3d6ae Mon Sep 17 00:00:00 2001 From: "Adrien W." Date: Sat, 19 Sep 2026 13:21:43 +0200 Subject: [PATCH] Added export/import features --- README.md | 41 ++ package-lock.json | 19 +- package.json | 1 + public/backup-fixture.json | 1130 +++++++++++++++++++++++++++++ src/App.svelte | 5 +- src/components/BackupPanel.svelte | 223 ++++++ src/lib/backup-db.test.js | 119 +++ src/lib/backup-import.test.js | 65 ++ src/lib/backup.js | 241 ++++++ src/lib/backup.test.js | 278 +++++++ src/lib/day.svelte.js | 47 +- src/lib/db.js | 63 ++ src/lib/fixtures/backup.json | 1130 +++++++++++++++++++++++++++++ src/routes/Settings.svelte | 15 +- 14 files changed, 3363 insertions(+), 14 deletions(-) create mode 100644 public/backup-fixture.json create mode 100644 src/components/BackupPanel.svelte create mode 100644 src/lib/backup-db.test.js create mode 100644 src/lib/backup-import.test.js create mode 100644 src/lib/backup.js create mode 100644 src/lib/backup.test.js create mode 100644 src/lib/fixtures/backup.json 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 - +
{/if} @@ -62,18 +62,21 @@ 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

+ + + + + + + + {#if busy}

Traitement en cours…

{/if} + + {#if 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} +

+
+ + +
+ {:else} + +

+ Ajoute les journées absentes. Celles déjà enregistrées ici ne sont pas touchées, + et les réglages sont conservés. +

+ +

+ Efface les données locales et adopte le fichier, réglages compris. +

+ + {/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

- +