Multiple minor changes and improvements

This commit is contained in:
2026-10-08 21:14:54 +02:00
parent 163424f6f1
commit 5cf19bb8ac
30 changed files with 500 additions and 174 deletions

View File

@@ -75,7 +75,7 @@ export function automateDay(date, config, now = Date.now(), command = {}) {
!entries.some((e) => e.kind === 'lunch') && lunchAt >= day.startedTs &&
lunchAt < day.plannedEndTs && lunchAt < (finishAt ?? Infinity) && lunchAt <= horizon) {
// A late manual action blocks backdated replacement of the morning.
if (current() && lastActivity() <= lunchAt) await beginLunch(lunchAt, true);
if (current() && current().kind !== 'break' && lastActivity() <= lunchAt) await beginLunch(lunchAt, true);
else { await db.days.update(date, { autoLunchHandled: true }); await reload(); }
}

View File

@@ -11,7 +11,7 @@ import { localDateKey } from './time.js';
import { AUTOMATION_DEFAULTS } from './automation-options.js';
export const EXPORT_FORMAT = 'timelog-export';
export const EXPORT_VERSION = 1;
export const EXPORT_VERSION = 2;
export const MAX_BACKUP_BYTES = 20 * 1024 * 1024;
/** Thrown for anything the user needs to read; message is shown verbatim. */
@@ -76,7 +76,7 @@ export function buildBackup({ days, entries, settings }, now = Date.now()) {
}
export function backupFilename(now = Date.now()) {
return `timelog-${localDateKey(now)}.json`;
return `pardis-${localDateKey(now)}.json`;
}
/* ------------------------------------------------------------------- import */
@@ -87,7 +87,7 @@ export function backupFilename(now = Date.now()) {
* @param {string} text raw file contents
* @returns {{days: Array, entries: Array, settings: object|null, exportedAt: string|null,
* dayCount: number, entryCount: number, dropped: number,
* repaired: number, closedActive: number}}
* repaired: number, closedActive: number, adjustments: Array}}
* @throws {BackupError}
*/
export function parseBackup(text) {
@@ -102,25 +102,25 @@ export function parseBackup(text) {
}
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
throw new BackupError("Ce fichier n'est pas un export TimeLog.");
throw new BackupError("Ce fichier n'est pas un export Pardis.");
}
if (raw.format !== EXPORT_FORMAT) {
throw new BackupError("Ce fichier n'est pas un export TimeLog.");
throw new BackupError("Ce fichier n'est pas un export Pardis.");
}
if (isNum(raw.version) && raw.version > EXPORT_VERSION) {
throw new BackupError(
'Fichier créé par une version plus récente de TimeLog. Mettez à jour avant de l’importer.',
'Fichier créé par une version plus récente de Pardis. Mettez à jour avant de l’importer.',
);
}
if (raw.version !== EXPORT_VERSION) {
throw new BackupError('Version du format TimeLog non prise en charge.');
if (![1, EXPORT_VERSION].includes(raw.version)) {
throw new BackupError('Version du format Pardis non prise en charge.');
}
if (!Array.isArray(raw.days) || !Array.isArray(raw.entries)) {
throw new BackupError('Export incomplet : journées ou postes manquants.');
}
const exportTs = typeof raw.exportedAt === 'string' ? Date.parse(raw.exportedAt) : NaN;
const { days, entries, dropped, repaired, closedActive } = normalise(raw.days, raw.entries, exportTs);
const { days, entries, dropped, repaired, closedActive, adjustments } = normalise(raw.days, raw.entries, exportTs);
// An export from a new device is a valid (empty) backup. An entirely damaged
// non-empty file must never become a successful "replace with nothing".
if (!days.length && (raw.days.length || raw.entries.length)) {
@@ -137,6 +137,7 @@ export function parseBackup(text) {
dropped,
repaired,
closedActive,
adjustments,
};
}
@@ -148,6 +149,7 @@ export function parseBackup(text) {
* active day, which the day lifecycle assumes cannot happen.
*/
function normalise(rawDays, rawEntries, exportTs) {
const adjustments = [];
let dropped = 0;
let repairedCount = 0;
let closedActive = 0;
@@ -156,6 +158,7 @@ function normalise(rawDays, rawEntries, exportTs) {
for (const entry of rawEntries) {
if (!isObject(entry) || !isDate(entry.date) || !isTimestamp(entry.startTs)) {
dropped++;
adjustments.push({ date: entry?.date ?? '?', task: entry?.task ?? 'Poste illisible', action: 'ignoré', fields: [] });
continue;
}
if (!byDate.has(entry.date)) byDate.set(entry.date, []);
@@ -168,6 +171,7 @@ function normalise(rawDays, rawEntries, exportTs) {
for (const rawDay of [...rawDays].sort((a, b) => String(a?.date).localeCompare(String(b?.date)))) {
if (!isObject(rawDay) || !isDate(rawDay.date)) {
adjustments.push({ date: rawDay?.date ?? '?', task: 'Journée illisible', action: 'ignorée', fields: [] });
dropped++;
continue;
}
@@ -180,6 +184,7 @@ function normalise(rawDays, rawEntries, exportTs) {
);
// A day with nothing in it produces an empty resume; leave it out.
if (!own.length) {
adjustments.push({ date: rawDay.date, task: 'Journée sans poste exploitable', action: 'ignorée', fields: [] });
dropped++;
continue;
}
@@ -190,9 +195,9 @@ function normalise(rawDays, rawEntries, exportTs) {
const repaired = own.map((entry, seq) => ({
date: rawDay.date,
seq,
kind: ['travel', 'work', 'lunch'].includes(entry.kind) ? entry.kind : 'work',
kind: ['travel', 'work', 'lunch', 'break'].includes(entry.kind) ? entry.kind : 'work',
task: typeof entry.task === 'string' && entry.task ? entry.task : 'Poste',
client: typeof entry.client === 'string' && entry.client ? entry.client : null,
client: ['lunch', 'break'].includes(entry.kind) ? null : typeof entry.client === 'string' && entry.client ? entry.client : null,
startTs: entry.startTs,
endTs: isTimestamp(entry.endTs) ? entry.endTs : null,
...(typeof entry.automatic === 'boolean' ? { automatic: entry.automatic } : {}),
@@ -212,7 +217,7 @@ function normalise(rawDays, rawEntries, exportTs) {
}
last.endTs = isTimestamp(end) ? Math.max(last.startTs, end) : last.startTs;
} else if (last.endTs < last.startTs) last.endTs = last.startTs;
if (rawDay.status === 'active') closedActive++;
if (rawDay.status === 'active') { closedActive++; adjustments.push({ date: rawDay.date, task: 'Journée en cours', action: 'clôturée à l’export', fields: [] }); }
const endedTs = last.endTs;
const lunchType = rawDay.lunchType === 'restaurant' ? 'restaurant' : 'panier';
@@ -239,10 +244,14 @@ function normalise(rawDays, rawEntries, exportTs) {
const changed = repaired.filter((entry, i) =>
Object.keys(entry).some((key) => entry[key] !== own[i][key]),
).length;
for (const entry of repaired) entries.push(entry);
repaired.forEach((entry, i) => {
const fields = Object.keys(entry).filter(key => entry[key] !== own[i][key]);
if (fields.length) adjustments.push({ date: entry.date, task: entry.task, seq: entry.seq, action: 'ajusté', fields: fields.map(key => `${key} : ${own[i][key] ?? 'absent'} → ${entry[key] ?? 'vide'}`) });
entries.push(entry);
});
repairedCount += changed;
}
for (const orphaned of byDate.values()) dropped += orphaned.length;
return { days, entries, dropped, repaired: repairedCount, closedActive };
for (const orphaned of byDate.values()) { dropped += orphaned.length; for (const entry of orphaned) adjustments.push({ date: entry.date, task: entry.task ?? 'Poste', action: 'ignoré (journée absente)', fields: [] }); }
return { days, entries, dropped, repaired: repairedCount, closedActive, adjustments };
}

View File

@@ -49,7 +49,7 @@ describe('buildBackup', () => {
});
it('names the file by date', () => {
expect(backupFilename(at('2026-09-06', '12:00'))).toBe('timelog-2026-09-06.json');
expect(backupFilename(at('2026-09-06', '12:00'))).toBe('pardis-2026-09-06.json');
});
});
@@ -78,7 +78,7 @@ describe('parseBackup rejects what it should', () => {
});
it('refuses a JSON file that is not ours', () => {
expect(() => parseBackup('{"hello":"world"}')).toThrow(/pas un export TimeLog/);
expect(() => parseBackup('{"hello":"world"}')).toThrow(/pas un export Pardis/);
});
it('refuses a bare array', () => {

115
src/lib/break.test.js Normal file
View File

@@ -0,0 +1,115 @@
import 'fake-indexeddb/auto';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { db, getDay, getEntries, reopenDay, closeDay } from './db.js';
import { buildResume, resumeToText } from './resume.js';
import { buildStats } from './stats.js';
import { buildBackup, parseBackup } from './backup.js';
import { buildModel } from './predict.js';
import { automateDay } from './automation.js';
import { insertRecordedEntry, removeRecordedEntry, saveRecordedEntry } from './editing.js';
import { DEFAULT_SETTINGS } from './settings.svelte.js';
import { atLocalTime, HOUR, MINUTE } from './time.js';
const date = '2026-10-08';
const at = time => atLocalTime(date, time);
const day = { date, status: 'closed', startedTs: at('08:00'), endedTs: at('17:00'), plannedStartTs: at('08:00'), plannedEndTs: at('17:00'), lunchType: 'panier' };
const slot = (seq, kind, start, end) => ({ date, seq, kind, task: kind === 'break' ? 'Pause' : kind === 'lunch' ? 'Repas' : 'Chantier', client: kind === 'work' ? 'Client A' : null, startTs: at(start), endTs: end ? at(end) : null });
beforeEach(async () => { await db.days.clear(); await db.entries.clear(); await db.settings.clear(); });
async function install(entries, record = day) { await db.days.add(record); await db.entries.bulkAdd(entries); }
describe('pauses and confirmed reopening', () => {
it('counts 8h30 work and 30 minutes break, with no recap or suggestion pollution', () => {
const entries = [slot(0, 'work', '08:00','12:00'), slot(1,'break','12:00','12:30'),slot(2,'work','12:30','17:00')];
const resume = buildResume(day, entries);
expect(resume.workedMs).toBe(8.5 * HOUR);
expect(resumeToText(resume)).not.toContain('Pause');
const stats = buildStats([day], entries);
expect(stats.breakMs).toBe(30 * MINUTE); expect(stats.lunchMs).toBe(0);
expect(stats.tasks.map(t => t.name)).toEqual(['Chantier']);
expect(buildModel(entries,[day]).taskCount.has('Pause')).toBe(false);
});
it('continues work from the recorded end', async () => {
await install([slot(0,'work','08:00','17:00')]);
expect(await reopenDay(date, at('18:00'), 'continue')).toBe(true);
expect((await getEntries(date))).toHaveLength(1);
expect(buildResume(await getDay(date),await getEntries(date), 15).workedMs).toBeGreaterThanOrEqual(9 * HOUR);
await closeDay(date, at('18:00'));
expect(buildResume(await getDay(date),await getEntries(date)).workedMs).toBe(10 * HOUR);
});
it('commits one discontinuous reopening under concurrent calls', async () => {
await install([slot(0,'work','08:00','17:00')]);
expect(await Promise.all([reopenDay(date,at('18:00'),'break'),reopenDay(date,at('18:00'),'break')])).toEqual([true,false]);
const rows = await getEntries(date);
expect(rows.map(e => e.kind)).toEqual(['work','break','work']);
expect(rows[0].endTs).toBe(at('17:00')); expect(rows[1]).toMatchObject({startTs:at('17:00'),endTs:at('18:00')});
expect(rows[2]).toMatchObject({startTs:at('18:00'),endTs:null,task:'Chantier',client:'Client A'});
});
for (const kind of ['lunch','break']) for (const mode of ['continue','break']) {
it(`preserves a last ${kind} on ${mode}`, async () => {
await install([slot(0,'work','08:00','16:00'),slot(1,kind,'16:00','17:00')]);
await reopenDay(date,at('18:00'),mode);
const rows = await getEntries(date);
expect(rows[1].kind).toBe(kind);
expect(rows[1].endTs).toBe(at(mode === 'continue' ? '18:00' : '17:00'));
expect(rows.at(-1)).toMatchObject({kind:'work',startTs:at('18:00'),task:'Chantier'});
});
}
it('creates no zero-duration break and rejects past days and invalid choices', async () => {
await install([slot(0,'work','08:00','17:00')]);
expect(await reopenDay(date,atLocalTime('2026-10-09','18:00'),'break')).toBe(false);
await expect(reopenDay(date,at('18:00'),'invalid')).rejects.toThrow();
expect((await getDay(date)).status).toBe('closed');
await reopenDay(date,at('17:00'),'break');
expect((await getEntries(date)).map(e=>e.kind)).toEqual(['work','work']);
});
it('rolls back the day and slots when writing the resumed work fails', async () => {
await install([slot(0,'work','08:00','17:00')]);
const real = db.entries.add.bind(db.entries);
const spy = vi.spyOn(db.entries,'add').mockImplementation(record => record.kind === 'work' ? Promise.reject(new Error('write failure')) : real(record));
try { await expect(reopenDay(date,at('18:00'),'break')).rejects.toThrow('write failure'); }
finally { spy.mockRestore(); }
expect((await getDay(date)).status).toBe('closed'); expect(await getEntries(date)).toHaveLength(1);
});
it('does not replace a pause at automatic lunch or closing', async () => {
await install([slot(0,'work','08:00','11:30'),slot(1,'break','11:30',null)], {...day,status:'active',endedTs:null});
await automateDay(date, {...DEFAULT_SETTINGS,autoLunch:true}, at('13:30'));
expect((await getEntries(date)).at(-1).kind).toBe('break');
await automateDay(date,DEFAULT_SETTINGS,at('17:00'),{end:true});
expect((await getEntries(date)).at(-1)).toMatchObject({kind:'break',endTs:at('17:00')});
});
it('inserts, corrects and deletes a closed-day pause with recalculated totals', async () => {
await install([slot(0,'work','08:00','17:00')]);
const id = await insertRecordedEntry((await getEntries(date))[0], {...slot(0,'break','12:00','12:30')}, at('18:00'));
let entry = await db.entries.get(id);
await saveRecordedEntry(entry, {...entry,endTs:at('13:00')}, at('18:00'));
expect(buildStats([await getDay(date)],await getEntries(date)).breakMs).toBe(HOUR);
await removeRecordedEntry(await db.entries.get(id));
expect(buildResume(await getDay(date),await getEntries(date)).workedMs).toBe(9*HOUR);
expect((await getDay(date)).status).toBe('closed');
});
it('round-trips breaks and imported gaps, and imports v1', () => {
const entries = [slot(0,'work','08:00','11:00'),slot(1,'break','12:00','12:30'),slot(2,'work','13:00','17:00')];
const payload = buildBackup({days:[day],entries,settings:null});
expect(payload.version).toBe(2);
expect(parseBackup(JSON.stringify(payload)).entries).toEqual(entries);
expect(parseBackup(JSON.stringify({...payload,version:1,entries:[slot(0,'work','08:00','17:00')]})).dayCount).toBe(1);
expect(() => parseBackup(JSON.stringify({...payload,version:3}))).toThrow(/plus récente/);
});
});
describe('preserving imported gaps while editing', () => {
it('does not fill a gap when correcting a pause', async () => {
await install([slot(0,'work','08:00','11:00'),slot(1,'break','12:00','12:30'),slot(2,'work','13:00','17:00')]);
const original = (await getEntries(date))[1];
await saveRecordedEntry(original,{...original,startTs:at('11:30'),endTs:at('12:45')},at('18:00'));
const rows = await getEntries(date);
expect(rows[0].endTs).toBe(at('11:00')); expect(rows[2].startTs).toBe(at('13:00'));
expect(buildStats([day],rows).quality.uncoveredMs).toBe(45*MINUTE);
await expect(saveRecordedEntry(rows[1],{...rows[1],startTs:at('10:00')},at('18:00'))).rejects.toThrow(/voisin/);
});
it('does not fill surrounding gaps when deleting a pause', async () => {
await install([slot(0,'work','08:00','11:00'),slot(1,'break','12:00','12:30'),slot(2,'work','13:00','17:00')]);
await removeRecordedEntry((await getEntries(date))[1]);
const rows = await getEntries(date);
expect(rows[0].endTs).toBe(at('11:00')); expect(rows[1].startTs).toBe(at('13:00'));
});
});

View File

@@ -7,7 +7,6 @@
*/
import {
cleanupOldDays,
createDay,
deleteEntry,
getActiveDay,
@@ -50,8 +49,6 @@ export const running = () => dayState.entries.find((e) => e.endTs == null) ?? nu
/* --------------------------------------------------------------- reconciling */
/** Retention runs once per session, not on every 30 s tick. */
let cleanedUpFor = null;
const pendingReconciliations = new Set();
/**
@@ -83,17 +80,6 @@ async function reconcileDay(now) {
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, now);
} catch (error) {
console.error('[timelog] retention cleanup failed', error);
}
}
// 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) {
@@ -181,7 +167,7 @@ export async function startDayNow(now = Date.now()) {
* Close the running entry and open the next one. The single mutation every button
* on the Today screen goes through.
*
* @param {{kind?: 'travel'|'work'|'lunch', task: string, client?: string|null}} slot
* @param {{kind?: 'travel'|'work'|'lunch'|'break', task: string, client?: string|null}} slot
* @param {number} [now]
*/
export async function advance({ kind = 'work', task, client }, now = Date.now()) {
@@ -245,9 +231,8 @@ export async function applyImport(payload, mode) {
const result = await importBackup(payload, mode);
if (mode === 'replace') await loadSettings();
// Do not immediately purge old imported days or create a new working day.
// Normal retention resumes next session/day; manual purge remains available.
// Retention is explicitly confirmed in settings.
dayState.today = localDateKey();
cleanedUpFor = dayState.today;
await refresh();
return result;
} finally {
@@ -274,9 +259,9 @@ export async function keepWorking(now = Date.now()) {
}
/** Undo a premature end-of-day, continuing the last task without a gap. */
export async function reopenToday(now = Date.now()) {
export async function reopenToday(now = Date.now(), mode = 'continue') {
if (dayState.importing) return;
await reopenDay(localDateKey(now), now);
await reopenDay(localDateKey(now), now, mode);
await refresh(now);
}
@@ -292,6 +277,7 @@ export function predictNext(now = Date.now()) {
if (!model) return { task: TRANSPORT_TASK, client: null, tasks: [], clients: [] };
const current = running();
const lastWork = [...dayState.entries].reverse().find((e) => e.kind === 'work') ?? dayState.previousWork;
const tasks = suggestTasks(model, {
prevTask: current?.task ?? null,
prevElapsedMs: current ? now - current.startTs : 0,
@@ -302,10 +288,10 @@ export function predictNext(now = Date.now()) {
const clients = suggestClients(model, now);
return {
task: settings.autoPreviousTask && current?.kind === 'travel' && current.seq === 0
task: current?.kind === 'break' ? lastWork?.task ?? 'Travaux sur site' : settings.autoPreviousTask && current?.kind === 'travel' && current.seq === 0
? dayState.previousWork?.task ?? 'Travaux sur site'
: tasks[0]?.value ?? TRANSPORT_TASK,
client: current?.client ?? clients[0]?.value ?? null,
client: current?.kind === 'break' ? lastWork?.client ?? null : current?.client ?? clients[0]?.value ?? null,
tasks,
clients,
};

View File

@@ -88,7 +88,7 @@ describe('start and resume', () => {
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 }]);
expect(after).toEqual([entries[0], { ...entries[1], endTs: null, automatic: false }]);
await endDay(at('18:00'));
expect(buildResume(await getDay(DATE), await getEntries(DATE)).workedMs).toBe(10.5 * HOUR);
});
@@ -225,3 +225,22 @@ describe('history and retention', () => {
expect(await db.settings.count()).toBe(0);
});
});
describe('settings persistence across tabs', () => {
it('applies a patch to the last stored settings rather than stale local choices', async () => {
await db.settings.put({key:'app',value:{...DEFAULT_SETTINGS,autoLunch:false,lunchMinutes:45}});
Object.assign(settings,DEFAULT_SETTINGS);
await updateSettings({theme:'nuit'});
const stored = (await db.settings.get('app')).value;
expect(stored).toMatchObject({autoLunch:false,lunchMinutes:45,theme:'nuit'});
expect(settings.autoLunch).toBe(false);
});
it('restores persisted display when a settings write fails', async () => {
await db.settings.put({key:'app',value:{...DEFAULT_SETTINGS,theme:'jour'}});
const spy = vi.spyOn(db.settings,'put').mockRejectedValue(new Error('write failed'));
try { await updateSettings({theme:'nuit'}); }
finally { spy.mockRestore(); }
expect(settings.theme).toBe('jour');
expect(settingsError.message).toContain('write failed');
});
});

View File

@@ -24,13 +24,13 @@ import { DAY, HOUR, localDateKey } from './time.js';
/**
* One stretch of the day. `endTs === null` marks the single running entry; every
* other entry's `endTs` equals the next entry's `startTs`.
* other entry has a finite end; imported histories may contain gaps.
*
* @typedef {object} EntryRecord
* @property {number} [id]
* @property {string} date
* @property {number} seq position within the day, dense from 0
* @property {'travel'|'work'|'lunch'} kind
* @property {'travel'|'work'|'lunch'|'break'} kind
* @property {string} task
* @property {string|null} client
* @property {number} startTs
@@ -120,15 +120,30 @@ export function closeDay(date, ts, stale = false) {
});
}
/** Undo a premature close; no new entry or unaccounted interval is introduced. */
export function reopenDay(date, now) {
/** Confirmed same-day reopening; status and all resumed slots commit together. */
export function reopenDay(date, now, mode = 'continue') {
return db.transaction('rw', db.days, db.entries, async () => {
const day = await getDay(date);
if (!day || day.status !== 'closed' || await getActiveDay()) return false;
if (!['continue', 'break'].includes(mode)) throw new Error('Choix de reprise invalide.');
if (!day || date !== localDateKey(now) || 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 });
const boundary = day.endedTs;
if (!Number.isFinite(boundary) || now < boundary) return false;
if (mode === 'continue' && ['work', 'travel'].includes(last.kind)) {
await db.entries.update(last.id, { endTs: null, automatic: false });
} else {
let seq = last.seq + 1;
if (now > boundary) {
if (mode === 'continue') await db.entries.update(last.id, { endTs: now, automatic: false });
else await db.entries.add({ date, seq: seq++, kind: 'break', task: 'Pause', client: null, startTs: boundary, endTs: now });
}
const history = await db.entries.toArray();
const work = history.filter((e) => e.kind === 'work' && e.date <= date)
.sort((a, b) => b.date.localeCompare(a.date) || b.seq - a.seq)[0];
await db.entries.add({ date, seq, kind: 'work', task: work?.task || 'Travaux sur site', client: work?.client ?? null, startTs: now, endTs: null });
}
await patchDay(date, {
status: 'active', endedTs: null, closePromptedTs: null,
plannedEndTs: Math.max(day.plannedEndTs, now + HOUR),
@@ -230,13 +245,13 @@ export function setEntryStart(id, ts, now = Date.now()) {
const list = await db.entries.where('date').equals(entry.date).sortBy('seq');
const prev = list[list.findIndex((e) => e.id === id) - 1];
const lower = prev ? prev.startTs : Number.NEGATIVE_INFINITY;
const lower = prev ? (prev.endTs === entry.startTs ? prev.startTs : prev.endTs) : Number.NEGATIVE_INFINITY;
const upper = entry.endTs ?? now;
const stored = Math.min(Math.max(ts, lower), upper);
await db.entries.update(entry.id, { startTs: stored, ...(entry.automatic ? { automatic: false } : {}) });
if (prev) await db.entries.update(prev.id, { endTs: stored, ...(prev.automatic ? { automatic: false } : {}) });
else await db.days.update(entry.date, { startedTs: stored });
if (prev && prev.endTs === entry.startTs) await db.entries.update(prev.id, { endTs: stored, ...(prev.automatic ? { automatic: false } : {}) });
else if (!prev) await db.days.update(entry.date, { startedTs: stored });
return stored;
});
@@ -257,11 +272,11 @@ export function deleteEntry(id) {
const next = list[i + 1];
await db.entries.delete(id);
if (prev) {
if (prev && prev.endTs === victim.startTs && (!next || victim.endTs === next.startTs)) {
// Extend the previous entry over the hole; if nothing follows, it inherits
// the victim's end (including `null`, i.e. it becomes the running entry).
await db.entries.update(prev.id, { endTs: next ? next.startTs : victim.endTs });
} else if (next) {
} else if (!prev && next && victim.endTs === next.startTs) {
await db.entries.update(next.id, { startTs: victim.startTs });
}
// Re-number so `seq` stays dense.

21
src/lib/dialog.js Normal file
View File

@@ -0,0 +1,21 @@
/** Focus ownership, keyboard trap and restoration for modal panels. */
export function modalFocus(node) {
const origin = document.activeElement;
const controls = () => [...node.querySelectorAll('button, input, select, textarea, a[href], [tabindex="0"]')]
.filter(el => !el.disabled && el.getClientRects().length);
queueMicrotask(() => (controls()[0] ?? node).focus());
function trap(event) {
if (event.key !== 'Tab') return;
const dialogs = [...document.querySelectorAll('[role="dialog"]')];
if (dialogs.at(-1) !== node) return;
const list = controls(), first = list[0], last = list.at(-1);
if (!first) { event.preventDefault(); node.focus(); return; }
if (event.shiftKey && (document.activeElement === first || !node.contains(document.activeElement))) {
event.preventDefault(); last.focus();
} else if (!event.shiftKey && (document.activeElement === last || !node.contains(document.activeElement))) {
event.preventDefault(); first.focus();
}
}
document.addEventListener('keydown', trap);
return { destroy() { document.removeEventListener('keydown', trap); if (origin instanceof HTMLElement && origin.isConnected) origin.focus(); } };
}

View File

@@ -13,10 +13,10 @@ async function readOriginal(original) {
}
function content(values) {
if (!['work', 'travel', 'lunch'].includes(values.kind)) throw new Error('Choisissez un type de poste.');
const task = values.kind === 'lunch' ? 'Pause déjeuner' : String(values.task ?? '').trim();
if (!['work', 'travel', 'lunch', 'break'].includes(values.kind)) throw new Error('Choisissez un type de poste.');
const task = values.kind === 'lunch' ? 'Pause déjeuner' : String(values.task ?? '').trim() || (values.kind === 'break' ? 'Pause' : '');
if (!task) throw new Error('Renseignez le nom de la tâche.');
return { kind: values.kind, task, client: values.kind === 'lunch' ? null : String(values.client ?? '').trim() || null };
return { kind: values.kind, task, client: ['lunch', 'break'].includes(values.kind) ? null : String(values.client ?? '').trim() || null };
}
function validateTimes(start, end, now) {
@@ -45,13 +45,13 @@ export function saveRecordedEntry(original, values, now = Date.now()) {
const { startTs, endTs } = values;
if ((entry.endTs == null) !== (endTs == null)) throw conflict();
validateTimes(startTs, endTs ?? now, now);
if (prev && startTs < prev.startTs || next && endTs > (next.endTs ?? now)) {
if (prev && startTs < (prev.endTs === entry.startTs ? prev.startTs : prev.endTs) || next && endTs > (next.startTs === entry.endTs ? next.endTs ?? now : next.startTs)) {
throw new Error('Ces horaires dépassent le poste voisin. Corrigez d’abord ce voisin.');
}
if (!prev && localDateKey(startTs) !== day.date) throw new Error('Le début doit rester à la date de cette journée.');
await db.entries.update(entry.id, { ...content(values), startTs, endTs, ...(entry.automatic ? { automatic: false } : {}) });
if (prev && startTs !== entry.startTs) await db.entries.update(prev.id, { endTs: startTs, ...(prev.automatic ? { automatic: false } : {}) });
if (next && endTs !== entry.endTs) await db.entries.update(next.id, { startTs: endTs, ...(next.automatic ? { automatic: false } : {}) });
if (prev && prev.endTs === entry.startTs && startTs !== entry.startTs) await db.entries.update(prev.id, { endTs: startTs, ...(prev.automatic ? { automatic: false } : {}) });
if (next && next.startTs === entry.endTs && endTs !== entry.endTs) await db.entries.update(next.id, { startTs: endTs, ...(next.automatic ? { automatic: false } : {}) });
if (entry.kind === 'lunch') await db.days.update(day.date, { autoLunchHandled: true });
if (!prev) await db.days.update(day.date, { startedTs: startTs });
if (!next && day.status === 'closed') await db.days.update(day.date, { endedTs: endTs });

View File

@@ -107,7 +107,7 @@ export function buildModel(entries, days = []) {
// Work entries only: lunch and its restaurant are modelled separately.
const work = entries
.filter((e) => e.kind !== 'lunch' && e.task)
.filter((e) => ['work', 'travel'].includes(e.kind) && e.task)
.sort((a, b) => a.startTs - b.startTs);
const byDate = new Map();

View File

@@ -26,6 +26,7 @@ export function buildResume(day, entries, roundingMinutes = 15) {
let omitted = 0;
for (const e of [...entries].sort((a, b) => a.seq - b.seq)) {
if (e.kind === 'break') continue;
const start = roundTs(e.startTs, roundingMinutes);
const end = roundTs(e.endTs ?? fallbackEnd, roundingMinutes);
const ms = Math.max(0, end - start);

View File

@@ -6,14 +6,14 @@
* worker should land on.
*/
const ROUTES = ['resume', 'today', 'settings', 'stats'];
const ROUTES = ['resume', 'today', 'settings', 'stats', 'details', 'automations', 'non-working'];
function fromHash() {
const h = location.hash.replace(/^#\/?/, '');
return ROUTES.includes(h) ? h : null;
}
export const router = $state({ route: fromHash() });
export const router = $state({ route: fromHash(), selectedDate: '' });
window.addEventListener('hashchange', () => {
router.route = fromHash();

View File

@@ -56,18 +56,21 @@ 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
// too. IndexedDB clones structurally and refuses proxies with a
// DataCloneError, so the plain snapshot is required, not optional.
const { loaded, ...persisted } = $state.snapshot(settings);
const { loaded, ...base } = $state.snapshot(settings);
const cleanPatch = JSON.parse(JSON.stringify(patch));
try {
await writeSettings(persisted);
const persisted = await db.transaction('rw', db.settings, async () => {
const next = { ...base, ...await readSettings(), ...cleanPatch };
parseSettings(next);
await writeSettings(next);
return next;
});
Object.assign(settings, persisted);
applyTheme();
settingsError.message = null;
} catch (error) {
settingsError.message = `Enregistrement impossible : ${error.message}`;
console.error('[timelog] failed to persist settings', error);
try { await loadSettings(); } catch { Object.assign(settings, base); applyTheme(); }
}
}

View File

@@ -51,7 +51,7 @@ export function buildStats(days, entries, { from = '', to = '', includeActive =
const list = byDate.get(day.date) ?? [];
const dayEnd = day.status === 'active' ? now : day.endedTs;
const start = roundTs(day.startedTs, roundingMinutes), end = roundTs(dayEnd, roundingMinutes);
const row = { date: day.date, status: day.status, workedMs: 0, lunchMs: 0, travelMs: 0, spanMs: Math.max(0, end - start), entries: list.length, meal: false, clients: new Set() };
const row = { date: day.date, status: day.status, workedMs: 0, breakMs: 0, lunchMs: 0, travelMs: 0, spanMs: Math.max(0, end - start), entries: list.length, meal: false, clients: new Set() };
const intervals = [];
for (const entry of list) {
const startTs = roundTs(entry.startTs, roundingMinutes);
@@ -59,6 +59,7 @@ export function buildStats(days, entries, { from = '', to = '', includeActive =
const ms = Math.max(0, endTs - startTs);
intervals.push([startTs, endTs]);
if (!ms) quality.zeroEntries++;
if (entry.kind === 'break') { row.breakMs += ms; continue; }
if (entry.kind === 'lunch') { row.lunchMs += ms; row.meal = true; continue; }
row.workedMs += ms;
if (entry.kind === 'travel') row.travelMs += ms;
@@ -100,7 +101,7 @@ export function buildStats(days, entries, { from = '', to = '', includeActive =
daily: [...daily].reverse(), clients: clientRows, tasks: taskRows, pairs: finish(pairs), restaurants: finish(restaurants), meals: finish(meals), weekdays, quality,
days: daily.length, closedDays: closed.length, activeDays: daily.length - closed.length,
entries: sum(daily, 'entries'), clientCount: clientRows.filter((row) => row.key !== null).length,
taskCount: taskRows.length, workedMs, lunchMs: sum(daily, 'lunchMs'), travelMs: sum(daily, 'travelMs'), spanMs: sum(daily, 'spanMs'),
taskCount: taskRows.length, workedMs, breakMs: sum(daily, 'breakMs'), lunchMs: sum(daily, 'lunchMs'), travelMs: sum(daily, 'travelMs'), spanMs: sum(daily, 'spanMs'),
mealDays: daily.filter((d) => d.meal).length, noMealDays: closed.filter((d) => !d.meal).length,
averageWorkMs: average(closed, 'workedMs'), medianWorkMs: medianMs,
averageLunchMs: average(mealsClosed, 'lunchMs'), averageSpanMs: average(closed, 'spanMs'),