Multiple minor changes and improvements

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

View File

@@ -6,13 +6,22 @@
import Resume from './routes/Resume.svelte';
import Settings from './routes/Settings.svelte';
import Today from './routes/Today.svelte';
import DayDetails from './routes/DayDetails.svelte';
import Automations from './routes/Automations.svelte';
import NonWorkingSettings from './routes/NonWorkingSettings.svelte';
import Stats from './routes/Stats.svelte';
let ready = $state(false);
let bootError = $state('');
let retry = $state(0);
async function tick() { try { await reconcile(); } catch (error) { bootError = `Actualisation impossible : ${error.message}`; } }
$effect(() => {
retry;
bootError = '';
let alive = true;
(async () => {
try {
await loadSettings();
if (import.meta.env.DEV) {
const { installDevSeed } = await import('./lib/seed.js');
@@ -20,14 +29,15 @@
}
await reconcile();
if (alive) ready = true;
} catch (error) { if (alive) bootError = `Accès aux données impossible : ${error.message}`; }
})();
return () => (alive = false);
});
// Time drives this app, so the clock is polled rather than waited on.
$effect(() => {
const id = setInterval(() => reconcile(), 30_000);
const onVisible = () => document.visibilityState === 'visible' && reconcile();
const id = setInterval(tick, 30_000);
const onVisible = () => document.visibilityState === 'visible' && tick();
document.addEventListener('visibilitychange', onVisible);
return () => {
clearInterval(id);
@@ -40,7 +50,7 @@
</script>
{#if !ready}
<div class="boot"><p>Chargement…</p></div>
<div class="boot">{#if bootError}<div class="stack"><p role="alert">{bootError}</p><button onclick={() => (retry += 1)}>Réessayer</button></div>{:else}<p>Chargement…</p>{/if}</div>
{:else}
{#if needsRefresh.value && !dayState.day}
<!-- Only offered while no day is running, so an update can never interrupt
@@ -51,7 +61,14 @@
</div>
{/if}
{#if view === 'settings'}
{#if bootError}<p role="alert">{bootError}</p><button onclick={tick}>Réessayer</button>{/if}
{#if view === 'details'}
<DayDetails />
{:else if view === 'automations'}
<Automations />
{:else if view === 'non-working'}
<NonWorkingSettings />
{:else if ['settings', 'automations', 'non-working'].includes(view)}
<Settings />
{:else if view === 'today'}
<Today />
@@ -64,7 +81,7 @@
<nav class:with-stats={view === 'stats'}>
<button
type="button"
class:sel={view === 'resume'}
class:sel={['resume', 'details'].includes(view)}
disabled={dayState.importing}
aria-current={view === 'resume'}
onclick={() => go('resume')}>Récap</button
@@ -78,9 +95,9 @@
>
<button
type="button"
class:sel={view === 'settings'}
class:sel={['settings', 'automations', 'non-working'].includes(view)}
disabled={dayState.importing}
aria-current={view === 'settings'}
aria-current={['settings', 'automations', 'non-working'].includes(view)}
onclick={() => go('settings')}>Réglages</button
>
{#if view === 'stats'}

View File

@@ -1,4 +1,5 @@
<script>
import Sheet from './Sheet.svelte';
/**
* Export the whole dataset to one file, and import it back.
*
@@ -12,7 +13,7 @@
parseBackup,
} from '../lib/backup.js';
import { applyImport, dayState } from '../lib/day.svelte.js';
import { exportBackup } from '../lib/db.js';
import { db, exportBackup } from '../lib/db.js';
import { fmtDayLabel, fmtTime, localDateKey } from '../lib/time.js';
/** @type {HTMLInputElement} */
@@ -23,6 +24,7 @@
let result = $state(null);
let confirmingReplace = $state(false);
let busy = $state(false);
let lastExport = $state(localStorage.getItem('pardis-last-export') ?? '');
let filename = $state('');
function reset() {
@@ -54,6 +56,7 @@
// Give browsers (including standalone mobile apps) time to start reading.
setTimeout(() => URL.revokeObjectURL(url), 60_000);
}
lastExport = payload.exportedAt; localStorage.setItem('pardis-last-export', lastExport);
result = `Export préparé : ${payload.days.length} journée${payload.days.length > 1 ? 's' : ''}, ${payload.entries.length} poste${payload.entries.length > 1 ? 's' : ''}.`;
} catch (cause) {
error = `Export impossible : ${cause.message}`;
@@ -62,6 +65,14 @@
}
}
async function recoverPurge() {
try {
const record = await db.settings.get('before-purge');
if (!record) throw new Error('Aucune sauvegarde avant purge disponible.');
staged = parseBackup(JSON.stringify(record.value)); filename = 'Sauvegarde avant purge';
} catch (cause) { error = cause.message; }
}
async function onPick(event) {
if (busy) return;
// Capture the File before reset() clears the input and its FileList.
@@ -109,6 +120,9 @@
<button type="button" disabled={busy} onclick={exportAll}>Exporter les données</button>
<p class="dim small">Dernier export préparé : {lastExport ? new Date(lastExport).toLocaleString('fr-FR') : 'aucun'}. Vérifiez que le fichier est bien enregistré et testez sa restauration. Pensez à exporter régulièrement.</p>
<button disabled={busy} onclick={recoverPurge}>Récupérer la sauvegarde avant purge</button>
<!-- The real input is hidden: a styled button is a far bigger target. -->
<input
bind:this={picker}
@@ -148,13 +162,22 @@
{#if staged.repaired}
<p class="dim small">{staged.repaired} poste(s) ajusté(s) pour rendre les données exploitables.</p>
{/if}
{#if staged.adjustments?.length}
<details><summary>Examiner les ajustements et les éléments ignorés</summary>
<ul>{#each staged.adjustments as item}<li>{item.date} · {item.task}{item.seq != null ? ` (poste ${item.seq + 1})` : ''} : {item.action}. {item.fields.join(' ; ')}</li>{/each}</ul>
</details>
{/if}
{#if staged.days.some(d => dayState.closedDays.some(local => local.date === d.date) || dayState.day?.date === d.date)}
<p>Fusion : journées locales conservées et ignorées du fichier : {staged.days.filter(d => dayState.closedDays.some(local => local.date === d.date) || dayState.day?.date === d.date).map(d => d.date).join(', ')}.</p>
{/if}
{#if staged.closedActive}
<p class="dim small">
{staged.closedActive} journée(s) en cours seront enregistrées comme terminées à l’heure de l’export.
</p>
{/if}
{#if confirmingReplace}
<Sheet open={confirmingReplace} title="Remplacer les données" onclose={() => { if (!busy) confirmingReplace = false; }}>
<p class="notice error">
Toutes les journées et tous les réglages de cet appareil seront remplacés par ceux du fichier.
{#if dayState.day}La journée en cours sera également remplacée.{/if}
@@ -165,7 +188,8 @@
Confirmer
</button>
</div>
{:else}
</Sheet>
{#if !confirmingReplace}
<button type="button" class="btn-primary" disabled={busy} onclick={() => run('merge')}>
Fusionner
</button>

View File

@@ -9,7 +9,7 @@
import Sheet from './Sheet.svelte';
import Timeline from './Timeline.svelte';
let { day, entries = [], now = Date.now(), onchange = () => {} } = $props();
let { day, entries = [], now = Date.now(), onchange = () => {}, editable = true } = $props();
let mode = $state('');
let original = $state(null);
let draft = $state({ kind: 'work', task: '', client: '', startTs: 0, endTs: 0, lunchType: 'panier', restaurant: '' });
@@ -72,9 +72,9 @@
<div class="card stack">
<h3>Postes de la journée</h3>
<p class="dim hint">Touchez un poste pour corriger ses horaires, sa tâche ou son client.</p>
<Timeline {entries} rounding={settings.roundingMinutes} {now} onselect={(entry) => select(entry, 'edit')} />
<button type="button" disabled={!ordered.length || dayState.importing} onclick={() => select(ordered[0], 'insert')}>Ajouter un poste oublié</button>
{#if editable}<p class="dim hint">Touchez un poste pour corriger ses horaires, sa tâche ou son client.</p>{/if}
<Timeline {entries} rounding={settings.roundingMinutes} {now} onselect={editable ? (entry) => select(entry, 'edit') : null} />
{#if editable}<button type="button" disabled={!ordered.length || dayState.importing} onclick={() => select(ordered[0], 'insert')}>Ajouter un poste oublié</button>{/if}
</div>
<Sheet open={Boolean(mode)} title={mode === 'insert' ? 'Ajouter un poste' : 'Modifier le poste'} onclose={close}>
@@ -93,13 +93,11 @@
{:else}
<p class="dim hint">Changer une limite ajuste aussi le poste voisin. Le premier début et la dernière fin définissent la durée de la journée.</p>
{/if}
<label class="meal-toggle">
<input type="checkbox" checked={draft.kind === 'lunch'} onchange={(event) => {
draft.kind = event.currentTarget.checked ? 'lunch' : mode === 'edit' && original.kind === 'travel' ? 'travel' : 'work';
}} />
Pause déjeuner
</label>
{#if draft.kind === 'lunch'}<p class="dim hint">Cette pause est exclue du total travaillé.</p>{/if}
<div class="btn-row" role="group" aria-label="Nature du poste">
<button type="button" aria-pressed={['work', 'travel'].includes(draft.kind)} onclick={() => (draft.kind = original.kind === 'travel' ? 'travel' : 'work')}>Poste ordinaire</button>
<button type="button" aria-pressed={draft.kind === 'lunch'} onclick={() => (draft.kind = 'lunch')}>Repas</button>
<button type="button" aria-pressed={draft.kind === 'break'} onclick={() => { draft.kind = 'break'; draft.task = 'Pause'; }}>Pause</button>
</div>
<TimeField label="Début du poste" ts={draft.startTs} rounding={settings.roundingMinutes}
min={mode === 'insert' ? original.startTs : previous?.startTs ?? startOfDay(day.date)}
max={draft.endTs} onchange={(ts) => (draft.startTs = ts)} />
@@ -117,6 +115,8 @@
placeholder="Nom du restaurant" emptyText="Aucun restaurant enregistré" level={1}
onchange={(value) => (draft.restaurant = value)} />
{/if}
{:else if draft.kind === 'break'}
<label class="stack">Libellé<input bind:value={draft.task} placeholder="Pause" /></label>
{:else}
<SmartField label="Tâche" value={draft.task} suggestions={suggestions.tasks}
placeholder="Nom de la tâche" level={1} onchange={(value) => (draft.task = value)} />
@@ -130,7 +130,7 @@
<button type="button" onclick={close}>Annuler</button>
{#if mode === 'edit' && original.endTs != null && entries.length > 1}
{#if confirmingDelete}
<p class="hint">Le poste voisin absorbera cette durée. Supprimer un repas peut modifier le total travaillé.</p>
<p class="hint">Le poste voisin absorbera cette durée. Supprimer un repas ou une pause peut modifier le total travaillé.</p>
<button type="button" class="btn-danger" onclick={() => commit(true)}>Confirmer la suppression</button>
{:else}
<button type="button" onclick={() => (confirmingDelete = true)}>Supprimer ce poste</button>
@@ -146,6 +146,4 @@
.hint { font-size: 0.85rem; }
select { width: 100%; min-width: 0; }
[role='alert'] { color: var(--danger); }
.meal-toggle { display: flex; align-items: center; gap: 12px; min-height: var(--tap); font-weight: 700; }
.meal-toggle input { width: 26px; height: 26px; accent-color: var(--accent); }
</style>

View File

@@ -0,0 +1,36 @@
<script>
import Sheet from './Sheet.svelte';
import { dayState, reopenToday } from '../lib/day.svelte.js';
import { go } from '../lib/router.svelte.js';
import { fmtTime } from '../lib/time.js';
let { open = false, onclose } = $props();
let mode = $state('break');
let busy = $state(false);
let error = $state('');
let now = $state(Date.now());
$effect(() => { if (open) { now = Date.now(); mode = 'break'; error = ''; } });
const day = $derived(dayState.todayRecord);
const last = $derived(dayState.historyEntries.filter(e => e.date === day?.date).sort((a,b) => a.seq-b.seq).at(-1));
async function confirm() {
if (busy || dayState.importing) return;
busy = true;
try { await reopenToday(Date.now(), mode); onclose(); go('today'); }
catch (cause) { error = cause.message; }
finally { busy = false; }
}
</script>
<Sheet {open} title="Reprendre la journée" onclose={() => { if (!busy) onclose(); }}>
<p>Clôture enregistrée : {fmtTime(day?.endedTs ?? now)}. Heure actuelle : {fmtTime(now)}.</p>
<fieldset disabled={busy || dayState.importing} class="stack">
{#if now > (day?.endedTs ?? now)}
<label><input type="radio" bind:group={mode} value="break" /> Créer une pause jusqu’à maintenant</label>
<p class="dim">Conserver la fin enregistrée, ajouter une pause et démarrer le travail à la reprise.</p>
<label><input type="radio" bind:group={mode} value="continue" /> Continuer depuis la fin enregistrée</label>
<p class="dim">{last?.kind === 'lunch' ? 'Prolonger le repas' : last?.kind === 'break' ? 'Prolonger la pause' : 'Prolonger le dernier poste et compter cet intervalle comme travaillé'}, puis reprendre le travail. La tâche et le client restent modifiables.</p>
{:else}<p>Aucun intervalle à ajouter. Aucune pause vide ne sera créée.</p>{/if}
{#if error}<p role="alert">{error}</p>{/if}
<button class="btn-primary" onclick={confirm}>Confirmer la reprise</button>
<button onclick={onclose}>Annuler</button>
</fieldset>
</Sheet>
<style>fieldset { border: 0; padding: 0; margin: 0; } label { display: flex; gap: 12px; align-items: center; } input { width: 24px; height: 24px; }</style>

View File

@@ -1,4 +1,5 @@
<script>
import { modalFocus } from '../lib/dialog.js';
/**
* Bottom sheet. Large close target, dismissable by backdrop, Escape, or back.
*
@@ -16,7 +17,7 @@
}
</script>
<svelte:window {onkeydown} />
<svelte:window {onkeydown} onhashchange={() => { if (open) onclose?.(); }} />
{#if open}
<div
@@ -30,6 +31,8 @@
></div>
<div
use:modalFocus
tabindex="-1"
class="sheet"
style="z-index: {41 + level * 10}"
role="dialog"

View File

@@ -38,7 +38,7 @@
</span>
<span class="dur tnum">{fmtDuration(duration(entry))}</span>
<span class="what">
<strong>{entry.task}</strong>
<strong>{entry.kind === 'break' && entry.task !== 'Pause' ? `Pause · ${entry.task}` : entry.task}</strong>
{#if entry.automatic}<span class="dim">Auto</span>{/if}
{#if entry.client}<span class="dim">{entry.client}</span>{/if}
</span>

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,27 @@
<script>
import { AUTOMATION_OPTIONS } from '../lib/automation-options.js';
import { settings, settingsError, updateSettings } from '../lib/settings.svelte.js';
import { dayState } from '../lib/day.svelte.js';
import { go } from '../lib/router.svelte.js';
</script>
<fieldset class="screen" disabled={dayState.importing}>
<p class="dim">Réglages</p><button onclick={() => go('settings')}>Retour aux réglages</button>
{#if settingsError.message}<p role="alert">{settingsError.message}</p>{/if}
<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>
</fieldset>
<style>fieldset { border: 0; margin: 0; min-width: 0; } label { display:flex; align-items:center; gap:12px; } input { width:26px; height:26px; } .small { font-size:.85rem; }</style>

View File

@@ -0,0 +1,17 @@
<script>
import DayEditor from '../components/DayEditor.svelte';
import { dayState } from '../lib/day.svelte.js';
import { go, router } from '../lib/router.svelte.js';
import { fmtDayLabel } from '../lib/time.js';
let editable = $state(false);
const day = $derived(dayState.closedDays.find(d => d.date === router.selectedDate) ?? dayState.day ?? dayState.lastClosed);
const entries = $derived(dayState.historyEntries.filter(e => e.date === day?.date));
</script>
<div class="screen">
<button onclick={() => go('resume')}>Retour au récapitulatif</button>
{#if day}
<h1>{fmtDayLabel(day.date)}</h1>
<DayEditor {day} {entries} {editable} />
{#if !editable}<button disabled={dayState.importing} onclick={() => (editable = true)}>Corriger cette journée</button>{/if}
{:else}<p>Aucune journée enregistrée.</p>{/if}
</div>

View File

@@ -0,0 +1,10 @@
<script>
import NonWorkingDays from '../components/NonWorkingDays.svelte';
import { dayState } from '../lib/day.svelte.js';
import { go } from '../lib/router.svelte.js';
</script>
<fieldset class="screen" disabled={dayState.importing}>
<p class="dim">Réglages</p><button onclick={() => go('settings')}>Retour aux réglages</button>
<NonWorkingDays />
</fieldset>
<style>fieldset { border:0; margin:0; min-width:0; }</style>

View File

@@ -1,16 +1,15 @@
<script>
import DayEditor from '../components/DayEditor.svelte';
import { dayState, reopenToday } from '../lib/day.svelte.js';
import ReopenDialog from '../components/ReopenDialog.svelte';
import { dayState } from '../lib/day.svelte.js';
import { buildResume, resumeToText } from '../lib/resume.js';
import { go } from '../lib/router.svelte.js';
import { go, router } from '../lib/router.svelte.js';
import { settings } from '../lib/settings.svelte.js';
import { fmtDayLabel, fmtDuration, weekday } from '../lib/time.js';
let copied = $state(false);
let selectedDate = $state('');
let selectedDate = $state(router.selectedDate);
let reopening = $state(false);
let error = $state('');
let editing = $state(false);
const selectedDay = $derived(dayState.closedDays.find((d) => d.date === selectedDate) ?? dayState.lastClosed);
const selectedEntries = $derived(dayState.historyEntries.filter((e) => e.date === selectedDay?.date));
@@ -31,15 +30,6 @@
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() {
try {
await navigator.clipboard.writeText(text);
@@ -57,7 +47,7 @@
{#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; editing = false; }}>
<select value={selectedDay?.date} onchange={(event) => { selectedDate = event.currentTarget.value; router.selectedDate = selectedDate; copied = false; }}>
{#each dayState.closedDays as day (day.date)}
<option value={day.date}>{fmtDayLabel(day.date)} {day.date.slice(0, 4)}</option>
{/each}
@@ -76,6 +66,9 @@
<h1>{resume.label}</h1>
</div>
{#if selectedEntries.length && selectedEntries.every(e => e.automatic === true)}
<p role="status" class="card">Journée entièrement déduite par les automatismes : vérifiez ses postes dans les détails avant de recopier.</p>
{/if}
<div class="card stack">
{#each resume.clients as client (client.name)}
<div class="client">
@@ -112,6 +105,7 @@
<strong class="tnum">{fmtDuration(resume.workedMs)}</strong>
</div>
<p class="dim small">Transport inclus ; repas et pauses exclus.</p>
{#if resume.omitted}
<p class="dim small">
{resume.omitted} poste{resume.omitted > 1 ? 's' : ''} de moins de {settings.roundingMinutes}
@@ -123,21 +117,17 @@
<button type="button" class="btn-primary" onclick={copy}>
{copied ? '✓ Copié' : 'Copier le récapitulatif'}
</button>
<button type="button" onclick={() => (editing = !editing)}>{editing ? 'Masquer les postes' : 'Corriger cette journée'}</button>
{#if editing}
{#key selectedDay.date}
<DayEditor day={selectedDay} entries={selectedEntries} onchange={() => (copied = false)} />
{/key}
{/if}
<button type="button" onclick={() => { router.selectedDate = selectedDay.date; go('details'); }}>Voir les détails</button>
{/if}
{#if closedToday}
<button
type="button"
disabled={reopening}
onclick={reopen}>Reprendre la journée</button
disabled={dayState.importing}
onclick={() => (reopening = true)}>Reprendre la journée</button
>
<p class="dim small">Annule la clôture et continue la dernière tâche depuis l’arrêt.</p>
<p class="dim small">Choisir comment comptabiliser le temps depuis la clôture.</p>
{:else if !dayState.day && !dayState.todayRecord && settings.nonWorkingDates.includes(dayState.today)}
<p class="dim center small">Aujourd’hui est bloqué comme jour non travaillé.</p>
{:else if !dayState.day && !dayState.todayRecord && startsAt}
@@ -151,6 +141,8 @@
{/if}
</div>
<ReopenDialog open={reopening} onclose={() => (reopening = false)} />
<style>
.client + .client {
border-top: 2px solid var(--line);

View File

@@ -1,9 +1,9 @@
<script>
import BackupPanel from '../components/BackupPanel.svelte';
import NonWorkingDays from '../components/NonWorkingDays.svelte';
import { go } from '../lib/router.svelte.js';
import { AUTOMATION_OPTIONS } from '../lib/automation-options.js';
import { cleanupOldDays } from '../lib/db.js';
import Sheet from '../components/Sheet.svelte';
import { DAY, localDateKey } from '../lib/time.js';
import { cleanupOldDays, exportBackup, db } from '../lib/db.js';
import { dayState, refresh } from '../lib/day.svelte.js';
import {
ROUNDING_CHOICES,
@@ -14,6 +14,10 @@
} from '../lib/settings.svelte.js';
let purged = $state(null);
let purgeOpen = $state(false);
let purgeError = $state('');
let purgeBusy = $state(false);
const purgeDates = $derived(dayState.closedDays.filter(d => settings.retentionDays > 0 && d.date < localDateKey(Date.now() - settings.retentionDays * DAY) && d.date !== dayState.lastClosed?.date).map(d => d.date));
function toggleWorkday(value) {
const next = settings.workdays.includes(value)
@@ -23,9 +27,15 @@
}
async function purge() {
const n = await cleanupOldDays(settings.retentionDays);
await refresh();
purged = n;
if (purgeBusy || dayState.importing) return;
purgeBusy = true; purgeError = '';
try {
const backup = await exportBackup();
await db.settings.put({ key: 'before-purge', value: backup });
purged = await cleanupOldDays(settings.retentionDays);
await refresh(); purgeOpen = false;
} catch (error) { purgeError = error.message; }
finally { purgeBusy = false; }
}
</script>
@@ -58,7 +68,7 @@
</div>
</section>
<NonWorkingDays />
<section class="card stack"><h2>Jours non travaillés</h2><button onclick={() => go('non-working')}>Gérer les jours non travaillés</button></section>
<section class="card stack">
<h2>Horaires</h2>
@@ -119,21 +129,7 @@
<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 class="card stack"><h2>Automatismes</h2><button onclick={() => go('automations')}>Gérer les automatismes</button></section>
<section class="card stack">
<h2>Arrondi</h2>
@@ -189,9 +185,9 @@
sont justes. La dernière journée close est toujours conservée.
</p>
<p class="dim small">
La purge s'exécute aussi automatiquement à l'ouverture de l'application.
La purge nécessite une confirmation et conserve une sauvegarde locale récupérable.
</p>
<button type="button" onclick={purge}>Purger maintenant</button>
<button type="button" onclick={() => (purgeOpen = true)}>Purger maintenant</button>
{#if purged !== null}
<p class="dim small">
{purged === 0
@@ -202,6 +198,13 @@
</section>
</fieldset>
<Sheet open={purgeOpen} title="Confirmer la purge" onclose={() => { if (!purgeBusy) purgeOpen = false; }}>
<p>Journées concernées : {purgeDates.length ? purgeDates.join(', ') : 'aucune'}.</p>
<p>Une sauvegarde récupérable sera conservée sur cet appareil avant la suppression.</p>
{#if purgeError}<p role="alert">{purgeError}</p>{/if}
<button disabled={purgeBusy || dayState.importing || !purgeDates.length} class="btn-danger" onclick={purge}>Confirmer la purge</button>
<button disabled={purgeBusy} onclick={() => (purgeOpen = false)}>Annuler</button>
</Sheet>
<style>
fieldset.screen {
border: 0;
@@ -244,7 +247,4 @@
border-color: var(--danger);
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>

View File

@@ -62,6 +62,7 @@
<h2 id="stats-overview">Vue d’ensemble</h2>
<div class="metrics">
<div><span>Temps travaillé</span><strong class="tnum" data-testid="stats-worked">{fmtDuration(stats.workedMs)}</strong></div>
<div><span>Temps de pause</span><strong class="tnum">{fmtDuration(stats.breakMs)}</strong></div>
<div><span>Temps de repas</span><strong class="tnum" data-testid="stats-lunch">{fmtDuration(stats.lunchMs)}</strong></div>
<div><span>Journées enregistrées</span><strong>{stats.days}</strong></div>
<div><span>Postes enregistrés</span><strong>{stats.entries}</strong></div>

View File

@@ -1,4 +1,8 @@
<script>
import Sheet from '../components/Sheet.svelte';
import { modalFocus } from '../lib/dialog.js';
import ReopenDialog from '../components/ReopenDialog.svelte';
let reopening = $state(false);
import DayEditor from '../components/DayEditor.svelte';
import SmartField from '../components/SmartField.svelte';
import TimeField from '../components/TimeField.svelte';
@@ -65,7 +69,7 @@
const actionLabel = $derived(
current?.kind === 'travel'
? 'Arrivé sur site'
: current?.kind === 'lunch'
: ['lunch', 'break'].includes(current?.kind)
? 'Reprendre le travail'
: 'Tâche suivante',
);
@@ -104,14 +108,14 @@
<h2>{closedToday ? 'Journée terminée' : blockedToday ? 'Journée non travaillée' : 'Journée non commencée'}</h2>
<p class="dim">
{#if closedToday}
Reprendre annule la clôture et continue la dernière tâche, y compris le temps écoulé depuis l’arrêt.
Choisir comment comptabiliser le temps depuis la clôture.
{:else if blockedToday}
Cette date est bloquée dans les réglages. Aucun démarrage automatique n’est prévu.
{:else}
Aujourd'hui n'est pas un jour travaillé, ou l'heure de début n'est pas atteinte.
{/if}
</p>
<button type="button" class="btn-primary" disabled={busy} onclick={() => act(closedToday ? reopenToday : startDayNow)}>
<button type="button" class="btn-primary" disabled={busy} onclick={() => closedToday ? (reopening = true) : act(startDayNow)}>
{closedToday ? 'Reprendre la journée' : blockedToday ? 'Démarrer quand même' : 'Démarrer la journée'}
</button>
</div>
@@ -160,6 +164,8 @@
onchange={(v) => act(() => setLunch({ lunchType: 'restaurant', restaurant: v }))}
/>
{/if}
{:else if current.kind === 'break'}
<h3>Pause en cours</h3>
{:else}
<SmartField
label="Tâche"
@@ -198,29 +204,24 @@
<button type="button" disabled={busy} onclick={() => act(startLunch)}>Pause déjeuner</button>
{/if}
{#if current?.kind !== 'break'}
<button type="button" disabled={busy} onclick={() => act(() => advance({ kind: 'break', task: 'Pause', client: null }))}>Commencer une pause</button>
{/if}
<!-- Day so far -------------------------------------------------------- -->
<DayEditor day={dayState.day} entries={dayState.entries} {now} />
<!-- Separated from everything frequent, and confirmed. ----------------- -->
<div class="end">
{#if confirmingEnd}
<div class="btn-row">
<button type="button" onclick={() => (confirmingEnd = false)}>Annuler</button>
<button type="button" class="btn-danger" disabled={busy} onclick={() => act(finishDay)}>
Confirmer
</button>
</div>
{:else}
<button type="button" onclick={() => (confirmingEnd = true)}>
Terminer la journée
</button>
{/if}
</div>
<div class="end"><button type="button" onclick={() => (confirmingEnd = true)}>Terminer la journée</button></div>
<Sheet open={confirmingEnd} title="Terminer la journée" onclose={() => { if (!busy) confirmingEnd = false; }}>
<p>Clôturer le poste en cours et terminer la journée maintenant ?</p>
<button disabled={busy} onclick={() => (confirmingEnd = false)}>Annuler</button>
<button class="btn-danger" disabled={busy} onclick={() => act(finishDay)}>Confirmer</button>
</Sheet>
</div>
<!-- End-of-day grace prompt --------------------------------------------- -->
{#if dayState.needsClosePrompt}
<div class="prompt" role="dialog" aria-modal="true" aria-labelledby="close-day-title">
<div use:modalFocus tabindex="-1" class="prompt" role="dialog" aria-modal="true" aria-labelledby="close-day-title">
<div class="card stack">
<h2 id="close-day-title">Fin de journée</h2>
<p>
@@ -235,6 +236,7 @@
{/if}
{/if}
<ReopenDialog open={reopening} onclose={() => (reopening = false)} />
<style>
.action-error { margin: var(--pad); color: var(--danger); }
.big {