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

@@ -15,12 +15,13 @@ npm run dev # http://localhost:5173
npm test # unit and IndexedDB regression tests
npm run check # svelte-check
npm run build # production build into dist/
npm run validate # tests, Svelte check and full PWA build
```
## Exporter et importer les données
Dans **Réglages → Données**, **Exporter les données** télécharge un fichier
`timelog-AAAA-MM-JJ.json`. Il contient toutes les journées conservées, leurs postes
`pardis-AAAA-MM-JJ.json`. Il contient toutes les journées conservées, leurs postes
et les réglages enregistrés. L'export fonctionne hors ligne et ne modifie pas les
données de l'appareil. Les identifiants internes des postes sont recréés à l'import.
@@ -41,7 +42,7 @@ exportée en cours, le dernier poste est arrêté à l'heure de l'export : le te
écoulé est conservé. Les intervalles sans travail déjà présents sont conservés.
Une sauvegarde vide est valide et peut servir à restaurer un appareil vide.
Le format JSON est identifié par `format: "timelog-export"` et `version: 1`.
Le format JSON est identifié par `format: "timelog-export"` et `version: 2`. Le format v1 reste importable. Les anciennes applications refusent le format v2 pour éviter de transformer les pauses en travail.
Les fichiers étrangers, versions non prises en charge, dates en double et réglages
invalides sont refusés. Les postes inutilisables et les corrections de plages
horaires sont signalés avant confirmation. La limite de taille est de 20 Mo.
@@ -49,9 +50,7 @@ L'écriture est transactionnelle : un échec annule le remplacement ou la fusion
dans son intégralité. Le récapitulatif, le thème et les suggestions sont ensuite
actualisés sans recharger la page.
L'import ne déclenche pas immédiatement de purge. La durée de conservation
s'applique de nouveau à la prochaine ouverture ou au changement de jour ; le
bouton **Purger maintenant** reste disponible.
La purge automatique est désactivée. **Purger maintenant** présente les dates concernées et demande confirmation. Une sauvegarde locale est conservée avant la suppression ; **Récupérer la sauvegarde avant purge** permet de la fusionner ou de la télécharger après restauration. Elle reste sur le même appareil : un export externe demeure nécessaire.
Les tests d'import/export couvrent le format, les réglages, la fidélité des durées,
les fichiers invalides, la fusion, le remplacement et l'annulation transactionnelle
@@ -65,9 +64,7 @@ il n'est pas publié avec l'application.
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.
- Une journée terminée aujourd’hui propose **Reprendre la journée** avec confirmation : prolonger le dernier poste, ou créer une pause jusqu’à maintenant (choix initial). Un dernier repas ou une pause garde sa nature ; le travail reprend dans un nouveau poste. Les journées passées se corrigent par **Voir les détails → Corriger cette journé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
@@ -299,3 +296,22 @@ src/lib/
Pure helpers and database/lifecycle operations have regression tests. `db.js`
keeps day and entry changes in shared transactions, including creation, closure,
reopening and boundary corrections.
## Pauses et nouveaux parcours
**Journée → Commencer une pause** ferme le poste courant. **Reprendre le travail**
propose la dernière tâche de travail et son client, immédiatement modifiables.
Une pause a ses propres horaires et son libellé, sans client ni type de repas.
Le détail montre tous les postes ; la consultation reste séparée de la correction.
Le récapitulatif et sa copie excluent les pauses. Le transport entre dans le total
travaillé, les repas et pauses en sont exclus. Seules les statistiques montrent le
cumul des pauses, séparément des repas. Les intervalles vides importés sont préservés.
**Réglages → Gérer les automatismes** et **Gérer les jours non travaillés** ouvrent
des pages dédiées ; le bouton Retour du navigateur fonctionne via les routes hash.
Les réglages sont écrits par transaction sur la dernière valeur persistée pour
préserver les modifications d’autres onglets. Les erreurs de démarrage et
actualisation sont affichées avec une action Réessayer.
La checklist avant production et les validations restant à exécuter sont suivies
dans [PRODUCTION.md](PRODUCTION.md).

View File

@@ -6,6 +6,7 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"validate": "npm test && npm run check && npm run build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 466 B

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 {