From d8d9d4c4fe120559540f62e123b95128649f7852 Mon Sep 17 00:00:00 2001 From: "Adrien W." Date: Sun, 20 Sep 2026 14:53:20 +0200 Subject: [PATCH] Added scheduled off-work days --- README.md | 18 ++++ src/components/NonWorkingDays.svelte | 71 +++++++++++++++ src/lib/backup.js | 3 +- src/lib/day.svelte.js | 5 +- src/lib/db.js | 10 +- src/lib/non-working.test.js | 131 +++++++++++++++++++++++++++ src/lib/settings.svelte.js | 23 ++++- src/routes/Resume.svelte | 6 +- src/routes/Settings.svelte | 3 + src/routes/Today.svelte | 7 +- 10 files changed, 267 insertions(+), 10 deletions(-) create mode 100644 src/components/NonWorkingDays.svelte create mode 100644 src/lib/non-working.test.js 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é.

+
{ event.preventDefault(); save(null, date); }} class="stack"> + + +
+ {#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 @@