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

Added about page
This commit is contained in:
2026-10-11 10:19:41 +02:00
parent e02730f5db
commit b11fe56993
14 changed files with 210 additions and 24 deletions

View File

@@ -7,7 +7,7 @@
# reachable from the internet — port 80 is what the ACME challenge uses. # reachable from the internet — port 80 is what the ACME challenge uses.
SITE_ADDRESS=pardis.example.com SITE_ADDRESS=pardis.example.com
# Notified by Let's Encrypt before a certificate expires. Optional but advised. # Required contact address for Let's Encrypt certificate management.
ACME_EMAIL=you@example.com ACME_EMAIL=you@example.com
# Adresse locale d’écoute ; laisser 0.0.0.0 pour toutes les interfaces. # Adresse locale d’écoute ; laisser 0.0.0.0 pour toutes les interfaces.

View File

@@ -54,8 +54,12 @@ Commandes : `npx playwright install chromium` puis `npm run validate:all`.
Un navigateur installé peut être choisi avec `PARDIS_CHROME_PATH`. Un navigateur installé peut être choisi avec `PARDIS_CHROME_PATH`.
La CI installe Chromium avec ses dépendances et lance les mêmes parcours. La CI installe Chromium avec ses dépendances et lance les mêmes parcours.
`docker compose config --quiet` a été validé avec des variables d’exemple. `docker compose config --quiet` a été validé avec des variables d’exemple.
Le build et le lancement de l’image Docker n’ont pas été exécutés ici (accès au Cette sandbox ne pouvait pas lancer les conteneurs (socket Docker indisponible),
socket Docker indisponible dans le bac à sable). mais l'opérateur confirme que le déploiement Docker de production et son job
CI/CD fonctionnent et sont en service depuis plusieurs commits. La VM qui héberge
l'instance reçoit une sauvegarde quotidienne. Celle-ci protège l'hébergement et
le service de synchronisation, mais pas les données locales conservées dans les
navigateurs des utilisateurs.
## Vérifications sur appareils réels à terminer avant publication ## Vérifications sur appareils réels à terminer avant publication

View File

@@ -263,6 +263,7 @@ docker compose up -d --build
Requirements: Requirements:
- `SITE_ADDRESS` must already resolve to the server. - `SITE_ADDRESS` must already resolve to the server.
- `ACME_EMAIL` is required and must be a valid contact address.
- Ports **80 and 443** must be reachable from the internet. Port 80 is not optional — - Ports **80 and 443** must be reachable from the internet. Port 80 is not optional —
it is what the ACME challenge uses to prove domain ownership. it is what the ACME challenge uses to prove domain ownership.

View File

