Added scheduled off-work days
This commit is contained in:
18
README.md
18
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
|
||||
|
||||
71
src/components/NonWorkingDays.svelte
Normal file
71
src/components/NonWorkingDays.svelte
Normal 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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
|
||||
131
src/lib/non-working.test.js
Normal file
131
src/lib/non-working.test.js
Normal 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([]);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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</button
|
||||
>
|
||||
<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}
|
||||
<p class="dim center small">
|
||||
La journée démarrera automatiquement à {startsAt}.
|
||||
</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}
|
||||
<p class="dim center small">Aujourd'hui n'est pas un jour travaillé.</p>
|
||||
{/if}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script>
|
||||
import BackupPanel from '../components/BackupPanel.svelte';
|
||||
import NonWorkingDays from '../components/NonWorkingDays.svelte';
|
||||
import { go } from '../lib/router.svelte.js';
|
||||
import { AUTOMATION_OPTIONS } from '../lib/automation-options.js';
|
||||
import { cleanupOldDays } from '../lib/db.js';
|
||||
@@ -57,6 +58,8 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<NonWorkingDays />
|
||||
|
||||
<section class="card stack">
|
||||
<h2>Horaires</h2>
|
||||
<label>
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
|
||||
const current = $derived(running());
|
||||
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));
|
||||
|
||||
// Snapshotted against writes, not against the clock: the proposed next task must
|
||||
@@ -100,16 +101,18 @@
|
||||
{#if !dayState.day}
|
||||
<div class="screen">
|
||||
<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">
|
||||
{#if closedToday}
|
||||
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}
|
||||
Aujourd'hui n'est pas un jour travaillé, ou l'heure de début n'est pas atteinte.
|
||||
{/if}
|
||||
</p>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user