Fixed a few minor bugs

This commit is contained in:
2026-09-19 19:58:13 +02:00
parent 870457955b
commit 9bb9260c09
11 changed files with 520 additions and 135 deletions

View File

@@ -27,7 +27,7 @@ const isDate = (v) => {
};
/** Only recognised settings enter the database; missing values use app defaults. */
function parseSettings(raw) {
export function parseSettings(raw) {
if (raw == null) return null;
if (!isObject(raw)) throw new BackupError('Réglages invalides dans le fichier.');
const clock = (v) => typeof v === 'string' && /^([01]\d|2[0-3]):[0-5]\d$/.test(v);
@@ -45,7 +45,7 @@ function parseSettings(raw) {
const out = {};
for (const [key, validate] of Object.entries(rules)) {
if (!Object.hasOwn(raw, key)) continue;
if (!validate(raw[key])) throw new BackupError(`Réglage invalide dans le fichier : ${key}.`);
if (!validate(raw[key])) throw new BackupError(`Réglage invalide : ${key}.`);
out[key] = key === 'workdays' ? [...new Set(raw[key])].sort() : raw[key];
}
return out;

View File

@@ -9,16 +9,16 @@
import {
cleanupOldDays,
closeAndStart,
closeDay,
createDay,
deleteEntry,
getActiveDay,
getDay,
getEntries,
getHistory,
getLastClosedDay,
importBackup,
patchDay,
patchActiveDay,
patchEntry,
putDay,
reopenDay,
setEntryStart,
} from './db.js';
import { buildModel, suggestClients, suggestTasks, TRANSPORT_TASK } from './predict.js';
@@ -33,6 +33,9 @@ export const dayState = $state({
/** Last closed day + its entries — what the resume screen renders. */
lastClosed: null,
lastClosedEntries: [],
closedDays: [],
historyEntries: [],
todayRecord: null,
/** Prediction model, rebuilt whenever history changes. */
model: null,
/** True once the configured end time has passed and the worker has not answered. */
@@ -56,6 +59,7 @@ const pendingReconciliations = new Set();
*/
export function reconcile(now = Date.now()) {
if (dayState.importing) return Promise.resolve();
if (pendingReconciliations.size) return [...pendingReconciliations][0];
const pending = reconcileDay(now);
pendingReconciliations.add(pending);
pending.then(
@@ -68,25 +72,24 @@ export function reconcile(now = Date.now()) {
async function reconcileDay(now) {
dayState.today = localDateKey(now);
const active = await getActiveDay();
// Settle stale work before retention, so an open day cannot vanish on return.
if (active && active.date < dayState.today) {
await closeDay(active.date, now, true);
return reconcileDay(now);
}
// Applying retention here is what makes the setting mean anything; leaving it to
// the manual button in settings meant it effectively never ran.
if (cleanedUpFor !== dayState.today) {
cleanedUpFor = dayState.today;
try {
await cleanupOldDays(settings.retentionDays);
await cleanupOldDays(settings.retentionDays, now);
} catch (error) {
console.error('[timelog] retention cleanup failed', error);
}
}
const active = await getActiveDay();
// A day left open from an earlier date never stays open.
if (active && active.date < dayState.today) {
await forceClose(active);
return reconcile(now);
}
// Auto-start. Only when no record exists for today at all: a day the worker has
// already ended must not spring back to life.
if (!active) {
@@ -103,21 +106,14 @@ async function reconcileDay(now) {
await refresh(now);
}
/** Close a stale day at the earlier of its planned end and its last recorded activity. */
async function forceClose(day) {
const entries = await getEntries(day.date);
const lastEnd = entries.reduce((max, e) => Math.max(max, e.endTs ?? e.startTs), 0);
const endedTs = Math.min(day.plannedEndTs, lastEnd || day.plannedEndTs);
await closeAndStart(day.date, endedTs, null);
await patchDay(day.date, { status: 'closed', endedTs });
}
/**
* Create a day and immediately open the transport task: per the workflow, the drive
* out from base *is* the first task, and "Arrivé sur site" is what ends it.
*/
async function openDay(dateKey, startTs) {
await putDay({
const { entries, days } = await getHistory();
const model = buildModel(entries, days);
await createDay({
date: dateKey,
status: 'active',
plannedStartTs: atLocalTime(dateKey, settings.dayStart),
@@ -127,11 +123,7 @@ async function openDay(dateKey, startTs) {
lunchType: settings.lunchType,
restaurant: null,
closePromptedTs: null,
});
const { entries, days } = await getHistory();
const model = buildModel(entries, days);
await closeAndStart(dateKey, startTs, {
}, {
kind: 'travel',
task: TRANSPORT_TASK,
client: model.lastClient,
@@ -139,16 +131,23 @@ async function openDay(dateKey, startTs) {
}
/** Reload everything from the database and rebuild the model. */
let refreshVersion = 0;
export async function refresh(now = Date.now()) {
const active = await getActiveDay();
dayState.day = active ?? null;
dayState.entries = active ? await getEntries(active.date) : [];
const lastClosed = await getLastClosedDay();
dayState.lastClosed = lastClosed ?? null;
dayState.lastClosedEntries = lastClosed ? await getEntries(lastClosed.date) : [];
const version = ++refreshVersion;
const { entries, days } = await getHistory();
if (version !== refreshVersion) return;
const active = days.find((d) => d.status === 'active');
const closed = days.filter((d) => d.status === 'closed').sort((a, b) => b.date.localeCompare(a.date));
const lastClosed = closed[0];
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.day = active ?? null;
dayState.entries = entriesFor(active);
dayState.lastClosed = lastClosed ?? null;
dayState.lastClosedEntries = entriesFor(lastClosed);
dayState.closedDays = closed;
dayState.historyEntries = entries;
dayState.model = buildModel(entries, days);
dayState.needsClosePrompt = Boolean(
@@ -161,7 +160,8 @@ export async function refresh(now = Date.now()) {
/** Manual start, for a Saturday or an early departure. */
export async function startDayNow(now = Date.now()) {
await openDay(dayState.today, now);
if (dayState.importing) return;
await openDay(localDateKey(now), now);
await refresh(now);
}
@@ -173,27 +173,27 @@ export async function startDayNow(now = Date.now()) {
* @param {number} [now]
*/
export async function advance({ kind = 'work', task, client }, now = Date.now()) {
if (!dayState.day) return;
await closeAndStart(dayState.day.date, now, { kind, task, client: client ?? null });
if (!dayState.day || dayState.importing) return;
await closeAndStart(dayState.day.date, now, { kind, task, client: client ?? null }, running()?.id ?? null);
await refresh(now);
}
export async function startLunch(now = Date.now()) {
if (!dayState.day) return;
if (!dayState.day || dayState.importing || running()?.kind === 'lunch') return;
await closeAndStart(dayState.day.date, now, {
kind: 'lunch',
task: 'Pause déjeuner',
client: null,
});
}, running()?.id ?? null);
await refresh(now);
}
export async function setLunch({ lunchType, restaurant }) {
if (!dayState.day) return;
await patchDay(dayState.day.date, {
if (!dayState.day || dayState.importing) return;
await patchActiveDay(dayState.day.date, () => ({
lunchType,
restaurant: lunchType === 'restaurant' ? (restaurant ?? null) : null,
});
}));
await refresh();
}
@@ -247,46 +247,27 @@ export async function applyImport(payload, mode) {
}
export async function endDay(now = Date.now()) {
if (!dayState.day) return;
if (!dayState.day || dayState.importing) return;
const date = dayState.day.date;
await closeAndStart(date, now, null);
await patchDay(date, { status: 'closed', endedTs: now });
await closeDay(date, now);
await refresh(now);
}
/** "Toujours en cours" — push the end out an hour so the prompt returns later. */
export async function keepWorking(now = Date.now()) {
if (!dayState.day) return;
await patchDay(dayState.day.date, {
plannedEndTs: Math.max(dayState.day.plannedEndTs, now) + HOUR,
if (!dayState.day || dayState.importing) return;
const expectedEnd = dayState.day.plannedEndTs;
await patchActiveDay(dayState.day.date, (day) => day.plannedEndTs !== expectedEnd ? null : ({
plannedEndTs: Math.max(day.plannedEndTs, now) + HOUR,
closePromptedTs: now,
});
}));
await refresh(now);
}
/** Undo a premature end-of-day: reopens today and starts a fresh task from now. */
/** Undo a premature end-of-day, continuing the last task without a gap. */
export async function reopenToday(now = Date.now()) {
const day = await getDay(dayState.today);
if (!day || day.status !== 'closed') return;
await patchDay(dayState.today, {
status: 'active',
endedTs: null,
plannedEndTs: Math.max(day.plannedEndTs, now + HOUR),
});
const { entries, days } = await getHistory();
const model = buildModel(entries, days);
const [best] = suggestTasks(model, {
prevTask: null,
prevElapsedMs: 0,
seq: (await getEntries(dayState.today)).length,
ts: now,
client: model.lastClient,
});
await closeAndStart(dayState.today, now, {
kind: 'work',
task: best?.value ?? TRANSPORT_TASK,
client: model.lastClient,
});
if (dayState.importing) return;
await reopenDay(localDateKey(now), now);
await refresh(now);
}

227
src/lib/day.test.js Normal file
View File

@@ -0,0 +1,227 @@
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 { atLocalTime, HOUR } from './time.js';
import { buildResume } from './resume.js';
const DATE = '2026-09-07';
const at = (time, date = DATE) => atLocalTime(date, time);
const first = { kind: 'travel', task: 'Transport', client: 'Client A' };
const makeDay = (date = DATE) => ({
date, status: 'active', plannedStartTs: at('07:30', date), plannedEndTs: at('17:30', date),
startedTs: at('07:30', date), endedTs: null, lunchType: 'restaurant', restaurant: 'Le Relais', closePromptedTs: null,
});
const records = async () => ({ days: await db.days.toArray(), entries: await db.entries.toArray() });
beforeEach(async () => {
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(at('08:00'));
vi.stubGlobal('document', { documentElement: { dataset: {} } });
await db.days.clear(); await db.entries.clear(); await db.settings.clear();
Object.assign(settings, DEFAULT_SETTINGS, { workdays: [], retentionDays: 0 });
dayState.importing = false;
await refresh();
});
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); vi.useRealTimers(); });
describe('day closure', () => {
it('closes forgotten work at the planned end rather than zeroing the last task', async () => {
await startDayNow(at('07:30'));
await advance({ task: 'Travaux', client: 'Client A' }, at('14:00'));
await reconcile(at('06:00', '2026-09-08'));
const day = await getDay(DATE), entries = await getEntries(DATE);
expect(day.status).toBe('closed');
expect(day.endedTs).toBe(at('17:30'));
expect(entries.at(-1).endTs - entries.at(-1).startTs).toBe(3.5 * HOUR);
expect(buildResume(day, entries).workedMs).toBe(10 * HOUR);
});
it('never places the day end before late recorded activity', async () => {
await startDayNow(at('07:30'));
await advance({ task: 'Travaux tardifs' }, at('18:00'));
await reconcile(at('06:00', '2026-09-08'));
const day = await getDay(DATE), entries = await getEntries(DATE);
expect(day.endedTs).toBe(at('18:00'));
expect(entries.at(-1).endTs).toBe(day.endedTs);
});
it('uses the stored extended end even if settings subsequently change', async () => {
await startDayNow(at('07:30'));
await advance({ task: 'Travaux' }, at('14:00'));
await keepWorking(at('17:30'));
settings.dayEnd = '16:00';
await reconcile(at('06:00', '2026-09-08'));
expect((await getDay(DATE)).endedTs).toBe(at('18:30'));
});
it('manual closure clamps the day and entry to the same end after a clock reversal', async () => {
await startDayNow(at('09:00'));
await endDay(at('08:00'));
expect((await getDay(DATE)).endedTs).toBe(at('09:00'));
expect((await getEntries(DATE))[0].endTs).toBe(at('09:00'));
});
it('repeated close requests do not move an already recorded end', async () => {
await createDay(makeDay(), first);
await Promise.all([closeDay(DATE, at('17:00')), closeDay(DATE, at('18:00'))]);
expect((await getDay(DATE)).endedTs).toBe(at('17:00'));
});
});
describe('start and resume', () => {
it('does not replace a closed day or lose its lunch when start is called again', async () => {
await createDay(makeDay(), first);
await closeDay(DATE, at('17:00'));
const before = await records();
await startDayNow(at('17:15'));
expect(await records()).toEqual(before);
expect(dayState.todayRecord.status).toBe('closed');
});
it('resumes the last task and keeps all metadata without a gap or new entry', async () => {
await createDay(makeDay(), first);
await closeAndStart(DATE, at('08:00'), { kind: 'work', task: 'Pose', client: 'Client B' });
await closeDay(DATE, at('17:00'));
const before = await getDay(DATE), entries = await getEntries(DATE);
await reopenToday(at('17:15'));
const reopened = await getDay(DATE), after = await getEntries(DATE);
expect(reopened).toMatchObject({ status: 'active', startedTs: before.startedTs, lunchType: before.lunchType, restaurant: before.restaurant });
expect(after).toEqual([entries[0], { ...entries[1], endTs: null }]);
await endDay(at('18:00'));
expect(buildResume(await getDay(DATE), await getEntries(DATE)).workedMs).toBe(10.5 * HOUR);
});
it('does not reopen yesterday when the UI still holds yesterday’s date', async () => {
await createDay(makeDay(), first); await closeDay(DATE, at('17:00'));
dayState.today = DATE;
await reopenToday(at('01:00', '2026-09-08'));
expect((await getDay(DATE)).status).toBe('closed');
});
it('does not automatically reopen a day already ended today', async () => {
await createDay(makeDay(), first); await closeDay(DATE, at('10:00'));
settings.workdays = [1];
await reconcile(at('12:00'));
expect((await getDay(DATE)).status).toBe('closed');
expect(await db.entries.count()).toBe(1);
});
});
describe('concurrent actions', () => {
it('concurrent clock reconciliations create one day and one transport entry', async () => {
settings.workdays = [1];
await Promise.all([reconcile(at('08:00')), reconcile(at('08:00'))]);
expect(await db.days.count()).toBe(1);
expect(await db.entries.count()).toBe(1);
});
it('separate callers creating the same date are idempotent at the database boundary', async () => {
const results = await Promise.all([createDay(makeDay(), first), createDay(makeDay(), first)]);
expect(results).toEqual([true, false]);
expect(await db.entries.count()).toBe(1);
});
it('cannot open two different dates simultaneously', async () => {
await Promise.all([createDay(makeDay(), first), createDay(makeDay('2026-09-08'), first)]);
expect(await db.days.where('status').equals('active').count()).toBe(1);
expect(await db.entries.count()).toBe(1);
});
it.each(['close-first', 'advance-first'])('keeps a concurrently closed day free of running entries: %s', async (order) => {
await startDayNow(at('07:30'));
const close = () => endDay(at('17:00'));
const next = () => advance({ task: 'Suivante' }, at('17:00'));
await Promise.all(order === 'close-first' ? [close(), next()] : [next(), close()]);
expect((await getDay(DATE)).status).toBe('closed');
expect((await getEntries(DATE)).every((e) => e.endTs !== null)).toBe(true);
});
it('two actions on the same observed entry advance only once', async () => {
await createDay(makeDay(), first);
const id = (await getEntries(DATE))[0].id;
const next = { kind: 'work', task: 'Pose', client: 'Client A' };
await Promise.all([closeAndStart(DATE, at('08:00'), next, id), closeAndStart(DATE, at('08:00'), next, id)]);
const entries = await getEntries(DATE);
expect(entries).toHaveLength(2);
expect(entries[0].endTs).toBe(entries[1].startTs);
});
it('repeated lunch taps do not create duplicate lunch entries', async () => {
await startDayNow(at('07:30'));
await Promise.all([startLunch(at('12:00')), startLunch(at('12:00'))]);
await startLunch(at('12:01'));
expect((await getEntries(DATE)).filter((e) => e.kind === 'lunch')).toHaveLength(1);
});
it('two resume requests reopen the same entry only once', async () => {
await createDay(makeDay(), first); await closeDay(DATE, at('17:00'));
await Promise.all([reopenDay(DATE, at('17:15')), reopenDay(DATE, at('17:15'))]);
expect(await db.entries.count()).toBe(1);
expect((await getEntries(DATE))[0].endTs).toBeNull();
});
it('does not update lunch from a stale screen after another caller closed the day', async () => {
await createDay(makeDay(), first); await refresh();
await closeDay(DATE, at('17:00'));
await setLunch({ lunchType: 'panier', restaurant: null });
expect((await getDay(DATE)).restaurant).toBe('Le Relais');
});
it('extends the close prompt only once for two simultaneous responses', async () => {
await startDayNow(at('07:30'));
await Promise.all([keepWorking(at('17:30')), keepWorking(at('17:30'))]);
expect((await getDay(DATE)).plannedEndTs).toBe(at('18:30'));
});
});
describe('transaction failure', () => {
it('rolls back the day when its first entry cannot be saved', async () => {
vi.spyOn(db.entries, 'add').mockRejectedValueOnce(new Error('Write failed'));
await expect(createDay(makeDay(), first)).rejects.toThrow('Write failed');
expect(await records()).toEqual({ days: [], entries: [] });
});
it.each(['close', 'resume'])('rolls back both stores when the %s operation fails', async (operation) => {
await createDay(makeDay(), first);
if (operation === 'resume') await closeDay(DATE, at('17:00'));
const before = await records();
vi.spyOn(db.days, 'update').mockRejectedValueOnce(new Error('Write failed'));
await expect(operation === 'close' ? closeDay(DATE, at('17:00')) : reopenDay(DATE, at('17:15'))).rejects.toThrow('Write failed');
expect(await records()).toEqual(before);
});
});
describe('history and retention', () => {
it('never purges a day that is still active', async () => {
await createDay(makeDay('2020-01-06'), first);
expect(await cleanupOldDays(30, at('08:00'))).toBe(0);
expect(await db.days.count()).toBe(1);
});
it('closes stale work before retention and retains the most recent closed day', async () => {
await createDay(makeDay('2020-01-06'), first);
settings.retentionDays = 30;
await reconcile(at('08:00', '2026-09-09'));
expect((await getDay('2020-01-06')).status).toBe('closed');
expect((await getEntries('2020-01-06'))[0].endTs).toBe(at('17:30', '2020-01-06'));
});
it('loads older closed days and their entries in the recap state', async () => {
for (const date of ['2026-09-03', '2026-09-04']) {
await createDay(makeDay(date), first); await closeDay(date, at('17:30', date));
}
await refresh();
expect(dayState.closedDays.map((d) => d.date)).toEqual(['2026-09-04', '2026-09-03']);
expect(dayState.historyEntries).toHaveLength(2);
expect(dayState.lastClosed.date).toBe('2026-09-04');
});
it('rejects empty time settings before they can disable the day clock', async () => {
await updateSettings({ dayEnd: '' });
expect(settings.dayEnd).toBe('17:30');
expect(settingsError.message).toMatch(/dayEnd/);
expect(await db.settings.count()).toBe(0);
});
});

View File

@@ -1,6 +1,6 @@
import Dexie from 'dexie';
import { BackupError, buildBackup, EXPORT_FORMAT, EXPORT_VERSION, parseBackup } from './backup.js';
import { DAY, localDateKey } from './time.js';
import { DAY, HOUR, localDateKey } from './time.js';
/**
* A single working day. `plannedStartTs` / `plannedEndTs` are snapshotted from the
@@ -88,6 +88,59 @@ 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 () => {
if (await getDay(day.date) || await getActiveDay()) return false;
await db.days.add(day);
await closeAndStart(day.date, day.startedTs, first);
return true;
});
}
/** Close the day and its last entry at the same authoritative timestamp. */
export function closeDay(date, ts, stale = false) {
return db.transaction('rw', db.days, db.entries, async () => {
const day = await getDay(date);
if (!day || day.status !== 'active') return false;
const entries = await getEntries(date);
const lastActivity = entries.reduce((max, e) => Math.max(max, e.startTs, e.endTs ?? e.startTs), day.startedTs);
const endedTs = Math.max(stale ? day.plannedEndTs : ts, lastActivity);
await closeAndStart(date, endedTs, null);
await patchDay(date, { status: 'closed', endedTs });
return true;
});
}
/** Undo a premature close; no new entry or unaccounted interval is introduced. */
export function reopenDay(date, now) {
return db.transaction('rw', db.days, db.entries, async () => {
const day = await getDay(date);
if (!day || day.status !== 'closed' || await getActiveDay()) return false;
const entries = await getEntries(date);
const last = entries.at(-1);
if (!last) return false;
await db.entries.update(last.id, { endTs: null });
await patchDay(date, {
status: 'active', endedTs: null, closePromptedTs: null,
plannedEndTs: Math.max(day.plannedEndTs, now + HOUR),
});
return true;
});
}
/** Conditional day updates cannot resurrect or modify a concurrently closed day. */
export function patchActiveDay(date, makePatch) {
return db.transaction('rw', db.days, async () => {
const day = await getDay(date);
if (day?.status !== 'active') return false;
const patch = makePatch(day);
if (!patch) return false;
await patchDay(date, patch);
return true;
});
}
/** The one day still running, if any — including a stale one from a past date. */
export function getActiveDay() {
return db.days.where('status').equals('active').first();
@@ -116,12 +169,16 @@ export function getEntries(dateKey) {
* @param {number} ts
* @param {Pick<EntryRecord, 'kind'|'task'|'client'>|null} next
* Pass null to only close the running entry (end of day).
* @param {number|null} [expectedId] running entry observed by the action's caller
* @returns {Promise<number|null>} id of the new entry
*/
export function closeAndStart(dateKey, ts, next) {
return db.transaction('rw', db.entries, async () => {
export function closeAndStart(dateKey, ts, next, expectedId) {
return db.transaction('rw', db.days, db.entries, async () => {
if ((await getDay(dateKey))?.status !== 'active') return null;
const list = await db.entries.where('date').equals(dateKey).sortBy('seq');
const running = list.find((e) => e.endTs == null);
// Two taps/tabs acting on the same entry may advance it only once.
if (expectedId !== undefined && (running?.id ?? null) !== expectedId) return null;
let boundary = ts;
if (running) {
@@ -211,33 +268,30 @@ export function deleteEntry(id) {
/** Everything the prediction model is built from. */
export async function getHistory() {
const [entries, days] = await Promise.all([
db.entries.toArray(),
db.days.toArray(),
]);
return { entries, days };
return db.transaction('r', db.entries, db.days, async () => {
const [entries, days] = await Promise.all([db.entries.toArray(), db.days.toArray()]);
return { entries, days };
});
}
/**
* Drop days older than the retention window. The most recent closed day is always
* kept — it is the app's primary output and must survive any setting.
*/
export async function cleanupOldDays(retentionDays) {
export async function cleanupOldDays(retentionDays, now = Date.now()) {
if (!retentionDays || retentionDays <= 0) return 0;
const cutoff = localDateKey(Date.now() - retentionDays * DAY);
const keep = await getLastClosedDay();
const stale = (await db.days.toArray()).filter(
(d) => d.date < cutoff && d.date !== keep?.date,
);
if (!stale.length) return 0;
const keys = stale.map((d) => d.date);
await db.transaction('rw', db.days, db.entries, async () => {
const cutoff = localDateKey(now - retentionDays * DAY);
return db.transaction('rw', db.days, db.entries, async () => {
const keep = await getLastClosedDay();
const stale = (await db.days.toArray()).filter(
(d) => d.status === 'closed' && d.date < cutoff && d.date !== keep?.date,
);
const keys = stale.map((d) => d.date);
if (!keys.length) return 0;
await db.days.bulkDelete(keys);
await db.entries.where('date').anyOf(keys).delete();
return keys.length;
});
return keys.length;
}
/**

13
src/lib/lunch.js Normal file
View File

@@ -0,0 +1,13 @@
import { atLocalTime, MINUTE } from './time.js';
/** Clock hints only: lunch boundaries always come from the worker's actions. */
export function lunchGuidance(day, entries, settings, now) {
if (!day || day.status !== 'active') return null;
const current = entries.find((e) => e.endTs == null);
if (current?.kind === 'lunch') {
const resumeTs = current.startTs + settings.lunchMinutes * MINUTE;
return { resumeTs, due: now >= resumeTs };
}
if (entries.some((e) => e.kind === 'lunch') || now >= day.plannedEndTs) return null;
return now >= atLocalTime(day.date, settings.lunchStart) ? { resumeTs: null, due: true } : null;
}

27
src/lib/lunch.test.js Normal file
View File

@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { lunchGuidance } from './lunch.js';
import { atLocalTime } from './time.js';
const at = (time) => atLocalTime('2026-09-07', time);
const day = { date: '2026-09-07', status: 'active', plannedEndTs: at('17:30') };
const settings = { lunchStart: '12:00', lunchMinutes: 45 };
describe('lunch clock hints', () => {
it('uses the configured lunch time without starting lunch automatically', () => {
const entries = [{ kind: 'work', startTs: at('08:00'), endTs: null }];
expect(lunchGuidance(day, entries, settings, at('11:59'))).toBeNull();
expect(lunchGuidance(day, entries, settings, at('12:00'))).toEqual({ resumeTs: null, due: true });
expect(entries[0].endTs).toBeNull();
});
it('uses the real lunch start and configured duration for the return hint', () => {
const entries = [{ kind: 'lunch', startTs: at('12:15'), endTs: null }];
expect(lunchGuidance(day, entries, settings, at('12:30'))).toEqual({ resumeTs: at('13:00'), due: false });
expect(lunchGuidance(day, entries, settings, at('13:00')).due).toBe(true);
expect(entries[0].endTs).toBeNull();
});
it('stops suggesting lunch after it was taken or after the day ends', () => {
const entries = [{ kind: 'lunch', startTs: at('12:00'), endTs: at('12:45') }];
expect(lunchGuidance(day, entries, settings, at('14:00'))).toBeNull();
expect(lunchGuidance(day, [], settings, at('18:00'))).toBeNull();
expect(lunchGuidance({ ...day, status: 'closed' }, [], settings, at('12:00'))).toBeNull();
});
});

View File

@@ -1,4 +1,5 @@
import { readSettings, writeSettings } from './db.js';
import { parseSettings } from './backup.js';
export const DEFAULT_SETTINGS = {
/** JS weekday numbers, 0 = Sunday. Monday–Friday by default. */
@@ -50,6 +51,8 @@ export async function loadSettings() {
}
export async function updateSettings(patch) {
try { parseSettings({ ...$state.snapshot(settings), ...patch }); }
catch (error) { settingsError.message = error.message; return; }
Object.assign(settings, patch);
applyTheme();
// `settings` is a $state proxy and its nested values (workdays) are proxies