Minor UI changes
Some checks failed
Tests et déploiement / validate (push) Failing after 3m19s
Tests et déploiement / deploy (push) Has been skipped

This commit is contained in:
2026-10-11 09:16:57 +02:00
parent 0be1d54347
commit e02730f5db
7 changed files with 62 additions and 57 deletions

View File

@@ -1,8 +1,7 @@
<script>
import { db } from './lib/db.js';
import { dayState, reconcile, refresh } from './lib/day.svelte.js';
import { backupState, loadBackupStatus } from './lib/backup-state.svelte.js';
import { backupReminderDue } from './lib/backup-safety.js';
import { loadBackupStatus } from './lib/backup-state.svelte.js';
import { go, router } from './lib/router.svelte.js';
import { loadSettings, settings } from './lib/settings.svelte.js';
import { needsRefresh, applyUpdate } from './lib/updates.svelte.js';
@@ -19,7 +18,15 @@
import { loadSyncStatus, synchronize, takeAutomationRole } from './lib/sync/client.js';
import { syncState } from './lib/sync/state.svelte.js';
let roleDismissed = $state(false);
const roleNoticeDismissedKey = 'pardis:automation-role-notice-dismissed';
function wasRoleNoticeDismissed() {
try { return localStorage.getItem(roleNoticeDismissedKey) === '1'; } catch { return false; }
}
let roleDismissed = $state(wasRoleNoticeDismissed());
function dismissRoleNotice() {
roleDismissed = true;
try { localStorage.setItem(roleNoticeDismissedKey, '1'); } catch { /* Keep the dismissal for this app session. */ }
}
let roleError = $state('');
async function takeRole() {
try { await takeAutomationRole(); roleError = ''; await tick(); }
@@ -29,8 +36,7 @@
let ready = $state(false);
let bootError = $state('');
let retry = $state(0);
let clockNow = $state(Date.now());
async function tick() { if (!ready) return; try { await db.open(); if (router.route === 'sync' && !syncState.linked) await refresh(); else await reconcile(); await loadBackupStatus(); clockNow = Date.now(); bootError = ''; } catch (error) { bootError = `Actualisation impossible : ${error.message}`; } }
async function tick() { if (!ready) return; try { await db.open(); if (router.route === 'sync' && !syncState.linked) await refresh(); else await reconcile(); await loadBackupStatus(); bootError = ''; } catch (error) { bootError = `Actualisation impossible : ${error.message}`; } }
$effect(() => {
retry;
@@ -91,12 +97,9 @@
<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 syncState.enabled && syncState.owner && syncState.owner !== syncState.deviceId && !roleDismissed && view !== 'sync'}
<div class="update"><span>Les automatismes sont exécutés sur un autre appareil.</span><button disabled={syncState.busy} onclick={takeRole}>Utiliser cet appareil pour les automatismes</button><button onclick={() => { roleDismissed = true; }}>Plus tard</button></div>
<div class="update"><span>Les automatismes sont exécutés sur un autre appareil.</span><button disabled={syncState.busy} onclick={takeRole}>Utiliser cet appareil pour les automatismes</button><button onclick={dismissRoleNotice}>Plus tard</button></div>
{#if roleError}<p role="status">{roleError}</p>{/if}
{/if}
{#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
a day in progress. -->

View File

@@ -13,7 +13,7 @@
parseBackup,
} from '../lib/backup.js';
import { applyImport, dayState } from '../lib/day.svelte.js';
import { prepareVerifiedExport, verifyExportFile, backupReminderDue } from '../lib/backup-safety.js';
import { prepareVerifiedExport, verifyExportFile } from '../lib/backup-safety.js';
import { backupState, loadBackupStatus } from '../lib/backup-state.svelte.js';
import { db } from '../lib/db.js';
import { syncState } from '../lib/sync/state.svelte.js';
@@ -133,7 +133,6 @@
<p class="dim small">Dernier export préparé : {backupState.status.exportedAt ? new Date(backupState.status.exportedAt).toLocaleString('fr-FR') : 'aucun'}.</p>
<p class="dim small">Dernière sauvegarde vérifiée : {backupState.status.verifiedExportedAt ? new Date(backupState.status.verifiedExportedAt).toLocaleString('fr-FR') : 'aucune'}.</p>
{#if backupState.loaded && backupReminderDue(backupState.status)}<p role="status">Pensez à sauvegarder : exportez puis vérifiez le fichier enregistré. Un rappel apparaît après sept jours sans sauvegarde vérifiée.</p>{/if}
<button disabled={busy || !backupState.status.checksum} onclick={() => { verifying = true; picker.click(); }}>Vérifier le fichier exporté…</button>
<p class="dim small">Choisissez le fichier téléchargé : son contenu complet sera comparé à l’export. Aucune journée ne sera modifiée.</p>
<button disabled={busy} onclick={recoverPurge}>Récupérer la sauvegarde avant purge</button>

View File

@@ -2,8 +2,6 @@ import { db, exportBackup, readSettings } from './db.js';
import { parseBackup } from './backup.js';
import { DAY, localDateKey } from './time.js';
export const BACKUP_REMINDER_DAYS = 7;
export async function readBackupStatus() {
return (await db.settings.get('backup-status'))?.value ?? {};
}
@@ -44,11 +42,6 @@ export async function verifyExportFile(text, now = Date.now()) {
});
}
export function backupReminderDue(status, now = Date.now()) {
const verified = Date.parse(status.verifiedExportedAt ?? '');
return !Number.isFinite(verified) || now - verified >= BACKUP_REMINDER_DAYS * DAY;
}
export function purgeCandidates(days, retentionDays, now = Date.now()) {
const closed = days.filter(day => day.status === 'closed').sort((a, b) => b.date.localeCompare(a.date));
const cutoff = localDateKey(now - retentionDays * DAY);

View File

@@ -1,9 +1,9 @@
import 'fake-indexeddb/auto';
import { beforeEach, expect, it, vi } from 'vitest';
import { db, getDay, getEntries, importBackup } from './db.js';
import { backupReminderDue, prepareVerifiedExport, purgeCandidates, purgeReviewedDays, readBackupStatus, verifyExportFile } from './backup-safety.js';
import { prepareVerifiedExport, purgeCandidates, purgeReviewedDays, readBackupStatus, verifyExportFile } from './backup-safety.js';
import { parseBackup } from './backup.js';
import { atLocalTime, DAY } from './time.js';
import { atLocalTime } from './time.js';
const now = atLocalTime('2026-10-08', '18:00');
const record = date => ({ date, status: 'closed', startedTs: atLocalTime(date,'08:00'), endedTs: atLocalTime(date,'17:00'), plannedStartTs: atLocalTime(date,'08:00'), plannedEndTs: atLocalTime(date,'17:00'), lunchType: 'panier' });
@@ -24,11 +24,6 @@ it('only verifies the complete exported file, without altering the history', asy
const corrupted = JSON.parse(prepared.text); corrupted.entries[0].task = 'Changed';
await expect(verifyExportFile(JSON.stringify(corrupted))).rejects.toThrow(/ne correspond pas/);
});
it('reminds after seven days from the verified export, rather than the day it was checked', () => {
expect(backupReminderDue({},now)).toBe(true);
expect(backupReminderDue({verifiedExportedAt:new Date(now-6*DAY).toISOString()},now)).toBe(false);
expect(backupReminderDue({verifiedExportedAt:new Date(now-7*DAY).toISOString(),verifiedAt:now},now)).toBe(true);
});
it('preserves an importable snapshot and purges only the dates reviewed', async () => {
await install('2025-01-01'); await install('2026-10-08');
const old = await reviewed('2025-01-01');

View File

@@ -24,16 +24,26 @@
<fieldset class="screen" disabled={dayState.importing}>
<h1>Réglages</h1>
<section class="card stack">
<h2>Statistiques</h2>
<p class="dim small">Heures par client et tâche, repas et évolution de l’activité.</p>
<button type="button" onclick={() => go('stats')}>Consulter les statistiques</button>
</section>
{#if settingsError.message}
<p class="error card">{settingsError.message}</p>
{/if}
<section class="card stack">
<h2>Affichage</h2>
<div class="btn-row">
<button
type="button"
class:sel={settings.theme === 'jour'}
onclick={() => updateSettings({ theme: 'jour' })}>Jour</button
>
<button
type="button"
class:sel={settings.theme === 'nuit'}
onclick={() => updateSettings({ theme: 'nuit' })}>Nuit</button
>
</div>
</section>
<section class="card stack">
<h2>Jours travaillés</h2>
<div class="days">
@@ -108,11 +118,9 @@
>
</div>
<p class="dim small">Type par défaut ; modifiable chaque jour.</p>
<p class="dim small">Utiliser ces horaires pour les rappels et, si activée ci-dessous, la pause automatique.</p>
<p class="dim small">Utiliser ces horaires pour les rappels et, si activée, la pause automatique.</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>
<div class="btn-row">
@@ -132,29 +140,22 @@
</p>
</section>
<section class="card stack">
<h2>Affichage</h2>
<div class="btn-row">
<button
type="button"
class:sel={settings.theme === 'jour'}
onclick={() => updateSettings({ theme: 'jour' })}>Jour</button
>
<button
type="button"
class:sel={settings.theme === 'nuit'}
onclick={() => updateSettings({ theme: 'nuit' })}>Nuit</button
>
</div>
</section>
<section class="card stack"><h2>Automatismes</h2><button onclick={() => go('automations')}>Gérer les automatismes</button></section>
<section class="card stack">
<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>
<p class="dim small">Pensez à exporter et vérifier régulièrement une sauvegarde pour pouvoir restaurer vos données en cas de perte de l’appareil.</p>
<button type="button" onclick={() => go('data')}>Gérer les données et sauvegardes</button>
</section>
</fieldset>
<section class="card stack">
<h2>Statistiques</h2>
<p class="dim small">Heures par client et tâche, repas et évolution de l’activité.</p>
<button type="button" onclick={() => go('stats')}>Consulter les statistiques</button>
</section>
<style>
fieldset.screen {
border: 0;

View File

@@ -13,6 +13,7 @@ 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 expect(page.getByText('Pensez à exporter et vérifier régulièrement une sauvegarde pour pouvoir restaurer vos données en cas de perte de l’appareil.',{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)) });
@@ -21,6 +22,11 @@ async function importFixture(page, payload = fixture()) {
await expect(page.getByRole('status').filter({hasText:'Données remplacées'})).toBeVisible();
await page.getByRole('button',{name:'Récap',exact:true}).click();
}
async function openDataSettings(page) {
await page.getByRole('button',{name:'Réglages',exact:true}).click();
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();
}
async function entries(page) {
return page.evaluate(() => new Promise((resolve,reject) => {
const request = indexedDB.open('TimeLogDB');
@@ -88,8 +94,7 @@ test('consultation, correction, recalcul, copie et statistiques à 320 px', asyn
await expect(page.getByText('Heures de présence',{exact:true})).toBeVisible();
await expect(page.getByTestId('stats-worked')).toHaveText('9h00');
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
// Wider or larger fonts must also fit the backup reminder on CI and mobile.
await expect(page.locator('.update')).toContainText('Sauvegarde à vérifier');
await expect(page.getByText('Sauvegarde à vérifier',{exact:true})).toHaveCount(0);
await page.addStyleTag({content:'.update { font-family: monospace; font-size: 24px; }'});
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
});
@@ -113,7 +118,7 @@ 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}) => {
test('écran données dédié, conservation persistée et accès depuis les réglages', 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);
@@ -144,14 +149,13 @@ test('écran données dédié, conservation persistée et accès depuis le rappe
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 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();
});
test('échec de conservation restaure le champ et les données, puis permet de réessayer', async ({page}) => {
await importFixture(page,fixture({dates:['2025-01-01',date]}));
await page.getByRole('button',{name:'Sauvegarder',exact:true}).click();
await openDataSettings(page);
const input = page.getByLabel('Conserver (jours)');
await expect(input).toHaveValue('30');
const before = await storedData(page);
@@ -250,7 +254,7 @@ test('export relu et vérifié, PWA relancée hors ligne', async ({page,context}
test('export modifié refusé sans écriture, puis vérification du fichier original', async ({page}) => {
await importFixture(page);
await page.getByRole('button',{name:'Sauvegarder',exact:true}).click();
await openDataSettings(page);
const downloading = page.waitForEvent('download');
await page.getByRole('button',{name:'Exporter les données',exact:true}).click();
const download = await downloading;
@@ -311,7 +315,7 @@ test('purge confirmée et restauration de sa sauvegarde récupérable', async ({
test('échec de purge conserve les journées et la sauvegarde précédente, puis permet de réessayer', async ({page}) => {
await importFixture(page,fixture({dates:['2025-01-01',date]}));
await page.getByRole('button',{name:'Sauvegarder',exact:true}).click();
await openDataSettings(page);
const dialog = page.getByRole('dialog',{name:'Confirmer la purge'});
await page.getByRole('button',{name:'Purger maintenant'}).click();
await dialog.getByRole('button',{name:'Confirmer la purge'}).click();

View File

@@ -102,6 +102,16 @@ test('association, modification hors ligne, conflit, rôle et révocation entre
await page.getByRole('button', { name: 'Retour aux appareils', exact: true }).click();
await sync(page);
await expect(page.getByRole('button', { name: 'Utiliser cet appareil pour les automatismes', exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Retour aux données', exact: true }).click();
const roleNotice = page.getByText('Les automatismes sont exécutés sur un autre appareil.', { exact: true });
await expect(roleNotice).toBeVisible();
await page.getByRole('button', { name: 'Plus tard', exact: true }).click();
await expect(roleNotice).toHaveCount(0);
await page.reload();
await expect(page.getByRole('heading', { name: 'Données et sauvegardes', exact: true })).toBeVisible();
await expect(roleNotice).toHaveCount(0);
await page.goto('/#/sync');
await expect(page.getByRole('heading', { name: 'Synchronisation et appareils', exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Retirer cet appareil…', exact: true }).click();
await page.getByRole('button', { name: 'Confirmer le retrait', exact: true }).click();
await expect(page.getByRole('button', { name: 'Synchroniser maintenant', exact: true })).toBeEnabled();