Fixed a few minor bugs
This commit is contained in:
37
README.md
37
README.md
@@ -1,8 +1,8 @@
|
|||||||
# TimeLog
|
# TimeLog
|
||||||
|
|
||||||
Offline-first PWA for tracking hours on site. The day starts and ends on its own, each
|
Offline-first PWA for tracking hours on site. While the app is running, it can start
|
||||||
task begins where the previous one ended, and the app's main output is a concise
|
the day automatically and prompt for its end. Each task begins where the previous
|
||||||
summary of the last working day, ready to copy into a paper logbook.
|
one ended. Closed days have concise summaries ready to copy into a paper logbook.
|
||||||
|
|
||||||
Built for use outdoors: high contrast, large targets, usable with gloves on. All data
|
Built for use outdoors: high contrast, large targets, usable with gloves on. All data
|
||||||
stays on the device (IndexedDB); there is no backend and nothing is uploaded.
|
stays on the device (IndexedDB); there is no backend and nothing is uploaded.
|
||||||
@@ -12,7 +12,7 @@ stays on the device (IndexedDB); there is no backend and nothing is uploaded.
|
|||||||
```sh
|
```sh
|
||||||
npm install
|
npm install
|
||||||
npm run dev # http://localhost:5173
|
npm run dev # http://localhost:5173
|
||||||
npm test # unit tests for the time, prediction, resume and boundary logic
|
npm test # unit and IndexedDB regression tests
|
||||||
npm run check # svelte-check
|
npm run check # svelte-check
|
||||||
npm run build # production build into dist/
|
npm run build # production build into dist/
|
||||||
```
|
```
|
||||||
@@ -58,6 +58,29 @@ les fichiers invalides, la fusion, le remplacement et l'annulation transactionne
|
|||||||
sur une IndexedDB simulée. `src/lib/fixtures/backup.json` est le jeu d'essai fourni ;
|
sur une IndexedDB simulée. `src/lib/fixtures/backup.json` est le jeu d'essai fourni ;
|
||||||
il n'est pas publié avec l'application.
|
il n'est pas publié avec l'application.
|
||||||
|
|
||||||
|
## Journées, reprise et historique
|
||||||
|
|
||||||
|
- Une journée oubliée est clôturée au retour dans l'application, à son heure de
|
||||||
|
fin prévue (prolongations comprises), sans jamais précéder la dernière activité
|
||||||
|
enregistrée. Cette heure est une estimation : l'application ne mesure pas le
|
||||||
|
travail lorsqu'elle est fermée. Le dernier poste conserve ainsi sa durée jusqu'à
|
||||||
|
cette limite, au lieu d'être ramené à zéro.
|
||||||
|
- Une journée terminée propose **Reprendre la journée**. Cela annule sa clôture et
|
||||||
|
continue le dernier poste, client et déjeuner conservés. Le temps écoulé depuis
|
||||||
|
l'arrêt est compté dans ce poste : la reprise corrige une clôture prématurée.
|
||||||
|
- La création, la clôture et la reprise sont transactionnelles. Les actions
|
||||||
|
simultanées sur le même poste ne créent pas de doublons ; une journée clôturée
|
||||||
|
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.
|
||||||
|
- 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
|
||||||
|
manuelles : aucun temps de travail n'est transformé automatiquement en pause.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
Les tests couvrent notamment les clôtures oubliées, les reprises, les appels
|
||||||
|
concurrents, les retours d'horloge, les échecs transactionnels et la conservation.
|
||||||
|
|
||||||
In dev the console exposes `timelog.seed()` to generate 20 days of realistic history
|
In dev the console exposes `timelog.seed()` to generate 20 days of realistic history
|
||||||
(useful for exercising the suggestion engine) and `timelog.wipe()` to clear it.
|
(useful for exercising the suggestion engine) and `timelog.wipe()` to clear it.
|
||||||
|
|
||||||
@@ -132,6 +155,6 @@ src/lib/
|
|||||||
day.svelte.js day lifecycle state machine
|
day.svelte.js day lifecycle state machine
|
||||||
```
|
```
|
||||||
|
|
||||||
The four pure modules hold the logic worth testing and carry the unit tests. `db.js`
|
Pure helpers and database/lifecycle operations have regression tests. `db.js`
|
||||||
concentrates every write that touches a boundary into `closeAndStart` and
|
keeps day and entry changes in shared transactions, including creation, closure,
|
||||||
`setEntryStart`, so the timeline cannot drift out of order from anywhere else.
|
reopening and boundary corrections.
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const isDate = (v) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** Only recognised settings enter the database; missing values use app defaults. */
|
/** Only recognised settings enter the database; missing values use app defaults. */
|
||||||
function parseSettings(raw) {
|
export function parseSettings(raw) {
|
||||||
if (raw == null) return null;
|
if (raw == null) return null;
|
||||||
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);
|
||||||
@@ -45,7 +45,7 @@ function parseSettings(raw) {
|
|||||||
const out = {};
|
const out = {};
|
||||||
for (const [key, validate] of Object.entries(rules)) {
|
for (const [key, validate] of Object.entries(rules)) {
|
||||||
if (!Object.hasOwn(raw, key)) continue;
|
if (!Object.hasOwn(raw, key)) continue;
|
||||||
if (!validate(raw[key])) throw new BackupError(`Réglage invalide 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];
|
out[key] = key === 'workdays' ? [...new Set(raw[key])].sort() : raw[key];
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
|
|||||||
@@ -9,16 +9,16 @@
|
|||||||
import {
|
import {
|
||||||
cleanupOldDays,
|
cleanupOldDays,
|
||||||
closeAndStart,
|
closeAndStart,
|
||||||
|
closeDay,
|
||||||
|
createDay,
|
||||||
deleteEntry,
|
deleteEntry,
|
||||||
getActiveDay,
|
getActiveDay,
|
||||||
getDay,
|
getDay,
|
||||||
getEntries,
|
|
||||||
getHistory,
|
getHistory,
|
||||||
getLastClosedDay,
|
|
||||||
importBackup,
|
importBackup,
|
||||||
patchDay,
|
patchActiveDay,
|
||||||
patchEntry,
|
patchEntry,
|
||||||
putDay,
|
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';
|
||||||
@@ -33,6 +33,9 @@ export const dayState = $state({
|
|||||||
/** Last closed day + its entries — what the resume screen renders. */
|
/** Last closed day + its entries — what the resume screen renders. */
|
||||||
lastClosed: null,
|
lastClosed: null,
|
||||||
lastClosedEntries: [],
|
lastClosedEntries: [],
|
||||||
|
closedDays: [],
|
||||||
|
historyEntries: [],
|
||||||
|
todayRecord: 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. */
|
||||||
@@ -56,6 +59,7 @@ const pendingReconciliations = new Set();
|
|||||||
*/
|
*/
|
||||||
export function reconcile(now = Date.now()) {
|
export function reconcile(now = Date.now()) {
|
||||||
if (dayState.importing) return Promise.resolve();
|
if (dayState.importing) return Promise.resolve();
|
||||||
|
if (pendingReconciliations.size) return [...pendingReconciliations][0];
|
||||||
const pending = reconcileDay(now);
|
const pending = reconcileDay(now);
|
||||||
pendingReconciliations.add(pending);
|
pendingReconciliations.add(pending);
|
||||||
pending.then(
|
pending.then(
|
||||||
@@ -68,25 +72,24 @@ export function reconcile(now = Date.now()) {
|
|||||||
async function reconcileDay(now) {
|
async function reconcileDay(now) {
|
||||||
dayState.today = localDateKey(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
|
// Applying retention here is what makes the setting mean anything; leaving it to
|
||||||
// the manual button in settings meant it effectively never ran.
|
// the manual button in settings meant it effectively never ran.
|
||||||
if (cleanedUpFor !== dayState.today) {
|
if (cleanedUpFor !== dayState.today) {
|
||||||
cleanedUpFor = dayState.today;
|
cleanedUpFor = dayState.today;
|
||||||
try {
|
try {
|
||||||
await cleanupOldDays(settings.retentionDays);
|
await cleanupOldDays(settings.retentionDays, now);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[timelog] retention cleanup failed', 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
|
// Auto-start. Only when no record exists for today at all: a day the worker has
|
||||||
// already ended must not spring back to life.
|
// already ended must not spring back to life.
|
||||||
if (!active) {
|
if (!active) {
|
||||||
@@ -103,21 +106,14 @@ async function reconcileDay(now) {
|
|||||||
await refresh(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
|
* 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) {
|
||||||
await putDay({
|
const { entries, days } = await getHistory();
|
||||||
|
const model = buildModel(entries, days);
|
||||||
|
await createDay({
|
||||||
date: dateKey,
|
date: dateKey,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
plannedStartTs: atLocalTime(dateKey, settings.dayStart),
|
plannedStartTs: atLocalTime(dateKey, settings.dayStart),
|
||||||
@@ -127,11 +123,7 @@ async function openDay(dateKey, startTs) {
|
|||||||
lunchType: settings.lunchType,
|
lunchType: settings.lunchType,
|
||||||
restaurant: null,
|
restaurant: null,
|
||||||
closePromptedTs: null,
|
closePromptedTs: null,
|
||||||
});
|
}, {
|
||||||
|
|
||||||
const { entries, days } = await getHistory();
|
|
||||||
const model = buildModel(entries, days);
|
|
||||||
await closeAndStart(dateKey, startTs, {
|
|
||||||
kind: 'travel',
|
kind: 'travel',
|
||||||
task: TRANSPORT_TASK,
|
task: TRANSPORT_TASK,
|
||||||
client: model.lastClient,
|
client: model.lastClient,
|
||||||
@@ -139,16 +131,23 @@ async function openDay(dateKey, startTs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Reload everything from the database and rebuild the model. */
|
/** Reload everything from the database and rebuild the model. */
|
||||||
|
let refreshVersion = 0;
|
||||||
export async function refresh(now = Date.now()) {
|
export async function refresh(now = Date.now()) {
|
||||||
const active = await getActiveDay();
|
const version = ++refreshVersion;
|
||||||
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 { entries, days } = await getHistory();
|
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.model = buildModel(entries, days);
|
||||||
|
|
||||||
dayState.needsClosePrompt = Boolean(
|
dayState.needsClosePrompt = Boolean(
|
||||||
@@ -161,7 +160,8 @@ 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()) {
|
||||||
await openDay(dayState.today, now);
|
if (dayState.importing) return;
|
||||||
|
await openDay(localDateKey(now), now);
|
||||||
await refresh(now);
|
await refresh(now);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,27 +173,27 @@ export async function startDayNow(now = Date.now()) {
|
|||||||
* @param {number} [now]
|
* @param {number} [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) return;
|
if (!dayState.day || dayState.importing) return;
|
||||||
await closeAndStart(dayState.day.date, now, { kind, task, client: client ?? null });
|
await closeAndStart(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) return;
|
if (!dayState.day || dayState.importing || running()?.kind === 'lunch') return;
|
||||||
await closeAndStart(dayState.day.date, now, {
|
await closeAndStart(dayState.day.date, now, {
|
||||||
kind: 'lunch',
|
kind: 'lunch',
|
||||||
task: 'Pause déjeuner',
|
task: 'Pause déjeuner',
|
||||||
client: null,
|
client: null,
|
||||||
});
|
}, running()?.id ?? null);
|
||||||
await refresh(now);
|
await refresh(now);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setLunch({ lunchType, restaurant }) {
|
export async function setLunch({ lunchType, restaurant }) {
|
||||||
if (!dayState.day) return;
|
if (!dayState.day || dayState.importing) return;
|
||||||
await patchDay(dayState.day.date, {
|
await patchActiveDay(dayState.day.date, () => ({
|
||||||
lunchType,
|
lunchType,
|
||||||
restaurant: lunchType === 'restaurant' ? (restaurant ?? null) : null,
|
restaurant: lunchType === 'restaurant' ? (restaurant ?? null) : null,
|
||||||
});
|
}));
|
||||||
await refresh();
|
await refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,46 +247,27 @@ export async function applyImport(payload, mode) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function endDay(now = Date.now()) {
|
export async function endDay(now = Date.now()) {
|
||||||
if (!dayState.day) return;
|
if (!dayState.day || dayState.importing) return;
|
||||||
const date = dayState.day.date;
|
const date = dayState.day.date;
|
||||||
await closeAndStart(date, now, null);
|
await closeDay(date, now);
|
||||||
await patchDay(date, { status: 'closed', endedTs: now });
|
|
||||||
await refresh(now);
|
await refresh(now);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** "Toujours en cours" — push the end out an hour so the prompt returns later. */
|
/** "Toujours en cours" — push the end out an hour so the prompt returns later. */
|
||||||
export async function keepWorking(now = Date.now()) {
|
export async function keepWorking(now = Date.now()) {
|
||||||
if (!dayState.day) return;
|
if (!dayState.day || dayState.importing) return;
|
||||||
await patchDay(dayState.day.date, {
|
const expectedEnd = dayState.day.plannedEndTs;
|
||||||
plannedEndTs: Math.max(dayState.day.plannedEndTs, now) + HOUR,
|
await patchActiveDay(dayState.day.date, (day) => day.plannedEndTs !== expectedEnd ? null : ({
|
||||||
|
plannedEndTs: Math.max(day.plannedEndTs, now) + HOUR,
|
||||||
closePromptedTs: now,
|
closePromptedTs: now,
|
||||||
});
|
}));
|
||||||
await refresh(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()) {
|
export async function reopenToday(now = Date.now()) {
|
||||||
const day = await getDay(dayState.today);
|
if (dayState.importing) return;
|
||||||
if (!day || day.status !== 'closed') return;
|
await reopenDay(localDateKey(now), now);
|
||||||
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,
|
|
||||||
});
|
|
||||||
await refresh(now);
|
await refresh(now);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
227
src/lib/day.test.js
Normal file
227
src/lib/day.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import Dexie from 'dexie';
|
import Dexie from 'dexie';
|
||||||
import { BackupError, buildBackup, EXPORT_FORMAT, EXPORT_VERSION, parseBackup } from './backup.js';
|
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
|
* 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);
|
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. */
|
/** The one day still running, if any — including a stale one from a past date. */
|
||||||
export function getActiveDay() {
|
export function getActiveDay() {
|
||||||
return db.days.where('status').equals('active').first();
|
return db.days.where('status').equals('active').first();
|
||||||
@@ -116,12 +169,16 @@ export function getEntries(dateKey) {
|
|||||||
* @param {number} ts
|
* @param {number} ts
|
||||||
* @param {Pick<EntryRecord, 'kind'|'task'|'client'>|null} next
|
* @param {Pick<EntryRecord, 'kind'|'task'|'client'>|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
|
||||||
* @returns {Promise<number|null>} id of the new entry
|
* @returns {Promise<number|null>} id of the new entry
|
||||||
*/
|
*/
|
||||||
export function closeAndStart(dateKey, ts, next) {
|
export function closeAndStart(dateKey, ts, next, expectedId) {
|
||||||
return db.transaction('rw', db.entries, async () => {
|
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 list = await db.entries.where('date').equals(dateKey).sortBy('seq');
|
||||||
const running = list.find((e) => e.endTs == null);
|
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;
|
let boundary = ts;
|
||||||
if (running) {
|
if (running) {
|
||||||
@@ -211,33 +268,30 @@ export function deleteEntry(id) {
|
|||||||
|
|
||||||
/** Everything the prediction model is built from. */
|
/** Everything the prediction model is built from. */
|
||||||
export async function getHistory() {
|
export async function getHistory() {
|
||||||
const [entries, days] = await Promise.all([
|
return db.transaction('r', db.entries, db.days, async () => {
|
||||||
db.entries.toArray(),
|
const [entries, days] = await Promise.all([db.entries.toArray(), db.days.toArray()]);
|
||||||
db.days.toArray(),
|
return { entries, days };
|
||||||
]);
|
});
|
||||||
return { entries, days };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Drop days older than the retention window. The most recent closed day is always
|
* 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.
|
* 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;
|
if (!retentionDays || retentionDays <= 0) return 0;
|
||||||
const cutoff = localDateKey(Date.now() - retentionDays * DAY);
|
const cutoff = localDateKey(now - retentionDays * DAY);
|
||||||
const keep = await getLastClosedDay();
|
return db.transaction('rw', db.days, db.entries, async () => {
|
||||||
|
const keep = await getLastClosedDay();
|
||||||
const stale = (await db.days.toArray()).filter(
|
const stale = (await db.days.toArray()).filter(
|
||||||
(d) => d.date < cutoff && d.date !== keep?.date,
|
(d) => d.status === 'closed' && d.date < cutoff && d.date !== keep?.date,
|
||||||
);
|
);
|
||||||
if (!stale.length) return 0;
|
const keys = stale.map((d) => d.date);
|
||||||
|
if (!keys.length) return 0;
|
||||||
const keys = stale.map((d) => d.date);
|
|
||||||
await db.transaction('rw', db.days, db.entries, async () => {
|
|
||||||
await db.days.bulkDelete(keys);
|
await db.days.bulkDelete(keys);
|
||||||
await db.entries.where('date').anyOf(keys).delete();
|
await db.entries.where('date').anyOf(keys).delete();
|
||||||
|
return keys.length;
|
||||||
});
|
});
|
||||||
return keys.length;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
13
src/lib/lunch.js
Normal file
13
src/lib/lunch.js
Normal 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
27
src/lib/lunch.test.js
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { readSettings, writeSettings } from './db.js';
|
import { readSettings, writeSettings } from './db.js';
|
||||||
|
import { parseSettings } from './backup.js';
|
||||||
|
|
||||||
export const DEFAULT_SETTINGS = {
|
export const DEFAULT_SETTINGS = {
|
||||||
/** JS weekday numbers, 0 = Sunday. Monday–Friday by default. */
|
/** JS weekday numbers, 0 = Sunday. Monday–Friday by default. */
|
||||||
@@ -50,6 +51,8 @@ export async function loadSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function updateSettings(patch) {
|
export async function updateSettings(patch) {
|
||||||
|
try { parseSettings({ ...$state.snapshot(settings), ...patch }); }
|
||||||
|
catch (error) { settingsError.message = error.message; return; }
|
||||||
Object.assign(settings, patch);
|
Object.assign(settings, patch);
|
||||||
applyTheme();
|
applyTheme();
|
||||||
// `settings` is a $state proxy and its nested values (workdays) are proxies
|
// `settings` is a $state proxy and its nested values (workdays) are proxies
|
||||||
|
|||||||
@@ -3,14 +3,18 @@
|
|||||||
import { buildResume, resumeToText } from '../lib/resume.js';
|
import { buildResume, resumeToText } from '../lib/resume.js';
|
||||||
import { go } from '../lib/router.svelte.js';
|
import { go } from '../lib/router.svelte.js';
|
||||||
import { settings } from '../lib/settings.svelte.js';
|
import { settings } from '../lib/settings.svelte.js';
|
||||||
import { fmtDuration, weekday } from '../lib/time.js';
|
import { fmtDayLabel, fmtDuration, weekday } from '../lib/time.js';
|
||||||
|
|
||||||
let copied = $state(false);
|
let copied = $state(false);
|
||||||
|
let selectedDate = $state('');
|
||||||
|
let reopening = $state(false);
|
||||||
|
let error = $state('');
|
||||||
|
const selectedDay = $derived(dayState.closedDays.find((d) => d.date === selectedDate) ?? dayState.lastClosed);
|
||||||
|
|
||||||
const resume = $derived(
|
const resume = $derived(
|
||||||
buildResume(
|
buildResume(
|
||||||
dayState.lastClosed,
|
selectedDay,
|
||||||
dayState.lastClosedEntries,
|
dayState.historyEntries.filter((e) => e.date === selectedDay?.date),
|
||||||
settings.roundingMinutes,
|
settings.roundingMinutes,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -22,7 +26,16 @@
|
|||||||
settings.workdays.includes(weekday(dayState.today)) ? settings.dayStart : null,
|
settings.workdays.includes(weekday(dayState.today)) ? settings.dayStart : null,
|
||||||
);
|
);
|
||||||
|
|
||||||
const closedToday = $derived(dayState.lastClosed?.date === dayState.today);
|
const closedToday = $derived(selectedDay?.date === dayState.today && !dayState.day);
|
||||||
|
|
||||||
|
async function reopen() {
|
||||||
|
if (reopening) return;
|
||||||
|
reopening = true;
|
||||||
|
error = '';
|
||||||
|
try { await reopenToday(); go('today'); }
|
||||||
|
catch (cause) { error = `Reprise impossible : ${cause.message}`; }
|
||||||
|
finally { reopening = false; }
|
||||||
|
}
|
||||||
|
|
||||||
async function copy() {
|
async function copy() {
|
||||||
try {
|
try {
|
||||||
@@ -37,6 +50,17 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="screen">
|
<div class="screen">
|
||||||
|
{#if error}<p role="alert">{error}</p>{/if}
|
||||||
|
{#if dayState.closedDays.length > 1}
|
||||||
|
<label class="stack">
|
||||||
|
<span>Journée à consulter</span>
|
||||||
|
<select value={selectedDay?.date} onchange={(event) => { selectedDate = event.currentTarget.value; copied = false; }}>
|
||||||
|
{#each dayState.closedDays as day (day.date)}
|
||||||
|
<option value={day.date}>{fmtDayLabel(day.date)} {day.date.slice(0, 4)}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
{#if !resume}
|
{#if !resume}
|
||||||
<div class="card center stack">
|
<div class="card center stack">
|
||||||
<h2>Aucune journée enregistrée</h2>
|
<h2>Aucune journée enregistrée</h2>
|
||||||
@@ -101,16 +125,15 @@
|
|||||||
{#if closedToday}
|
{#if closedToday}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={async () => {
|
disabled={reopening}
|
||||||
await reopenToday();
|
onclick={reopen}>Reprendre la journée</button
|
||||||
go('today');
|
|
||||||
}}>Reprendre la journée</button
|
|
||||||
>
|
>
|
||||||
{:else if startsAt}
|
<p class="dim small">Annule la clôture et continue la dernière tâche depuis l’arrêt.</p>
|
||||||
|
{:else if !dayState.day && !dayState.todayRecord && startsAt}
|
||||||
<p class="dim center small">
|
<p class="dim center small">
|
||||||
La journée démarrera automatiquement à {startsAt}.
|
La journée démarrera automatiquement à {startsAt}.
|
||||||
</p>
|
</p>
|
||||||
{:else}
|
{:else if !dayState.day && !dayState.todayRecord}
|
||||||
<p class="dim center small">Aujourd'hui n'est pas un jour travaillé.</p>
|
<p class="dim center small">Aujourd'hui n'est pas un jour travaillé.</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -105,6 +105,7 @@
|
|||||||
>
|
>
|
||||||
</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>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card stack">
|
<section class="card stack">
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
predictForCurrent,
|
predictForCurrent,
|
||||||
predictNext,
|
predictNext,
|
||||||
removeEntry,
|
removeEntry,
|
||||||
|
reopenToday,
|
||||||
running,
|
running,
|
||||||
setLunch,
|
setLunch,
|
||||||
startDayNow,
|
startDayNow,
|
||||||
@@ -21,6 +22,7 @@
|
|||||||
} from '../lib/day.svelte.js';
|
} from '../lib/day.svelte.js';
|
||||||
import { suggestClients, suggestRestaurants, suggestTasks } from '../lib/predict.js';
|
import { suggestClients, suggestRestaurants, suggestTasks } from '../lib/predict.js';
|
||||||
import { go } from '../lib/router.svelte.js';
|
import { go } from '../lib/router.svelte.js';
|
||||||
|
import { lunchGuidance } from '../lib/lunch.js';
|
||||||
import { settings } from '../lib/settings.svelte.js';
|
import { settings } from '../lib/settings.svelte.js';
|
||||||
import { fmtDuration, fmtTime } from '../lib/time.js';
|
import { fmtDuration, fmtTime } from '../lib/time.js';
|
||||||
|
|
||||||
@@ -28,6 +30,17 @@
|
|||||||
let confirmingEnd = $state(false);
|
let confirmingEnd = $state(false);
|
||||||
/** id of the timeline entry being corrected, if any */
|
/** id of the timeline entry being corrected, if any */
|
||||||
let editingId = $state(null);
|
let editingId = $state(null);
|
||||||
|
let busy = $state(false);
|
||||||
|
let actionError = $state('');
|
||||||
|
|
||||||
|
async function act(action) {
|
||||||
|
if (busy) return;
|
||||||
|
busy = true;
|
||||||
|
actionError = '';
|
||||||
|
try { await action(); }
|
||||||
|
catch (error) { actionError = `Action impossible : ${error.message}`; }
|
||||||
|
finally { busy = false; }
|
||||||
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const id = setInterval(() => (now = Date.now()), 30_000);
|
const id = setInterval(() => (now = Date.now()), 30_000);
|
||||||
@@ -35,6 +48,8 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const current = $derived(running());
|
const current = $derived(running());
|
||||||
|
const closedToday = $derived(dayState.todayRecord?.status === 'closed');
|
||||||
|
const lunchHint = $derived(lunchGuidance(dayState.day, dayState.entries, settings, now));
|
||||||
|
|
||||||
// Snapshotted against writes, not against the clock: the proposed next task must
|
// Snapshotted against writes, not against the clock: the proposed next task must
|
||||||
// not shift under the worker's thumb while they reach for the button.
|
// not shift under the worker's thumb while they reach for the button.
|
||||||
@@ -82,7 +97,7 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
function goNext() {
|
function goNext() {
|
||||||
advance({ kind: 'work', task: next.task, client: next.client });
|
return advance({ kind: 'work', task: next.task, client: next.client });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -97,7 +112,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function chooseLunchType(lunchType) {
|
function chooseLunchType(lunchType) {
|
||||||
setLunch({
|
return setLunch({
|
||||||
lunchType,
|
lunchType,
|
||||||
restaurant:
|
restaurant:
|
||||||
lunchType === 'restaurant'
|
lunchType === 'restaurant'
|
||||||
@@ -107,15 +122,21 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#if actionError}<p class="action-error card" role="alert">{actionError}</p>{/if}
|
||||||
|
|
||||||
{#if !dayState.day}
|
{#if !dayState.day}
|
||||||
<div class="screen">
|
<div class="screen">
|
||||||
<div class="card center stack">
|
<div class="card center stack">
|
||||||
<h2>Journée non commencée</h2>
|
<h2>{closedToday ? 'Journée terminée' : 'Journée non commencée'}</h2>
|
||||||
<p class="dim">
|
<p class="dim">
|
||||||
Aujourd'hui n'est pas un jour travaillé, ou l'heure de début n'est pas atteinte.
|
{#if closedToday}
|
||||||
|
Reprendre annule la clôture et continue la dernière tâche, y compris le temps écoulé depuis l’arrêt.
|
||||||
|
{:else}
|
||||||
|
Aujourd'hui n'est pas un jour travaillé, ou l'heure de début n'est pas atteinte.
|
||||||
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
<button type="button" class="btn-primary" onclick={() => startDayNow()}>
|
<button type="button" class="btn-primary" disabled={busy} onclick={() => act(closedToday ? reopenToday : startDayNow)}>
|
||||||
Démarrer la journée
|
{closedToday ? 'Reprendre la journée' : 'Démarrer la journée'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -137,7 +158,7 @@
|
|||||||
Number.NEGATIVE_INFINITY}
|
Number.NEGATIVE_INFINITY}
|
||||||
max={now}
|
max={now}
|
||||||
rounding={settings.roundingMinutes}
|
rounding={settings.roundingMinutes}
|
||||||
onchange={(ts) => editEntryStart(current.id, ts)}
|
onchange={(ts) => act(() => editEntryStart(current.id, ts))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if current.kind === 'lunch'}
|
{#if current.kind === 'lunch'}
|
||||||
@@ -145,12 +166,12 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class:sel={dayState.day.lunchType !== 'restaurant'}
|
class:sel={dayState.day.lunchType !== 'restaurant'}
|
||||||
onclick={() => chooseLunchType('panier')}>Panier</button
|
disabled={busy} onclick={() => act(() => chooseLunchType('panier'))}>Panier</button
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class:sel={dayState.day.lunchType === 'restaurant'}
|
class:sel={dayState.day.lunchType === 'restaurant'}
|
||||||
onclick={() => chooseLunchType('restaurant')}>Restaurant</button
|
disabled={busy} onclick={() => act(() => chooseLunchType('restaurant'))}>Restaurant</button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
{#if dayState.day.lunchType === 'restaurant'}
|
{#if dayState.day.lunchType === 'restaurant'}
|
||||||
@@ -160,7 +181,7 @@
|
|||||||
suggestions={restaurants}
|
suggestions={restaurants}
|
||||||
placeholder="Nom du restaurant"
|
placeholder="Nom du restaurant"
|
||||||
emptyText="Aucun restaurant enregistré"
|
emptyText="Aucun restaurant enregistré"
|
||||||
onchange={(v) => setLunch({ lunchType: 'restaurant', restaurant: v })}
|
onchange={(v) => act(() => setLunch({ lunchType: 'restaurant', restaurant: v }))}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
@@ -169,7 +190,7 @@
|
|||||||
value={current.task}
|
value={current.task}
|
||||||
suggestions={forCurrent.tasks}
|
suggestions={forCurrent.tasks}
|
||||||
placeholder="Nom de la tâche"
|
placeholder="Nom de la tâche"
|
||||||
onchange={(v) => editEntry(current.id, { task: v })}
|
onchange={(v) => act(() => editEntry(current.id, { task: v }))}
|
||||||
/>
|
/>
|
||||||
<SmartField
|
<SmartField
|
||||||
label="Client"
|
label="Client"
|
||||||
@@ -177,17 +198,28 @@
|
|||||||
suggestions={forCurrent.clients}
|
suggestions={forCurrent.clients}
|
||||||
placeholder="Nom du client"
|
placeholder="Nom du client"
|
||||||
emptyText="Aucun client enregistré"
|
emptyText="Aucun client enregistré"
|
||||||
onchange={(v) => editEntry(current.id, { client: v })}
|
onchange={(v) => act(() => editEntry(current.id, { client: v }))}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Primary actions --------------------------------------------------- -->
|
<!-- Primary actions --------------------------------------------------- -->
|
||||||
<button type="button" class="btn-primary" onclick={goNext}>{actionLabel}</button>
|
<button type="button" class="btn-primary" disabled={busy} onclick={() => act(goNext)}>{actionLabel}</button>
|
||||||
|
|
||||||
|
{#if lunchHint}
|
||||||
|
<p class="dim" role="status">
|
||||||
|
{#if lunchHint.resumeTs != null}
|
||||||
|
Reprise indicative à {fmtTime(lunchHint.resumeTs)}.
|
||||||
|
{#if lunchHint.due}La durée habituelle de la pause est atteinte.{/if}
|
||||||
|
{:else}
|
||||||
|
L’heure habituelle du déjeuner ({settings.lunchStart}) est atteinte.
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if current?.kind !== 'lunch'}
|
{#if current?.kind !== 'lunch'}
|
||||||
<button type="button" onclick={() => startLunch()}>Pause déjeuner</button>
|
<button type="button" disabled={busy} onclick={() => act(startLunch)}>Pause déjeuner</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Day so far -------------------------------------------------------- -->
|
<!-- Day so far -------------------------------------------------------- -->
|
||||||
@@ -209,7 +241,7 @@
|
|||||||
{#if confirmingEnd}
|
{#if confirmingEnd}
|
||||||
<div class="btn-row">
|
<div class="btn-row">
|
||||||
<button type="button" onclick={() => (confirmingEnd = false)}>Annuler</button>
|
<button type="button" onclick={() => (confirmingEnd = false)}>Annuler</button>
|
||||||
<button type="button" class="btn-danger" onclick={finishDay}>
|
<button type="button" class="btn-danger" disabled={busy} onclick={() => act(finishDay)}>
|
||||||
Confirmer
|
Confirmer
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -228,30 +260,31 @@
|
|||||||
clients={editingSuggestions.clients}
|
clients={editingSuggestions.clients}
|
||||||
rounding={settings.roundingMinutes}
|
rounding={settings.roundingMinutes}
|
||||||
{now}
|
{now}
|
||||||
onstart={editEntryStart}
|
onstart={(id, ts) => act(() => editEntryStart(id, ts))}
|
||||||
onedit={editEntry}
|
onedit={(id, patch) => act(() => editEntry(id, patch))}
|
||||||
ondelete={removeEntry}
|
ondelete={(id) => act(() => removeEntry(id))}
|
||||||
onclose={() => (editingId = null)}
|
onclose={() => (editingId = null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- End-of-day grace prompt --------------------------------------------- -->
|
<!-- End-of-day grace prompt --------------------------------------------- -->
|
||||||
{#if dayState.needsClosePrompt}
|
{#if dayState.needsClosePrompt}
|
||||||
<div class="prompt" role="dialog" aria-modal="true">
|
<div class="prompt" role="dialog" aria-modal="true" aria-labelledby="close-day-title">
|
||||||
<div class="card stack">
|
<div class="card stack">
|
||||||
<h2>Fin de journée</h2>
|
<h2 id="close-day-title">Fin de journée</h2>
|
||||||
<p>
|
<p>
|
||||||
L'heure de fin ({settings.dayEnd}) est passée. Terminer la journée maintenant ?
|
L'heure de fin ({fmtTime(dayState.day.plannedEndTs)}) est passée. Terminer la journée maintenant ?
|
||||||
</p>
|
</p>
|
||||||
<button type="button" class="btn-primary" onclick={finishDay}>
|
<button type="button" class="btn-primary" disabled={busy} onclick={() => act(finishDay)}>
|
||||||
Terminer la journée
|
Terminer la journée
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onclick={() => keepWorking()}>Toujours en cours</button>
|
<button type="button" disabled={busy} onclick={() => act(keepWorking)}>Toujours en cours</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.action-error { margin: var(--pad); color: var(--danger); }
|
||||||
.big {
|
.big {
|
||||||
font-size: 1.6rem;
|
font-size: 1.6rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
|
|||||||
Reference in New Issue
Block a user