diff --git a/README.md b/README.md
index a99b59b..8b737e9 100644
--- a/README.md
+++ b/README.md
@@ -127,6 +127,24 @@ manuel laisse au moins une heure avant une clôture automatique. Les transitions
automatiques sont transactionnelles : une erreur annule toute l'opération et
des appels simultanés ne créent pas de doublons.
+## Jours non travaillés
+
+Dans **Réglages → Jours non travaillés**, ajouter les dates à bloquer à l'avance.
+La liste est triée par date ; chaque date peut être modifiée ou supprimée.
+Les doublons sont refusés. Ces exceptions priment sur les jours de semaine
+habituellement travaillés et empêchent l'ouverture automatique, y compris lors
+du rattrapage à la réouverture de l'app ou hors ligne.
+
+Aucune journée ni aucun poste n'est créé pour une date bloquée. Les journées
+déjà commencées ou terminées restent conservées. Dans **Journée**, le bouton
+**Démarrer quand même** permet un démarrage manuel exceptionnel.
+Retirer une date rétablit les règles habituelles de démarrage automatique.
+
+La liste est enregistrée avec les réglages et incluse dans l'export des données.
+Un import en remplacement rétablit la liste de la sauvegarde ; un import en
+fusion conserve la liste locale, comme les autres réglages. Une ancienne
+sauvegarde sans cette liste la réinitialise à vide lors d'un remplacement.
+
## Corriger les postes et les journées terminées
Le **Total travaillé** exclut le repas, qui reste affiché séparément dans le
diff --git a/src/components/NonWorkingDays.svelte b/src/components/NonWorkingDays.svelte
new file mode 100644
index 0000000..63ed3de
--- /dev/null
+++ b/src/components/NonWorkingDays.svelte
@@ -0,0 +1,71 @@
+
+
+
+ Jours non travaillés
+ Empêcher le démarrage automatique aux dates de cette liste, même un jour habituellement travaillé.
+
+ {#if error}{error}
{/if}
+ {#if message}{message}
{/if}
+ {#if !settings.nonWorkingDates.length}
+ Aucune date bloquée.
+ {:else}
+
+ {/if}
+ Conserver les journées déjà commencées. Un démarrage manuel reste possible si vous travaillez finalement ce jour-là.
+
+
+
diff --git a/src/lib/backup.js b/src/lib/backup.js
index 3a7e987..6fceaf9 100644
--- a/src/lib/backup.js
+++ b/src/lib/backup.js
@@ -35,6 +35,7 @@ export function parseSettings(raw) {
const rules = {
...Object.fromEntries(Object.keys(AUTOMATION_DEFAULTS).map((key) => [key, (v) => typeof v === 'boolean'])),
workdays: (v) => Array.isArray(v) && v.every((n) => Number.isInteger(n) && n >= 0 && n <= 6),
+ nonWorkingDates: (v) => Array.isArray(v) && v.every(isDate),
dayStart: clock,
dayEnd: clock,
lunchStart: clock,
@@ -48,7 +49,7 @@ export function parseSettings(raw) {
for (const [key, validate] of Object.entries(rules)) {
if (!Object.hasOwn(raw, key)) continue;
if (!validate(raw[key])) throw new BackupError(`Réglage invalide : ${key}.`);
- out[key] = key === 'workdays' ? [...new Set(raw[key])].sort() : raw[key];
+ out[key] = ['workdays', 'nonWorkingDates'].includes(key) ? [...new Set(raw[key])].sort() : raw[key];
}
return out;
}
diff --git a/src/lib/day.svelte.js b/src/lib/day.svelte.js
index e42714d..abc680a 100644
--- a/src/lib/day.svelte.js
+++ b/src/lib/day.svelte.js
@@ -98,7 +98,8 @@ async function reconcileDay(now) {
// already ended must not spring back to life.
if (!active) {
const existing = await getDay(dayState.today);
- if (!existing && settings.autoStartDay && settings.workdays.includes(weekday(dayState.today))) {
+ if (!existing && settings.autoStartDay && settings.workdays.includes(weekday(dayState.today)) &&
+ !settings.nonWorkingDates.includes(dayState.today)) {
const plannedStart = atLocalTime(dayState.today, settings.dayStart);
if (now >= plannedStart) {
await openDay(dayState.today, plannedStart);
@@ -137,7 +138,7 @@ async function openDay(dateKey, startTs, manual = false) {
task: TRANSPORT_TASK,
client: model.lastClient,
automatic: true,
- });
+ }, manual ? null : settings.nonWorkingDates);
}
/** Reload everything from the database and rebuild the model. */
diff --git a/src/lib/db.js b/src/lib/db.js
index a324fd0..9869086 100644
--- a/src/lib/db.js
+++ b/src/lib/db.js
@@ -92,9 +92,13 @@ export function patchDay(dateKey, patch) {
return db.days.update(dateKey, patch);
}
-/** Create once, together with the first entry, even from competing tabs. */
-export function createDay(day, first) {
- return db.transaction('rw', db.days, db.entries, async () => {
+/** Create once. Automatic callers supply exclusions, checked again under lock. */
+export function createDay(day, first, nonWorkingDates = null) {
+ return db.transaction('rw', db.days, db.entries, db.settings, async () => {
+ if (nonWorkingDates != null) {
+ const stored = await readSettings();
+ if ((stored?.nonWorkingDates ?? nonWorkingDates).includes(day.date)) return false;
+ }
if (await getDay(day.date) || await getActiveDay()) return false;
await db.days.add(day);
await closeAndStart(day.date, day.startedTs, first);
diff --git a/src/lib/non-working.test.js b/src/lib/non-working.test.js
new file mode 100644
index 0000000..037d6e9
--- /dev/null
+++ b/src/lib/non-working.test.js
@@ -0,0 +1,131 @@
+import 'fake-indexeddb/auto';
+import { afterEach, beforeEach, expect, it, vi } from 'vitest';
+import { buildBackup, parseBackup, parseSettings } from './backup.js';
+import { createDay, db, exportBackup, getDay, importBackup, readSettings, writeSettings } from './db.js';
+import { dayState, reconcile, refresh, startDayNow } from './day.svelte.js';
+import { AUTOMATION_DEFAULTS } from './automation-options.js';
+import { changeNonWorkingDate, DEFAULT_SETTINGS, loadSettings, settings } from './settings.svelte.js';
+import { atLocalTime } from './time.js';
+
+const DATE = '2026-09-21', NEXT = '2026-09-22';
+const at = (time, date = DATE) => atLocalTime(date, time);
+const tick = async (time, date = DATE) => { vi.setSystemTime(at(time, date)); await reconcile(at(time, date)); };
+const records = async () => ({ days: await db.days.toArray(), entries: await db.entries.toArray() });
+const allOn = () => Object.assign(settings, Object.fromEntries(Object.keys(AUTOMATION_DEFAULTS).map(key => [key, true])));
+
+beforeEach(async () => {
+ vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(at('07:00'));
+ vi.stubGlobal('document', { documentElement: { dataset: {} } });
+ await db.days.clear(); await db.entries.clear(); await db.settings.clear();
+ Object.assign(settings, DEFAULT_SETTINGS, { nonWorkingDates: [], retentionDays: 0 });
+ dayState.importing = false; await refresh();
+});
+afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); vi.useRealTimers(); });
+
+it('loads old settings with no excluded dates', async () => {
+ await writeSettings({ theme: 'nuit' }); await loadSettings();
+ expect(settings.nonWorkingDates).toEqual([]); expect(settings.theme).toBe('nuit');
+});
+
+it('normalizes dates and accepts leap days', () => {
+ expect(parseSettings({ nonWorkingDates: [NEXT, DATE, DATE, '2028-02-29'] }).nonWorkingDates)
+ .toEqual([DATE, NEXT, '2028-02-29']);
+});
+it.each([null, DATE, [null], ['2026-02-29'], ['2026-04-31'], ['2026-9-21'], ['2026-09-21T12:00:00Z']])('rejects invalid date lists: %j', value => {
+ expect(() => parseSettings({ nonWorkingDates: value })).toThrow();
+});
+
+it('adds, edits and removes dates while preserving unrelated settings', async () => {
+ await writeSettings({ theme: 'nuit', roundingMinutes: 5 });
+ await changeNonWorkingDate(null, NEXT); await changeNonWorkingDate(null, DATE);
+ expect(settings.nonWorkingDates).toEqual([DATE, NEXT]);
+ await changeNonWorkingDate(NEXT, '2026-09-23'); await changeNonWorkingDate(DATE, null);
+ expect(settings.nonWorkingDates).toEqual(['2026-09-23']);
+ expect(await readSettings()).toMatchObject({ theme: 'nuit', roundingMinutes: 5, nonWorkingDates: ['2026-09-23'] });
+});
+
+it('rejects duplicate additions and edits without changing the list', async () => {
+ await changeNonWorkingDate(null, DATE); await changeNonWorkingDate(null, NEXT);
+ await expect(changeNonWorkingDate(null, DATE)).rejects.toThrow('déjà');
+ await expect(changeNonWorkingDate(NEXT, DATE)).rejects.toThrow('déjà');
+ expect(settings.nonWorkingDates).toEqual([DATE, NEXT]);
+ expect((await readSettings()).nonWorkingDates).toEqual([DATE, NEXT]);
+});
+
+it('retains both concurrent additions and refuses an edit removed elsewhere', async () => {
+ await Promise.all([changeNonWorkingDate(null, DATE), changeNonWorkingDate(null, NEXT)]);
+ expect((await readSettings()).nonWorkingDates).toEqual([DATE, NEXT]);
+ await writeSettings({ nonWorkingDates: [DATE] });
+ await expect(changeNonWorkingDate(NEXT, '2026-09-23')).rejects.toThrow('ailleurs');
+ expect((await readSettings()).nonWorkingDates).toEqual([DATE]);
+});
+
+it('leaves the saved and displayed list intact if saving fails', async () => {
+ await changeNonWorkingDate(null, DATE);
+ vi.spyOn(db.settings, 'put').mockRejectedValueOnce(new Error('Disk full'));
+ await expect(changeNonWorkingDate(DATE, NEXT)).rejects.toThrow('Disk full');
+ expect(settings.nonWorkingDates).toEqual([DATE]);
+ expect((await readSettings()).nonWorkingDates).toEqual([DATE]);
+});
+
+it('blocks a full-day catch-up with all automations enabled, then allows the following day', async () => {
+ allOn(); await changeNonWorkingDate(null, DATE);
+ await tick('08:00'); await tick('18:00');
+ expect(await records()).toEqual({ days: [], entries: [] });
+ await tick('18:00', NEXT);
+ expect((await getDay(NEXT)).status).toBe('closed');
+ expect(await getDay(DATE)).toBeUndefined();
+});
+
+it('allows automatic opening again after removing the exclusion', async () => {
+ await changeNonWorkingDate(null, DATE); await tick('10:00');
+ await changeNonWorkingDate(DATE, null); await tick('10:00');
+ expect((await getDay(DATE)).startedTs).toBe(at('07:30'));
+});
+
+it('checks persisted exclusions despite stale in-memory settings', async () => {
+ await writeSettings({ nonWorkingDates: [DATE] });
+ expect(settings.nonWorkingDates).toEqual([]); await tick('18:00');
+ expect(await records()).toEqual({ days: [], entries: [] });
+});
+
+it('checks the persisted exclusion again under the day creation lock', async () => {
+ await writeSettings({ nonWorkingDates: [DATE] });
+ const day = { date: DATE, status: 'active', startedTs: at('08:00') };
+ expect(await createDay(day, { kind: 'travel', task: 'Transport' }, [])).toBe(false);
+ expect(await records()).toEqual({ days: [], entries: [] });
+});
+
+it('permits an explicit manual start on an excluded date', async () => {
+ await changeNonWorkingDate(null, DATE); await tick('10:00'); await startDayNow(at('10:00'));
+ expect((await getDay(DATE)).startedTs).toBe(at('10:00'));
+});
+
+it('does not alter an existing day when its date is blocked', async () => {
+ await tick('08:00'); const before = await records();
+ await changeNonWorkingDate(null, DATE); await tick('10:00');
+ expect(await records()).toEqual(before);
+});
+
+it('finishes yesterday while keeping blocked today absent and the closed day unchanged', async () => {
+ allOn(); await changeNonWorkingDate(null, NEXT); await tick('08:00'); await tick('18:00', NEXT);
+ expect((await getDay(DATE)).status).toBe('closed'); expect(await getDay(NEXT)).toBeUndefined();
+ const before = await records(); await changeNonWorkingDate(null, DATE); await tick('19:00', NEXT);
+ expect(await records()).toEqual(before);
+});
+
+it('round-trips exclusions in replacement backups and preserves local exclusions when merging', async () => {
+ await changeNonWorkingDate(null, DATE);
+ const backup = parseBackup(JSON.stringify(await exportBackup()));
+ expect(backup.settings.nonWorkingDates).toEqual([DATE]);
+ await changeNonWorkingDate(DATE, NEXT); await importBackup(backup, 'merge'); await loadSettings();
+ expect(settings.nonWorkingDates).toEqual([NEXT]);
+ await importBackup(backup, 'replace'); await loadSettings(); expect(settings.nonWorkingDates).toEqual([DATE]);
+ await tick('18:00'); expect(await getDay(DATE)).toBeUndefined();
+});
+
+it('replacing with an older backup resets exclusions to the empty default', async () => {
+ await changeNonWorkingDate(null, DATE);
+ await importBackup(buildBackup({ days: [], entries: [], settings: { theme: 'jour' } }), 'replace');
+ await loadSettings(); expect(settings.nonWorkingDates).toEqual([]);
+});
diff --git a/src/lib/settings.svelte.js b/src/lib/settings.svelte.js
index 861449a..c948e16 100644
--- a/src/lib/settings.svelte.js
+++ b/src/lib/settings.svelte.js
@@ -1,4 +1,4 @@
-import { readSettings, writeSettings } from './db.js';
+import { db, readSettings, writeSettings } from './db.js';
import { parseSettings } from './backup.js';
import { AUTOMATION_DEFAULTS } from './automation-options.js';
@@ -6,6 +6,7 @@ export const DEFAULT_SETTINGS = {
...AUTOMATION_DEFAULTS,
/** JS weekday numbers, 0 = Sunday. Monday–Friday by default. */
workdays: [1, 2, 3, 4, 5],
+ nonWorkingDates: [],
dayStart: '07:30',
dayEnd: '17:30',
lunchStart: '12:00',
@@ -77,3 +78,23 @@ export function applyTheme() {
export function isWorkday(weekdayNumber) {
return settings.workdays.includes(weekdayNumber);
}
+
+/** Apply a date-list edit against the latest saved list, without lost updates. */
+export async function changeNonWorkingDate(original, replacement) {
+ if (replacement != null) parseSettings({ nonWorkingDates: [replacement] });
+ const nextDates = await db.transaction('rw', db.settings, async () => {
+ const { loaded, ...base } = $state.snapshot(settings);
+ const stored = { ...base, ...await readSettings() };
+ const dates = stored.nonWorkingDates ?? [];
+ if (original != null && replacement != null && !dates.includes(original)) {
+ throw new Error('Cette date a été modifiée ailleurs. Actualiser la liste avant de réessayer.');
+ }
+ if (replacement != null && replacement !== original && dates.includes(replacement)) {
+ throw new Error('Cette date est déjà dans la liste.');
+ }
+ const next = [...new Set([...dates.filter((date) => date !== original), ...(replacement ? [replacement] : [])])].sort();
+ await writeSettings({ ...stored, nonWorkingDates: next });
+ return next;
+ });
+ settings.nonWorkingDates = nextDates;
+}
diff --git a/src/routes/Resume.svelte b/src/routes/Resume.svelte
index f65ff8d..41041c8 100644
--- a/src/routes/Resume.svelte
+++ b/src/routes/Resume.svelte
@@ -26,7 +26,7 @@
/** Shown when today is a work day whose start time has not arrived yet. */
const startsAt = $derived(
- settings.workdays.includes(weekday(dayState.today)) ? settings.dayStart : null,
+ settings.autoStartDay && !settings.nonWorkingDates.includes(dayState.today) && settings.workdays.includes(weekday(dayState.today)) ? settings.dayStart : null,
);
const closedToday = $derived(selectedDay?.date === dayState.today && !dayState.day);
@@ -138,10 +138,14 @@
onclick={reopen}>Reprendre la journée
Annule la clôture et continue la dernière tâche depuis l’arrêt.
+ {:else if !dayState.day && !dayState.todayRecord && settings.nonWorkingDates.includes(dayState.today)}
+ Aujourd’hui est bloqué comme jour non travaillé.
{:else if !dayState.day && !dayState.todayRecord && startsAt}
La journée démarrera automatiquement à {startsAt}.
+ {:else if !dayState.day && !dayState.todayRecord && !settings.autoStartDay}
+ Le démarrage automatique est désactivé.
{:else if !dayState.day && !dayState.todayRecord}
Aujourd'hui n'est pas un jour travaillé.
{/if}
diff --git a/src/routes/Settings.svelte b/src/routes/Settings.svelte
index 2ddd1db..c1f3aaa 100644
--- a/src/routes/Settings.svelte
+++ b/src/routes/Settings.svelte
@@ -1,5 +1,6 @@