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

@@ -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
>

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,
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;

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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);