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 test # unit and IndexedDB regression tests
npm run check # svelte-check npm run check # svelte-check
npm run build # production build into dist/ npm run build # production build into dist/
npm run validate # tests, Svelte check and full PWA build
``` ```
## Exporter et importer les données ## Exporter et importer les données
Dans **Réglages → Données**, **Exporter les données** télécharge un fichier 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 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. 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. é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. 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 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 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. 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 dans son intégralité. Le récapitulatif, le thème et les suggestions sont ensuite
actualisés sans recharger la page. actualisés sans recharger la page.
L'import ne déclenche pas immédiatement de purge. La durée de conservation 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.
s'applique de nouveau à la prochaine ouverture ou au changement de jour ; le
bouton **Purger maintenant** reste disponible.
Les tests d'import/export couvrent le format, les réglages, la fidélité des durées, 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 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 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'à travail lorsqu'elle est fermée. Le dernier poste conserve ainsi sa durée jusqu'à
cette limite, au lieu d'être ramené à zéro. 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 - 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**.
continue le dernier poste, client et déjeuner conservés. Le temps écoulé depuis
l'arrêt est compté dans ce poste : la reprise corrige une clôture prématurée.
- La création, la clôture et la reprise sont transactionnelles. Les actions - 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 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 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` Pure helpers and database/lifecycle operations have regression tests. `db.js`
keeps day and entry changes in shared transactions, including creation, closure, keeps day and entry changes in shared transactions, including creation, closure,
reopening and boundary corrections. 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": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"validate": "npm test && npm run check && npm run build",
"preview": "vite preview", "preview": "vite preview",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "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 Resume from './routes/Resume.svelte';
import Settings from './routes/Settings.svelte'; import Settings from './routes/Settings.svelte';
import Today from './routes/Today.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'; import Stats from './routes/Stats.svelte';
let ready = $state(false); 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(() => { $effect(() => {
retry;
bootError = '';
let alive = true; let alive = true;
(async () => { (async () => {
try {
await loadSettings(); await loadSettings();
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
const { installDevSeed } = await import('./lib/seed.js'); const { installDevSeed } = await import('./lib/seed.js');
@@ -20,14 +29,15 @@
} }
await reconcile(); await reconcile();
if (alive) ready = true; if (alive) ready = true;
} catch (error) { if (alive) bootError = `Accès aux données impossible : ${error.message}`; }
})(); })();
return () => (alive = false); return () => (alive = false);
}); });
// Time drives this app, so the clock is polled rather than waited on. // Time drives this app, so the clock is polled rather than waited on.
$effect(() => { $effect(() => {
const id = setInterval(() => reconcile(), 30_000); const id = setInterval(tick, 30_000);
const onVisible = () => document.visibilityState === 'visible' && reconcile(); const onVisible = () => document.visibilityState === 'visible' && tick();
document.addEventListener('visibilitychange', onVisible); document.addEventListener('visibilitychange', onVisible);
return () => { return () => {
clearInterval(id); clearInterval(id);
@@ -40,7 +50,7 @@
</script> </script>
{#if !ready} {#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} {:else}
{#if needsRefresh.value && !dayState.day} {#if needsRefresh.value && !dayState.day}
<!-- Only offered while no day is running, so an update can never interrupt <!-- Only offered while no day is running, so an update can never interrupt
@@ -51,7 +61,14 @@
</div> </div>
{/if} {/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 /> <Settings />
{:else if view === 'today'} {:else if view === 'today'}
<Today /> <Today />
@@ -64,7 +81,7 @@
<nav class:with-stats={view === 'stats'}> <nav class:with-stats={view === 'stats'}>
<button <button
type="button" type="button"
class:sel={view === 'resume'} class:sel={['resume', 'details'].includes(view)}
disabled={dayState.importing} disabled={dayState.importing}
aria-current={view === 'resume'} aria-current={view === 'resume'}
onclick={() => go('resume')}>Récap</button onclick={() => go('resume')}>Récap</button
@@ -78,9 +95,9 @@
> >
<button <button
type="button" type="button"
class:sel={view === 'settings'} class:sel={['settings', 'automations', 'non-working'].includes(view)}
disabled={dayState.importing} disabled={dayState.importing}
aria-current={view === 'settings'} aria-current={['settings', 'automations', 'non-working'].includes(view)}
onclick={() => go('settings')}>Réglages</button onclick={() => go('settings')}>Réglages</button
> >
{#if view === 'stats'} {#if view === 'stats'}

View File

@@ -1,4 +1,5 @@
<script> <script>
import Sheet from './Sheet.svelte';
/** /**
* Export the whole dataset to one file, and import it back. * Export the whole dataset to one file, and import it back.
* *
@@ -12,7 +13,7 @@
parseBackup, parseBackup,
} from '../lib/backup.js'; } from '../lib/backup.js';
import { applyImport, dayState } from '../lib/day.svelte.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'; import { fmtDayLabel, fmtTime, localDateKey } from '../lib/time.js';
/** @type {HTMLInputElement} */ /** @type {HTMLInputElement} */
@@ -23,6 +24,7 @@
let result = $state(null); let result = $state(null);
let confirmingReplace = $state(false); let confirmingReplace = $state(false);
let busy = $state(false); let busy = $state(false);
let lastExport = $state(localStorage.getItem('pardis-last-export') ?? '');
let filename = $state(''); let filename = $state('');
function reset() { function reset() {
@@ -54,6 +56,7 @@
// Give browsers (including standalone mobile apps) time to start reading. // Give browsers (including standalone mobile apps) time to start reading.
setTimeout(() => URL.revokeObjectURL(url), 60_000); 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' : ''}.`; 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) { } catch (cause) {
error = `Export impossible : ${cause.message}`; 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) { async function onPick(event) {
if (busy) return; if (busy) return;
// Capture the File before reset() clears the input and its FileList. // 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> <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. --> <!-- The real input is hidden: a styled button is a far bigger target. -->
<input <input
bind:this={picker} bind:this={picker}
@@ -148,13 +162,22 @@
{#if staged.repaired} {#if staged.repaired}
<p class="dim small">{staged.repaired} poste(s) ajusté(s) pour rendre les données exploitables.</p> <p class="dim small">{staged.repaired} poste(s) ajusté(s) pour rendre les données exploitables.</p>
{/if} {/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} {#if staged.closedActive}
<p class="dim small"> <p class="dim small">
{staged.closedActive} journée(s) en cours seront enregistrées comme terminées à l’heure de l’export. {staged.closedActive} journée(s) en cours seront enregistrées comme terminées à l’heure de l’export.
</p> </p>
{/if} {/if}
{#if confirmingReplace} <Sheet open={confirmingReplace} title="Remplacer les données" onclose={() => { if (!busy) confirmingReplace = false; }}>
<p class="notice error"> <p class="notice error">
Toutes les journées et tous les réglages de cet appareil seront remplacés par ceux du fichier. 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} {#if dayState.day}La journée en cours sera également remplacée.{/if}
@@ -165,7 +188,8 @@
Confirmer Confirmer
</button> </button>
</div> </div>
{:else} </Sheet>
{#if !confirmingReplace}
<button type="button" class="btn-primary" disabled={busy} onclick={() => run('merge')}> <button type="button" class="btn-primary" disabled={busy} onclick={() => run('merge')}>
Fusionner Fusionner
</button> </button>

View File

@@ -9,7 +9,7 @@
import Sheet from './Sheet.svelte'; import Sheet from './Sheet.svelte';
import Timeline from './Timeline.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 mode = $state('');
let original = $state(null); let original = $state(null);
let draft = $state({ kind: 'work', task: '', client: '', startTs: 0, endTs: 0, lunchType: 'panier', restaurant: '' }); let draft = $state({ kind: 'work', task: '', client: '', startTs: 0, endTs: 0, lunchType: 'panier', restaurant: '' });
@@ -72,9 +72,9 @@
<div class="card stack"> <div class="card stack">
<h3>Postes de la journée</h3> <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> {#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={(entry) => select(entry, 'edit')} /> <Timeline {entries} rounding={settings.roundingMinutes} {now} onselect={editable ? (entry) => select(entry, 'edit') : null} />
<button type="button" disabled={!ordered.length || dayState.importing} onclick={() => select(ordered[0], 'insert')}>Ajouter un poste oublié</button> {#if editable}<button type="button" disabled={!ordered.length || dayState.importing} onclick={() => select(ordered[0], 'insert')}>Ajouter un poste oublié</button>{/if}
</div> </div>
<Sheet open={Boolean(mode)} title={mode === 'insert' ? 'Ajouter un poste' : 'Modifier le poste'} onclose={close}> <Sheet open={Boolean(mode)} title={mode === 'insert' ? 'Ajouter un poste' : 'Modifier le poste'} onclose={close}>
@@ -93,13 +93,11 @@
{:else} {: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> <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} {/if}
<label class="meal-toggle"> <div class="btn-row" role="group" aria-label="Nature du poste">
<input type="checkbox" checked={draft.kind === 'lunch'} onchange={(event) => { <button type="button" aria-pressed={['work', 'travel'].includes(draft.kind)} onclick={() => (draft.kind = original.kind === 'travel' ? 'travel' : 'work')}>Poste ordinaire</button>
draft.kind = event.currentTarget.checked ? 'lunch' : mode === 'edit' && original.kind === 'travel' ? 'travel' : 'work'; <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>
Pause déjeuner </div>
</label>
{#if draft.kind === 'lunch'}<p class="dim hint">Cette pause est exclue du total travaillé.</p>{/if}
<TimeField label="Début du poste" ts={draft.startTs} rounding={settings.roundingMinutes} <TimeField label="Début du poste" ts={draft.startTs} rounding={settings.roundingMinutes}
min={mode === 'insert' ? original.startTs : previous?.startTs ?? startOfDay(day.date)} min={mode === 'insert' ? original.startTs : previous?.startTs ?? startOfDay(day.date)}
max={draft.endTs} onchange={(ts) => (draft.startTs = ts)} /> max={draft.endTs} onchange={(ts) => (draft.startTs = ts)} />
@@ -117,6 +115,8 @@
placeholder="Nom du restaurant" emptyText="Aucun restaurant enregistré" level={1} placeholder="Nom du restaurant" emptyText="Aucun restaurant enregistré" level={1}
onchange={(value) => (draft.restaurant = value)} /> onchange={(value) => (draft.restaurant = value)} />
{/if} {/if}
{:else if draft.kind === 'break'}
<label class="stack">Libellé<input bind:value={draft.task} placeholder="Pause" /></label>
{:else} {:else}
<SmartField label="Tâche" value={draft.task} suggestions={suggestions.tasks} <SmartField label="Tâche" value={draft.task} suggestions={suggestions.tasks}
placeholder="Nom de la tâche" level={1} onchange={(value) => (draft.task = value)} /> placeholder="Nom de la tâche" level={1} onchange={(value) => (draft.task = value)} />
@@ -130,7 +130,7 @@
<button type="button" onclick={close}>Annuler</button> <button type="button" onclick={close}>Annuler</button>
{#if mode === 'edit' && original.endTs != null && entries.length > 1} {#if mode === 'edit' && original.endTs != null && entries.length > 1}
{#if confirmingDelete} {#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> <button type="button" class="btn-danger" onclick={() => commit(true)}>Confirmer la suppression</button>
{:else} {:else}
<button type="button" onclick={() => (confirmingDelete = true)}>Supprimer ce poste</button> <button type="button" onclick={() => (confirmingDelete = true)}>Supprimer ce poste</button>
@@ -146,6 +146,4 @@
.hint { font-size: 0.85rem; } .hint { font-size: 0.85rem; }
select { width: 100%; min-width: 0; } select { width: 100%; min-width: 0; }
[role='alert'] { color: var(--danger); } [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> </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> <script>
import { modalFocus } from '../lib/dialog.js';
/** /**
* Bottom sheet. Large close target, dismissable by backdrop, Escape, or back. * Bottom sheet. Large close target, dismissable by backdrop, Escape, or back.
* *
@@ -16,7 +17,7 @@
} }
</script> </script>
<svelte:window {onkeydown} /> <svelte:window {onkeydown} onhashchange={() => { if (open) onclose?.(); }} />
{#if open} {#if open}
<div <div
@@ -30,6 +31,8 @@
></div> ></div>
<div <div
use:modalFocus
tabindex="-1"
class="sheet" class="sheet"
style="z-index: {41 + level * 10}" style="z-index: {41 + level * 10}"
role="dialog" role="dialog"

View File

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

View File

@@ -75,7 +75,7 @@ export function automateDay(date, config, now = Date.now(), command = {}) {
!entries.some((e) => e.kind === 'lunch') && lunchAt >= day.startedTs && !entries.some((e) => e.kind === 'lunch') && lunchAt >= day.startedTs &&
lunchAt < day.plannedEndTs && lunchAt < (finishAt ?? Infinity) && lunchAt <= horizon) { lunchAt < day.plannedEndTs && lunchAt < (finishAt ?? Infinity) && lunchAt <= horizon) {
// A late manual action blocks backdated replacement of the morning. // 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(); } 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'; import { AUTOMATION_DEFAULTS } from './automation-options.js';
export const EXPORT_FORMAT = 'timelog-export'; export const EXPORT_FORMAT = 'timelog-export';
export const EXPORT_VERSION = 1; export const EXPORT_VERSION = 2;
export const MAX_BACKUP_BYTES = 20 * 1024 * 1024; export const MAX_BACKUP_BYTES = 20 * 1024 * 1024;
/** Thrown for anything the user needs to read; message is shown verbatim. */ /** 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()) { export function backupFilename(now = Date.now()) {
return `timelog-${localDateKey(now)}.json`; return `pardis-${localDateKey(now)}.json`;
} }
/* ------------------------------------------------------------------- import */ /* ------------------------------------------------------------------- import */
@@ -87,7 +87,7 @@ export function backupFilename(now = Date.now()) {
* @param {string} text raw file contents * @param {string} text raw file contents
* @returns {{days: Array, entries: Array, settings: object|null, exportedAt: string|null, * @returns {{days: Array, entries: Array, settings: object|null, exportedAt: string|null,
* dayCount: number, entryCount: number, dropped: number, * dayCount: number, entryCount: number, dropped: number,
* repaired: number, closedActive: number}} * repaired: number, closedActive: number, adjustments: Array}}
* @throws {BackupError} * @throws {BackupError}
*/ */
export function parseBackup(text) { export function parseBackup(text) {
@@ -102,25 +102,25 @@ export function parseBackup(text) {
} }
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { 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) { 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) { if (isNum(raw.version) && raw.version > EXPORT_VERSION) {
throw new BackupError( 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) { if (![1, EXPORT_VERSION].includes(raw.version)) {
throw new BackupError('Version du format TimeLog non prise en charge.'); throw new BackupError('Version du format Pardis non prise en charge.');
} }
if (!Array.isArray(raw.days) || !Array.isArray(raw.entries)) { if (!Array.isArray(raw.days) || !Array.isArray(raw.entries)) {
throw new BackupError('Export incomplet : journées ou postes manquants.'); throw new BackupError('Export incomplet : journées ou postes manquants.');
} }
const exportTs = typeof raw.exportedAt === 'string' ? Date.parse(raw.exportedAt) : NaN; 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 // 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". // non-empty file must never become a successful "replace with nothing".
if (!days.length && (raw.days.length || raw.entries.length)) { if (!days.length && (raw.days.length || raw.entries.length)) {
@@ -137,6 +137,7 @@ export function parseBackup(text) {
dropped, dropped,
repaired, repaired,
closedActive, closedActive,
adjustments,
}; };
} }
@@ -148,6 +149,7 @@ export function parseBackup(text) {
* active day, which the day lifecycle assumes cannot happen. * active day, which the day lifecycle assumes cannot happen.
*/ */
function normalise(rawDays, rawEntries, exportTs) { function normalise(rawDays, rawEntries, exportTs) {
const adjustments = [];
let dropped = 0; let dropped = 0;
let repairedCount = 0; let repairedCount = 0;
let closedActive = 0; let closedActive = 0;
@@ -156,6 +158,7 @@ function normalise(rawDays, rawEntries, exportTs) {
for (const entry of rawEntries) { for (const entry of rawEntries) {
if (!isObject(entry) || !isDate(entry.date) || !isTimestamp(entry.startTs)) { if (!isObject(entry) || !isDate(entry.date) || !isTimestamp(entry.startTs)) {
dropped++; dropped++;
adjustments.push({ date: entry?.date ?? '?', task: entry?.task ?? 'Poste illisible', action: 'ignoré', fields: [] });
continue; continue;
} }
if (!byDate.has(entry.date)) byDate.set(entry.date, []); 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)))) { for (const rawDay of [...rawDays].sort((a, b) => String(a?.date).localeCompare(String(b?.date)))) {
if (!isObject(rawDay) || !isDate(rawDay.date)) { if (!isObject(rawDay) || !isDate(rawDay.date)) {
adjustments.push({ date: rawDay?.date ?? '?', task: 'Journée illisible', action: 'ignorée', fields: [] });
dropped++; dropped++;
continue; continue;
} }
@@ -180,6 +184,7 @@ function normalise(rawDays, rawEntries, exportTs) {
); );
// A day with nothing in it produces an empty resume; leave it out. // A day with nothing in it produces an empty resume; leave it out.
if (!own.length) { if (!own.length) {
adjustments.push({ date: rawDay.date, task: 'Journée sans poste exploitable', action: 'ignorée', fields: [] });
dropped++; dropped++;
continue; continue;
} }
@@ -190,9 +195,9 @@ function normalise(rawDays, rawEntries, exportTs) {
const repaired = own.map((entry, seq) => ({ const repaired = own.map((entry, seq) => ({
date: rawDay.date, date: rawDay.date,
seq, 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', 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, startTs: entry.startTs,
endTs: isTimestamp(entry.endTs) ? entry.endTs : null, endTs: isTimestamp(entry.endTs) ? entry.endTs : null,
...(typeof entry.automatic === 'boolean' ? { automatic: entry.automatic } : {}), ...(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; last.endTs = isTimestamp(end) ? Math.max(last.startTs, end) : last.startTs;
} else if (last.endTs < last.startTs) last.endTs = 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 endedTs = last.endTs;
const lunchType = rawDay.lunchType === 'restaurant' ? 'restaurant' : 'panier'; const lunchType = rawDay.lunchType === 'restaurant' ? 'restaurant' : 'panier';
@@ -239,10 +244,14 @@ function normalise(rawDays, rawEntries, exportTs) {
const changed = repaired.filter((entry, i) => const changed = repaired.filter((entry, i) =>
Object.keys(entry).some((key) => entry[key] !== own[i][key]), Object.keys(entry).some((key) => entry[key] !== own[i][key]),
).length; ).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; repairedCount += changed;
} }
for (const orphaned of byDate.values()) dropped += orphaned.length; 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 }; return { days, entries, dropped, repaired: repairedCount, closedActive, adjustments };
} }

View File

@@ -49,7 +49,7 @@ describe('buildBackup', () => {
}); });
it('names the file by date', () => { 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', () => { 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', () => { 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 { import {
cleanupOldDays,
createDay, createDay,
deleteEntry, deleteEntry,
getActiveDay, getActiveDay,
@@ -50,8 +49,6 @@ export const running = () => dayState.entries.find((e) => e.endTs == null) ?? nu
/* --------------------------------------------------------------- reconciling */ /* --------------------------------------------------------------- reconciling */
/** Retention runs once per session, not on every 30 s tick. */
let cleanedUpFor = null;
const pendingReconciliations = new Set(); const pendingReconciliations = new Set();
/** /**
@@ -83,17 +80,6 @@ async function reconcileDay(now) {
return 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 // Auto-start. Only when no record exists for today at all: a day the worker has
// already ended must not spring back to life. // already ended must not spring back to life.
if (!active) { if (!active) {
@@ -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 * Close the running entry and open the next one. The single mutation every button
* on the Today screen goes through. * 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] * @param {number} [now]
*/ */
export async function advance({ kind = 'work', task, client }, now = Date.now()) { export async function advance({ kind = 'work', task, client }, now = Date.now()) {
@@ -245,9 +231,8 @@ export async function applyImport(payload, mode) {
const result = await importBackup(payload, mode); const result = await importBackup(payload, mode);
if (mode === 'replace') await loadSettings(); if (mode === 'replace') await loadSettings();
// Do not immediately purge old imported days or create a new working day. // 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(); dayState.today = localDateKey();
cleanedUpFor = dayState.today;
await refresh(); await refresh();
return result; return result;
} finally { } 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. */ /** 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; if (dayState.importing) return;
await reopenDay(localDateKey(now), now); await reopenDay(localDateKey(now), now, mode);
await refresh(now); await refresh(now);
} }
@@ -292,6 +277,7 @@ export function predictNext(now = Date.now()) {
if (!model) return { task: TRANSPORT_TASK, client: null, tasks: [], clients: [] }; if (!model) return { task: TRANSPORT_TASK, client: null, tasks: [], clients: [] };
const current = running(); const current = running();
const lastWork = [...dayState.entries].reverse().find((e) => e.kind === 'work') ?? dayState.previousWork;
const tasks = suggestTasks(model, { const tasks = suggestTasks(model, {
prevTask: current?.task ?? null, prevTask: current?.task ?? null,
prevElapsedMs: current ? now - current.startTs : 0, prevElapsedMs: current ? now - current.startTs : 0,
@@ -302,10 +288,10 @@ export function predictNext(now = Date.now()) {
const clients = suggestClients(model, now); const clients = suggestClients(model, now);
return { 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' ? dayState.previousWork?.task ?? 'Travaux sur site'
: tasks[0]?.value ?? TRANSPORT_TASK, : 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, tasks,
clients, clients,
}; };

View File

@@ -88,7 +88,7 @@ describe('start and resume', () => {
await reopenToday(at('17:15')); await reopenToday(at('17:15'));
const reopened = await getDay(DATE), after = await getEntries(DATE); const reopened = await getDay(DATE), after = await getEntries(DATE);
expect(reopened).toMatchObject({ status: 'active', startedTs: before.startedTs, lunchType: before.lunchType, restaurant: before.restaurant }); 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')); await endDay(at('18:00'));
expect(buildResume(await getDay(DATE), await getEntries(DATE)).workedMs).toBe(10.5 * HOUR); 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); 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 * 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 * @typedef {object} EntryRecord
* @property {number} [id] * @property {number} [id]
* @property {string} date * @property {string} date
* @property {number} seq position within the day, dense from 0 * @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} task
* @property {string|null} client * @property {string|null} client
* @property {number} startTs * @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. */ /** Confirmed same-day reopening; status and all resumed slots commit together. */
export function reopenDay(date, now) { export function reopenDay(date, now, mode = 'continue') {
return db.transaction('rw', db.days, db.entries, async () => { return db.transaction('rw', db.days, db.entries, async () => {
const day = await getDay(date); 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 entries = await getEntries(date);
const last = entries.at(-1); const last = entries.at(-1);
if (!last) return false; 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, { await patchDay(date, {
status: 'active', endedTs: null, closePromptedTs: null, status: 'active', endedTs: null, closePromptedTs: null,
plannedEndTs: Math.max(day.plannedEndTs, now + HOUR), 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 list = await db.entries.where('date').equals(entry.date).sortBy('seq');
const prev = list[list.findIndex((e) => e.id === id) - 1]; 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 upper = entry.endTs ?? now;
const stored = Math.min(Math.max(ts, lower), upper); const stored = Math.min(Math.max(ts, lower), upper);
await db.entries.update(entry.id, { startTs: stored, ...(entry.automatic ? { automatic: false } : {}) }); 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 } : {}) }); if (prev && prev.endTs === entry.startTs) await db.entries.update(prev.id, { endTs: stored, ...(prev.automatic ? { automatic: false } : {}) });
else await db.days.update(entry.date, { startedTs: stored }); else if (!prev) await db.days.update(entry.date, { startedTs: stored });
return stored; return stored;
}); });
@@ -257,11 +272,11 @@ export function deleteEntry(id) {
const next = list[i + 1]; const next = list[i + 1];
await db.entries.delete(id); 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 // 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). // 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 }); 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 }); await db.entries.update(next.id, { startTs: victim.startTs });
} }
// Re-number so `seq` stays dense. // 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) { function content(values) {
if (!['work', 'travel', 'lunch'].includes(values.kind)) throw new Error('Choisissez un type de poste.'); 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(); 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.'); 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) { function validateTimes(start, end, now) {
@@ -45,13 +45,13 @@ export function saveRecordedEntry(original, values, now = Date.now()) {
const { startTs, endTs } = values; const { startTs, endTs } = values;
if ((entry.endTs == null) !== (endTs == null)) throw conflict(); if ((entry.endTs == null) !== (endTs == null)) throw conflict();
validateTimes(startTs, endTs ?? now, now); 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.'); throw new Error('Ces horaires dépassent le poste voisin. Corrigez d’abord ce voisin.');
} }
if (!prev && localDateKey(startTs) !== day.date) throw new Error('Le début doit rester à la date de cette journée.'); if (!prev && localDateKey(startTs) !== day.date) throw new Error('Le début doit rester à la date de cette journée.');
await db.entries.update(entry.id, { ...content(values), startTs, endTs, ...(entry.automatic ? { automatic: false } : {}) }); 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 (prev && prev.endTs === entry.startTs && 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 (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 (entry.kind === 'lunch') await db.days.update(day.date, { autoLunchHandled: true });
if (!prev) await db.days.update(day.date, { startedTs: startTs }); if (!prev) await db.days.update(day.date, { startedTs: startTs });
if (!next && day.status === 'closed') await db.days.update(day.date, { endedTs: endTs }); if (!next && day.status === 'closed') await db.days.update(day.date, { endedTs: endTs });

View File

@@ -107,7 +107,7 @@ export function buildModel(entries, days = []) {
// Work entries only: lunch and its restaurant are modelled separately. // Work entries only: lunch and its restaurant are modelled separately.
const work = entries 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); .sort((a, b) => a.startTs - b.startTs);
const byDate = new Map(); const byDate = new Map();

View File

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

View File

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

View File

@@ -56,18 +56,21 @@ export async function loadSettings() {
export async function updateSettings(patch) { export async function updateSettings(patch) {
try { parseSettings({ ...$state.snapshot(settings), ...patch }); } try { parseSettings({ ...$state.snapshot(settings), ...patch }); }
catch (error) { settingsError.message = error.message; return; } catch (error) { settingsError.message = error.message; return; }
Object.assign(settings, patch); const { loaded, ...base } = $state.snapshot(settings);
applyTheme(); const cleanPatch = JSON.parse(JSON.stringify(patch));
// `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);
try { 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; settingsError.message = null;
} catch (error) { } catch (error) {
settingsError.message = `Enregistrement impossible : ${error.message}`; 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 list = byDate.get(day.date) ?? [];
const dayEnd = day.status === 'active' ? now : day.endedTs; const dayEnd = day.status === 'active' ? now : day.endedTs;
const start = roundTs(day.startedTs, roundingMinutes), end = roundTs(dayEnd, roundingMinutes); 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 = []; const intervals = [];
for (const entry of list) { for (const entry of list) {
const startTs = roundTs(entry.startTs, roundingMinutes); 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); const ms = Math.max(0, endTs - startTs);
intervals.push([startTs, endTs]); intervals.push([startTs, endTs]);
if (!ms) quality.zeroEntries++; if (!ms) quality.zeroEntries++;
if (entry.kind === 'break') { row.breakMs += ms; continue; }
if (entry.kind === 'lunch') { row.lunchMs += ms; row.meal = true; continue; } if (entry.kind === 'lunch') { row.lunchMs += ms; row.meal = true; continue; }
row.workedMs += ms; row.workedMs += ms;
if (entry.kind === 'travel') row.travelMs += 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, 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, days: daily.length, closedDays: closed.length, activeDays: daily.length - closed.length,
entries: sum(daily, 'entries'), clientCount: clientRows.filter((row) => row.key !== null).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, mealDays: daily.filter((d) => d.meal).length, noMealDays: closed.filter((d) => !d.meal).length,
averageWorkMs: average(closed, 'workedMs'), medianWorkMs: medianMs, averageWorkMs: average(closed, 'workedMs'), medianWorkMs: medianMs,
averageLunchMs: average(mealsClosed, 'lunchMs'), averageSpanMs: average(closed, 'spanMs'), 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> <script>
import DayEditor from '../components/DayEditor.svelte'; import ReopenDialog from '../components/ReopenDialog.svelte';
import { dayState, reopenToday } from '../lib/day.svelte.js'; import { dayState } from '../lib/day.svelte.js';
import { buildResume, resumeToText } from '../lib/resume.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 { settings } from '../lib/settings.svelte.js';
import { fmtDayLabel, fmtDuration, weekday } from '../lib/time.js'; import { fmtDayLabel, fmtDuration, weekday } from '../lib/time.js';
let copied = $state(false); let copied = $state(false);
let selectedDate = $state(''); let selectedDate = $state(router.selectedDate);
let reopening = $state(false); let reopening = $state(false);
let error = $state(''); let error = $state('');
let editing = $state(false);
const selectedDay = $derived(dayState.closedDays.find((d) => d.date === selectedDate) ?? dayState.lastClosed); const selectedDay = $derived(dayState.closedDays.find((d) => d.date === selectedDate) ?? dayState.lastClosed);
const selectedEntries = $derived(dayState.historyEntries.filter((e) => e.date === selectedDay?.date)); const selectedEntries = $derived(dayState.historyEntries.filter((e) => e.date === selectedDay?.date));
@@ -31,15 +30,6 @@
const closedToday = $derived(selectedDay?.date === dayState.today && !dayState.day); const closedToday = $derived(selectedDay?.date === dayState.today && !dayState.day);
async function reopen() {
if (reopening) return;
reopening = true;
error = '';
try { await reopenToday(); go('today'); }
catch (cause) { error = `Reprise impossible : ${cause.message}`; }
finally { reopening = false; }
}
async function copy() { async function copy() {
try { try {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
@@ -57,7 +47,7 @@
{#if dayState.closedDays.length > 1} {#if dayState.closedDays.length > 1}
<label class="stack"> <label class="stack">
<span>Journée à consulter</span> <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)} {#each dayState.closedDays as day (day.date)}
<option value={day.date}>{fmtDayLabel(day.date)} {day.date.slice(0, 4)}</option> <option value={day.date}>{fmtDayLabel(day.date)} {day.date.slice(0, 4)}</option>
{/each} {/each}
@@ -76,6 +66,9 @@
<h1>{resume.label}</h1> <h1>{resume.label}</h1>
</div> </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"> <div class="card stack">
{#each resume.clients as client (client.name)} {#each resume.clients as client (client.name)}
<div class="client"> <div class="client">
@@ -112,6 +105,7 @@
<strong class="tnum">{fmtDuration(resume.workedMs)}</strong> <strong class="tnum">{fmtDuration(resume.workedMs)}</strong>
</div> </div>
<p class="dim small">Transport inclus ; repas et pauses exclus.</p>
{#if resume.omitted} {#if resume.omitted}
<p class="dim small"> <p class="dim small">
{resume.omitted} poste{resume.omitted > 1 ? 's' : ''} de moins de {settings.roundingMinutes} {resume.omitted} poste{resume.omitted > 1 ? 's' : ''} de moins de {settings.roundingMinutes}
@@ -123,21 +117,17 @@
<button type="button" class="btn-primary" onclick={copy}> <button type="button" class="btn-primary" onclick={copy}>
{copied ? '✓ Copié' : 'Copier le récapitulatif'} {copied ? '✓ Copié' : 'Copier le récapitulatif'}
</button> </button>
<button type="button" onclick={() => (editing = !editing)}>{editing ? 'Masquer les postes' : 'Corriger cette journée'}</button> <button type="button" onclick={() => { router.selectedDate = selectedDay.date; go('details'); }}>Voir les détails</button>
{#if editing}
{#key selectedDay.date}
<DayEditor day={selectedDay} entries={selectedEntries} onchange={() => (copied = false)} />
{/key}
{/if}
{/if} {/if}
{#if closedToday} {#if closedToday}
<button <button
type="button" type="button"
disabled={reopening} disabled={dayState.importing}
onclick={reopen}>Reprendre la journée</button 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)} {: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> <p class="dim center small">Aujourd’hui est bloqué comme jour non travaillé.</p>
{:else if !dayState.day && !dayState.todayRecord && startsAt} {:else if !dayState.day && !dayState.todayRecord && startsAt}
@@ -151,6 +141,8 @@
{/if} {/if}
</div> </div>
<ReopenDialog open={reopening} onclose={() => (reopening = false)} />
<style> <style>
.client + .client { .client + .client {
border-top: 2px solid var(--line); border-top: 2px solid var(--line);

View File

@@ -1,9 +1,9 @@
<script> <script>
import BackupPanel from '../components/BackupPanel.svelte'; import BackupPanel from '../components/BackupPanel.svelte';
import NonWorkingDays from '../components/NonWorkingDays.svelte';
import { go } from '../lib/router.svelte.js'; import { go } from '../lib/router.svelte.js';
import { AUTOMATION_OPTIONS } from '../lib/automation-options.js'; import Sheet from '../components/Sheet.svelte';
import { cleanupOldDays } from '../lib/db.js'; import { DAY, localDateKey } from '../lib/time.js';
import { cleanupOldDays, exportBackup, db } from '../lib/db.js';
import { dayState, refresh } from '../lib/day.svelte.js'; import { dayState, refresh } from '../lib/day.svelte.js';
import { import {
ROUNDING_CHOICES, ROUNDING_CHOICES,
@@ -14,6 +14,10 @@
} from '../lib/settings.svelte.js'; } from '../lib/settings.svelte.js';
let purged = $state(null); 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) { function toggleWorkday(value) {
const next = settings.workdays.includes(value) const next = settings.workdays.includes(value)
@@ -23,9 +27,15 @@
} }
async function purge() { async function purge() {
const n = await cleanupOldDays(settings.retentionDays); if (purgeBusy || dayState.importing) return;
await refresh(); purgeBusy = true; purgeError = '';
purged = n; 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> </script>
@@ -58,7 +68,7 @@
</div> </div>
</section> </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"> <section class="card stack">
<h2>Horaires</h2> <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> <p class="dim small">Utiliser ces horaires pour les rappels et, si activée ci-dessous, la pause automatique.</p>
</section> </section>
<section class="card stack"> <section class="card stack"><h2>Automatismes</h2><button onclick={() => go('automations')}>Gérer les automatismes</button></section>
<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"> <section class="card stack">
<h2>Arrondi</h2> <h2>Arrondi</h2>
@@ -189,9 +185,9 @@
sont justes. La dernière journée close est toujours conservée. sont justes. La dernière journée close est toujours conservée.
</p> </p>
<p class="dim small"> <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> </p>
<button type="button" onclick={purge}>Purger maintenant</button> <button type="button" onclick={() => (purgeOpen = true)}>Purger maintenant</button>
{#if purged !== null} {#if purged !== null}
<p class="dim small"> <p class="dim small">
{purged === 0 {purged === 0
@@ -202,6 +198,13 @@
</section> </section>
</fieldset> </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> <style>
fieldset.screen { fieldset.screen {
border: 0; border: 0;
@@ -244,7 +247,4 @@
border-color: var(--danger); border-color: var(--danger);
font-weight: 700; font-weight: 700;
} }
.automation-option + .automation-option { border-top: 2px solid var(--line); padding-top: 12px; }
label.automation-toggle { justify-content: flex-start; min-height: var(--tap); }
.automation-toggle input { width: 26px; height: 26px; accent-color: var(--accent); }
</style> </style>

View File

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

View File

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