Edited wording
All checks were successful
Tests et déploiement / validate (push) Successful in 1m42s
Tests et déploiement / deploy (push) Successful in 32s

This commit is contained in:
2026-10-10 12:34:40 +02:00
parent 6183ecb181
commit 277b9c887e
9 changed files with 66 additions and 39 deletions

View File

@@ -23,7 +23,15 @@ npm run validate:all # include browser tests in full validation
## Exporter et importer les données ## Exporter et importer les données
Dans **Réglages → Données**, **Exporter les données** télécharge un fichier Dans **Réglages → Gérer les données et sauvegardes**, l’écran **Données et
sauvegardes** regroupe l’export, l’import, la vérification des sauvegardes et la
durée de conservation. Le rappel **Sauvegarder** ouvre aussi cet écran.
Les données restent dans le navigateur sur cet appareil, sans synchronisation.
Effacer les données du site ou perdre l’appareil peut faire perdre l’historique :
gardez une copie vérifiée du fichier exporté sur un autre support.
**Exporter les données** télécharge un fichier
`pardis-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 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. données de l'appareil. Les identifiants internes des postes sont recréés à l'import.
@@ -53,7 +61,17 @@ 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 dans son intégralité. Le récapitulatif, le thème et les suggestions sont ensuite
actualisés sans recharger la page. actualisés sans recharger la page.
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. Dans **Durée de conservation**, **Conserver (jours)** détermine les journées
anciennes proposées à la purge. Modifier cette durée ne supprime rien ; la purge
automatique est désactivée. L’historique conservé alimente les suggestions et les
statistiques. La dernière journée terminée et les journées actives sont conservées.
**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 remplacer les données,
puis de l’exporter après restauration. Cette copie est remplacée à la prochaine
purge et disparaît si les données du site sont effacées : un fichier externe
reste nécessaire.
Les tests d'import/export couvrent le format, les réglages, la fidélité des durées, 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 les fichiers invalides, la fusion, le remplacement et l'annulation transactionnelle
@@ -147,12 +165,13 @@ sauvegarde sans cette liste la réinitialise à vide lors d'un remplacement.
## Corriger les postes et les journées terminées ## Corriger les postes et les journées terminées
Le **Total travaillé** exclut le repas, qui reste affiché séparément dans le Le **Total des heures de présence** exclut les pauses et le repas. Le repas reste affiché
récapitulatif et le texte copié. Les totaux suivent le réglage d'arrondi existant. séparément dans le récapitulatif et le texte copié. Les totaux suivent le réglage
d’arrondi existant.
Dans **Journée**, touchez un poste dans **Postes de la journée** pour modifier Dans **Journée**, touchez un poste dans **Postes de la journée** pour modifier
son début, sa fin, sa tâche ou son client. La case **Pause déjeuner** distingue son début, sa fin, sa tâche ou son client. Les boutons **Poste ordinaire**,
le repas du travail : il n'y a plus de menu « Type de poste ». Le type interne **Repas** et **Pause** permettent de choisir la nature du poste. Le type interne
d'un transport déjà enregistré est conservé quand on corrige ses horaires ou d'un transport déjà enregistré est conservé quand on corrige ses horaires ou
son libellé. son libellé.
Les sélecteurs d'heure ne proposent pas de date : ils conservent la date du Les sélecteurs d'heure ne proposent pas de date : ils conservent la date du
@@ -174,17 +193,17 @@ Il suffit de changer une seule borne pour corriger le début ou la fin de la pla
Les portions avant et après sont Les portions avant et après sont
conservées avec leur tâche et leur client. Par exemple, un poste 08:00–12:00 conservées avec leur tâche et leur client. Par exemple, un poste 08:00–12:00
peut devenir 08:00–09:00, une tâche oubliée 09:00–10:00, puis 10:00–12:00. peut devenir 08:00–09:00, une tâche oubliée 09:00–10:00, puis 10:00–12:00.
La case **Pause déjeuner** permet aussi d'ajouter une pause oubliée et de renseigner Le bouton **Repas** permet aussi d’ajouter un déjeuner oublié et de renseigner
panier ou restaurant. Pour ajouter juste après une frontière existante, panier ou restaurant ; **Pause** ajoute une pause hors repas. Pour ajouter juste
choisissez le poste qui commence à cette frontière. après une frontière existante, choisissez le poste qui commence à cette frontière.
Dans **Récap**, choisissez la date puis **Corriger cette journée** : les mêmes Dans **Récap**, choisissez la date puis **Voir les détails → Corriger cette
actions sont disponibles sans réouverture, même si une autre journée est en journée** : les mêmes actions sont disponibles sans réouverture, même si une autre journée est en
cours. Le récapitulatif et son texte à copier sont recalculés après chaque cours. Le récapitulatif et son texte à copier sont recalculés après chaque
enregistrement. La suppression d'un poste demande confirmation ; le voisin enregistrement. La suppression d'un poste demande confirmation ; le voisin
absorbe sa durée, et le dernier poste d'une journée ne peut pas être supprimé. absorbe sa durée, et le dernier poste d'une journée ne peut pas être supprimé.
Supprimer un repas réaffecte sa durée au poste voisin et peut donc changer le Supprimer un repas réaffecte sa durée au poste voisin et peut donc changer le
total travaillé. total des heures de présence.
Chaque correction est transactionnelle. Les horaires inversés, les insertions Chaque correction est transactionnelle. Les horaires inversés, les insertions
hors de la plage choisie et les formulaires devenus périmés après une autre hors de la plage choisie et les formulaires devenus périmés après une autre
@@ -204,9 +223,9 @@ de semaine restent calculés sur les seules journées terminées enregistrées.
Les statistiques présentent : Les statistiques présentent :
- Le travail hors repas, les pauses, le transport identifié, les nombres de - Les heures de présence hors repas et pauses, les pauses, le transport identifié, les nombres de
journées, postes, tâches et clients. journées, postes, tâches et clients.
- Les heures par client et par tâche, leurs parts du travail, les nombres de - Les heures de présence par client et par tâche, leurs parts des heures de présence, les nombres de
journées concernées et les détails croisés, accessibles en ouvrant une ligne. journées concernées et les détails croisés, accessibles en ouvrant une ligne.
- L'évolution par mois ou semaine, sans inventer des heures pour les jours absents. - L'évolution par mois ou semaine, sans inventer des heures pour les jours absents.
- Les journées avec repas panier ou restaurant, les restaurants fréquentés, - Les journées avec repas panier ou restaurant, les restaurants fréquentés,
@@ -217,7 +236,7 @@ Les statistiques présentent :
- Les clients ou restaurants non renseignés, les postes à zéro après arrondi, - Les clients ou restaurants non renseignés, les postes à zéro après arrondi,
les journées vides, les plages non couvertes et les chevauchements éventuels. les journées vides, les plages non couvertes et les chevauchements éventuels.
Le calcul reprend l'arrondi du récapitulatif et exclut toujours le repas du travail. Le calcul reprend l'arrondi du récapitulatif et exclut toujours le repas et les pauses des heures de présence.
Seules les données encore conservées sont analysées : les jours absents ou purgés Seules les données encore conservées sont analysées : les jours absents ou purgés
ne permettent pas de conclure à une absence de travail. Le transport identifié ne permettent pas de conclure à une absence de travail. Le transport identifié
repose sur le type enregistré, pas sur une déduction à partir du nom de la tâche. repose sur le type enregistré, pas sur une déduction à partir du nom de la tâche.
@@ -310,11 +329,13 @@ 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. 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 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 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 des heures de présence, 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. 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 **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. des pages dédiées ; le bouton Retour du navigateur fonctionne via les routes hash.
**Gérer les données et sauvegardes** ouvre aussi une page dédiée, avec retour aux
réglages, conservation de la durée après rechargement et accès hors ligne.
Les réglages sont écrits par transaction sur la dernière valeur persistée pour 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 préserver les modifications d’autres onglets. Les erreurs de démarrage et
actualisation sont affichées avec une action Réessayer. actualisation sont affichées avec une action Réessayer.
@@ -337,7 +358,10 @@ nouvelle consultation. Les autres journées récemment importées restent intact
Les tests Playwright utilisent des contextes neufs et des données synthétiques, Les tests Playwright utilisent des contextes neufs et des données synthétiques,
à 320 px. Ils vérifient consultation/correction, focus, Retour, reprise à deux à 320 px. Ils vérifient consultation/correction, focus, Retour, reprise à deux
onglets, export vérifié, purge/restauration et rechargement hors ligne. Traces, onglets, export vérifié, purge/restauration et rechargement hors ligne. Ils couvrent
aussi le nouvel écran Données, le retour du champ de conservation à sa valeur
persistée après un échec, le refus d’un export modifié et l’annulation complète
d’une purge en erreur, avec préservation de la sauvegarde précédente. Traces,
vidéos et captures sont désactivées. Pour un Chrome déjà installé, définir vidéos et captures sont désactivées. Pour un Chrome déjà installé, définir
`PARDIS_CHROME_PATH` avec son chemin ; sinon installer Chromium avec Playwright. `PARDIS_CHROME_PATH` avec son chemin ; sinon installer Chromium avec Playwright.
La CI installe Chromium Headless Shell et ses dépendances avant de lancer ces La CI installe Chromium Headless Shell et ses dépendances avant de lancer ces

View File

@@ -138,7 +138,7 @@
<button type="button" onclick={close}>Annuler</button> <button type="button" onclick={close}>Annuler</button>
{#if mode === 'edit' && original.endTs != null && entries.length > 1} {#if mode === 'edit' && original.endTs != null && entries.length > 1}
{#if confirmingDelete} {#if confirmingDelete}
<p class="hint">Le poste voisin absorbera cette durée. Supprimer un repas ou une pause 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 des heures de présence.</p>
<button type="button" class="btn-danger" onclick={() => commit(true)}>Confirmer la suppression</button> <button type="button" class="btn-danger" onclick={() => commit(true)}>Confirmer la suppression</button>
{:else} {:else}
<button type="button" onclick={() => (confirmingDelete = true)}>Supprimer ce poste</button> <button type="button" onclick={() => (confirmingDelete = true)}>Supprimer ce poste</button>

View File

@@ -43,7 +43,7 @@
<label><input type="radio" bind:group={mode} value="break" /> Créer une pause jusqu’à maintenant</label> <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> <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" /> {last?.kind === 'lunch' ? 'Prolonger le repas depuis la fin enregistrée' : last?.kind === 'break' ? 'Prolonger la pause depuis la fin enregistrée' : 'Continuer depuis la fin enregistrée'}</label> <label><input type="radio" bind:group={mode} value="continue" /> {last?.kind === 'lunch' ? 'Prolonger le repas depuis la fin enregistrée' : last?.kind === 'break' ? 'Prolonger la pause depuis la fin enregistrée' : '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> <p class="dim">{last?.kind === 'lunch' ? 'Prolonger le repas' : last?.kind === 'break' ? 'Prolonger la pause' : 'Prolonger le dernier poste et compter cet intervalle dans les heures de présence'}, 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} {:else}<p>Aucun intervalle à ajouter. Aucune pause vide ne sera créée.</p>{/if}
{#if error}<p role="alert">{error}</p>{/if} {#if error}<p role="alert">{error}</p>{/if}
<button class="btn-primary" onclick={confirm}>Confirmer la reprise</button> <button class="btn-primary" onclick={confirm}>Confirmer la reprise</button>

View File

@@ -52,7 +52,7 @@ describe('forgotten entries', () => {
expect(record.status).toBe('closed'); expect(entries[1].client).toBeNull(); expect(record.status).toBe('closed'); expect(entries[1].client).toBeNull();
expect(resume.lunch).toEqual({ type: 'restaurant', restaurant: 'Le Relais', ms: HOUR }); expect(resume.lunch).toEqual({ type: 'restaurant', restaurant: 'Le Relais', ms: HOUR });
expect(resume.workedMs).toBe(8 * HOUR); expect(resume.workedMs).toBe(8 * HOUR);
expect(resumeToText(resume)).toContain('Total travaillé : 8h00'); expect(resumeToText(resume)).toContain('Total des heures de présence : 8h00');
const backup = await exportBackup(); const backup = await exportBackup();
expect(backup.entries).toHaveLength(3); expect(backup.days[0].status).toBe('closed'); expect(backup.entries).toHaveLength(3); expect(backup.days[0].status).toBe('closed');
}); });

View File

@@ -100,7 +100,7 @@ export function resumeToText(resume) {
lines.push(''); lines.push('');
} }
lines.push(`Total travaillé : ${fmtDuration(resume.workedMs)}`); lines.push(`Total des heures de présence : ${fmtDuration(resume.workedMs)}`);
return lines.join('\n').replace(/\n{3,}/g, '\n\n').trim(); return lines.join('\n').replace(/\n{3,}/g, '\n\n').trim();
} }

View File

@@ -92,7 +92,7 @@ describe('resumeToText', () => {
'', '',
'Déjeuner : restaurant (Le Relais), 1h00', 'Déjeuner : restaurant (Le Relais), 1h00',
'', '',
'Total travaillé : 9h00', 'Total des heures de présence : 9h00',
].join('\n'), ].join('\n'),
); );
}); });

View File

@@ -101,7 +101,7 @@
<hr /> <hr />
<div class="row-between total"> <div class="row-between total">
<strong>Total travaillé</strong> <strong>Total des heures de présence</strong>
<strong class="tnum">{fmtDuration(resume.workedMs)}</strong> <strong class="tnum">{fmtDuration(resume.workedMs)}</strong>
</div> </div>

View File

@@ -61,7 +61,7 @@
<section class="card stack" aria-labelledby="stats-overview"> <section class="card stack" aria-labelledby="stats-overview">
<h2 id="stats-overview">Vue d’ensemble</h2> <h2 id="stats-overview">Vue d’ensemble</h2>
<div class="metrics"> <div class="metrics">
<div><span>Temps travaillé</span><strong class="tnum" data-testid="stats-worked">{fmtDuration(stats.workedMs)}</strong></div> <div><span>Heures de présence</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 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>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>Journées enregistrées</span><strong>{stats.days}</strong></div>
@@ -69,7 +69,7 @@
<div><span>Clients renseignés</span><strong>{stats.clientCount}</strong></div> <div><span>Clients renseignés</span><strong>{stats.clientCount}</strong></div>
<div><span>Tâches distinctes</span><strong>{stats.taskCount}</strong></div> <div><span>Tâches distinctes</span><strong>{stats.taskCount}</strong></div>
</div> </div>
<p class="small">Transport identifié : <strong>{fmtDuration(stats.travelMs)}</strong> ({pct(percent(stats.travelMs, stats.workedMs))} du temps travaillé). Ce chiffre dépend des postes enregistrés comme transport.</p> <p class="small">Transport identifié : <strong>{fmtDuration(stats.travelMs)}</strong> ({pct(percent(stats.travelMs, stats.workedMs))} des heures de présence). Ce chiffre dépend des postes enregistrés comme transport.</p>
{#if stats.activeDays}<p class="small" role="status">Les cumuls et les détails par client, tâche ou repas incluent la journée en cours jusqu’à maintenant. Les moyennes de « Repères et observations » restent limitées aux journées terminées.</p>{/if} {#if stats.activeDays}<p class="small" role="status">Les cumuls et les détails par client, tâche ou repas incluent la journée en cours jusqu’à maintenant. Les moyennes de « Repères et observations » restent limitées aux journées terminées.</p>{/if}
</section> </section>
@@ -77,26 +77,26 @@
<h2>Repères et observations</h2> <h2>Repères et observations</h2>
{#if stats.closedDays} {#if stats.closedDays}
<dl> <dl>
<div><dt>Moyenne travaillée / jour</dt><dd>{fmtDuration(stats.averageWorkMs)}</dd></div> <div><dt>Heures de présence moyennes / jour</dt><dd>{fmtDuration(stats.averageWorkMs)}</dd></div>
<div><dt>Médiane travaillée / jour</dt><dd>{fmtDuration(stats.medianWorkMs)}</dd></div> <div><dt>Médiane des heures de présence / jour</dt><dd>{fmtDuration(stats.medianWorkMs)}</dd></div>
<div><dt>Amplitude moyenne, repas et pauses compris</dt><dd>{fmtDuration(stats.averageSpanMs)}</dd></div> <div><dt>Amplitude moyenne, repas et pauses compris</dt><dd>{fmtDuration(stats.averageSpanMs)}</dd></div>
<div><dt>Repas moyen, jours avec pause</dt><dd>{fmtDuration(stats.averageLunchMs)}</dd></div> <div><dt>Repas moyen, jours avec pause</dt><dd>{fmtDuration(stats.averageLunchMs)}</dd></div>
</dl> </dl>
<p class="small">Journée la plus longue en travail : <strong>{dateLabel(stats.longest.date)}, {fmtDuration(stats.longest.workedMs)}</strong>. La plus courte : {dateLabel(stats.shortest.date)}, {fmtDuration(stats.shortest.workedMs)}.</p> <p class="small">Journée avec le plus d’heures de présence : <strong>{dateLabel(stats.longest.date)}, {fmtDuration(stats.longest.workedMs)}</strong>. Celle avec le moins : {dateLabel(stats.shortest.date)}, {fmtDuration(stats.shortest.workedMs)}.</p>
{:else}<p class="small">Les moyennes seront disponibles avec une journée terminée dans cette période.</p>{/if} {:else}<p class="small">Les moyennes seront disponibles avec une journée terminée dans cette période.</p>{/if}
{#if stats.topClient && stats.workedMs} {#if stats.topClient && stats.workedMs}
<p><strong>{stats.topClient.name}</strong> représente {pct(stats.topClientShare)} du temps travaillé enregistré.</p> <p><strong>{stats.topClient.name}</strong> représente {pct(stats.topClientShare)} des heures de présence enregistrées.</p>
{/if} {/if}
{#if stats.tasks.length && stats.workedMs} {#if stats.tasks.length && stats.workedMs}
<p>La tâche la plus longue au total est <strong>{stats.tasks[0].name}</strong> : {fmtDuration(stats.tasks[0].ms)}, soit {pct(stats.topTaskShare)} du travail.</p> <p>La tâche la plus longue au total est <strong>{stats.tasks[0].name}</strong> : {fmtDuration(stats.tasks[0].ms)}, soit {pct(stats.topTaskShare)} des heures de présence.</p>
{/if} {/if}
<p class="small dim">Ces observations décrivent les saisies de la période ; elles ne mesurent pas la productivité.</p> <p class="small dim">Ces observations décrivent les saisies de la période ; elles ne mesurent pas la productivité.</p>
</section> </section>
<section class="card stack"> <section class="card stack">
<h2>Évolution des heures</h2> <h2>Évolution des heures de présence</h2>
<label class="stack">Regrouper par<select bind:value={periodMode}><option value="month">Mois</option><option value="week">Semaine</option></select></label> <label class="stack">Regrouper par<select bind:value={periodMode}><option value="month">Mois</option><option value="week">Semaine</option></select></label>
<p class="small dim">Les barres montrent le travail hors repas. Les périodes sans données ne sont pas affichées ; les premières et dernières peuvent être partielles.</p> <p class="small dim">Les barres montrent les heures de présence hors repas et pauses. Les périodes sans données ne sont pas affichées ; les premières et dernières peuvent être partielles.</p>
<ul class="periods"> <ul class="periods">
{#each periods as period (period.key)} {#each periods as period (period.key)}
<li class="stack"> <li class="stack">
@@ -109,14 +109,14 @@
</section> </section>
<section class="card stack"> <section class="card stack">
<h2>Heures par client</h2> <h2>Heures de présence par client</h2>
<p class="small dim">Ouvrez un client pour voir ses tâches. La moyenne porte sur les journées où ce client apparaît.</p> <p class="small dim">Ouvrez un client pour voir ses tâches. La moyenne porte sur les journées où ce client apparaît.</p>
{#if !stats.clients.length}<p>Aucun poste de travail enregistré.</p>{/if} {#if !stats.clients.length}<p>Aucun poste de travail enregistré.</p>{/if}
{#each stats.clients as client (client.key)} {#each stats.clients as client (client.key)}
<details class="breakdown"> <details class="breakdown">
<summary><span>{client.name}</span><strong class="tnum">{fmtDuration(client.ms)}</strong></summary> <summary><span>{client.name}</span><strong class="tnum">{fmtDuration(client.ms)}</strong></summary>
<div class="stack detail-body"> <div class="stack detail-body">
<p class="small">{pct(percent(client.ms, stats.workedMs))} du travail · {client.days} journée(s) · {client.count} poste(s) · moyenne {fmtDuration(client.averageMs)} / jour concerné.</p> <p class="small">{pct(percent(client.ms, stats.workedMs))} des heures de présence · {client.days} journée(s) · {client.count} poste(s) · moyenne {fmtDuration(client.averageMs)} / jour concerné.</p>
<ul class="lines"> <ul class="lines">
{#each stats.pairs.filter((pair) => pair.client === client.key) as pair (pair.key)} {#each stats.pairs.filter((pair) => pair.client === client.key) as pair (pair.key)}
<li class="row"><span>{pair.name}</span><strong class="tnum">{fmtDuration(pair.ms)}</strong></li> <li class="row"><span>{pair.name}</span><strong class="tnum">{fmtDuration(pair.ms)}</strong></li>
@@ -128,13 +128,13 @@
</section> </section>
<section class="card stack"> <section class="card stack">
<h2>Heures par tâche</h2> <h2>Heures de présence par tâche</h2>
{#if !stats.tasks.length}<p>Aucun poste de travail enregistré.</p>{/if} {#if !stats.tasks.length}<p>Aucun poste de travail enregistré.</p>{/if}
{#each stats.tasks as task (task.key)} {#each stats.tasks as task (task.key)}
<details class="breakdown"> <details class="breakdown">
<summary><span>{task.name}</span><strong class="tnum">{fmtDuration(task.ms)}</strong></summary> <summary><span>{task.name}</span><strong class="tnum">{fmtDuration(task.ms)}</strong></summary>
<div class="stack detail-body"> <div class="stack detail-body">
<p class="small">{pct(percent(task.ms, stats.workedMs))} du travail · {task.days} journée(s) · {task.count} poste(s) · moyenne {fmtDuration(task.averageMs)} / jour concerné.</p> <p class="small">{pct(percent(task.ms, stats.workedMs))} des heures de présence · {task.days} journée(s) · {task.count} poste(s) · moyenne {fmtDuration(task.averageMs)} / jour concerné.</p>
<ul class="lines"> <ul class="lines">
{#each stats.pairs.filter((pair) => pair.name === task.key) as pair (pair.key)} {#each stats.pairs.filter((pair) => pair.name === task.key) as pair (pair.key)}
<li class="row"><span>{pair.clientName}</span><strong class="tnum">{fmtDuration(pair.ms)}</strong></li> <li class="row"><span>{pair.clientName}</span><strong class="tnum">{fmtDuration(pair.ms)}</strong></li>
@@ -180,8 +180,8 @@
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Keyboard focus enables horizontal scrolling of this labelled table region.) --> <!-- svelte-ignore a11y_no_noninteractive_tabindex (Keyboard focus enables horizontal scrolling of this labelled table region.) -->
<div class="table-wrap" role="region" aria-label="Détail quotidien" tabindex="0"> <div class="table-wrap" role="region" aria-label="Détail quotidien" tabindex="0">
<table> <table>
<caption>Travail hors repas et pauses par journée</caption> <caption>Heures de présence hors repas et pauses par journée</caption>
<thead><tr><th scope="col">Date</th><th scope="col">Travail</th><th scope="col">Repas</th><th scope="col">Amplitude</th><th scope="col">Postes</th></tr></thead> <thead><tr><th scope="col">Date</th><th scope="col">Présence</th><th scope="col">Repas</th><th scope="col">Amplitude</th><th scope="col">Postes</th></tr></thead>
<tbody>{#each stats.daily as day (day.date)} <tbody>{#each stats.daily as day (day.date)}
<tr><th scope="row">{dateLabel(day.date)}{day.status === 'active' ? ' (en cours)' : ''}</th><td>{fmtDuration(day.workedMs)}</td><td>{fmtDuration(day.lunchMs)}</td><td>{fmtDuration(day.spanMs)}</td><td>{day.entries}</td></tr> <tr><th scope="row">{dateLabel(day.date)}{day.status === 'active' ? ' (en cours)' : ''}</th><td>{fmtDuration(day.workedMs)}</td><td>{fmtDuration(day.lunchMs)}</td><td>{fmtDuration(day.spanMs)}</td><td>{day.entries}</td></tr>
{/each}</tbody> {/each}</tbody>

View File

@@ -57,10 +57,12 @@ test('consultation, correction, recalcul, copie et statistiques à 320 px', asyn
await context.grantPermissions(['clipboard-read','clipboard-write']); await context.grantPermissions(['clipboard-read','clipboard-write']);
await importFixture(page); await importFixture(page);
await expect(page.locator('.total')).toContainText('8h30'); await expect(page.locator('.total')).toContainText('8h30');
await expect(page.locator('.total')).toContainText('Total des heures de présence');
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
await expect(page.locator('.screen')).not.toContainText('0h30'); await expect(page.locator('.screen')).not.toContainText('0h30');
await page.getByRole('button',{name:'Copier le récapitulatif'}).click(); await page.getByRole('button',{name:'Copier le récapitulatif'}).click();
const copied = await page.evaluate(() => navigator.clipboard.readText()); const copied = await page.evaluate(() => navigator.clipboard.readText());
expect(copied).toContain('8h30'); expect(copied).not.toContain('Pause'); expect(copied).toContain('Total des heures de présence : 8h30'); expect(copied).not.toContain('Pause');
await page.getByRole('button',{name:'Voir les détails'}).click(); await page.getByRole('button',{name:'Voir les détails'}).click();
await expect(page.locator('.timeline button')).toHaveCount(3); await expect(page.locator('.timeline button')).toHaveCount(3);
await expect(page.locator('.timeline button').nth(1)).toBeDisabled(); await expect(page.locator('.timeline button').nth(1)).toBeDisabled();
@@ -83,6 +85,7 @@ test('consultation, correction, recalcul, copie et statistiques à 320 px', asyn
await expect(page.locator('.total')).toContainText('9h00'); await expect(page.locator('.total')).toContainText('9h00');
await page.getByRole('button',{name:'Réglages',exact:true}).click(); await page.getByRole('button',{name:'Réglages',exact:true}).click();
await page.getByRole('button',{name:'Consulter les statistiques'}).click(); await page.getByRole('button',{name:'Consulter les statistiques'}).click();
await expect(page.getByText('Heures de présence',{exact:true})).toBeVisible();
await expect(page.getByTestId('stats-worked')).toHaveText('9h00'); await expect(page.getByTestId('stats-worked')).toHaveText('9h00');
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
// Wider or larger fonts must also fit the backup reminder on CI and mobile. // Wider or larger fonts must also fit the backup reminder on CI and mobile.