Nested UI for data & storage options
This commit is contained in:
@@ -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'}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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('?');
|
||||
|
||||
121
src/routes/DataSettings.svelte
Normal file
121
src/routes/DataSettings.svelte
Normal 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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user