@@ -62,6 +62,8 @@ http://{$SITE_ADDRESS} {
header { header {
Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'" Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'"
# Apply HSTS to this site address only; deliberately omit includeSubDomains.
Strict-Transport-Security "max-age=31536000"
X-Content-Type-Options nosniff X-Content-Type-Options nosniff
X-Frame-Options DENY X-Frame-Options DENY
Referrer-Policy strict-origin-when-cross-origin Referrer-Policy strict-origin-when-cross-origin

View File

@@ -14,6 +14,7 @@
import Stats from './routes/Stats.svelte'; import Stats from './routes/Stats.svelte';
import DataSettings from './routes/DataSettings.svelte'; import DataSettings from './routes/DataSettings.svelte';
import SyncSettings from './routes/SyncSettings.svelte'; import SyncSettings from './routes/SyncSettings.svelte';
import About from './routes/About.svelte';
import { liveQuery } from 'dexie'; import { liveQuery } from 'dexie';
import { loadSyncStatus, synchronize, takeAutomationRole } from './lib/sync/client.js'; import { loadSyncStatus, synchronize, takeAutomationRole } from './lib/sync/client.js';
import { syncState } from './lib/sync/state.svelte.js'; import { syncState } from './lib/sync/state.svelte.js';
@@ -120,6 +121,8 @@
<DataSettings /> <DataSettings />
{:else if view === 'sync'} {:else if view === 'sync'}
<SyncSettings /> <SyncSettings />
{:else if view === 'about'}
<About />
{:else if ['settings', 'automations', 'non-working'].includes(view)} {:else if ['settings', 'automations', 'non-working'].includes(view)}
<Settings /> <Settings />
{:else if view === 'today'} {:else if view === 'today'}

View File

@@ -8,6 +8,27 @@
* - Nothing tappable is under 64px; primary actions are 88px. * - Nothing tappable is under 64px; primary actions are 88px.
*/ */
/*
* When the font files are ready, add their @font-face declarations here and
* place them under public/fonts/ (for example, /fonts/pardis-text.woff2).
* Keeping these declarations commented avoids requests for files not yet shipped.
*
* @font-face {
* font-family: 'Pardis Text';
* src: url('/fonts/pardis-text.woff2') format('woff2');
* font-style: normal;
* font-weight: 400;
* font-display: swap;
* }
* @font-face {
* font-family: 'Pardis Headings';
* src: url('/fonts/pardis-headings.woff2') format('woff2');
* font-style: normal;
* font-weight: 400;
* font-display: swap;
* }
*/
:root { :root {
--bg: #ffffff; --bg: #ffffff;
--fg: #000000; --fg: #000000;
@@ -31,9 +52,10 @@
--radius: 10px; --radius: 10px;
--border: 3px; --border: 3px;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif; --font-body: 'Pardis Text', system-ui, 'Segoe UI', Roboto, sans-serif;
--font-heading: 'Pardis Headings', system-ui, 'Segoe UI', Roboto, sans-serif;
font-family: var(--sans); font-family: var(--font-body);
font-size: 20px; font-size: 20px;
line-height: 1.35; line-height: 1.35;
color: var(--fg); color: var(--fg);
@@ -44,12 +66,12 @@
:root[data-theme='nuit'] { :root[data-theme='nuit'] {
--bg: #000000; --bg: #000000;
--fg: #ffffff; --fg: #e2bfbf;
--fg-dim: #cccccc; --fg-dim: #dba7a7;
--surface: #1a1a1a; --surface: #1a1a1a;
--line: #ffffff; --line: #774a4a;
--accent: #ffb000; /* amber reads well at night and preserves dark adaptation */ --accent: #e63100; /* amber reads well at night and preserves dark adaptation */
--accent-fg: #000000; --accent-fg: #000000;
--accent-soft: #3a2a00; --accent-soft: #3a2a00;
@@ -89,6 +111,7 @@ body {
h1, h1,
h2, h2,
h3 { h3 {
font-family: var(--font-heading);
margin: 0; margin: 0;
font-weight: 800; font-weight: 800;
letter-spacing: -0.01em; letter-spacing: -0.01em;

View File

@@ -0,0 +1,19 @@
<footer class="screen-footer">
<a href="#/about">À propos de Pardis</a>
</footer>
<style>
.screen-footer {
margin-top: auto;
padding-top: var(--pad);
text-align: center;
}
a {
display: inline-flex;
min-height: var(--tap);
align-items: center;
color: var(--fg-dim);
text-underline-offset: 0.2em;
}
</style>

View File

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

83
src/routes/About.svelte Normal file
View File

@@ -0,0 +1,83 @@
<script>
import { back } from '../lib/router.svelte.js';
</script>
<main class="screen">
<button type="button" onclick={back}>Retour</button>
<h1>À propos de Pardis</h1>
<p>
Pardis est une application hors ligne de suivi des heures de chantier. Elle
enregistre les journées, les tâches, les clients, les repas et les pauses,
puis prépare un récapitulatif à consulter ou à copier.
</p>
<section class="card stack">
<h2>Fonctionnement</h2>
<p>
Les données sont enregistrées dans le stockage du navigateur sur cet
appareil. L’application fonctionne hors ligne après son premier chargement.
Elle n’exécute pas les automatismes lorsque le navigateur ou la PWA est
fermé ou suspendu ; elle rattrape les étapes prévues à la prochaine
ouverture.
</p>
<p>
Les horaires automatiques sont des estimations. Vérifiez les journées
entièrement déduites avant de vous servir de leur récapitulatif.
</p>
</section>
<section class="card stack">
<h2>Données et confidentialité</h2>
<p>
Pardis ne demande ni compte, ni adresse e-mail, ni mot de passe. Aucun
service d’analyse ou de suivi publicitaire n’est chargé. Les statistiques
sont calculées sur l’appareil.
</p>
<p>
L’historique local reste dans le navigateur jusqu’à sa suppression par
l’utilisateur ou à l’effacement du stockage du site. Le réglage de
conservation détermine quelles journées peuvent être purgées ; la purge
est manuelle. Le stockage du navigateur n’est pas chiffré par Pardis et
reste accessible à cette origine sur un navigateur déverrouillé. Un export
peut être conservé ailleurs, mais il n’est pas chiffré.
</p>
<p>
La synchronisation entre appareils est facultative. Lorsqu’elle est
activée, le contenu des journées et des réglages synchronisés est chiffré
de bout en bout. Le serveur ne peut pas lire cet historique, mais reçoit
des métadonnées nécessaires au service : adresse IP, identifiants et clés
publiques des appareils, liens entre révisions, tailles et dates de
connexion. Ces données sont conservées un an, conformément à la loi.
</p>
<p>
Supprimer une journée de l’historique synchronisé ne supprime pas ses
anciennes révisions chiffrées du coffre. Elles y restent jusqu’à la
suppression du coffre. Les sauvegardes de l’hébergement peuvent aussi
conserver des données supprimées jusqu’à l’expiration de leur rétention.
</p>
</section>
<section class="card stack">
<h2>Opérateur de cette instance</h2>
<p>
Le service de synchronisation de cette instance est hébergé sur un serveur
sauvegardé quotidiennement. Les sauvegardes de ce serveur
protègent le service en ligne ; elles ne sauvegardent pas les données
conservées uniquement dans le navigateur de chaque utilisateur.
</p>
<p>
L'application est développée par Adrien Weiskopff.
L'application et son serveur de production sont hébergés en France par
clawd.fr (EI Adrien Weiskopff).
Pour plus d'informations rendez-vous sur le site <a href="https://clawd.fr" target="_blank" title="vers le site de l'hébergeur">https://clawd.fr</a> ou écrivez
à l'adresse <a href="mailto:pardis@clawd.fr" title="contacter le développeur">pardis@clawd.fr</a>.
</p>
</section>
</main>
<style>
.screen > p,
.card p {
line-height: 1.5;
}
</style>

View File

@@ -5,10 +5,10 @@
import { go } from '../lib/router.svelte.js'; import { go } from '../lib/router.svelte.js';
</script> </script>
<fieldset class="screen" disabled={dayState.importing}> <fieldset class="screen" disabled={dayState.importing}>
<p class="dim">Réglages</p><button onclick={() => go('settings')}>Retour aux réglages</button> <h1>Automatismes</h1>
<button type="button" onclick={() => go('settings')}>Retour aux réglages</button>
{#if settingsError.message}<p role="alert">{settingsError.message}</p>{/if} {#if settingsError.message}<p role="alert">{settingsError.message}</p>{/if}
<section class="card stack"> <section class="card stack">
<h2>Automatismes</h2>
{#each AUTOMATION_OPTIONS as option (option.key)} {#each AUTOMATION_OPTIONS as option (option.key)}
<div class="automation-option stack"> <div class="automation-option stack">
<label class="automation-toggle"> <label class="automation-toggle">

View File

@@ -1,5 +1,6 @@
<script> <script>
import ReopenDialog from '../components/ReopenDialog.svelte'; import ReopenDialog from '../components/ReopenDialog.svelte';
import AboutLink from '../components/AboutLink.svelte';
import { dayState } from '../lib/day.svelte.js'; import { dayState } from '../lib/day.svelte.js';
import { buildResume, resumeToText } from '../lib/resume.js'; import { buildResume, resumeToText } from '../lib/resume.js';
import { go, router } from '../lib/router.svelte.js'; import { go, router } from '../lib/router.svelte.js';
@@ -46,7 +47,7 @@
{#if error}<p role="alert">{error}</p>{/if} {#if error}<p role="alert">{error}</p>{/if}
{#if dayState.closedDays.length > 1} {#if dayState.closedDays.length > 1}
<label class="stack"> <label class="stack">
<span>Journée à consulter</span> <h1>Récapitulatif</h1>
<select value={selectedDay?.date} onchange={(event) => { router.selectedDate = event.currentTarget.value; copied = false; }}> <select value={selectedDay?.date} onchange={(event) => { router.selectedDate = event.currentTarget.value; copied = false; }}>
{#each dayState.closedDays as day (day.date)} {#each dayState.closedDays as day (day.date)}
<option value={day.date}>{fmtDayLabel(day.date)} {day.date.slice(0, 4)}</option> <option value={day.date}>{fmtDayLabel(day.date)} {day.date.slice(0, 4)}</option>
@@ -62,9 +63,6 @@
</p> </p>
</div> </div>
{:else} {:else}
<div class="row-between">
<h1>{resume.label}</h1>
</div>
{#if selectedEntries.length && selectedEntries.every(e => e.automatic === true)} {#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> <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>
@@ -139,6 +137,8 @@
{:else if !dayState.day && !dayState.todayRecord} {:else if !dayState.day && !dayState.todayRecord}
<p class="dim center small">Aujourd'hui n'est pas un jour travaillé.</p> <p class="dim center small">Aujourd'hui n'est pas un jour travaillé.</p>
{/if} {/if}
<AboutLink />
</div> </div>
<ReopenDialog open={reopening} onclose={() => (reopening = false)} /> <ReopenDialog open={reopening} onclose={() => (reopening = false)} />

View File

@@ -1,5 +1,6 @@
<script> <script>
import { go } from '../lib/router.svelte.js'; import { go } from '../lib/router.svelte.js';
import AboutLink from '../components/AboutLink.svelte';
import { dayState } from '../lib/day.svelte.js'; import { dayState } from '../lib/day.svelte.js';
import { import {
ROUNDING_CHOICES, ROUNDING_CHOICES,
@@ -21,7 +22,8 @@
} }
</script> </script>
<fieldset class="screen" disabled={dayState.importing}> <div class="screen">
<fieldset class="settings-content" disabled={dayState.importing}>
<h1>Réglages</h1> <h1>Réglages</h1>
{#if settingsError.message} {#if settingsError.message}
@@ -156,11 +158,18 @@
<button type="button" onclick={() => go('stats')}>Consulter les statistiques</button> <button type="button" onclick={() => go('stats')}>Consulter les statistiques</button>
</section> </section>
<AboutLink />
</div>
<style> <style>
fieldset.screen { fieldset.settings-content {
display: flex;
flex-direction: column;
gap: var(--gap);
border: 0; border: 0;
margin: 0; margin: 0;
min-width: 0; min-width: 0;
padding: 0;
} }
.days { .days {

View File

@@ -242,7 +242,7 @@
{/if} {/if}
</section> </section>
{:else} {:else}
<label>Nom de cet appareil<input bind:value={name} maxlength="80" autocomplete="off" /></label> <label>Nom de cet appareil<input type="text" bind:value={name} maxlength="80" autocomplete="off" /></label>
<section class="card stack"> <section class="card stack">
<h2>Créer mon coffre</h2> <h2>Créer mon coffre</h2>
<p>Le serveur ne peut pas lire votre historique. Sans appareil autorisé ni document de secours, l’accès sera perdu.</p> <p>Le serveur ne peut pas lire votre historique. Sans appareil autorisé ni document de secours, l’accès sera perdu.</p>
@@ -252,7 +252,7 @@
<section class="card stack"> <section class="card stack">
<h2>Lier à un appareil existant</h2> <h2>Lier à un appareil existant</h2>
<p>Ouvrez cet écran sur l’appareil déjà lié. Vous pouvez saisir son code ici, ou scanner un QR code avec un smartphone, quel que soit l’appareil qui le présente.</p> <p>Ouvrez cet écran sur l’appareil déjà lié. Vous pouvez saisir son code ici, ou scanner un QR code avec un smartphone, quel que soit l’appareil qui le présente.</p>
<label>Code d’association<input bind:value={code} maxlength="12" autocomplete="off" autocapitalize="characters" spellcheck="false" /></label> <label>Code d’association<input type="text" bind:value={code} maxlength="12" autocomplete="off" autocapitalize="characters" spellcheck="false" /></label>
<button disabled={!!joining} onclick={() => run(async () => { <button disabled={!!joining} onclick={() => run(async () => {
joining = await joinInvitation(code, name, pin); joining = await joinInvitation(code, name, pin);
joiningQr = await makeJoiningQr(joining); joiningQr = await makeJoiningQr(joining);
@@ -331,7 +331,7 @@
<code class="fingerprint">{comparison}</code> <code class="fingerprint">{comparison}</code>
<button onclick={() => scanQr('approve')}>Scanner le QR du nouvel appareil</button> <button onclick={() => scanQr('approve')}>Scanner le QR du nouvel appareil</button>
{#if scanning && scanPurpose === 'approve'}<video bind:this={scanVideo} playsinline muted aria-label="Caméra de lecture QR"></video><button onclick={stopScanner}>Arrêter la caméra</button>{/if} {#if scanning && scanPurpose === 'approve'}<video bind:this={scanVideo} playsinline muted aria-label="Caméra de lecture QR"></video><button onclick={stopScanner}>Arrêter la caméra</button>{/if}
<label>Nom du nouvel appareil<input bind:value={applicantName} maxlength="80" /></label> <label>Nom du nouvel appareil<input type="text" bind:value={applicantName} maxlength="80" /></label>
<label class="check"><input type="checkbox" bind:checked={compared} /> Les deux empreintes sont identiques</label> <label class="check"><input type="checkbox" bind:checked={compared} /> Les deux empreintes sont identiques</label>
<button disabled={!compared} onclick={() => run(async () => { await approveInvitation(invite.code, applicant, applicantName); invite = null; applicant = null; message = 'Appareil autorisé.'; })}>Autoriser cet appareil</button> <button disabled={!compared} onclick={() => run(async () => { await approveInvitation(invite.code, applicant, applicantName); invite = null; applicant = null; message = 'Appareil autorisé.'; })}>Autoriser cet appareil</button>
{:else}<p role="status">En attente d’une demande sur cet écran…</p>{/if} {:else}<p role="status">En attente d’une demande sur cet écran…</p>{/if}
@@ -373,7 +373,7 @@
{:else} {:else}
<h2>Supprimer le coffre en ligne ?</h2> <h2>Supprimer le coffre en ligne ?</h2>
<p>Les données en ligne et les accès des appareils seront supprimés. Les copies déjà téléchargées restent sur les appareils. Les sauvegardes de l’hébergeur suivent sa durée de conservation.</p> <p>Les données en ligne et les accès des appareils seront supprimés. Les copies déjà téléchargées restent sur les appareils. Les sauvegardes de l’hébergeur suivent sa durée de conservation.</p>
<label>Tapez SUPPRIMER<input bind:value={deleteText} autocomplete="off" /></label> <label>Tapez SUPPRIMER<input type="text" bind:value={deleteText} autocomplete="off" /></label>
<button disabled={deleteText !== 'SUPPRIMER'} class="btn-danger" onclick={() => run(async () => { await deleteVault(); action = ''; })}>Confirmer la suppression du coffre</button> <button disabled={deleteText !== 'SUPPRIMER'} class="btn-danger" onclick={() => run(async () => { await deleteVault(); action = ''; })}>Confirmer la suppression du coffre</button>
{/if} {/if}
<button onclick={() => { action = ''; }}>Annuler</button> <button onclick={() => { action = ''; }}>Annuler</button>
@@ -386,7 +386,45 @@
label { display: flex; flex-direction: column; gap: var(--gap); } label { display: flex; flex-direction: column; gap: var(--gap); }
.check { flex-direction: row; align-items: start; } .check { flex-direction: row; align-items: start; }
.check input { width: auto; min-height: auto; margin-top: .3rem; } .check input { width: auto; min-height: auto; margin-top: .3rem; }
textarea { width: 100%; box-sizing: border-box; font: inherit; } input[type='file'] {
width: 100%;
min-width: 0;
min-height: var(--tap);
padding: 8px;
font: inherit;
color: var(--fg);
background: var(--bg);
border: var(--border) solid var(--line);
border-radius: var(--radius);
}
input[type='file']::file-selector-button {
min-height: 44px;
margin-right: 12px;
padding: 0 12px;
font: inherit;
font-weight: 700;
color: var(--fg);
background: var(--bg);
border: var(--border) solid var(--line);
border-radius: var(--radius);
}
textarea {
width: 100%;
min-width: 0;
min-height: var(--tap);
box-sizing: border-box;
padding: 8px 12px;
font: inherit;
color: var(--fg);
background: var(--bg);
border: var(--border) solid var(--line);
border-radius: var(--radius);
}
textarea:focus-visible,
input[type='file']:focus-visible {
outline: 4px solid var(--accent);
outline-offset: 2px;
}
.fingerprint, .code { display: block; overflow-wrap: anywhere; font-size: 1.15rem; font-weight: bold; } .fingerprint, .code { display: block; overflow-wrap: anywhere; font-size: 1.15rem; font-weight: bold; }
img { max-width: 100%; height: auto; align-self: center; } img { max-width: 100%; height: auto; align-self: center; }
.device { border: var(--border) solid var(--line); border-radius: .4rem; padding: .6rem; } .device { border: var(--border) solid var(--line); border-radius: .4rem; padding: .6rem; }

View File

@@ -25,7 +25,8 @@
import { go } from '../lib/router.svelte.js'; import { go } from '../lib/router.svelte.js';
import { lunchGuidance } from '../lib/lunch.js'; import { lunchGuidance } from '../lib/lunch.js';
import { settings } from '../lib/settings.svelte.js'; import { settings } from '../lib/settings.svelte.js';
import { fmtDuration, fmtTime } from '../lib/time.js'; import AboutLink from '../components/AboutLink.svelte';
import { fmtDuration, fmtTime, weekday } from '../lib/time.js';
let now = $state(Date.now()); let now = $state(Date.now());
let confirmingEnd = $state(false); let confirmingEnd = $state(false);
@@ -49,6 +50,7 @@
const current = $derived(running()); const current = $derived(running());
const closedToday = $derived(dayState.todayRecord?.status === 'closed'); const closedToday = $derived(dayState.todayRecord?.status === 'closed');
const blockedToday = $derived(settings.nonWorkingDates.includes(dayState.today)); const blockedToday = $derived(settings.nonWorkingDates.includes(dayState.today));
const nonWorkingToday = $derived(blockedToday || !settings.workdays.includes(weekday(dayState.today)));
const lunchHint = $derived(lunchGuidance(dayState.day, dayState.entries, settings, now)); const lunchHint = $derived(lunchGuidance(dayState.day, dayState.entries, settings, now));
// Snapshotted against writes, not against the clock: the proposed next task must // Snapshotted against writes, not against the clock: the proposed next task must
@@ -118,6 +120,7 @@
{closedToday ? 'Reprendre la journée' : blockedToday ? 'Démarrer quand même' : 'Démarrer la journée'} {closedToday ? 'Reprendre la journée' : blockedToday ? 'Démarrer quand même' : 'Démarrer la journée'}
</button> </button>
</div> </div>
{#if nonWorkingToday}<AboutLink />{/if}
</div> </div>
{:else} {:else}
<div class="screen"> <div class="screen">
@@ -216,6 +219,7 @@
<button disabled={busy || dayState.importing} onclick={() => (confirmingEnd = false)}>Annuler</button> <button disabled={busy || dayState.importing} onclick={() => (confirmingEnd = false)}>Annuler</button>
<button class="btn-danger" disabled={busy || dayState.importing} onclick={() => act(finishDay)}>Confirmer</button> <button class="btn-danger" disabled={busy || dayState.importing} onclick={() => act(finishDay)}>Confirmer</button>
</Sheet> </Sheet>
{#if nonWorkingToday}<AboutLink />{/if}
</div> </div>
<!-- End-of-day grace prompt --------------------------------------------- --> <!-- End-of-day grace prompt --------------------------------------------- -->