Multiple minor changes and improvements
This commit is contained in:
32
README.md
32
README.md
@@ -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).
|
||||
|
||||
@@ -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 |
@@ -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'}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
36
src/components/ReopenDialog.svelte
Normal file
36
src/components/ReopenDialog.svelte
Normal 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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(); }
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
115
src/lib/break.test.js
Normal 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'));
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
21
src/lib/dialog.js
Normal 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(); } };
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(); }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
27
src/routes/Automations.svelte
Normal file
27
src/routes/Automations.svelte
Normal 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>
|
||||
17
src/routes/DayDetails.svelte
Normal file
17
src/routes/DayDetails.svelte
Normal 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>
|
||||
10
src/routes/NonWorkingSettings.svelte
Normal file
10
src/routes/NonWorkingSettings.svelte
Normal 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>
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user