Added scheduled off-work days

This commit is contained in:
2026-09-20 14:53:20 +02:00
parent c9c524ff2a
commit d8d9d4c4fe
10 changed files with 267 additions and 10 deletions

View File

@@ -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 automatiques sont transactionnelles : une erreur annule toute l'opération et
des appels simultanés ne créent pas de doublons. 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 ## Corriger les postes et les journées terminées
Le **Total travaillé** exclut le repas, qui reste affiché séparément dans le Le **Total travaillé** exclut le repas, qui reste affiché séparément dans le

View File

@@ -0,0 +1,71 @@
<script>
import { changeNonWorkingDate, settings } from '../lib/settings.svelte.js';
import { fmtDayLabel } from '../lib/time.js';
let date = $state('');
let editing = $state(null);
let editedDate = $state('');
let busy = $state(false);
let error = $state('');
let message = $state('');
const label = (value) => `${fmtDayLabel(value)} ${value.slice(0, 4)}`;
async function save(original, replacement) {
if (busy) return;
busy = true; error = ''; message = '';
try {
await changeNonWorkingDate(original, replacement);
if (original == null) date = '';
editing = null;
message = replacement == null ? 'Date supprimée.' : original == null ? 'Date ajoutée.' : 'Date modifiée.';
} catch (cause) { error = cause.message; }
finally { busy = false; }
}
</script>
<section class="card stack" aria-labelledby="non-working-title">
<h2 id="non-working-title">Jours non travaillés</h2>
<p class="small dim">Empêcher le démarrage automatique aux dates de cette liste, même un jour habituellement travaillé.</p>
<form onsubmit={(event) => { event.preventDefault(); save(null, date); }} class="stack">
<label class="stack">Date à bloquer<input type="date" required bind:value={date} disabled={busy} /></label>
<button type="submit" disabled={busy || !date}>Ajouter la date</button>
</form>
{#if error}<p role="alert">{error}</p>{/if}
{#if message}<p role="status">{message}</p>{/if}
{#if !settings.nonWorkingDates.length}
<p class="small dim">Aucune date bloquée.</p>
{:else}
<ul>
{#each settings.nonWorkingDates as value (value)}
<li class="stack">
{#if editing === value}
<form class="stack" onsubmit={(event) => { event.preventDefault(); save(value, editedDate); }}>
<label class="stack">Nouvelle date<input type="date" required bind:value={editedDate} disabled={busy} /></label>
<div class="actions">
<button type="submit" disabled={busy || !editedDate}>Enregistrer</button>
<button type="button" disabled={busy} onclick={() => (editing = null)}>Annuler</button>
</div>
</form>
{:else}
<strong>{label(value)}</strong>
<div class="actions">
<button type="button" disabled={busy} aria-label={`Modifier : ${label(value)}`} onclick={() => { editing = value; editedDate = value; error = ''; message = ''; }}>Modifier</button>
<button type="button" disabled={busy} aria-label={`Supprimer : ${label(value)}`} onclick={() => save(value, null)}>Supprimer</button>
</div>
{/if}
</li>
{/each}
</ul>
{/if}
<p class="small dim">Conserver les journées déjà commencées. Un démarrage manuel reste possible si vous travaillez finalement ce jour-là.</p>
</section>
<style>
.small { font-size: 0.85rem; }
input { font: inherit; width: 100%; min-width: 0; color: var(--fg); background: var(--bg); border: var(--border) solid var(--line); border-radius: var(--radius); min-height: var(--tap); padding: 8px; }
ul { list-style: none; margin: 0; padding: 0; }
li { border-top: 2px solid var(--line); padding-top: 12px; margin-top: 12px; }
.actions { display: flex; flex-wrap: wrap; gap: 10px; }
.actions button { flex: 1; min-width: 0; font-size: 0.9rem; padding: 0 8px; }
[role='alert'] { color: var(--danger); }
</style>

View File

@@ -35,6 +35,7 @@ export function parseSettings(raw) {
const rules = { const rules = {
...Object.fromEntries(Object.keys(AUTOMATION_DEFAULTS).map((key) => [key, (v) => typeof v === 'boolean'])), ...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), 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, dayStart: clock,
dayEnd: clock, dayEnd: clock,
lunchStart: clock, lunchStart: clock,
@@ -48,7 +49,7 @@ export function parseSettings(raw) {
for (const [key, validate] of Object.entries(rules)) { for (const [key, validate] of Object.entries(rules)) {
if (!Object.hasOwn(raw, key)) continue; if (!Object.hasOwn(raw, key)) continue;
if (!validate(raw[key])) throw new BackupError(`Réglage invalide : ${key}.`); 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; return out;
} }

View File

@@ -98,7 +98,8 @@ async function reconcileDay(now) {
// already ended must not spring back to life. // already ended must not spring back to life.
if (!active) { if (!active) {
const existing = await getDay(dayState.today); 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); const plannedStart = atLocalTime(dayState.today, settings.dayStart);
if (now >= plannedStart) { if (now >= plannedStart) {
await openDay(dayState.today, plannedStart); await openDay(dayState.today, plannedStart);
@@ -137,7 +138,7 @@ async function openDay(dateKey, startTs, manual = false) {
task: TRANSPORT_TASK, task: TRANSPORT_TASK,
client: model.lastClient, client: model.lastClient,
automatic: true, automatic: true,
}); }, manual ? null : settings.nonWorkingDates);
} }
/** Reload everything from the database and rebuild the model. */ /** Reload everything from the database and rebuild the model. */

View File

@@ -92,9 +92,13 @@ export function patchDay(dateKey, patch) {
return db.days.update(dateKey, patch); return db.days.update(dateKey, patch);
} }
/** Create once, together with the first entry, even from competing tabs. */ /** Create once. Automatic callers supply exclusions, checked again under lock. */
export function createDay(day, first) { export function createDay(day, first, nonWorkingDates = null) {
return db.transaction('rw', db.days, db.entries, async () => { 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; if (await getDay(day.date) || await getActiveDay()) return false;
await db.days.add(day); await db.days.add(day);
await closeAndStart(day.date, day.startedTs, first); await closeAndStart(day.date, day.startedTs, first);

131
src/lib/non-working.test.js Normal file
View File

@@ -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([]);
});

View File

@@ -1,4 +1,4 @@
import { readSettings, writeSettings } from './db.js'; import { db, readSettings, writeSettings } from './db.js';
import { parseSettings } from './backup.js'; import { parseSettings } from './backup.js';
import { AUTOMATION_DEFAULTS } from './automation-options.js'; import { AUTOMATION_DEFAULTS } from './automation-options.js';
@@ -6,6 +6,7 @@ export const DEFAULT_SETTINGS = {
...AUTOMATION_DEFAULTS, ...AUTOMATION_DEFAULTS,
/** JS weekday numbers, 0 = Sunday. Monday–Friday by default. */ /** JS weekday numbers, 0 = Sunday. Monday–Friday by default. */
workdays: [1, 2, 3, 4, 5], workdays: [1, 2, 3, 4, 5],
nonWorkingDates: [],
dayStart: '07:30', dayStart: '07:30',
dayEnd: '17:30', dayEnd: '17:30',
lunchStart: '12:00', lunchStart: '12:00',
@@ -77,3 +78,23 @@ export function applyTheme() {
export function isWorkday(weekdayNumber) { export function isWorkday(weekdayNumber) {
return settings.workdays.includes(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;
}

View File

@@ -26,7 +26,7 @@
/** Shown when today is a work day whose start time has not arrived yet. */ /** Shown when today is a work day whose start time has not arrived yet. */
const startsAt = $derived( 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); const closedToday = $derived(selectedDay?.date === dayState.today && !dayState.day);
@@ -138,10 +138,14 @@
onclick={reopen}>Reprendre la journée</button onclick={reopen}>Reprendre la journée</button
> >
<p class="dim small">Annule la clôture et continue la dernière tâche depuis l’arrêt.</p> <p class="dim small">Annule la clôture et continue la dernière tâche depuis l’arrêt.</p>
{:else if !dayState.day && !dayState.todayRecord && settings.nonWorkingDates.includes(dayState.today)}
<p class="dim center small">Aujourd’hui est bloqué comme jour non travaillé.</p>
{:else if !dayState.day && !dayState.todayRecord && startsAt} {:else if !dayState.day && !dayState.todayRecord && startsAt}
<p class="dim center small"> <p class="dim center small">
La journée démarrera automatiquement à {startsAt}. La journée démarrera automatiquement à {startsAt}.
</p> </p>
{:else if !dayState.day && !dayState.todayRecord && !settings.autoStartDay}
<p class="dim center small">Le démarrage automatique est désactivé.</p>
{:else if !dayState.day && !dayState.todayRecord} {:else if !dayState.day && !dayState.todayRecord}
<p class="dim center small">Aujourd'hui n'est pas un jour travaillé.</p> <p class="dim center small">Aujourd'hui n'est pas un jour travaillé.</p>
{/if} {/if}

View File

@@ -1,5 +1,6 @@
<script> <script>
import BackupPanel from '../components/BackupPanel.svelte'; import BackupPanel from '../components/BackupPanel.svelte';
import NonWorkingDays from '../components/NonWorkingDays.svelte';
import { go } from '../lib/router.svelte.js'; import { go } from '../lib/router.svelte.js';
import { AUTOMATION_OPTIONS } from '../lib/automation-options.js'; import { AUTOMATION_OPTIONS } from '../lib/automation-options.js';
import { cleanupOldDays } from '../lib/db.js'; import { cleanupOldDays } from '../lib/db.js';
@@ -57,6 +58,8 @@
</div> </div>
</section> </section>
<NonWorkingDays />
<section class="card stack"> <section class="card stack">
<h2>Horaires</h2> <h2>Horaires</h2>
<label> <label>

View File

@@ -45,6 +45,7 @@
const current = $derived(running()); const current = $derived(running());
const closedToday = $derived(dayState.todayRecord?.status === 'closed'); const closedToday = $derived(dayState.todayRecord?.status === 'closed');
const blockedToday = $derived(settings.nonWorkingDates.includes(dayState.today));
const lunchHint = $derived(lunchGuidance(dayState.day, dayState.entries, settings, now)); const lunchHint = $derived(lunchGuidance(dayState.day, dayState.entries, settings, now));
// Snapshotted against writes, not against the clock: the proposed next task must // Snapshotted against writes, not against the clock: the proposed next task must
@@ -100,16 +101,18 @@
{#if !dayState.day} {#if !dayState.day}
<div class="screen"> <div class="screen">
<div class="card center stack"> <div class="card center stack">
<h2>{closedToday ? 'Journée terminée' : 'Journée non commencée'}</h2> <h2>{closedToday ? 'Journée terminée' : blockedToday ? 'Journée non travaillée' : 'Journée non commencée'}</h2>
<p class="dim"> <p class="dim">
{#if closedToday} {#if closedToday}
Reprendre annule la clôture et continue la dernière tâche, y compris le temps écoulé depuis l’arrêt. Reprendre annule la clôture et continue la dernière tâche, y compris le temps écoulé depuis l’arrêt.
{:else if blockedToday}
Cette date est bloquée dans les réglages. Aucun démarrage automatique n’est prévu.
{:else} {:else}
Aujourd'hui n'est pas un jour travaillé, ou l'heure de début n'est pas atteinte. Aujourd'hui n'est pas un jour travaillé, ou l'heure de début n'est pas atteinte.
{/if} {/if}
</p> </p>
<button type="button" class="btn-primary" disabled={busy} onclick={() => act(closedToday ? reopenToday : startDayNow)}> <button type="button" class="btn-primary" disabled={busy} onclick={() => act(closedToday ? reopenToday : startDayNow)}>
{closedToday ? 'Reprendre la journée' : 'Démarrer la journée'} {closedToday ? 'Reprendre la journée' : blockedToday ? 'Démarrer quand même' : 'Démarrer la journée'}
</button> </button>
</div> </div>
</div> </div>