Added export/import features

This commit is contained in:
2026-09-19 13:21:43 +02:00
parent f61b9025eb
commit 870457955b
14 changed files with 3363 additions and 14 deletions

View File

@@ -17,6 +17,47 @@ npm run check # svelte-check
npm run build # production build into dist/ 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 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. (useful for exercising the suggestion engine) and `timelog.wipe()` to clear it.

19
package-lock.json generated
View File

@@ -13,6 +13,7 @@
"devDependencies": { "devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.1.2", "@sveltejs/vite-plugin-svelte": "^7.1.2",
"@vite-pwa/assets-generator": "^1.0.2", "@vite-pwa/assets-generator": "^1.0.2",
"fake-indexeddb": "^6.2.5",
"svelte": "^5.56.4", "svelte": "^5.56.4",
"svelte-check": "^4.7.6", "svelte-check": "^4.7.6",
"vite": "^8.1.1", "vite": "^8.1.1",
@@ -68,7 +69,6 @@
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.7", "@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7", "@babel/generator": "^7.29.7",
@@ -2999,7 +2999,6 @@
"integrity": "sha512-MCbrb508JZHqe7bUibmZj/lyojdhLRnfkmyXnkrCM2zVrjTgL89U8UEfInpKTvPeTnxsw2hmyZxnhsdNR6yhwg==", "integrity": "sha512-MCbrb508JZHqe7bUibmZj/lyojdhLRnfkmyXnkrCM2zVrjTgL89U8UEfInpKTvPeTnxsw2hmyZxnhsdNR6yhwg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"cac": "^6.7.14", "cac": "^6.7.14",
"colorette": "^2.0.20", "colorette": "^2.0.20",
@@ -3062,7 +3061,6 @@
"integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -3076,7 +3074,6 @@
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1", "fast-uri": "^3.0.1",
@@ -3308,7 +3305,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.11.20", "baseline-browser-mapping": "^2.11.20",
"caniuse-lite": "^1.0.30001810", "caniuse-lite": "^1.0.30001810",
@@ -4033,6 +4029,16 @@
"node": ">=12.0.0" "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": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "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==", "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/estree": "1.0.9" "@types/estree": "1.0.9"
}, },
@@ -6407,7 +6412,6 @@
"integrity": "sha512-NdbDn7fl4be1ViUG0oq/lvG6OZy3oENolV2ONjiqqsfVoeAfzaQAKUcEX3MrQod/Bebv1PgwET9rfXhgn9s4Kg==", "integrity": "sha512-NdbDn7fl4be1ViUG0oq/lvG6OZy3oENolV2ONjiqqsfVoeAfzaQAKUcEX3MrQod/Bebv1PgwET9rfXhgn9s4Kg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@jridgewell/remapping": "^2.3.4", "@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/sourcemap-codec": "^1.5.0",
@@ -6835,7 +6839,6 @@
"integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"lightningcss": "^1.33.0", "lightningcss": "^1.33.0",
"picomatch": "^4.0.5", "picomatch": "^4.0.5",

View File

@@ -14,6 +14,7 @@
"devDependencies": { "devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.1.2", "@sveltejs/vite-plugin-svelte": "^7.1.2",
"@vite-pwa/assets-generator": "^1.0.2", "@vite-pwa/assets-generator": "^1.0.2",
"fake-indexeddb": "^6.2.5",
"svelte": "^5.56.4", "svelte": "^5.56.4",
"svelte-check": "^4.7.6", "svelte-check": "^4.7.6",
"vite": "^8.1.1", "vite": "^8.1.1",

1130
public/backup-fixture.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -46,7 +46,7 @@
a day in progress. --> a day in progress. -->
<div class="update"> <div class="update">
<span>Mise à jour disponible</span> <span>Mise à jour disponible</span>
<button type="button" onclick={applyUpdate}>Installer</button> <button type="button" disabled={dayState.importing} onclick={applyUpdate}>Installer</button>
</div> </div>
{/if} {/if}
@@ -62,18 +62,21 @@
<button <button
type="button" type="button"
class:sel={view === 'resume'} class:sel={view === 'resume'}
disabled={dayState.importing}
aria-current={view === 'resume'} aria-current={view === 'resume'}
onclick={() => go('resume')}>Récap</button onclick={() => go('resume')}>Récap</button
> >
<button <button
type="button" type="button"
class:sel={view === 'today'} class:sel={view === 'today'}
disabled={dayState.importing}
aria-current={view === 'today'} aria-current={view === 'today'}
onclick={() => go('today')}>Journée</button onclick={() => go('today')}>Journée</button
> >
<button <button
type="button" type="button"
class:sel={view === 'settings'} class:sel={view === 'settings'}
disabled={dayState.importing}
aria-current={view === 'settings'} aria-current={view === 'settings'}
onclick={() => go('settings')}>Réglages</button onclick={() => go('settings')}>Réglages</button
> >

View 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
View 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 });
});
});

