Added export/import features
This commit is contained in:
41
README.md
41
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.
|
||||
|
||||
|
||||
19
package-lock.json
generated
19
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
1130
public/backup-fixture.json
Normal file
1130
public/backup-fixture.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -46,7 +46,7 @@
|
||||
a day in progress. -->
|
||||
<div class="update">
|
||||
<span>Mise à jour disponible</span>
|
||||
<button type="button" onclick={applyUpdate}>Installer</button>
|
||||
<button type="button" disabled={dayState.importing} onclick={applyUpdate}>Installer</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -62,18 +62,21 @@
|
||||
<button
|
||||
type="button"
|
||||
class:sel={view === 'resume'}
|
||||
disabled={dayState.importing}
|
||||
aria-current={view === 'resume'}
|
||||
onclick={() => go('resume')}>Récap</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class:sel={view === 'today'}
|
||||
disabled={dayState.importing}
|
||||
aria-current={view === 'today'}
|
||||
onclick={() => go('today')}>Journée</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class:sel={view === 'settings'}
|
||||
disabled={dayState.importing}
|
||||
aria-current={view === 'settings'}
|
||||
onclick={() => go('settings')}>Réglages</button
|
||||
>
|
||||
|
||||
223
src/components/BackupPanel.svelte
Normal file
223
src/components/BackupPanel.svelte
Normal file
@@ -0,0 +1,223 @@
|
||||
<script>
|
||||
/**
|
||||
* Export the whole dataset to one file, and import it back.
|
||||
*
|
||||
* Importing is deliberately two-step: pick the file, read what it contains, then
|
||||
* choose how to apply it. Replacing is destructive and asks again.
|
||||
*/
|
||||
import {
|
||||
backupFilename,
|
||||
BackupError,
|
||||
MAX_BACKUP_BYTES,
|
||||
parseBackup,
|
||||
} from '../lib/backup.js';
|
||||
import { applyImport, dayState } from '../lib/day.svelte.js';
|
||||
import { exportBackup } from '../lib/db.js';
|
||||
import { fmtDayLabel, fmtTime, localDateKey } from '../lib/time.js';
|
||||
|
||||
/** @type {HTMLInputElement} */
|
||||
let picker;
|
||||
|
||||
let staged = $state(null);
|
||||
let error = $state(null);
|
||||
let result = $state(null);
|
||||
let confirmingReplace = $state(false);
|
||||
let busy = $state(false);
|
||||
let filename = $state('');
|
||||
|
||||
function reset() {
|
||||
staged = null;
|
||||
error = null;
|
||||
confirmingReplace = false;
|
||||
filename = '';
|
||||
if (picker) picker.value = '';
|
||||
}
|
||||
|
||||
async function exportAll() {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
error = null;
|
||||
result = null;
|
||||
try {
|
||||
const payload = await exportBackup();
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }),
|
||||
);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = backupFilename(Date.parse(payload.exportedAt));
|
||||
document.body.appendChild(link);
|
||||
try {
|
||||
link.click();
|
||||
} finally {
|
||||
link.remove();
|
||||
// Give browsers (including standalone mobile apps) time to start reading.
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}
|
||||
result = `Export préparé : ${payload.days.length} journée${payload.days.length > 1 ? 's' : ''}, ${payload.entries.length} poste${payload.entries.length > 1 ? 's' : ''}.`;
|
||||
} catch (cause) {
|
||||
error = `Export impossible : ${cause.message}`;
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onPick(event) {
|
||||
if (busy) return;
|
||||
// Capture the File before reset() clears the input and its FileList.
|
||||
const file = event.currentTarget.files?.[0];
|
||||
reset();
|
||||
result = null;
|
||||
if (!file) return;
|
||||
busy = true;
|
||||
try {
|
||||
if (file.size > MAX_BACKUP_BYTES) {
|
||||
throw new BackupError('Fichier trop volumineux (20 Mo maximum).');
|
||||
}
|
||||
staged = parseBackup(await file.text());
|
||||
filename = file.name;
|
||||
} catch (cause) {
|
||||
error =
|
||||
cause instanceof BackupError ? cause.message : `Lecture impossible : ${cause.message}`;
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function run(mode) {
|
||||
if (!staged || busy) return;
|
||||
busy = true;
|
||||
error = null;
|
||||
try {
|
||||
const counts = await applyImport($state.snapshot(staged), mode);
|
||||
result =
|
||||
mode === 'replace'
|
||||
? `Données remplacées : ${counts.added} journée${counts.added > 1 ? 's' : ''}, ${counts.entries} poste${counts.entries > 1 ? 's' : ''}.`
|
||||
: `${counts.added} journée${counts.added > 1 ? 's' : ''} ajoutée${counts.added > 1 ? 's' : ''}` +
|
||||
(counts.skipped ? `, ${counts.skipped} déjà présente${counts.skipped > 1 ? 's' : ''} conservée${counts.skipped > 1 ? 's' : ''}.` : '.');
|
||||
reset();
|
||||
} catch (cause) {
|
||||
error = `Import impossible : ${cause.message}`;
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="card stack" aria-busy={busy}>
|
||||
<h2>Données</h2>
|
||||
|
||||
<button type="button" disabled={busy} onclick={exportAll}>Exporter les données</button>
|
||||
|
||||
<!-- The real input is hidden: a styled button is a far bigger target. -->
|
||||
<input
|
||||
bind:this={picker}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
onchange={onPick}
|
||||
disabled={busy}
|
||||
hidden
|
||||
/>
|
||||
<button type="button" disabled={busy} onclick={() => picker.click()}>Importer un fichier…</button>
|
||||
|
||||
{#if busy}<p role="status">Traitement en cours…</p>{/if}
|
||||
|
||||
{#if error}
|
||||
<p class="notice error" role="alert">{error}</p>
|
||||
{/if}
|
||||
|
||||
{#if result}
|
||||
<p class="notice ok" role="status">{result}</p>
|
||||
{/if}
|
||||
|
||||
{#if staged}
|
||||
<div class="staged stack">
|
||||
<p class="filename"><strong>{filename}</strong></p>
|
||||
<p>
|
||||
<strong>{staged.dayCount}</strong> journée{staged.dayCount > 1 ? 's' : ''} ·
|
||||
<strong>{staged.entryCount}</strong> poste{staged.entryCount > 1 ? 's' : ''}
|
||||
{#if staged.exportedAt}
|
||||
<br /><span class="dim"
|
||||
>Exporté le {fmtDayLabel(localDateKey(Date.parse(staged.exportedAt)))} à {fmtTime(Date.parse(staged.exportedAt))}</span
|
||||
>
|
||||
{/if}
|
||||
</p>
|
||||
{#if staged.dropped}
|
||||
<p class="dim small">{staged.dropped} entrée(s) illisible(s) ignorée(s).</p>
|
||||
{/if}
|
||||
{#if staged.repaired}
|
||||
<p class="dim small">{staged.repaired} poste(s) ajusté(s) pour rendre les données exploitables.</p>
|
||||
{/if}
|
||||
{#if staged.closedActive}
|
||||
<p class="dim small">
|
||||
{staged.closedActive} journée(s) en cours seront enregistrées comme terminées à l’heure de l’export.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if confirmingReplace}
|
||||
<p class="notice error">
|
||||
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}
|
||||
</p>
|
||||
<div class="btn-row">
|
||||
<button type="button" disabled={busy} onclick={() => (confirmingReplace = false)}>Annuler</button>
|
||||
<button type="button" class="btn-danger" disabled={busy} onclick={() => run('replace')}>
|
||||
Confirmer
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button type="button" class="btn-primary" disabled={busy} onclick={() => run('merge')}>
|
||||
Fusionner
|
||||
</button>
|
||||
<p class="dim small">
|
||||
Ajoute les journées absentes. Celles déjà enregistrées ici ne sont pas touchées,
|
||||
et les réglages sont conservés.
|
||||
</p>
|
||||
<button type="button" disabled={busy} onclick={() => (confirmingReplace = true)}>
|
||||
Remplacer tout
|
||||
</button>
|
||||
<p class="dim small">
|
||||
Efface les données locales et adopte le fichier, réglages compris.
|
||||
</p>
|
||||
<button type="button" disabled={busy} onclick={reset}>Abandonner</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<p class="dim small">
|
||||
Un seul fichier JSON contient journées, postes et réglages. Les journées importées
|
||||
sont enregistrées comme terminées.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.staged {
|
||||
border: var(--border) solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--pad);
|
||||
}
|
||||
|
||||
.notice {
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--danger-fg);
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.ok {
|
||||
color: var(--accent-fg);
|
||||
background: var(--ok);
|
||||
}
|
||||
|
||||
.small {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.filename {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
</style>
|
||||
119
src/lib/backup-db.test.js
Normal file
119
src/lib/backup-db.test.js
Normal file
@@ -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 });
|
||||
});
|
||||
});
|
||||
65
src/lib/backup-import.test.js
Normal file
65
src/lib/backup-import.test.js
Normal file
@@ -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');
|
||||
});
|
||||
241
src/lib/backup.js
Normal file
241
src/lib/backup.js
Normal file
@@ -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 };
|
||||
}
|
||||
278
src/lib/backup.test.js
Normal file
278
src/lib/backup.test.js
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
|
||||
1130
src/lib/fixtures/backup.json
Normal file
1130
src/lib/fixtures/backup.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
<script>
|
||||
import BackupPanel from '../components/BackupPanel.svelte';
|
||||
import { cleanupOldDays } from '../lib/db.js';
|
||||
import { refresh } from '../lib/day.svelte.js';
|
||||
import { dayState, refresh } from '../lib/day.svelte.js';
|
||||
import {
|
||||
ROUNDING_CHOICES,
|
||||
settings,
|
||||
@@ -25,7 +26,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="screen">
|
||||
<fieldset class="screen" disabled={dayState.importing}>
|
||||
<h1>Réglages</h1>
|
||||
|
||||
{#if settingsError.message}
|
||||
@@ -141,6 +142,8 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<BackupPanel />
|
||||
|
||||
<section class="card stack">
|
||||
<h2>Historique</h2>
|
||||
<label>
|
||||
@@ -169,9 +172,15 @@
|
||||
</p>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<style>
|
||||
fieldset.screen {
|
||||
border: 0;
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.days {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
|
||||
Reference in New Issue
Block a user