Nested UI for data & storage options
All checks were successful
Tests et déploiement / validate (push) Successful in 1m55s
Tests et déploiement / deploy (push) Successful in 37s

This commit is contained in:
2026-10-10 12:08:06 +02:00
parent 0cb1e1678e
commit 485116f2da
6 changed files with 180 additions and 71 deletions

View File

@@ -13,6 +13,7 @@
import Automations from './routes/Automations.svelte';
import NonWorkingSettings from './routes/NonWorkingSettings.svelte';
import Stats from './routes/Stats.svelte';
import DataSettings from './routes/DataSettings.svelte';
let ready = $state(false);
let bootError = $state('');
@@ -58,8 +59,8 @@
{#if !ready}
<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 backupState.loaded && backupReminderDue(backupState.status, clockNow) && view !== 'settings'}
<div class="update"><span>Sauvegarde à vérifier</span><button disabled={dayState.importing} onclick={() => go('settings')}>Sauvegarder</button></div>
{#if backupState.loaded && backupReminderDue(backupState.status, clockNow) && !['settings', 'data'].includes(view)}
<div class="update"><span>Sauvegarde à vérifier</span><button disabled={dayState.importing} onclick={() => go('data')}>Sauvegarder</button></div>
{/if}
{#if needsRefresh.value && !dayState.day}
<!-- Only offered while no day is running, so an update can never interrupt
@@ -77,6 +78,8 @@
<Automations />
{:else if view === 'non-working'}
<NonWorkingSettings />
{:else if view === 'data'}
<DataSettings />
{:else if ['settings', 'automations', 'non-working'].includes(view)}
<Settings />
{:else if view === 'today'}
@@ -104,9 +107,9 @@
>
<button
type="button"
class:sel={['settings', 'automations', 'non-working'].includes(view)}
class:sel={['settings', 'automations', 'non-working', 'data'].includes(view)}
disabled={dayState.importing}
aria-current={['settings', 'automations', 'non-working'].includes(view)}
aria-current={['settings', 'automations', 'non-working', 'data'].includes(view)}
onclick={() => go('settings')}>Réglages</button
>
{#if view === 'stats'}

View File

@@ -126,7 +126,7 @@
</script>
<section class="card stack" aria-busy={busy}>
<h2>Données</h2>
<h2>Sauvegarde et restauration</h2>
<button type="button" disabled={busy} onclick={exportAll}>Exporter les données</button>

View File

@@ -6,7 +6,7 @@
* worker should land on.
*/
const ROUTES = ['resume', 'today', 'settings', 'stats', 'details', 'automations', 'non-working'];
const ROUTES = ['resume', 'today', 'settings', 'stats', 'details', 'automations', 'non-working', 'data'];
function fromHash() {
const [path, query = ''] = location.hash.replace(/^#\/?/, '').split('?');

View File

@@ -0,0 +1,121 @@
<script>
import BackupPanel from '../components/BackupPanel.svelte';
import Sheet from '../components/Sheet.svelte';
import { go } from '../lib/router.svelte.js';
import { purgeCandidates, purgeReviewedDays } from '../lib/backup-safety.js';
import { dayState, refresh } from '../lib/day.svelte.js';
import { settings, settingsError, updateSettings } from '../lib/settings.svelte.js';
let purged = $state(null);
let purgeOpen = $state(false);
let purgeError = $state('');
let purgeBusy = $state(false);
let reviewedPurge = $state([]);
let reviewedRetention = $state(0);
function reviewPurge() {
reviewedRetention = settings.retentionDays;
reviewedPurge = purgeCandidates(dayState.closedDays, reviewedRetention).map(day => ({
day: { ...day }, entries: dayState.historyEntries.filter(entry => entry.date === day.date).sort((a, b) => a.seq - b.seq).map(entry => ({ ...entry })),
}));
purgeError = ''; purgeOpen = true;
}
async function saveRetention(event) {
const input = event.currentTarget;
await updateSettings({ retentionDays: Number(input.value) });
input.value = String(settings.retentionDays);
}
async function purge() {
if (purgeBusy || dayState.importing) return;
purgeBusy = true; purgeError = '';
try {
purged = await purgeReviewedDays($state.snapshot(reviewedPurge), reviewedRetention);
await refresh(); purgeOpen = false;
} catch (error) { purgeError = error.message; }
finally { purgeBusy = false; }
}
</script>
<fieldset class="screen" disabled={dayState.importing}>
<h1>Données et sauvegardes</h1>
<button type="button" onclick={() => go('settings')}>Retour aux réglages</button>
<section class="card stack">
<h2>Vos données sur cet appareil</h2>
<p class="small">Vos journées, postes et réglages sont enregistrés dans ce navigateur, sur cet appareil, et restent accessibles hors ligne. Ils ne sont pas synchronisés entre vos appareils.</p>
<p class="small">Effacer les données du navigateur ou perdre cet appareil peut vous faire perdre votre historique. Exportez régulièrement un fichier, vérifiez-le puis gardez une copie ailleurs : vous pourrez le réimporter pour retrouver vos données ou les transférer sur un autre appareil.</p>
</section>
{#if settingsError.message}
<p class="error card" role="alert">{settingsError.message}</p>
{/if}
<BackupPanel />
<section class="card stack">
<h2>Durée de conservation</h2>
<label>
<span>Conserver (jours)</span>
<input
type="number"
min="30"
step="30"
value={settings.retentionDays}
onchange={saveRetention}
/>
</label>
<p class="dim small">
Cette durée détermine les journées anciennes proposées à la purge. La suppression
se fait uniquement avec « Purger maintenant », après confirmation ; changer la durée
ne supprime rien. La dernière journée terminée est toujours conservée.
</p>
<p class="dim small">
L’historique alimente les suggestions et les statistiques. La purge conserve une
sauvegarde récupérable sur cet appareil, remplacée à la prochaine purge. Exportez
aussi un fichier pour garder une copie ailleurs.
</p>
<button type="button" onclick={reviewPurge}>Purger maintenant</button>
{#if purged !== null}
<p class="dim small">
{purged === 0
? 'Aucune journée plus ancienne que la durée de conservation.'
: `${purged} journée${purged > 1 ? 's' : ''} supprimée${purged > 1 ? 's' : ''}.`}
</p>
{/if}
</section>
</fieldset>
<Sheet open={purgeOpen} title="Confirmer la purge" onclose={() => { if (!purgeBusy) purgeOpen = false; }}>
<p>Journées concernées : {reviewedPurge.length ? reviewedPurge.map(item => item.day.date).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 || !reviewedPurge.length} class="btn-danger" onclick={purge}>Confirmer la purge</button>
<button disabled={purgeBusy} onclick={() => (purgeOpen = false)}>Annuler</button>
</Sheet>
<style>
fieldset.screen {
border: 0;
margin: 0;
min-width: 0;
}
label {
display: flex;
flex-direction: column;
gap: var(--gap);
font-weight: 700;
}
.small {
font-size: 0.85rem;
}
.error {
color: var(--danger-fg);
background: var(--danger);
border-color: var(--danger);
font-weight: 700;
}
</style>

View File

@@ -1,9 +1,6 @@
<script>
import BackupPanel from '../components/BackupPanel.svelte';
import { go } from '../lib/router.svelte.js';
import Sheet from '../components/Sheet.svelte';
import { purgeCandidates, purgeReviewedDays } from '../lib/backup-safety.js';
import { dayState, refresh } from '../lib/day.svelte.js';
import { dayState } from '../lib/day.svelte.js';
import {
ROUNDING_CHOICES,
settings,
@@ -13,20 +10,6 @@
WEEKDAY_LABELS,
} from '../lib/settings.svelte.js';
let purged = $state(null);
let purgeOpen = $state(false);
let purgeError = $state('');
let purgeBusy = $state(false);
let reviewedPurge = $state([]);
let reviewedRetention = $state(0);
function reviewPurge() {
reviewedRetention = settings.retentionDays;
reviewedPurge = purgeCandidates(dayState.closedDays, reviewedRetention).map(day => ({
day: { ...day }, entries: dayState.historyEntries.filter(entry => entry.date === day.date).sort((a, b) => a.seq - b.seq).map(entry => ({ ...entry })),
}));
purgeError = ''; purgeOpen = true;
}
function toggleWorkday(value) {
return setWorkingDay(value, !settings.workdays.includes(value));
}
@@ -36,16 +19,6 @@
await updateSettings({ [key]: numeric ? Number(input.value) : input.value });
input.value = String(settings[key]);
}
async function purge() {
if (purgeBusy || dayState.importing) return;
purgeBusy = true; purgeError = '';
try {
purged = await purgeReviewedDays($state.snapshot(reviewedPurge), reviewedRetention);
await refresh(); purgeOpen = false;
} catch (error) { purgeError = error.message; }
finally { purgeBusy = false; }
}
</script>
<fieldset class="screen" disabled={dayState.importing}>
@@ -175,45 +148,13 @@
</div>
</section>
<BackupPanel />
<section class="card stack">
<h2>Historique</h2>
<label>
<span>Conserver (jours)</span>
<input
type="number"
min="30"
step="30"
value={settings.retentionDays}
onchange={(e) => saveField(e, 'retentionDays', true)}
/>
</label>
<p class="dim small">
L'historique alimente les suggestions : plus il est court, moins les propositions
sont justes. La dernière journée close est toujours conservée.
</p>
<p class="dim small">
La purge nécessite une confirmation et conserve une sauvegarde locale récupérable.
</p>
<button type="button" onclick={reviewPurge}>Purger maintenant</button>
{#if purged !== null}
<p class="dim small">
{purged === 0
? 'Aucune journée plus ancienne que la durée de conservation.'
: `${purged} journée${purged > 1 ? 's' : ''} supprimée${purged > 1 ? 's' : ''}.`}
</p>
{/if}
<h2>Données et sauvegardes</h2>
<p class="dim small">Sauvegarder, exporter ou importer les données et régler leur durée de conservation.</p>
<button type="button" onclick={() => go('data')}>Gérer les données et sauvegardes</button>
</section>
</fieldset>
<Sheet open={purgeOpen} title="Confirmer la purge" onclose={() => { if (!purgeBusy) purgeOpen = false; }}>
<p>Journées concernées : {reviewedPurge.length ? reviewedPurge.map(item => item.day.date).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 || !reviewedPurge.length} class="btn-danger" onclick={purge}>Confirmer la purge</button>
<button disabled={purgeBusy} onclick={() => (purgeOpen = false)}>Annuler</button>
</Sheet>
<style>
fieldset.screen {
border: 0;

View File

@@ -12,6 +12,8 @@ async function importFixture(page, payload = fixture()) {
await page.clock.install({ time:now });
await page.goto('/#/settings');
await expect(page.getByRole('heading',{name:'Réglages',exact:true})).toBeVisible();
await page.getByRole('button',{name:'Gérer les données et sauvegardes'}).click();
await expect(page.getByRole('heading',{name:'Données et sauvegardes',exact:true})).toBeVisible();
await page.locator('input[type=file]').setInputFiles({ name:'fixture-synthetique.json',mimeType:'application/json',buffer:Buffer.from(JSON.stringify(payload)) });
await page.getByRole('button',{name:'Remplacer tout',exact:true}).click();
await page.getByRole('dialog',{name:'Remplacer les données'}).getByRole('button',{name:'Confirmer',exact:true}).click();
@@ -88,6 +90,42 @@ test('date conservée, rechargement et Retour depuis les trois nouvelles pages',
await expect(page.getByRole('heading',{name:'Réglages',exact:true})).toBeVisible();
});
test('écran données dédié, conservation persistée et accès depuis le rappel', async ({page}) => {
await importFixture(page,fixture({dates:['2025-01-01',date]}));
await page.getByRole('button',{name:'Réglages',exact:true}).click();
await expect(page.getByRole('button',{name:'Exporter les données',exact:true})).toHaveCount(0);
await expect(page.getByLabel('Conserver (jours)')).toHaveCount(0);
await page.getByRole('button',{name:'Gérer les données et sauvegardes'}).click();
await expect(page.getByRole('heading',{name:'Données et sauvegardes',exact:true})).toBeVisible();
await expect(page.locator('nav').getByRole('button',{name:'Réglages',exact:true})).toHaveAttribute('aria-current','true');
const retention = page.getByLabel('Conserver (jours)');
await expect(retention).toHaveValue('30');
await retention.fill('60');
await retention.press('Tab');
await expect.poll(() => page.evaluate(() => new Promise((resolve,reject) => {
const request = indexedDB.open('TimeLogDB');
request.onerror = () => reject(request.error);
request.onsuccess = () => {
const db = request.result;
const query = db.transaction('settings').objectStore('settings').get('app');
query.onsuccess = () => { resolve(query.result.value.retentionDays); db.close(); };
query.onerror = () => { reject(query.error); db.close(); };
};
}))).toBe(60);
await page.reload();
await expect(retention).toHaveValue('60');
expect(await entries(page)).toHaveLength(6);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
await page.getByRole('button',{name:'Retour aux réglages'}).click();
await expect(page.getByRole('heading',{name:'Réglages',exact:true})).toBeVisible();
await page.getByRole('button',{name:'Gérer les données et sauvegardes'}).click();
await page.goBack();
await expect(page.getByRole('heading',{name:'Réglages',exact:true})).toBeVisible();
await page.getByRole('button',{name:'Récap',exact:true}).click();
await page.getByRole('button',{name:'Sauvegarder',exact:true}).click();
await expect(page.getByRole('heading',{name:'Données et sauvegardes',exact:true})).toBeVisible();
});
test('reprise annulée puis concurrente entre deux onglets, choix pause par défaut', async ({page,context}) => {
await importFixture(page,fixture({pause:false}));
const before = await entries(page);
@@ -101,7 +139,11 @@ test('reprise annulée puis concurrente entre deux onglets, choix pause par déf
await other.clock.install({time:now}); await other.goto('/#/today');
await Promise.all([page.getByRole('button',{name:'Reprendre la journée',exact:true}).click(),other.getByRole('button',{name:'Reprendre la journée',exact:true}).click()]);
await Promise.all([page.getByRole('button',{name:'Confirmer la reprise'}).dblclick(),other.getByRole('button',{name:'Confirmer la reprise'}).click()]);
await expect(page.getByRole('heading',{name:'En cours',exact:true})).toBeVisible();
// Either tab may win the transaction; the resumed entries must remain unique.
await expect.poll(async () =>
await page.getByRole('heading',{name:'En cours',exact:true}).isVisible() ||
await other.getByRole('heading',{name:'En cours',exact:true}).isVisible()
).toBe(true);
const rows = await entries(page);
expect(rows.map(row=>row.kind)).toEqual(['work','break','work']);
expect(rows[0].endTs).toBe(at('17:00')); expect(rows[1].startTs).toBe(at('17:00'));
@@ -136,6 +178,7 @@ for (const kind of ['lunch','break']) test(`reprise après ${kind} garde sa natu
test('export relu et vérifié, PWA relancée hors ligne', async ({page,context}) => {
await importFixture(page);
await page.getByRole('button',{name:'Réglages',exact:true}).click();
await page.getByRole('button',{name:'Gérer les données et sauvegardes'}).click();
const downloading = page.waitForEvent('download');
await page.getByRole('button',{name:'Exporter les données',exact:true}).click();
const download = await downloading;
@@ -147,7 +190,7 @@ test('export relu et vérifié, PWA relancée hors ligne', async ({page,context}
await expect.poll(() => page.evaluate(() => Boolean(navigator.serviceWorker.controller))).toBe(true);
await context.setOffline(true);
await page.reload();
await expect(page.getByRole('heading',{name:'Réglages',exact:true})).toBeVisible();
await expect(page.getByRole('heading',{name:'Données et sauvegardes',exact:true})).toBeVisible();
await page.getByRole('button',{name:'Récap',exact:true}).click();
await expect(page.locator('.total')).toContainText('8h30');
});
@@ -168,6 +211,7 @@ test('échec IndexedDB visible et reprise avec Réessayer', async ({page}) => {
test('purge confirmée et restauration de sa sauvegarde récupérable', async ({page}) => {
await importFixture(page,fixture({dates:['2025-01-01',date]}));
await page.getByRole('button',{name:'Réglages',exact:true}).click();
await page.getByRole('button',{name:'Gérer les données et sauvegardes'}).click();
await page.getByRole('button',{name:'Purger maintenant'}).click();
const dialog = page.getByRole('dialog',{name:'Confirmer la purge'});
await expect(dialog).toContainText('2025-01-01');