View 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
View 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
View 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);
});
});

View File

@@ -15,13 +15,14 @@ import {
getEntries, getEntries,
getHistory, getHistory,
getLastClosedDay, getLastClosedDay,
importBackup,
patchDay, patchDay,
patchEntry, patchEntry,
putDay, putDay,
setEntryStart, setEntryStart,
} from './db.js'; } from './db.js';
import { buildModel, suggestClients, suggestTasks, TRANSPORT_TASK } from './predict.js'; import { buildModel, suggestClients, suggestTasks, TRANSPORT_TASK } from './predict.js';
import { settings } from './settings.svelte.js'; import { loadSettings, settings } from './settings.svelte.js';
import { atLocalTime, HOUR, localDateKey, roundTs, weekday } from './time.js'; import { atLocalTime, HOUR, localDateKey, roundTs, weekday } from './time.js';
export const dayState = $state({ export const dayState = $state({
@@ -37,6 +38,8 @@ export const dayState = $state({
/** True once the configured end time has passed and the worker has not answered. */ /** True once the configured end time has passed and the worker has not answered. */
needsClosePrompt: false, needsClosePrompt: false,
loaded: 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; 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. */ /** Retention runs once per session, not on every 30 s tick. */
let cleanedUpFor = null; let cleanedUpFor = null;
const pendingReconciliations = new Set();
/** /**
* Bring stored state in line with the clock. Safe to call often — it is invoked on * 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. * 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); dayState.today = localDateKey(now);
// Applying retention here is what makes the setting mean anything; leaving it to // Applying retention here is what makes the setting mean anything; leaving it to
@@ -203,6 +218,34 @@ export async function removeEntry(id) {
await refresh(); 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()) { export async function endDay(now = Date.now()) {
if (!dayState.day) return; if (!dayState.day) return;
const date = dayState.day.date; const date = dayState.day.date;

View File

@@ -1,4 +1,5 @@
import Dexie from 'dexie'; import Dexie from 'dexie';
import { BackupError, buildBackup, EXPORT_FORMAT, EXPORT_VERSION, parseBackup } from './backup.js';
import { DAY, localDateKey } from './time.js'; import { DAY, localDateKey } from './time.js';
/** /**
@@ -238,3 +239,65 @@ export async function cleanupOldDays(retentionDays) {
}); });
return keys.length; 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

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
<script> <script>
import BackupPanel from '../components/BackupPanel.svelte';
import { cleanupOldDays } from '../lib/db.js'; import { cleanupOldDays } from '../lib/db.js';
import { refresh } from '../lib/day.svelte.js'; import { dayState, refresh } from '../lib/day.svelte.js';
import { import {
ROUNDING_CHOICES, ROUNDING_CHOICES,
settings, settings,
@@ -25,7 +26,7 @@
} }
</script> </script>
<div class="screen"> <fieldset class="screen" disabled={dayState.importing}>
<h1>Réglages</h1> <h1>Réglages</h1>
{#if settingsError.message} {#if settingsError.message}
@@ -141,6 +142,8 @@
</div> </div>
</section> </section>
<BackupPanel />
<section class="card stack"> <section class="card stack">
<h2>Historique</h2> <h2>Historique</h2>
<label> <label>
@@ -169,9 +172,15 @@
</p> </p>
{/if} {/if}
</section> </section>
</div> </fieldset>
<style> <style>
fieldset.screen {
border: 0;
margin: 0;
min-width: 0;
}
.days { .days {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(4, 1fr);