Improved import security, added browser tests
This commit is contained in:
84
src/lib/backup-safety.js
Normal file
84
src/lib/backup-safety.js
Normal file
@@ -0,0 +1,84 @@
|
||||
import { db, exportBackup, readSettings } from './db.js';
|
||||
import { parseBackup } from './backup.js';
|
||||
import { DAY, localDateKey } from './time.js';
|
||||
|
||||
export const BACKUP_REMINDER_DAYS = 7;
|
||||
|
||||
export async function readBackupStatus() {
|
||||
return (await db.settings.get('backup-status'))?.value ?? {};
|
||||
}
|
||||
|
||||
/** Verify the complete downloaded file, not just its timestamp or row counts. */
|
||||
async function digest(text) {
|
||||
const bytes = new TextEncoder().encode(text);
|
||||
const hash = await crypto.subtle.digest('SHA-256', bytes);
|
||||
return [...new Uint8Array(hash)].map(byte => byte.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
export async function prepareVerifiedExport(now = Date.now()) {
|
||||
const payload = await exportBackup();
|
||||
const text = JSON.stringify(payload, null, 2);
|
||||
// A backup must pass the same reader used for restoration before we offer it.
|
||||
parseBackup(text);
|
||||
const checksum = await digest(text);
|
||||
await db.transaction('rw', db.settings, async () => {
|
||||
const current = await readBackupStatus();
|
||||
await db.settings.put({ key: 'backup-status', value: {
|
||||
...current, preparedAt: now, exportedAt: payload.exportedAt, checksum,
|
||||
} });
|
||||
});
|
||||
return { payload, text };
|
||||
}
|
||||
|
||||
export async function verifyExportFile(text, now = Date.now()) {
|
||||
const payload = parseBackup(text);
|
||||
const checksum = await digest(text);
|
||||
return db.transaction('rw', db.settings, async () => {
|
||||
const current = await readBackupStatus();
|
||||
if (!current.checksum || checksum !== current.checksum) {
|
||||
throw new Error('Ce fichier ne correspond pas au dernier export préparé sur cet appareil. Exportez les données puis choisissez ce nouveau fichier.');
|
||||
}
|
||||
const next = { ...current, verifiedAt: now, verifiedExportedAt: current.exportedAt };
|
||||
await db.settings.put({ key: 'backup-status', value: next });
|
||||
return { status: next, days: payload.dayCount, entries: payload.entryCount };
|
||||
});
|
||||
}
|
||||
|
||||
export function backupReminderDue(status, now = Date.now()) {
|
||||
const verified = Date.parse(status.verifiedExportedAt ?? '');
|
||||
return !Number.isFinite(verified) || now - verified >= BACKUP_REMINDER_DAYS * DAY;
|
||||
}
|
||||
|
||||
export function purgeCandidates(days, retentionDays, now = Date.now()) {
|
||||
const closed = days.filter(day => day.status === 'closed').sort((a, b) => b.date.localeCompare(a.date));
|
||||
const cutoff = localDateKey(now - retentionDays * DAY);
|
||||
return retentionDays > 0 ? closed.filter(day => day.date < cutoff && day.date !== closed[0]?.date) : [];
|
||||
}
|
||||
|
||||
/** Delete only the reviewed, unchanged dates, preserving a snapshot under one lock. */
|
||||
export function purgeReviewedDays(reviewed, retentionDays, now = Date.now()) {
|
||||
return db.transaction('rw', db.days, db.entries, db.settings, async () => {
|
||||
const stored = await readSettings();
|
||||
if (stored?.retentionDays != null && stored.retentionDays !== retentionDays) {
|
||||
throw new Error('La durée de conservation a changé. Fermez puis ouvrez la confirmation.');
|
||||
}
|
||||
const days = await db.days.toArray();
|
||||
const entries = await db.entries.toArray();
|
||||
const eligible = new Set(purgeCandidates(days, retentionDays, now).map(day => day.date));
|
||||
for (const original of reviewed) {
|
||||
const current = days.find(day => day.date === original.day.date);
|
||||
const own = entries.filter(entry => entry.date === original.day.date).sort((a, b) => a.seq - b.seq);
|
||||
if (!eligible.has(original.day.date) || JSON.stringify(current) !== JSON.stringify(original.day) || JSON.stringify(own) !== JSON.stringify(original.entries)) {
|
||||
throw new Error('Une journée à purger a changé. Fermez puis ouvrez la confirmation.');
|
||||
}
|
||||
}
|
||||
if (!reviewed.length) return 0;
|
||||
const backup = await exportBackup();
|
||||
parseBackup(JSON.stringify(backup));
|
||||
await db.settings.put({ key: 'before-purge', value: backup });
|
||||
const dates = reviewed.map(item => item.day.date);
|
||||
await db.entries.where('date').anyOf(dates).delete();
|
||||
await db.days.bulkDelete(dates);
|
||||
return dates.length;
|
||||
});
|
||||
}
|
||||
71
src/lib/backup-safety.test.js
Normal file
71
src/lib/backup-safety.test.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import 'fake-indexeddb/auto';
|
||||
import { beforeEach, expect, it, vi } from 'vitest';
|
||||
import { db, getDay, getEntries, importBackup } from './db.js';
|
||||
import { backupReminderDue, prepareVerifiedExport, purgeCandidates, purgeReviewedDays, readBackupStatus, verifyExportFile } from './backup-safety.js';
|
||||
import { parseBackup } from './backup.js';
|
||||
import { atLocalTime, DAY } from './time.js';
|
||||
|
||||
const now = atLocalTime('2026-10-08', '18:00');
|
||||
const record = date => ({ date, status: 'closed', startedTs: atLocalTime(date,'08:00'), endedTs: atLocalTime(date,'17:00'), plannedStartTs: atLocalTime(date,'08:00'), plannedEndTs: atLocalTime(date,'17:00'), lunchType: 'panier' });
|
||||
async function install(date) {
|
||||
const day = record(date); await db.days.add(day);
|
||||
await db.entries.add({ date, seq:0, kind:'work', task:'Chantier de test', client:null, startTs:day.startedTs,endTs:day.endedTs });
|
||||
}
|
||||
async function reviewed(date) { return { day: await getDay(date), entries: await getEntries(date) }; }
|
||||
beforeEach(async () => { await db.days.clear(); await db.entries.clear(); await db.settings.clear(); });
|
||||
|
||||
it('only verifies the complete exported file, without altering the history', async () => {
|
||||
await install('2026-10-08');
|
||||
const prepared = await prepareVerifiedExport(now);
|
||||
const before = await getEntries('2026-10-08');
|
||||
await verifyExportFile(prepared.text,now);
|
||||
expect((await readBackupStatus()).verifiedExportedAt).toBe(prepared.payload.exportedAt);
|
||||
expect(await getEntries('2026-10-08')).toEqual(before);
|
||||
const corrupted = JSON.parse(prepared.text); corrupted.entries[0].task = 'Changed';
|
||||
await expect(verifyExportFile(JSON.stringify(corrupted))).rejects.toThrow(/ne correspond pas/);
|
||||
});
|
||||
it('reminds after seven days from the verified export, rather than the day it was checked', () => {
|
||||
expect(backupReminderDue({},now)).toBe(true);
|
||||
expect(backupReminderDue({verifiedExportedAt:new Date(now-6*DAY).toISOString()},now)).toBe(false);
|
||||
expect(backupReminderDue({verifiedExportedAt:new Date(now-7*DAY).toISOString(),verifiedAt:now},now)).toBe(true);
|
||||
});
|
||||
it('preserves an importable snapshot and purges only the dates reviewed', async () => {
|
||||
await install('2025-01-01'); await install('2026-10-08');
|
||||
const old = await reviewed('2025-01-01');
|
||||
await install('2025-02-01'); // another tab imports an eligible day after review
|
||||
expect(await purgeReviewedDays([old],30,now)).toBe(1);
|
||||
expect(await getDay('2025-01-01')).toBeUndefined();
|
||||
expect(await getDay('2025-02-01')).toBeDefined();
|
||||
const payload = parseBackup(JSON.stringify((await db.settings.get('before-purge')).value));
|
||||
await importBackup(payload,'merge');
|
||||
expect(await getEntries('2025-01-01')).toMatchObject(old.entries.map(({id,...rest})=>rest));
|
||||
});
|
||||
it('rejects a changed reviewed day, leaving its slots and previous backup intact', async () => {
|
||||
await install('2025-01-01'); await install('2026-10-08');
|
||||
const old = await reviewed('2025-01-01');
|
||||
await db.entries.update(old.entries[0].id,{task:'Correction dans un autre onglet'});
|
||||
await expect(purgeReviewedDays([old],30,now)).rejects.toThrow(/a changé/);
|
||||
expect(await getDay('2025-01-01')).toBeDefined();
|
||||
expect(await db.settings.get('before-purge')).toBeUndefined();
|
||||
});
|
||||
it('rolls back the saved snapshot and deletion on a database failure', async () => {
|
||||
await install('2025-01-01'); await install('2026-10-08');
|
||||
const old = await reviewed('2025-01-01');
|
||||
await db.settings.put({key:'before-purge',value:'existing backup'});
|
||||
const spy = vi.spyOn(db.days,'bulkDelete').mockRejectedValue(new Error('Storage failure'));
|
||||
try { await expect(purgeReviewedDays([old],30,now)).rejects.toThrow('Storage failure'); }
|
||||
finally { spy.mockRestore(); }
|
||||
expect(await getEntries('2025-01-01')).toEqual(old.entries);
|
||||
expect((await db.settings.get('before-purge')).value).toBe('existing backup');
|
||||
});
|
||||
it('always preserves the last closed day and active days', () => {
|
||||
const old = record('2025-01-01');
|
||||
expect(purgeCandidates([old],30,now)).toEqual([]);
|
||||
expect(purgeCandidates([old,{...record('2026-10-08'),status:'active'}],30,now)).toEqual([]);
|
||||
});
|
||||
it('rejects a retention change from another tab before writing a backup', async () => {
|
||||
await install('2025-01-01'); await install('2026-10-08');
|
||||
await db.settings.put({key:'app',value:{retentionDays:720}});
|
||||
await expect(purgeReviewedDays([await reviewed('2025-01-01')],30,now)).rejects.toThrow(/conservation a changé/);
|
||||
expect(await getDay('2025-01-01')).toBeDefined();
|
||||
});
|
||||
6
src/lib/backup-state.svelte.js
Normal file
6
src/lib/backup-state.svelte.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import { readBackupStatus } from './backup-safety.js';
|
||||
export const backupState = $state({ status: {}, loaded: false });
|
||||
export async function loadBackupStatus() {
|
||||
backupState.status = await readBackupStatus();
|
||||
backupState.loaded = true;
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { buildStats } from './stats.js';
|
||||
import { buildBackup, parseBackup } from './backup.js';
|
||||
import { buildModel } from './predict.js';
|
||||
import { automateDay } from './automation.js';
|
||||
import { insertRecordedEntry, removeRecordedEntry, saveRecordedEntry } from './editing.js';
|
||||
import { insertGapEntry, insertRecordedEntry, removeRecordedEntry, saveRecordedEntry } from './editing.js';
|
||||
import { DEFAULT_SETTINGS } from './settings.svelte.js';
|
||||
import { atLocalTime, HOUR, MINUTE } from './time.js';
|
||||
const date = '2026-10-08';
|
||||
@@ -113,3 +113,23 @@ describe('preserving imported gaps while editing', () => {
|
||||
expect(rows[0].endTs).toBe(at('11:00')); expect(rows[1].startTs).toBe(at('13:00'));
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses to resume a closure that was corrected after confirmation opened', async () => {
|
||||
await install([slot(0,'work','08:00','17:00')]);
|
||||
const original = (await getEntries(date))[0];
|
||||
await saveRecordedEntry(original,{...original,endTs:at('16:30')},at('18:00'));
|
||||
await expect(reopenDay(date,at('18:00'),'break',{date,endedTs:at('17:00'),last:original})).rejects.toThrow(/a changé/);
|
||||
expect((await getDay(date)).status).toBe('closed');
|
||||
expect(await getEntries(date)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('records a pause within a gap without changing either work neighbour', async () => {
|
||||
await install([slot(0,'work','08:00','11:00'),slot(1,'work','13:00','17:00')]);
|
||||
const [previous, following] = await getEntries(date);
|
||||
await insertGapEntry(previous, following, slot(0,'break','11:30','12:30'), at('18:00'));
|
||||
const rows = await getEntries(date);
|
||||
expect(rows.map(row => row.kind)).toEqual(['work','break','work']);
|
||||
expect(rows[0].endTs).toBe(at('11:00')); expect(rows[2].startTs).toBe(at('13:00'));
|
||||
expect(buildStats([day],rows).quality.uncoveredMs).toBe(HOUR);
|
||||
await expect(insertGapEntry(previous,following,slot(0,'break','11:30','12:30'),at('18:00'))).rejects.toThrow(/a changé/);
|
||||
});
|
||||
|
||||
@@ -21,7 +21,8 @@ import {
|
||||
} from './db.js';
|
||||
import { buildModel, suggestClients, suggestTasks, TRANSPORT_TASK } from './predict.js';
|
||||
import { automateDay, manualAdvance, previousContext } from './automation.js';
|
||||
import { loadSettings, settings } from './settings.svelte.js';
|
||||
import { loadBackupStatus } from './backup-state.svelte.js';
|
||||
import { applyTheme, loadSettings, settings } from './settings.svelte.js';
|
||||
import { atLocalTime, HOUR, localDateKey, roundTs, weekday } from './time.js';
|
||||
|
||||
export const dayState = $state({
|
||||
@@ -36,6 +37,7 @@ export const dayState = $state({
|
||||
historyEntries: [],
|
||||
todayRecord: null,
|
||||
previousWork: null,
|
||||
lastKnownWork: null,
|
||||
/** Prediction model, rebuilt whenever history changes. */
|
||||
model: null,
|
||||
/** True once the configured end time has passed and the worker has not answered. */
|
||||
@@ -70,7 +72,7 @@ export function reconcile(now = Date.now()) {
|
||||
async function reconcileDay(now) {
|
||||
// A settings change in another tab must also stop this tab's clock actions.
|
||||
const persisted = await readSettings();
|
||||
if (persisted) Object.assign(settings, persisted);
|
||||
if (persisted) { Object.assign(settings, persisted); applyTheme(); }
|
||||
dayState.today = localDateKey(now);
|
||||
|
||||
const active = await getActiveDay();
|
||||
@@ -139,7 +141,9 @@ export async function refresh(now = Date.now()) {
|
||||
const entriesFor = (day) => day ? entries.filter((e) => e.date === day.date).sort((a, b) => a.seq - b.seq) : [];
|
||||
dayState.today = localDateKey(now);
|
||||
dayState.todayRecord = days.find((d) => d.date === dayState.today) ?? null;
|
||||
dayState.previousWork = previousContext(days, entries, active?.date ?? dayState.today).previousWork;
|
||||
const previous = previousContext(days, entries, active?.date ?? dayState.today);
|
||||
dayState.previousWork = previous.previousWork;
|
||||
dayState.lastKnownWork = previous.lastKnownWork;
|
||||
dayState.day = active ?? null;
|
||||
dayState.entries = entriesFor(active);
|
||||
dayState.lastClosed = lastClosed ?? null;
|
||||
@@ -229,7 +233,7 @@ export async function applyImport(payload, mode) {
|
||||
// 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();
|
||||
if (mode === 'replace') { await loadSettings(); await loadBackupStatus(); }
|
||||
// Do not immediately purge old imported days or create a new working day.
|
||||
// Retention is explicitly confirmed in settings.
|
||||
dayState.today = localDateKey();
|
||||
@@ -259,10 +263,11 @@ export async function keepWorking(now = Date.now()) {
|
||||
}
|
||||
|
||||
/** Undo a premature end-of-day, continuing the last task without a gap. */
|
||||
export async function reopenToday(now = Date.now(), mode = 'continue') {
|
||||
export async function reopenToday(now = Date.now(), mode = 'continue', expected = null) {
|
||||
if (dayState.importing) return;
|
||||
await reopenDay(localDateKey(now), now, mode);
|
||||
const reopened = await reopenDay(expected?.date ?? localDateKey(now), now, mode, expected);
|
||||
await refresh(now);
|
||||
return reopened;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- predictions */
|
||||
@@ -277,7 +282,7 @@ export function predictNext(now = Date.now()) {
|
||||
if (!model) return { task: TRANSPORT_TASK, client: null, tasks: [], clients: [] };
|
||||
|
||||
const current = running();
|
||||
const lastWork = [...dayState.entries].reverse().find((e) => e.kind === 'work') ?? dayState.previousWork;
|
||||
const lastWork = [...dayState.entries].reverse().find((e) => e.kind === 'work') ?? dayState.lastKnownWork;
|
||||
const tasks = suggestTasks(model, {
|
||||
prevTask: current?.task ?? null,
|
||||
prevElapsedMs: current ? now - current.startTs : 0,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'fake-indexeddb/auto';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanupOldDays, closeAndStart, closeDay, createDay, db, getDay, getEntries, reopenDay } from './db.js';
|
||||
import { advance, dayState, endDay, keepWorking, reconcile, refresh, reopenToday, setLunch, startDayNow, startLunch } from './day.svelte.js';
|
||||
import { DEFAULT_SETTINGS, settings, settingsError, updateSettings } from './settings.svelte.js';
|
||||
import { advance, dayState, endDay, keepWorking, predictNext, reconcile, refresh, reopenToday, setLunch, startDayNow, startLunch } from './day.svelte.js';
|
||||
import { DEFAULT_SETTINGS, settings, settingsError, setWorkingDay, updateSettings } from './settings.svelte.js';
|
||||
import { atLocalTime, HOUR } from './time.js';
|
||||
import { buildResume } from './resume.js';
|
||||
|
||||
@@ -244,3 +244,22 @@ describe('settings persistence across tabs', () => {
|
||||
expect(settingsError.message).toContain('write failed');
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves weekdays changed in another tab when toggling one weekday', async () => {
|
||||
settings.workdays = [1];
|
||||
await db.settings.put({key:'app',value:{...DEFAULT_SETTINGS,workdays:[1,3]}});
|
||||
await setWorkingDay(2,true);
|
||||
expect((await db.settings.get('app')).value.workdays).toEqual([1,2,3]);
|
||||
});
|
||||
|
||||
it('resumes a pause using the last known work even when the previous day only has transport', async () => {
|
||||
const oldDate = '2026-09-04', recentDate = '2026-09-05';
|
||||
await db.days.bulkAdd([oldDate,recentDate].map(date => ({...makeDay(date),status:'closed',endedTs:at('17:00',date)})));
|
||||
await db.entries.bulkAdd([
|
||||
{date:oldDate,seq:0,kind:'work',task:'Pose ancienne',client:'Client ancien',startTs:at('08:00',oldDate),endTs:at('17:00',oldDate)},
|
||||
{date:recentDate,seq:0,...first,startTs:at('08:00',recentDate),endTs:at('17:00',recentDate)},
|
||||
]);
|
||||
await startDayNow(at('07:30'));
|
||||
await advance({kind:'break',task:'Pause'},at('08:00'));
|
||||
expect(predictNext(at('08:00'))).toMatchObject({task:'Pose ancienne',client:'Client ancien'});
|
||||
});
|
||||
|
||||
@@ -121,7 +121,7 @@ export function closeDay(date, ts, stale = false) {
|
||||
}
|
||||
|
||||
/** Confirmed same-day reopening; status and all resumed slots commit together. */
|
||||
export function reopenDay(date, now, mode = 'continue') {
|
||||
export function reopenDay(date, now, mode = 'continue', expected = null) {
|
||||
return db.transaction('rw', db.days, db.entries, async () => {
|
||||
const day = await getDay(date);
|
||||
if (!['continue', 'break'].includes(mode)) throw new Error('Choix de reprise invalide.');
|
||||
@@ -129,6 +129,10 @@ export function reopenDay(date, now, mode = 'continue') {
|
||||
const entries = await getEntries(date);
|
||||
const last = entries.at(-1);
|
||||
if (!last) return false;
|
||||
if (expected && (day.endedTs !== expected.endedTs ||
|
||||
['id', 'kind', 'task', 'client', 'startTs', 'endTs'].some(key => last[key] !== expected.last[key]))) {
|
||||
throw new Error('La clôture ou le dernier poste a changé. Fermez puis ouvrez la confirmation de reprise.');
|
||||
}
|
||||
const boundary = day.endedTs;
|
||||
if (!Number.isFinite(boundary) || now < boundary) return false;
|
||||
if (mode === 'continue' && ['work', 'travel'].includes(last.kind)) {
|
||||
|
||||
@@ -1,13 +1,28 @@
|
||||
/** Focus ownership, keyboard trap and restoration for modal panels. */
|
||||
/** Modal focus ownership follows visual stacking, including nested pickers. */
|
||||
const hidden = new WeakMap();
|
||||
function topDialog() {
|
||||
return [...document.querySelectorAll('[role="dialog"]')]
|
||||
.filter(node => node.isConnected)
|
||||
.sort((a, b) => (Number(getComputedStyle(a).zIndex) || 0) - (Number(getComputedStyle(b).zIndex) || 0)).at(-1);
|
||||
}
|
||||
|
||||
export function modalFocus(node) {
|
||||
const origin = document.activeElement;
|
||||
const controls = () => [...node.querySelectorAll('button, input, select, textarea, a[href], [tabindex="0"]')]
|
||||
.filter(el => !el.disabled && el.getClientRects().length);
|
||||
queueMicrotask(() => (controls()[0] ?? node).focus());
|
||||
.filter(el => !el.matches(':disabled') && !el.closest('[inert]') && el.getClientRects().length);
|
||||
const blocked = [];
|
||||
// Hide every branch outside this dialog from keyboard and assistive technology.
|
||||
for (let branch = node; branch.parentElement; branch = branch.parentElement) {
|
||||
for (const sibling of branch.parentElement.children) {
|
||||
if (sibling === branch || !(sibling instanceof HTMLElement)) continue;
|
||||
const record = hidden.get(sibling) ?? { count: 0, inert: sibling.inert };
|
||||
record.count++; hidden.set(sibling, record); sibling.inert = true; blocked.push(sibling);
|
||||
}
|
||||
}
|
||||
const focusFirst = () => (controls()[0] ?? node).focus();
|
||||
queueMicrotask(() => { if (node.isConnected && topDialog() === node) focusFirst(); });
|
||||
function trap(event) {
|
||||
if (event.key !== 'Tab') return;
|
||||
const dialogs = [...document.querySelectorAll('[role="dialog"]')];
|
||||
if (dialogs.at(-1) !== node) return;
|
||||
if (event.key !== 'Tab' || topDialog() !== node) return;
|
||||
const list = controls(), first = list[0], last = list.at(-1);
|
||||
if (!first) { event.preventDefault(); node.focus(); return; }
|
||||
if (event.shiftKey && (document.activeElement === first || !node.contains(document.activeElement))) {
|
||||
@@ -16,6 +31,18 @@ export function modalFocus(node) {
|
||||
event.preventDefault(); first.focus();
|
||||
}
|
||||
}
|
||||
function contain(event) {
|
||||
if (topDialog() === node && !node.contains(event.target)) focusFirst();
|
||||
}
|
||||
document.addEventListener('keydown', trap);
|
||||
return { destroy() { document.removeEventListener('keydown', trap); if (origin instanceof HTMLElement && origin.isConnected) origin.focus(); } };
|
||||
document.addEventListener('focusin', contain);
|
||||
return { destroy() {
|
||||
document.removeEventListener('keydown', trap);
|
||||
document.removeEventListener('focusin', contain);
|
||||
for (const element of blocked) {
|
||||
const record = hidden.get(element);
|
||||
if (--record.count === 0) { element.inert = record.inert; hidden.delete(element); }
|
||||
}
|
||||
if (origin instanceof HTMLElement && origin.isConnected && !origin.closest('[inert]')) origin.focus();
|
||||
} };
|
||||
}
|
||||
|
||||
@@ -103,3 +103,23 @@ export function removeRecordedEntry(original) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Record an explicit slot inside an imported empty interval without filling its remnants. */
|
||||
export function insertGapEntry(previousOriginal, followingOriginal, values, now = Date.now()) {
|
||||
return db.transaction('rw', db.days, db.entries, async () => {
|
||||
const { entry: previous, entries } = await readOriginal(previousOriginal);
|
||||
const { entry: following } = await readOriginal(followingOriginal);
|
||||
const index = entries.findIndex(entry => entry.id === previous.id);
|
||||
if (following.date !== previous.date || entries[index + 1]?.id !== following.id || previous.endTs == null) throw conflict();
|
||||
validateTimes(values.startTs, values.endTs, now);
|
||||
if (values.startTs < previous.endTs || values.endTs > following.startTs) {
|
||||
throw new Error('Le poste ajouté doit rester dans l’intervalle sans poste.');
|
||||
}
|
||||
for (const later of entries.slice(index + 1)) await db.entries.update(later.id, { seq: later.seq + 1 });
|
||||
const id = await db.entries.add({
|
||||
date: previous.date, seq: previous.seq + 1, ...content(values), startTs: values.startTs, endTs: values.endTs,
|
||||
});
|
||||
await saveLunch(previous.date, values);
|
||||
return id;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,18 +9,23 @@
|
||||
const ROUTES = ['resume', 'today', 'settings', 'stats', 'details', 'automations', 'non-working'];
|
||||
|
||||
function fromHash() {
|
||||
const h = location.hash.replace(/^#\/?/, '');
|
||||
return ROUTES.includes(h) ? h : null;
|
||||
const [path, query = ''] = location.hash.replace(/^#\/?/, '').split('?');
|
||||
const date = new URLSearchParams(query).get('date');
|
||||
return { route: ROUTES.includes(path) ? path : null,
|
||||
selectedDate: date && /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : '' };
|
||||
}
|
||||
|
||||
export const router = $state({ route: fromHash(), selectedDate: '' });
|
||||
export const router = $state(fromHash());
|
||||
|
||||
window.addEventListener('hashchange', () => {
|
||||
router.route = fromHash();
|
||||
const next = fromHash();
|
||||
router.route = next.route;
|
||||
if (next.selectedDate) router.selectedDate = next.selectedDate;
|
||||
});
|
||||
|
||||
export function go(route) {
|
||||
location.hash = route ? `#/${route}` : '';
|
||||
const date = ['resume', 'details'].includes(route) && router.selectedDate ? `?date=${router.selectedDate}` : '';
|
||||
location.hash = route ? `#/${route}${date}` : '';
|
||||
}
|
||||
|
||||
export function back() {
|
||||
|
||||
@@ -101,3 +101,23 @@ export async function changeNonWorkingDate(original, replacement) {
|
||||
});
|
||||
settings.nonWorkingDates = nextDates;
|
||||
}
|
||||
|
||||
/** Update one weekday against the persisted list, preserving other tabs' choices. */
|
||||
export async function setWorkingDay(value, enabled) {
|
||||
try {
|
||||
parseSettings({ workdays: [value] });
|
||||
const persisted = await db.transaction('rw', db.settings, async () => {
|
||||
const { loaded, ...base } = $state.snapshot(settings);
|
||||
const stored = { ...base, ...await readSettings() };
|
||||
const days = new Set(stored.workdays);
|
||||
if (enabled) days.add(value); else days.delete(value);
|
||||
const next = { ...stored, workdays: [...days].sort() };
|
||||
await writeSettings(next);
|
||||
return next;
|
||||
});
|
||||
Object.assign(settings, persisted); applyTheme(); settingsError.message = null;
|
||||
} catch (error) {
|
||||
settingsError.message = `Enregistrement impossible : ${error.message}`;
|
||||
try { await loadSettings(); } catch { /* Keep the last displayed state. */ }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user