Added automation
This commit is contained in:
50
README.md
50
README.md
@@ -73,14 +73,60 @@ il n'est pas publié avec l'application.
|
|||||||
ne peut pas recevoir un nouveau poste actif. La purge épargne les journées
|
ne peut pas recevoir un nouveau poste actif. La purge épargne les journées
|
||||||
actives et conserve toujours la dernière journée terminée.
|
actives et conserve toujours la dernière journée terminée.
|
||||||
- Les horaires de déjeuner affichent un rappel puis une heure indicative de
|
- Les horaires de déjeuner affichent un rappel puis une heure indicative de
|
||||||
reprise, calculée depuis le début réel de la pause. Les transitions restent
|
reprise. Les transitions restent manuelles sauf si l'option **Pause déjeuner**
|
||||||
manuelles : aucun temps de travail n'est transformé automatiquement en pause.
|
est activée dans les automatismes.
|
||||||
- Dans **Récap**, le sélecteur **Journée à consulter** permet d'afficher et de copier
|
- Dans **Récap**, le sélecteur **Journée à consulter** permet d'afficher et de copier
|
||||||
chaque journée terminée encore conservée, y compris les journées importées.
|
chaque journée terminée encore conservée, y compris les journées importées.
|
||||||
|
|
||||||
Les tests couvrent notamment les clôtures oubliées, les reprises, les appels
|
Les tests couvrent notamment les clôtures oubliées, les reprises, les appels
|
||||||
concurrents, les retours d'horloge, les échecs transactionnels et la conservation.
|
concurrents, les retours d'horloge, les échecs transactionnels et la conservation.
|
||||||
|
|
||||||
|
## Automatismes
|
||||||
|
|
||||||
|
Dans **Réglages → Automatismes**, chaque comportement a son interrupteur :
|
||||||
|
|
||||||
|
| Option | Comportement |
|
||||||
|
| --- | --- |
|
||||||
|
| Ouverture | Démarrer aux horaires et jours prévus. Activée par défaut, comme auparavant. |
|
||||||
|
| Début de journée | Au bouton « Arrivé sur site », proposer la dernière tâche de travail de la précédente journée enregistrée, hors repas et transport. |
|
||||||
|
| Pause déjeuner | Ouvrir la pause à l'heure habituelle et reprendre la tâche précédente après la durée réglée. Reprendre le type de repas et le restaurant de la précédente journée enregistrée. |
|
||||||
|
| Arrivée sur chantier | Lors du repas automatique, transformer un transport initial non renseigné en un transport de 1/5/15/30 minutes, selon l'arrondi, suivi de la tâche précédente. |
|
||||||
|
| Fin de demi-journée | À la pause ou à la clôture, remplacer une plage automatique non renseignée par la dernière tâche de travail connue. Conserver tout poste manuel. |
|
||||||
|
| Fin de journée | Clôturer à l'heure prévue sans confirmation. |
|
||||||
|
|
||||||
|
Les cinq nouvelles options sont désactivées par défaut. Elles sont conservées
|
||||||
|
dans les sauvegardes et rétablies lors d'un import en remplacement. L'option
|
||||||
|
**Arrivée sur chantier** intervient seulement avec un repas automatique.
|
||||||
|
Sans cette estimation de transport, **Fin de demi-journée** peut remplacer le
|
||||||
|
transport initial non renseigné par du travail sur toute la plage. Une tâche de
|
||||||
|
travail déjà présente suffit : aucun doublon n'est ajouté à la demi-journée.
|
||||||
|
|
||||||
|
« Précédente » désigne la dernière journée terminée enregistrée avant la date
|
||||||
|
courante, y compris après un week-end. La complétion utilise la dernière tâche
|
||||||
|
de travail connue ; sans historique de travail, elle utilise **Travaux sur site**.
|
||||||
|
Les postes produits automatiquement portent la mention **Auto**. Les corriger
|
||||||
|
les rend manuels et les protège des déductions suivantes. Les saisies anciennes
|
||||||
|
sans cette information sont considérées comme manuelles.
|
||||||
|
|
||||||
|
Une pause commencée ou corrigée manuellement n'est pas terminée par l'automatisme
|
||||||
|
de déjeuner. Un choix manuel du type ou du restaurant est conservé. Une pause
|
||||||
|
supprimée n'est pas recréée. Désactiver l'option arrête les transitions suivantes ;
|
||||||
|
cela ne supprime pas les postes déjà enregistrés. Une durée de déjeuner nulle
|
||||||
|
n'ajoute aucune pause automatique. La durée d'une pause automatique déjà commencée
|
||||||
|
est conservée si le réglage change ensuite.
|
||||||
|
|
||||||
|
L'app vérifie l'horloge toutes les 30 secondes et au retour à l'écran. Une PWA
|
||||||
|
fermée ou suspendue ne peut pas exécuter du code à heure fixe : à la réouverture,
|
||||||
|
les étapes manquées sont rattrapées avec leurs horaires prévus, sans écraser une
|
||||||
|
action manuelle plus tardive. Les journées déjà clôturées restent intactes et les
|
||||||
|
dates entièrement absentes ne sont pas créées. Pour une journée sans intervention,
|
||||||
|
activer les options voulues et ouvrir l'app au moins une fois pour cette journée.
|
||||||
|
|
||||||
|
Les prolongations et la reprise d'une journée sont respectées. Un démarrage
|
||||||
|
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.
|
||||||
|
|
||||||
## 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
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
<span class="dur tnum">{fmtDuration(duration(entry))}</span>
|
<span class="dur tnum">{fmtDuration(duration(entry))}</span>
|
||||||
<span class="what">
|
<span class="what">
|
||||||
<strong>{entry.task}</strong>
|
<strong>{entry.task}</strong>
|
||||||
|
{#if entry.automatic}<span class="dim">Auto</span>{/if}
|
||||||
{#if entry.client}<span class="dim">{entry.client}</span>{/if}
|
{#if entry.client}<span class="dim">{entry.client}</span>{/if}
|
||||||
</span>
|
</span>
|
||||||
{#if onselect}<span class="edit" aria-hidden="true">✎</span>{/if}
|
{#if onselect}<span class="edit" aria-hidden="true">✎</span>{/if}
|
||||||
|
|||||||
17
src/lib/automation-options.js
Normal file
17
src/lib/automation-options.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export const AUTOMATION_DEFAULTS = {
|
||||||
|
autoStartDay: true,
|
||||||
|
autoPreviousTask: false,
|
||||||
|
autoLunch: false,
|
||||||
|
autoArrival: false,
|
||||||
|
autoFillHalfDay: false,
|
||||||
|
autoEndDay: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AUTOMATION_OPTIONS = [
|
||||||
|
{ key: 'autoStartDay', name: 'Ouverture', description: 'Démarrer la journée à l’heure prévue, les jours travaillés.' },
|
||||||
|
{ key: 'autoPreviousTask', name: 'Début de journée', description: 'À l’arrivée sur chantier, reprendre la dernière tâche de travail de la journée précédente enregistrée.' },
|
||||||
|
{ key: 'autoLunch', name: 'Pause déjeuner', description: 'Commencer le repas à l’heure habituelle, puis reprendre le travail après la durée prévue. Reprendre le type de repas et le restaurant de la journée précédente enregistrée.' },
|
||||||
|
{ key: 'autoArrival', name: 'Arrivée sur chantier', description: 'Au repas automatique, limiter le transport non renseigné au pas d’arrondi choisi, puis remplir la matinée avec la tâche précédente.' },
|
||||||
|
{ key: 'autoFillHalfDay', name: 'Fin de demi-journée', description: 'À la pause ou à la clôture, compléter une plage non renseignée avec la dernière tâche de travail connue, sans modifier les postes manuels.' },
|
||||||
|
{ key: 'autoEndDay', name: 'Fin de journée', description: 'Clôturer la journée à l’heure de fin prévue, sans demander de confirmation.' },
|
||||||
|
];
|
||||||
105
src/lib/automation.js
Normal file
105
src/lib/automation.js
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { closeAndStart, closeDay, db, getDay, getEntries, getHistory, readSettings } from './db.js';
|
||||||
|
import { SEED_TASKS } from './predict.js';
|
||||||
|
import { atLocalTime, MINUTE } from './time.js';
|
||||||
|
|
||||||
|
/** Previous means the latest closed recorded day, not necessarily yesterday. */
|
||||||
|
export function previousContext(days, entries, date) {
|
||||||
|
const previousDays = days.filter((d) => d.date < date && d.status === 'closed').sort((a, b) => b.date.localeCompare(a.date));
|
||||||
|
const previousDay = previousDays[0] ?? null;
|
||||||
|
const previousWork = previousDay ? entries.filter((e) => e.date === previousDay.date && e.kind === 'work').sort((a, b) => b.seq - a.seq)[0] : null;
|
||||||
|
const dates = new Set(previousDays.map((d) => d.date));
|
||||||
|
const lastKnownWork = entries.filter((e) => dates.has(e.date) && e.kind === 'work')
|
||||||
|
.sort((a, b) => b.date.localeCompare(a.date) || b.seq - a.seq)[0] ?? null;
|
||||||
|
return { previousDay, previousWork: previousWork ?? null, lastKnownWork };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All clock events and their inferred entries commit together, across tabs too. */
|
||||||
|
export function automateDay(date, config, now = Date.now(), command = {}) {
|
||||||
|
return db.transaction('rw', db.days, db.entries, db.settings, async () => {
|
||||||
|
config = { ...config, ...await readSettings() };
|
||||||
|
if (!command.lunch && !command.end && !command.stale && !config.autoLunch && !config.autoEndDay) return false;
|
||||||
|
let day = await getDay(date);
|
||||||
|
if (day?.status !== 'active') return false;
|
||||||
|
let entries = await getEntries(date);
|
||||||
|
const current = () => entries.find((e) => e.endTs == null);
|
||||||
|
const reload = async () => { entries = await getEntries(date); day = await getDay(date); };
|
||||||
|
if (command.lunch && command.expectedId !== (current()?.id ?? null)) return false;
|
||||||
|
const history = await getHistory();
|
||||||
|
const { previousDay, lastKnownWork } = previousContext(history.days, history.entries, date);
|
||||||
|
const lastWork = () => [...entries].reverse().find((e) => e.kind === 'work') ?? lastKnownWork;
|
||||||
|
/** @returns {{kind: 'work', task: string, client: string|null, automatic: boolean}} */
|
||||||
|
const slot = () => ({ kind: 'work', task: lastWork()?.task || SEED_TASKS[1], client: lastWork()?.client ?? current()?.client ?? null, automatic: true });
|
||||||
|
const lastActivity = () => entries.reduce((ts, e) => Math.max(ts, e.startTs, e.endTs ?? e.startTs), day.startedTs);
|
||||||
|
const finishAt = command.end ? Math.max(now, lastActivity()) :
|
||||||
|
command.stale || config.autoEndDay && now >= day.plannedEndTs ? Math.max(day.plannedEndTs, lastActivity()) : null;
|
||||||
|
const horizon = finishAt ?? now;
|
||||||
|
|
||||||
|
async function completeHalf(boundary, inferArrival) {
|
||||||
|
const running = current();
|
||||||
|
const halfStart = [...entries].reverse().find((e) => e.kind === 'lunch' && e.endTs != null && e.endTs <= boundary)?.endTs ?? day.startedTs;
|
||||||
|
const half = entries.filter((e) => e.startTs < boundary && (e.endTs ?? boundary) > halfStart);
|
||||||
|
if (half.some((e) => e.kind === 'work')) return;
|
||||||
|
// Missing provenance on old records is treated as manual, never inferred.
|
||||||
|
if (running?.kind === 'travel' && running.automatic === true && half.every((e) => e.automatic === true)) {
|
||||||
|
if (inferArrival && entries.length === 1) {
|
||||||
|
const arrived = running.startTs + config.roundingMinutes * MINUTE;
|
||||||
|
if (arrived < boundary) {
|
||||||
|
await closeAndStart(date, arrived, slot()); await reload();
|
||||||
|
}
|
||||||
|
} else if (config.autoFillHalfDay) {
|
||||||
|
await db.entries.update(running.id, slot()); await reload();
|
||||||
|
}
|
||||||
|
} else if (!entries.length && config.autoFillHalfDay && boundary > day.startedTs) {
|
||||||
|
await closeAndStart(date, day.startedTs, slot()); await reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function beginLunch(at, automatic) {
|
||||||
|
await completeHalf(at, automatic && config.autoArrival);
|
||||||
|
await closeAndStart(date, at, { kind: 'lunch', task: 'Pause déjeuner', client: null, automatic });
|
||||||
|
const patch = { autoLunchHandled: true, autoLunchEndTs: automatic ? at + config.lunchMinutes * MINUTE : null };
|
||||||
|
if (automatic && !day.lunchManual && previousDay) {
|
||||||
|
Object.assign(patch, { lunchType: previousDay.lunchType ?? config.lunchType, restaurant: previousDay.lunchType === 'restaurant' ? previousDay.restaurant ?? null : null });
|
||||||
|
}
|
||||||
|
await db.days.update(date, patch); await reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.lunch) {
|
||||||
|
if (current()?.kind === 'lunch') return false;
|
||||||
|
await beginLunch(Math.max(now, lastActivity()), false);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lunchAt = atLocalTime(date, config.lunchStart);
|
||||||
|
if (config.autoLunch && config.lunchMinutes > 0 && !day.autoLunchHandled &&
|
||||||
|
!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);
|
||||||
|
else { await db.days.update(date, { autoLunchHandled: true }); await reload(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.autoLunch && current()?.kind === 'lunch' && current().automatic === true &&
|
||||||
|
day.autoLunchEndTs != null && day.autoLunchEndTs <= horizon &&
|
||||||
|
(finishAt == null || day.autoLunchEndTs < finishAt)) {
|
||||||
|
await closeAndStart(date, Math.max(day.autoLunchEndTs, current().startTs), slot()); await reload();
|
||||||
|
}
|
||||||
|
if (finishAt != null) {
|
||||||
|
await completeHalf(finishAt, false);
|
||||||
|
await closeDay(date, finishAt);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A manual transition claims its old entry as well as its new one. */
|
||||||
|
export function manualAdvance(date, ts, next, expectedId) {
|
||||||
|
return db.transaction('rw', db.days, db.entries, async () => {
|
||||||
|
const id = await closeAndStart(date, ts, next, expectedId);
|
||||||
|
if (id != null && expectedId != null) {
|
||||||
|
const old = await db.entries.get(expectedId);
|
||||||
|
if (old?.automatic) await db.entries.update(expectedId, { automatic: false });
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
});
|
||||||
|
}
|
||||||
235
src/lib/automation.test.js
Normal file
235
src/lib/automation.test.js
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
import 'fake-indexeddb/auto';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { automateDay } from './automation.js';
|
||||||
|
import { AUTOMATION_DEFAULTS } from './automation-options.js';
|
||||||
|
import { parseBackup, parseSettings } from './backup.js';
|
||||||
|
import { db, exportBackup, getDay, getEntries, importBackup, readSettings, writeSettings } from './db.js';
|
||||||
|
import { advance, dayState, editEntry, endDay, keepWorking, predictNext, reconcile, refresh, reopenToday, setLunch, startDayNow, startLunch } from './day.svelte.js';
|
||||||
|
import { removeRecordedEntry, saveRecordedEntry } from './editing.js';
|
||||||
|
import { DEFAULT_SETTINGS, loadSettings, settings } from './settings.svelte.js';
|
||||||
|
import { buildResume } from './resume.js';
|
||||||
|
import { atLocalTime, HOUR, MINUTE } from './time.js';
|
||||||
|
|
||||||
|
const DATE = '2026-09-07', PREV = '2026-09-04';
|
||||||
|
const at = (time, date = DATE) => atLocalTime(date, time);
|
||||||
|
const allOn = () => Object.assign(settings, Object.fromEntries(Object.keys(AUTOMATION_DEFAULTS).map((key) => [key, true])));
|
||||||
|
const tick = async (time, date = DATE) => { vi.setSystemTime(at(time, date)); await reconcile(at(time, date)); };
|
||||||
|
const rows = () => getEntries(DATE);
|
||||||
|
const snapshot = async () => ({ days: await db.days.toArray(), entries: await db.entries.toArray() });
|
||||||
|
|
||||||
|
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, { retentionDays: 0 }); dayState.importing = false;
|
||||||
|
await db.days.put({ date: PREV, status: 'closed', startedTs: at('08:00', PREV), endedTs: at('17:00', PREV), plannedStartTs: at('08:00', PREV), plannedEndTs: at('17:00', PREV), lunchType: 'restaurant', restaurant: 'Le Relais', closePromptedTs: null });
|
||||||
|
await db.entries.bulkAdd([
|
||||||
|
{ date: PREV, seq: 0, kind: 'work', task: 'Pose', client: 'Client A', startTs: at('08:00', PREV), endTs: at('16:30', PREV) },
|
||||||
|
{ date: PREV, seq: 1, kind: 'travel', task: 'Retour', client: 'Client A', startTs: at('16:30', PREV), endTs: at('17:00', PREV) },
|
||||||
|
]);
|
||||||
|
await refresh();
|
||||||
|
});
|
||||||
|
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); vi.useRealTimers(); });
|
||||||
|
|
||||||
|
describe('hands-free day', () => {
|
||||||
|
it('catches up a whole day at its planned times, including the inferred arrival', async () => {
|
||||||
|
allOn(); await tick('18:00');
|
||||||
|
const day = await getDay(DATE), list = await rows();
|
||||||
|
expect(day).toMatchObject({ status: 'closed', startedTs: at('07:30'), endedTs: at('17:30'), lunchType: 'restaurant', restaurant: 'Le Relais' });
|
||||||
|
expect(list.map(e => [e.kind, e.startTs, e.endTs])).toEqual([
|
||||||
|
['travel', at('07:30'), at('07:45')], ['work', at('07:45'), at('12:00')],
|
||||||
|
['lunch', at('12:00'), at('13:00')], ['work', at('13:00'), at('17:30')],
|
||||||
|
]);
|
||||||
|
expect(list.filter(e => e.kind === 'work').every(e => e.task === 'Pose' && e.client === 'Client A')).toBe(true);
|
||||||
|
expect(list.every(e => e.automatic)).toBe(true);
|
||||||
|
expect(buildResume(day, list).workedMs).toBe(9 * HOUR);
|
||||||
|
const before = await snapshot(); await tick('19:00'); expect(await snapshot()).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs each boundary once across frequent and concurrent reconciliations', async () => {
|
||||||
|
allOn(); await tick('11:59'); expect(await rows()).toHaveLength(1);
|
||||||
|
await Promise.all([reconcile(at('12:00')), reconcile(at('12:00'))]);
|
||||||
|
expect((await rows()).at(-1).kind).toBe('lunch'); expect(await rows()).toHaveLength(3);
|
||||||
|
const before = await rows(); await tick('12:30'); expect(await rows()).toEqual(before);
|
||||||
|
await tick('13:00'); expect((await rows()).at(-1).task).toBe('Pose');
|
||||||
|
await tick('17:30'); expect((await getDay(DATE)).status).toBe('closed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([1, 5, 15, 30])('uses the %i-minute precision as the inferred travel duration', async (roundingMinutes) => {
|
||||||
|
allOn(); settings.roundingMinutes = roundingMinutes; await tick('12:00');
|
||||||
|
const first = (await rows())[0]; expect(first.endTs - first.startTs).toBe(roundingMinutes * MINUTE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('settles a stale started day before opening today, without inventing absent days', async () => {
|
||||||
|
allOn(); await tick('08:00'); await tick('07:00', '2026-09-09');
|
||||||
|
expect((await getDay(DATE)).endedTs).toBe(at('17:30')); expect(await rows()).toHaveLength(4);
|
||||||
|
expect(await getDay('2026-09-08')).toBeUndefined(); expect(await getDay('2026-09-09')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses a named fallback when there is no previous work', async () => {
|
||||||
|
await db.days.clear(); await db.entries.clear(); allOn(); await tick('18:00');
|
||||||
|
expect((await rows()).filter(e => e.kind === 'work').map(e => e.task)).toEqual(['Travaux sur site', 'Travaux sur site']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('independent options', () => {
|
||||||
|
it('preserves the old behavior with new options disabled', async () => {
|
||||||
|
await tick('18:00'); expect(await rows()).toHaveLength(1);
|
||||||
|
expect((await getDay(DATE)).status).toBe('active'); expect(dayState.needsClosePrompt).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can disable automatic opening while keeping manual opening available', async () => {
|
||||||
|
allOn(); settings.autoStartDay = false; await tick('10:00'); expect(await getDay(DATE)).toBeUndefined();
|
||||||
|
await startDayNow(at('10:00')); expect((await getDay(DATE)).startedTs).toBe(at('10:00'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves an hour for a manually started day after the usual end', async () => {
|
||||||
|
allOn(); await startDayNow(at('18:00')); await tick('18:30');
|
||||||
|
expect((await getDay(DATE))).toMatchObject({ status: 'active', plannedEndTs: at('19:00') });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('proposes the last work task, ignoring the final return trip and weekends', async () => {
|
||||||
|
settings.autoPreviousTask = true; await tick('08:00'); expect(predictNext().task).toBe('Pose');
|
||||||
|
await advance({ task: predictNext().task, client: predictNext().client }, at('08:00'));
|
||||||
|
expect((await rows()).at(-1).task).toBe('Pose');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not infer arrival when lunch automation is off', async () => {
|
||||||
|
settings.autoArrival = true; await tick('12:00'); expect(await rows()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not shorten transport unless arrival inference or half-day completion is enabled', async () => {
|
||||||
|
settings.autoLunch = true; await tick('13:00');
|
||||||
|
expect((await rows()).map(e => e.kind)).toEqual(['travel', 'lunch', 'work']);
|
||||||
|
expect((await rows())[0].endTs).toBe(at('12:00'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fills an untouched half-day at a manual lunch or manual close', async () => {
|
||||||
|
settings.autoFillHalfDay = true; await tick('08:00'); await startLunch(at('12:00'));
|
||||||
|
expect((await rows())[0]).toMatchObject({ kind: 'work', task: 'Pose', startTs: at('07:30'), endTs: at('12:00') });
|
||||||
|
await advance({ task: 'Finitions', client: 'B' }, at('13:00')); await endDay(at('17:00'));
|
||||||
|
expect((await rows()).at(-1)).toMatchObject({ task: 'Finitions', client: 'B' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fills a full untouched day when no lunch was recorded', async () => {
|
||||||
|
settings.autoFillHalfDay = true; await tick('08:00'); await endDay(at('17:00'));
|
||||||
|
expect(await rows()).toHaveLength(1); expect((await rows())[0].task).toBe('Pose');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps an automatically started pause open while the option is disabled', async () => {
|
||||||
|
allOn(); await tick('12:00'); settings.autoLunch = false; await tick('13:15');
|
||||||
|
expect((await rows()).at(-1).kind).toBe('lunch');
|
||||||
|
settings.autoLunch = true; await tick('13:15'); expect((await rows()).at(-1).startTs).toBe(at('13:00'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the original scheduled end if lunch duration changes mid-pause', async () => {
|
||||||
|
allOn(); await tick('12:00'); settings.lunchMinutes = 5; await tick('12:30');
|
||||||
|
expect((await rows()).at(-1).kind).toBe('lunch'); await tick('13:00'); expect((await rows()).at(-1).kind).toBe('work');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('manual priority and edge cases', () => {
|
||||||
|
it('preserves actual arrival, task, client and restaurant corrections', async () => {
|
||||||
|
allOn(); await tick('08:00'); await advance({ task: 'Urgence', client: 'B' }, at('09:00'));
|
||||||
|
await setLunch({ lunchType: 'restaurant', restaurant: 'Choix du jour' }); await tick('18:00');
|
||||||
|
const list = await rows(); expect(list[0].endTs).toBe(at('09:00'));
|
||||||
|
expect(list[1]).toMatchObject({ task: 'Urgence', client: 'B' });
|
||||||
|
expect(list.at(-1)).toMatchObject({ task: 'Urgence', client: 'B' });
|
||||||
|
expect((await getDay(DATE)).restaurant).toBe('Choix du jour');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not shorten a manually edited transport placeholder', async () => {
|
||||||
|
allOn(); await tick('08:00'); await editEntry((await rows())[0].id, { client: 'Autre client' }); await tick('12:00');
|
||||||
|
expect((await rows())[0]).toMatchObject({ kind: 'travel', client: 'Autre client', endTs: at('12:00') });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a manually started lunch manual, without a second automatic pause', async () => {
|
||||||
|
allOn(); await tick('08:00'); await startLunch(at('11:45')); await tick('13:30');
|
||||||
|
expect((await rows()).at(-1)).toMatchObject({ kind: 'lunch', startTs: at('11:45'), endTs: null });
|
||||||
|
await advance({ task: 'Autre', client: 'B' }, at('13:30')); await tick('14:00');
|
||||||
|
expect((await rows()).filter(e => e.kind === 'lunch')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops automatic resumption after editing the automatic lunch', async () => {
|
||||||
|
allOn(); await tick('12:15'); const lunch = (await rows()).at(-1);
|
||||||
|
await saveRecordedEntry(lunch, { ...lunch, lunchType: 'panier' }, at('12:15')); await tick('13:15');
|
||||||
|
expect((await rows()).at(-1)).toMatchObject({ kind: 'lunch', automatic: false, endTs: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not recreate a deleted lunch after changing the scheduled hour', async () => {
|
||||||
|
allOn(); await tick('13:15'); await removeRecordedEntry((await rows()).find(e => e.kind === 'lunch'));
|
||||||
|
settings.lunchStart = '14:00'; await tick('14:00'); expect((await rows()).some(e => e.kind === 'lunch')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not backdate lunch across a later manual action when enabling late', async () => {
|
||||||
|
await tick('08:00'); await advance({ task: 'Après-midi', client: 'B' }, at('14:00'));
|
||||||
|
const before = await rows(); allOn(); await tick('15:00'); expect(await rows()).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never rewrites legacy entries without provenance', async () => {
|
||||||
|
await tick('08:00'); const first = (await rows())[0]; await db.entries.put({ ...first, automatic: undefined });
|
||||||
|
allOn(); await tick('12:00'); expect((await rows())[0].kind).toBe('travel'); expect((await rows())[0].endTs).toBe(at('12:00'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips zero-duration lunches and lunches outside the day', async () => {
|
||||||
|
allOn(); settings.lunchMinutes = 0; await tick('18:00'); expect((await rows()).some(e => e.kind === 'lunch')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips a lunch before a late start', async () => {
|
||||||
|
allOn(); await startDayNow(at('14:00')); await tick('18:00'); expect((await rows()).some(e => e.kind === 'lunch')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not create negative work when the morning is shorter than the travel estimate', async () => {
|
||||||
|
allOn(); await startDayNow(at('11:55')); await tick('12:00');
|
||||||
|
expect((await rows()).map(e => e.kind)).toEqual(['travel', 'lunch']);
|
||||||
|
expect((await rows())[0].endTs - (await rows())[0].startTs).toBe(5 * MINUTE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes a lunch at an early manual end without inventing afternoon work', async () => {
|
||||||
|
allOn(); await tick('08:00'); await endDay(at('12:30'));
|
||||||
|
expect((await rows()).at(-1)).toMatchObject({ kind: 'lunch', endTs: at('12:30') });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects extensions and a manual reopening', async () => {
|
||||||
|
allOn(); await tick('16:00'); await keepWorking(at('17:30')); await tick('18:00');
|
||||||
|
expect((await getDay(DATE)).status).toBe('active'); await tick('18:30');
|
||||||
|
await reopenToday(at('18:45')); await tick('19:00'); expect((await getDay(DATE)).status).toBe('active');
|
||||||
|
expect((await rows()).filter(e => e.kind === 'lunch')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('atomicity and persistence', () => {
|
||||||
|
it('honors an option disabled in another tab even with stale in-memory settings', async () => {
|
||||||
|
allOn(); await tick('08:00'); await writeSettings({ autoLunch: false, autoEndDay: false });
|
||||||
|
await automateDay(DATE, settings, at('18:00'));
|
||||||
|
expect(await rows()).toHaveLength(1); expect((await getDay(DATE)).status).toBe('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honors opening disabled in persisted settings before reconciling', async () => {
|
||||||
|
allOn(); await writeSettings({ autoStartDay: false }); await tick('10:00');
|
||||||
|
expect(await getDay(DATE)).toBeUndefined();
|
||||||
|
});
|
||||||
|
it('serializes independent clock callers without duplicate inferred entries', async () => {
|
||||||
|
allOn(); await tick('08:00'); await Promise.all([automateDay(DATE, settings, at('18:00')), automateDay(DATE, settings, at('18:00'))]);
|
||||||
|
expect(await rows()).toHaveLength(4); expect((await getDay(DATE)).status).toBe('closed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rolls back travel inference and lunch together when metadata fails', async () => {
|
||||||
|
allOn(); await tick('08:00'); const before = await snapshot();
|
||||||
|
vi.spyOn(db.days, 'update').mockRejectedValueOnce(new Error('disk'));
|
||||||
|
await expect(automateDay(DATE, settings, at('12:00'))).rejects.toThrow('disk');
|
||||||
|
expect(await snapshot()).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips switches, provenance and handled lunch flags in backups', async () => {
|
||||||
|
allOn(); await tick('18:00'); const config = Object.fromEntries(Object.keys(AUTOMATION_DEFAULTS).map(k => [k, settings[k]]));
|
||||||
|
await writeSettings(config); const backup = parseBackup(JSON.stringify(await exportBackup()));
|
||||||
|
expect(backup.settings).toEqual(config); expect(backup.entries.filter(e => e.date === DATE).every(e => e.automatic)).toBe(true);
|
||||||
|
expect(backup.days.find(d => d.date === DATE).autoLunchHandled).toBe(true);
|
||||||
|
await importBackup(backup, 'replace'); await loadSettings(); expect(await readSettings()).toEqual(config);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(Object.keys(AUTOMATION_DEFAULTS))('rejects non-boolean %s settings', (key) => {
|
||||||
|
expect(() => parseSettings({ [key]: 'true' })).toThrow(); expect(parseSettings({ [key]: false })).toEqual({ [key]: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { localDateKey } from './time.js';
|
import { localDateKey } from './time.js';
|
||||||
|
import { AUTOMATION_DEFAULTS } from './automation-options.js';
|
||||||
|
|
||||||
export const EXPORT_FORMAT = 'timelog-export';
|
export const EXPORT_FORMAT = 'timelog-export';
|
||||||
export const EXPORT_VERSION = 1;
|
export const EXPORT_VERSION = 1;
|
||||||
@@ -32,6 +33,7 @@ export function parseSettings(raw) {
|
|||||||
if (!isObject(raw)) throw new BackupError('Réglages invalides dans le fichier.');
|
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);
|
const clock = (v) => typeof v === 'string' && /^([01]\d|2[0-3]):[0-5]\d$/.test(v);
|
||||||
const rules = {
|
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),
|
workdays: (v) => Array.isArray(v) && v.every((n) => Number.isInteger(n) && n >= 0 && n <= 6),
|
||||||
dayStart: clock,
|
dayStart: clock,
|
||||||
dayEnd: clock,
|
dayEnd: clock,
|
||||||
@@ -192,6 +194,7 @@ function normalise(rawDays, rawEntries, exportTs) {
|
|||||||
client: typeof entry.client === 'string' && entry.client ? entry.client : null,
|
client: typeof entry.client === 'string' && entry.client ? entry.client : null,
|
||||||
startTs: entry.startTs,
|
startTs: entry.startTs,
|
||||||
endTs: isTimestamp(entry.endTs) ? entry.endTs : null,
|
endTs: isTimestamp(entry.endTs) ? entry.endTs : null,
|
||||||
|
...(typeof entry.automatic === 'boolean' ? { automatic: entry.automatic } : {}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
for (let i = 0; i < repaired.length - 1; i++) {
|
for (let i = 0; i < repaired.length - 1; i++) {
|
||||||
@@ -227,6 +230,9 @@ function normalise(rawDays, rawEntries, exportTs) {
|
|||||||
: null,
|
: null,
|
||||||
closePromptedTs: rawDay.status !== 'active' && isTimestamp(rawDay.closePromptedTs)
|
closePromptedTs: rawDay.status !== 'active' && isTimestamp(rawDay.closePromptedTs)
|
||||||
? rawDay.closePromptedTs : null,
|
? rawDay.closePromptedTs : null,
|
||||||
|
...(typeof rawDay.autoLunchHandled === 'boolean' ? { autoLunchHandled: rawDay.autoLunchHandled } : {}),
|
||||||
|
...(isTimestamp(rawDay.autoLunchEndTs) ? { autoLunchEndTs: rawDay.autoLunchEndTs } : {}),
|
||||||
|
...(typeof rawDay.lunchManual === 'boolean' ? { lunchManual: rawDay.lunchManual } : {}),
|
||||||
});
|
});
|
||||||
// Compare the fields we retain, not database ids or unrelated file metadata.
|
// Compare the fields we retain, not database ids or unrelated file metadata.
|
||||||
const changed = repaired.filter((entry, i) =>
|
const changed = repaired.filter((entry, i) =>
|
||||||
|
|||||||
@@ -8,8 +8,6 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
cleanupOldDays,
|
cleanupOldDays,
|
||||||
closeAndStart,
|
|
||||||
closeDay,
|
|
||||||
createDay,
|
createDay,
|
||||||
deleteEntry,
|
deleteEntry,
|
||||||
getActiveDay,
|
getActiveDay,
|
||||||
@@ -18,10 +16,12 @@ import {
|
|||||||
importBackup,
|
importBackup,
|
||||||
patchActiveDay,
|
patchActiveDay,
|
||||||
patchEntry,
|
patchEntry,
|
||||||
|
readSettings,
|
||||||
reopenDay,
|
reopenDay,
|
||||||
setEntryStart,
|
setEntryStart,
|
||||||
} from './db.js';
|
} from './db.js';
|
||||||
import { buildModel, suggestClients, suggestTasks, TRANSPORT_TASK } from './predict.js';
|
import { buildModel, suggestClients, suggestTasks, TRANSPORT_TASK } from './predict.js';
|
||||||
|
import { automateDay, manualAdvance, previousContext } from './automation.js';
|
||||||
import { loadSettings, settings } from './settings.svelte.js';
|
import { loadSettings, settings } from './settings.svelte.js';
|
||||||
import { atLocalTime, HOUR, localDateKey, roundTs, weekday } from './time.js';
|
import { atLocalTime, HOUR, localDateKey, roundTs, weekday } from './time.js';
|
||||||
|
|
||||||
@@ -36,6 +36,7 @@ export const dayState = $state({
|
|||||||
closedDays: [],
|
closedDays: [],
|
||||||
historyEntries: [],
|
historyEntries: [],
|
||||||
todayRecord: null,
|
todayRecord: null,
|
||||||
|
previousWork: null,
|
||||||
/** Prediction model, rebuilt whenever history changes. */
|
/** Prediction model, rebuilt whenever history changes. */
|
||||||
model: null,
|
model: null,
|
||||||
/** True once the configured end time has passed and the worker has not answered. */
|
/** True once the configured end time has passed and the worker has not answered. */
|
||||||
@@ -70,12 +71,15 @@ export function reconcile(now = Date.now()) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function reconcileDay(now) {
|
async function reconcileDay(now) {
|
||||||
|
// A settings change in another tab must also stop this tab's clock actions.
|
||||||
|
const persisted = await readSettings();
|
||||||
|
if (persisted) Object.assign(settings, persisted);
|
||||||
dayState.today = localDateKey(now);
|
dayState.today = localDateKey(now);
|
||||||
|
|
||||||
const active = await getActiveDay();
|
const active = await getActiveDay();
|
||||||
// Settle stale work before retention, so an open day cannot vanish on return.
|
// Settle stale work before retention, so an open day cannot vanish on return.
|
||||||
if (active && active.date < dayState.today) {
|
if (active && active.date < dayState.today) {
|
||||||
await closeDay(active.date, now, true);
|
await automateDay(active.date, settings, now, { stale: true });
|
||||||
return reconcileDay(now);
|
return reconcileDay(now);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,15 +98,18 @@ 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.workdays.includes(weekday(dayState.today))) {
|
if (!existing && settings.autoStartDay && settings.workdays.includes(weekday(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);
|
||||||
|
await automateDay(dayState.today, settings, now);
|
||||||
return refresh(now);
|
return refresh(now);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (active) await automateDay(active.date, settings, now);
|
||||||
|
|
||||||
await refresh(now);
|
await refresh(now);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,14 +117,16 @@ async function reconcileDay(now) {
|
|||||||
* Create a day and immediately open the transport task: per the workflow, the drive
|
* 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.
|
* out from base *is* the first task, and "Arrivé sur site" is what ends it.
|
||||||
*/
|
*/
|
||||||
async function openDay(dateKey, startTs) {
|
async function openDay(dateKey, startTs, manual = false) {
|
||||||
const { entries, days } = await getHistory();
|
const { entries, days } = await getHistory();
|
||||||
const model = buildModel(entries, days);
|
const model = buildModel(entries, days);
|
||||||
await createDay({
|
await createDay({
|
||||||
date: dateKey,
|
date: dateKey,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
plannedStartTs: atLocalTime(dateKey, settings.dayStart),
|
plannedStartTs: atLocalTime(dateKey, settings.dayStart),
|
||||||
plannedEndTs: atLocalTime(dateKey, settings.dayEnd),
|
plannedEndTs: manual && settings.autoEndDay
|
||||||
|
? Math.max(atLocalTime(dateKey, settings.dayEnd), startTs + HOUR)
|
||||||
|
: atLocalTime(dateKey, settings.dayEnd),
|
||||||
startedTs: startTs,
|
startedTs: startTs,
|
||||||
endedTs: null,
|
endedTs: null,
|
||||||
lunchType: settings.lunchType,
|
lunchType: settings.lunchType,
|
||||||
@@ -127,6 +136,7 @@ async function openDay(dateKey, startTs) {
|
|||||||
kind: 'travel',
|
kind: 'travel',
|
||||||
task: TRANSPORT_TASK,
|
task: TRANSPORT_TASK,
|
||||||
client: model.lastClient,
|
client: model.lastClient,
|
||||||
|
automatic: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,6 +152,7 @@ export async function refresh(now = Date.now()) {
|
|||||||
const entriesFor = (day) => day ? entries.filter((e) => e.date === day.date).sort((a, b) => a.seq - b.seq) : [];
|
const entriesFor = (day) => day ? entries.filter((e) => e.date === day.date).sort((a, b) => a.seq - b.seq) : [];
|
||||||
dayState.today = localDateKey(now);
|
dayState.today = localDateKey(now);
|
||||||
dayState.todayRecord = days.find((d) => d.date === dayState.today) ?? null;
|
dayState.todayRecord = days.find((d) => d.date === dayState.today) ?? null;
|
||||||
|
dayState.previousWork = previousContext(days, entries, active?.date ?? dayState.today).previousWork;
|
||||||
dayState.day = active ?? null;
|
dayState.day = active ?? null;
|
||||||
dayState.entries = entriesFor(active);
|
dayState.entries = entriesFor(active);
|
||||||
dayState.lastClosed = lastClosed ?? null;
|
dayState.lastClosed = lastClosed ?? null;
|
||||||
@@ -161,7 +172,7 @@ export async function refresh(now = Date.now()) {
|
|||||||
/** Manual start, for a Saturday or an early departure. */
|
/** Manual start, for a Saturday or an early departure. */
|
||||||
export async function startDayNow(now = Date.now()) {
|
export async function startDayNow(now = Date.now()) {
|
||||||
if (dayState.importing) return;
|
if (dayState.importing) return;
|
||||||
await openDay(localDateKey(now), now);
|
await openDay(localDateKey(now), now, true);
|
||||||
await refresh(now);
|
await refresh(now);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,17 +185,13 @@ export async function startDayNow(now = Date.now()) {
|
|||||||
*/
|
*/
|
||||||
export async function advance({ kind = 'work', task, client }, now = Date.now()) {
|
export async function advance({ kind = 'work', task, client }, now = Date.now()) {
|
||||||
if (!dayState.day || dayState.importing) return;
|
if (!dayState.day || dayState.importing) return;
|
||||||
await closeAndStart(dayState.day.date, now, { kind, task, client: client ?? null }, running()?.id ?? null);
|
await manualAdvance(dayState.day.date, now, { kind, task, client: client ?? null }, running()?.id ?? null);
|
||||||
await refresh(now);
|
await refresh(now);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startLunch(now = Date.now()) {
|
export async function startLunch(now = Date.now()) {
|
||||||
if (!dayState.day || dayState.importing || running()?.kind === 'lunch') return;
|
if (!dayState.day || dayState.importing || running()?.kind === 'lunch') return;
|
||||||
await closeAndStart(dayState.day.date, now, {
|
await automateDay(dayState.day.date, settings, now, { lunch: true, expectedId: running()?.id ?? null });
|
||||||
kind: 'lunch',
|
|
||||||
task: 'Pause déjeuner',
|
|
||||||
client: null,
|
|
||||||
}, running()?.id ?? null);
|
|
||||||
await refresh(now);
|
await refresh(now);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,6 +200,7 @@ export async function setLunch({ lunchType, restaurant }) {
|
|||||||
await patchActiveDay(dayState.day.date, () => ({
|
await patchActiveDay(dayState.day.date, () => ({
|
||||||
lunchType,
|
lunchType,
|
||||||
restaurant: lunchType === 'restaurant' ? (restaurant ?? null) : null,
|
restaurant: lunchType === 'restaurant' ? (restaurant ?? null) : null,
|
||||||
|
lunchManual: true,
|
||||||
}));
|
}));
|
||||||
await refresh();
|
await refresh();
|
||||||
}
|
}
|
||||||
@@ -249,7 +257,7 @@ export async function applyImport(payload, mode) {
|
|||||||
export async function endDay(now = Date.now()) {
|
export async function endDay(now = Date.now()) {
|
||||||
if (!dayState.day || dayState.importing) return;
|
if (!dayState.day || dayState.importing) return;
|
||||||
const date = dayState.day.date;
|
const date = dayState.day.date;
|
||||||
await closeDay(date, now);
|
await automateDay(date, settings, now, { end: true });
|
||||||
await refresh(now);
|
await refresh(now);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,7 +301,9 @@ export function predictNext(now = Date.now()) {
|
|||||||
const clients = suggestClients(model, now);
|
const clients = suggestClients(model, now);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
task: tasks[0]?.value ?? TRANSPORT_TASK,
|
task: 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?.client ?? clients[0]?.value ?? null,
|
||||||
tasks,
|
tasks,
|
||||||
clients,
|
clients,
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ import { DAY, HOUR, localDateKey } from './time.js';
|
|||||||
* @property {'panier'|'restaurant'|null} lunchType
|
* @property {'panier'|'restaurant'|null} lunchType
|
||||||
* @property {string|null} restaurant
|
* @property {string|null} restaurant
|
||||||
* @property {number|null} closePromptedTs
|
* @property {number|null} closePromptedTs
|
||||||
|
* @property {boolean} [autoLunchHandled]
|
||||||
|
* @property {number|null} [autoLunchEndTs]
|
||||||
|
* @property {boolean} [lunchManual]
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -32,6 +35,7 @@ import { DAY, HOUR, localDateKey } from './time.js';
|
|||||||
* @property {string|null} client
|
* @property {string|null} client
|
||||||
* @property {number} startTs
|
* @property {number} startTs
|
||||||
* @property {number|null} endTs
|
* @property {number|null} endTs
|
||||||
|
* @property {boolean} [automatic] true only for unconfirmed automatic entries
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -167,7 +171,7 @@ export function getEntries(dateKey) {
|
|||||||
*
|
*
|
||||||
* @param {string} dateKey
|
* @param {string} dateKey
|
||||||
* @param {number} ts
|
* @param {number} ts
|
||||||
* @param {Pick<EntryRecord, 'kind'|'task'|'client'>|null} next
|
* @param {(Pick<EntryRecord, 'kind'|'task'|'client'> & {automatic?: boolean})|null} next
|
||||||
* Pass null to only close the running entry (end of day).
|
* Pass null to only close the running entry (end of day).
|
||||||
* @param {number|null} [expectedId] running entry observed by the action's caller
|
* @param {number|null} [expectedId] running entry observed by the action's caller
|
||||||
* @returns {Promise<number|null>} id of the new entry
|
* @returns {Promise<number|null>} id of the new entry
|
||||||
@@ -200,7 +204,7 @@ export function closeAndStart(dateKey, ts, next, expectedId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function patchEntry(id, patch) {
|
export function patchEntry(id, patch) {
|
||||||
return db.entries.update(id, patch);
|
return db.entries.update(id, { ...patch, automatic: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -226,8 +230,8 @@ export function setEntryStart(id, ts, now = Date.now()) {
|
|||||||
const upper = entry.endTs ?? now;
|
const upper = entry.endTs ?? now;
|
||||||
const stored = Math.min(Math.max(ts, lower), upper);
|
const stored = Math.min(Math.max(ts, lower), upper);
|
||||||
|
|
||||||
await db.entries.update(entry.id, { startTs: stored });
|
await db.entries.update(entry.id, { startTs: stored, ...(entry.automatic ? { automatic: false } : {}) });
|
||||||
if (prev) await db.entries.update(prev.id, { endTs: stored });
|
if (prev) await db.entries.update(prev.id, { endTs: stored, ...(prev.automatic ? { automatic: false } : {}) });
|
||||||
else await db.days.update(entry.date, { startedTs: stored });
|
else await db.days.update(entry.date, { startedTs: stored });
|
||||||
|
|
||||||
return stored;
|
return stored;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { db, deleteEntry, getDay, getEntries } from './db.js';
|
|||||||
import { localDateKey } from './time.js';
|
import { localDateKey } from './time.js';
|
||||||
|
|
||||||
const conflict = () => new Error('Ce poste a changé. Fermez puis ouvrez à nouveau la correction.');
|
const conflict = () => new Error('Ce poste a changé. Fermez puis ouvrez à nouveau la correction.');
|
||||||
const fields = ['id', 'date', 'seq', 'kind', 'task', 'client', 'startTs', 'endTs'];
|
const fields = ['id', 'date', 'seq', 'kind', 'task', 'client', 'startTs', 'endTs', 'automatic'];
|
||||||
|
|
||||||
async function readOriginal(original) {
|
async function readOriginal(original) {
|
||||||
const entry = await db.entries.get(original.id);
|
const entry = await db.entries.get(original.id);
|
||||||
@@ -32,6 +32,7 @@ async function saveLunch(date, values) {
|
|||||||
await db.days.update(date, {
|
await db.days.update(date, {
|
||||||
lunchType: values.lunchType,
|
lunchType: values.lunchType,
|
||||||
restaurant: values.lunchType === 'restaurant' ? String(values.restaurant ?? '').trim() || null : null,
|
restaurant: values.lunchType === 'restaurant' ? String(values.restaurant ?? '').trim() || null : null,
|
||||||
|
lunchManual: true, autoLunchHandled: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,9 +49,10 @@ export function saveRecordedEntry(original, values, now = Date.now()) {
|
|||||||
throw new Error('Ces horaires dépassent le poste voisin. Corrigez d’abord ce voisin.');
|
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.');
|
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 });
|
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 });
|
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 });
|
if (next && 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 (!prev) await db.days.update(day.date, { startedTs: startTs });
|
||||||
if (!next && day.status === 'closed') await db.days.update(day.date, { endedTs: endTs });
|
if (!next && day.status === 'closed') await db.days.update(day.date, { endedTs: endTs });
|
||||||
await saveLunch(day.date, values);
|
await saveLunch(day.date, values);
|
||||||
@@ -80,7 +82,7 @@ export function insertRecordedEntry(original, values, now = Date.now()) {
|
|||||||
}
|
}
|
||||||
let insertedId;
|
let insertedId;
|
||||||
for (let i = 0; i < pieces.length; i++) {
|
for (let i = 0; i < pieces.length; i++) {
|
||||||
const piece = { ...pieces[i], seq: entry.seq + i };
|
const piece = { ...pieces[i], seq: entry.seq + i, ...(entry.automatic ? { automatic: false } : {}) };
|
||||||
const pieceId = i === keepIndex ? (await db.entries.put({ ...piece, id }), id) : await db.entries.add(piece);
|
const pieceId = i === keepIndex ? (await db.entries.put({ ...piece, id }), id) : await db.entries.add(piece);
|
||||||
if (i === insertedIndex) insertedId = pieceId;
|
if (i === insertedIndex) insertedId = pieceId;
|
||||||
}
|
}
|
||||||
@@ -95,6 +97,9 @@ export function removeRecordedEntry(original) {
|
|||||||
if (entries.length < 2) throw new Error('Le dernier poste de la journée ne peut pas être supprimé.');
|
if (entries.length < 2) throw new Error('Le dernier poste de la journée ne peut pas être supprimé.');
|
||||||
if (entry.endTs == null) throw new Error('Le poste en cours ne peut pas être supprimé.');
|
if (entry.endTs == null) throw new Error('Le poste en cours ne peut pas être supprimé.');
|
||||||
await deleteEntry(entry.id);
|
await deleteEntry(entry.id);
|
||||||
|
if (entry.kind === 'lunch') await db.days.update(entry.date, { autoLunchHandled: true });
|
||||||
|
for (const e of await getEntries(entry.date)) {
|
||||||
|
if (e.automatic && (e.seq === entry.seq || e.seq === entry.seq - 1)) await db.entries.update(e.id, { automatic: false });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { readSettings, writeSettings } from './db.js';
|
import { readSettings, writeSettings } from './db.js';
|
||||||
import { parseSettings } from './backup.js';
|
import { parseSettings } from './backup.js';
|
||||||
|
import { AUTOMATION_DEFAULTS } from './automation-options.js';
|
||||||
|
|
||||||
export const DEFAULT_SETTINGS = {
|
export const DEFAULT_SETTINGS = {
|
||||||
|
...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],
|
||||||
dayStart: '07:30',
|
dayStart: '07:30',
|
||||||
|
|||||||
@@ -56,6 +56,7 @@
|
|||||||
{#if error}<p role="alert">{error}</p>{/if}
|
{#if error}<p role="alert">{error}</p>{/if}
|
||||||
{#if dayState.closedDays.length > 1}
|
{#if dayState.closedDays.length > 1}
|
||||||
<label class="stack">
|
<label class="stack">
|
||||||
|
<span>Journée à consulter</span>
|
||||||
<select value={selectedDay?.date} onchange={(event) => { selectedDate = event.currentTarget.value; copied = false; editing = false; }}>
|
<select value={selectedDay?.date} onchange={(event) => { selectedDate = event.currentTarget.value; copied = false; editing = false; }}>
|
||||||
{#each dayState.closedDays as day (day.date)}
|
{#each dayState.closedDays as day (day.date)}
|
||||||
<option value={day.date}>{fmtDayLabel(day.date)} {day.date.slice(0, 4)}</option>
|
<option value={day.date}>{fmtDayLabel(day.date)} {day.date.slice(0, 4)}</option>
|
||||||
@@ -71,6 +72,10 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
<div class="row-between">
|
||||||
|
<h1>{resume.label}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card stack">
|
<div class="card stack">
|
||||||
{#each resume.clients as client (client.name)}
|
{#each resume.clients as client (client.name)}
|
||||||
<div class="client">
|
<div class="client">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import BackupPanel from '../components/BackupPanel.svelte';
|
import BackupPanel from '../components/BackupPanel.svelte';
|
||||||
import { go } from '../lib/router.svelte.js';
|
import { go } from '../lib/router.svelte.js';
|
||||||
|
import { AUTOMATION_OPTIONS } from '../lib/automation-options.js';
|
||||||
import { cleanupOldDays } from '../lib/db.js';
|
import { cleanupOldDays } from '../lib/db.js';
|
||||||
import { dayState, refresh } from '../lib/day.svelte.js';
|
import { dayState, refresh } from '../lib/day.svelte.js';
|
||||||
import {
|
import {
|
||||||
@@ -28,6 +29,8 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<fieldset class="screen" disabled={dayState.importing}>
|
<fieldset class="screen" disabled={dayState.importing}>
|
||||||
|
<h1>Réglages</h1>
|
||||||
|
|
||||||
<section class="card stack">
|
<section class="card stack">
|
||||||
<h2>Statistiques</h2>
|
<h2>Statistiques</h2>
|
||||||
<p class="dim small">Heures par client et tâche, repas et évolution de l’activité.</p>
|
<p class="dim small">Heures par client et tâche, repas et évolution de l’activité.</p>
|
||||||
@@ -110,7 +113,23 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<p class="dim small">Type par défaut ; modifiable chaque jour.</p>
|
<p class="dim small">Type par défaut ; modifiable chaque jour.</p>
|
||||||
<p class="dim small">L’heure et la durée affichent des repères sur la journée. Le début et la fin de la pause restent manuels.</p>
|
<p class="dim small">Utiliser ces horaires pour les rappels et, si activée ci-dessous, la pause automatique.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card stack">
|
||||||
|
<h2>Automatismes</h2>
|
||||||
|
{#each AUTOMATION_OPTIONS as option (option.key)}
|
||||||
|
<div class="automation-option stack">
|
||||||
|
<label class="automation-toggle">
|
||||||
|
<input type="checkbox" checked={settings[option.key]} aria-describedby={`help-${option.key}`}
|
||||||
|
onchange={(event) => updateSettings({ [option.key]: event.currentTarget.checked })} />
|
||||||
|
{option.name}
|
||||||
|
</label>
|
||||||
|
<p class="small dim" id={`help-${option.key}`}>{option.description}</p>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
<p class="small dim">Conserver les saisies manuelles. Sans tâche précédente, utiliser « Travaux sur site ». Une pause lancée ou corrigée manuellement reste manuelle.</p>
|
||||||
|
<p class="small dim">Exécuter les automatismes pendant l’utilisation et rattraper les étapes manquées à la réouverture. Ne pas créer les journées entièrement absentes de l’historique.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card stack">
|
<section class="card stack">
|
||||||
@@ -222,4 +241,7 @@
|
|||||||
border-color: var(--danger);
|
border-color: var(--danger);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
.automation-option + .automation-option { border-top: 2px solid var(--line); padding-top: 12px; }
|
||||||
|
label.automation-toggle { justify-content: flex-start; min-height: var(--tap); }
|
||||||
|
.automation-toggle input { width: 26px; height: 26px; accent-color: var(--accent); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -89,6 +89,7 @@
|
|||||||
{#if stats.tasks.length && stats.workedMs}
|
{#if stats.tasks.length && stats.workedMs}
|
||||||
<p>La tâche la plus longue au total est <strong>{stats.tasks[0].name}</strong> : {fmtDuration(stats.tasks[0].ms)}, soit {pct(stats.topTaskShare)} du travail.</p>
|
<p>La tâche la plus longue au total est <strong>{stats.tasks[0].name}</strong> : {fmtDuration(stats.tasks[0].ms)}, soit {pct(stats.topTaskShare)} du travail.</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
<p class="small dim">Ces observations décrivent les saisies de la période ; elles ne mesurent pas la productivité.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card stack">
|
<section class="card stack">
|
||||||
@@ -146,6 +147,7 @@
|
|||||||
<section class="card stack">
|
<section class="card stack">
|
||||||
<h2>Répartition des repas</h2>
|
<h2>Répartition des repas</h2>
|
||||||
<p>{stats.mealDays} journée(s) avec une pause repas enregistrée · {fmtDuration(stats.lunchMs)} au total.</p>
|
<p>{stats.mealDays} journée(s) avec une pause repas enregistrée · {fmtDuration(stats.lunchMs)} au total.</p>
|
||||||
|
<p class="small dim">Plusieurs pauses repas le même jour comptent pour une journée. Le type et le restaurant sont ceux renseignés pour la journée.</p>
|
||||||
<ul class="lines">
|
<ul class="lines">
|
||||||
{#each stats.meals as meal (meal.key)}
|
{#each stats.meals as meal (meal.key)}
|
||||||
<li class="stack"><div class="row"><strong>{meal.name}</strong><span>{meal.days} jour(s)</span></div>
|
<li class="stack"><div class="row"><strong>{meal.name}</strong><span>{meal.days} jour(s)</span></div>
|
||||||
@@ -196,6 +198,7 @@
|
|||||||
<li>Temps sans poste à l’intérieur des journées : {fmtDuration(stats.quality.uncoveredMs)}.</li>
|
<li>Temps sans poste à l’intérieur des journées : {fmtDuration(stats.quality.uncoveredMs)}.</li>
|
||||||
<li>Chevauchements de postes : {fmtDuration(stats.quality.overlapMs)}.</li>
|
<li>Chevauchements de postes : {fmtDuration(stats.quality.overlapMs)}.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
<p class="small dim">Un temps non renseigné n’est attribué ni au travail ni au repas. Ces points peuvent expliquer un écart avec l’amplitude entre début et fin.</p>
|
||||||
</details>
|
</details>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user