From 4bdcd52970499791612cfc7115964616de0aaf01 Mon Sep 17 00:00:00 2001 From: Siphonight Date: Tue, 29 Sep 2026 23:24:18 +0200 Subject: [PATCH] =?UTF-8?q?v1.12.0=20(web)=20:=20fix=20#70=20(inactifs=20s?= =?UTF-8?q?ans=20projection)=20+=20page=20Doses=20(glyphes=20de=20forme,?= =?UTF-8?q?=20marqueurs=20prise=20de=20sang)=20=E2=80=94=20miroir=20Androi?= =?UTF-8?q?d?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FIX #70 : generateForecastDoses garde !isActive — un traitement archivé à Posologie ne projette plus de créneaux ; l'horizon du chip Prévision (chart.js) ne considère que les Posologies ACTIVES. §6.bis inchangé : l'historique reste simulé, seul le FUTUR s'arrête. 3 tests miroirs. - Page Doses : routeGlyph (💊💉💧🩹🧴, miroir routeIcon) + encarts « prise de sang le même jour » 🧪↑/🧪↓ (labMarkersForDose, miroir) ; i18n FR/EN ; 10 tests doses-extras.test.js (⚠️ makeDose(ts, amount, overrides)). 194 tests + E2E verts (check.sh). WEB_VERSION alignée sur 1.12.0. --- README.md | 15 +++-- docs/CHANGELOG.md | 26 +++++++++ docs/DEVELOPPEMENT.md | 6 +- js/data/models.js | 22 +++++++ js/pk/pk-engine.js | 8 +++ js/ui/chart.js | 3 + js/ui/doses.js | 31 +++++++++- js/ui/settings.js | 2 +- js/util/format.js | 29 ++++++++++ js/util/i18n.js | 4 ++ tests/doses-extras.test.js | 116 +++++++++++++++++++++++++++++++++++++ 11 files changed, 249 insertions(+), 13 deletions(-) create mode 100644 tests/doses-extras.test.js diff --git a/README.md b/README.md index 910c030..693240b 100644 --- a/README.md +++ b/README.md @@ -17,14 +17,13 @@ > Fie-toi toujours à tes analyses de sang et aux consignes de ton > endocrinologue. -- **Statut** : web v1.11.0 — parité fonctionnelle avec l'Android v1.11.0 - sur la logique miroirée (page Traitements : inactifs regroupés tout en - bas sous un en-tête dédié, cartes atténuées — helper pur - `treatmentsForDisplay` miroir du Kotlin ; le fix #68 de la reco de prise - de sang reste couvert, cf changelog) ; - l'auto-backup journalier Android reste structurellement non porté, - cf §12 — l'export manuel couvre la donnée · - **184 tests verts** · E2E navigateur ✅ · conteneur testé en +- **Statut** : web v1.12.0 — parité fonctionnelle avec l'Android v1.12.0 + sur la logique miroirée (fix #70 : un traitement INACTIF ne projette plus + de créneaux — graphique Prévision ; page Doses : glyphes de forme de + prise 💊💉💧🩹🧴 et encarts « prise de sang le même jour » 🧪↑/🧪↓) ; + l'auto-backup journalier et les notifications Android restent + structurellement non portés, cf §12 · + **194 tests verts** · E2E navigateur ✅ · conteneur testé en configuration durcie ✅ · lint/i18n ✅ - **Journal des versions web** : [docs/CHANGELOG.md](docs/CHANGELOG.md) - **Releases** : [gitea.cloudyfy.fr/Siphonight/HormoneTrack-web/releases](https://gitea.cloudyfy.fr/Siphonight/HormoneTrack-web/releases) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 8269cf0..305595c 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,6 +5,32 @@ > section la plus récente EN PREMIER ; ce fichier est lu par le dialog > « Nouveautés » de l'app web (fetch `docs/CHANGELOG.md`). +## [1.12.0] — 2026-09-29 + +### Corrigé — un traitement inactif à Posologie projetait encore ses injections (#70) + +- Miroir de l'Android v1.12.0 (remontée : « un traitement mis en inactif ne + devrait plus être pris en compte pour les futures prédictions ») : + `generateForecastDoses` (js/pk/pk-engine.js) garde `!isActive` — et + l'horizon du chip Prévision du graphique (js/ui/chart.js) ne considère + que les Posologies ACTIVES. +- §6.bis inchangé : l'historique d'un inactif reste simulé — seul le FUTUR + s'arrête. 3 tests miroirs (inactif vide, actif inchangé, avant/après + archivage). + +### Ajouté — page Doses : glyphes de forme de prise + marqueurs « prise de sang le même jour » + +- Chaque ligne de dose porte le glyphe de sa forme (💊 oral, 💉 injection + IM/SC, 💧 gel, 🩹 patch, 🧴 autre — helper pur `routeGlyph` dans + js/data/models.js, miroir de `routeIcon`). +- Une analyse de sang LE MÊME JOUR qu'une dose affiche un encart latéral + 🧪↑ (avant la dose) et/ou 🧪↓ (après) — helper pur `labMarkersForDose` + (js/util/format.js, jour calendaire LOCAL ; lab à l'heure exacte = + APRÈS, convention documentée). +- 10 tests (`tests/doses-extras.test.js`, miroirs de DosesExtrasTest.kt). + +Vérifié : 194 tests verts + E2E (`check.sh`). + ## [1.11.0] — 2026-09-29 ### Ajouté — les traitements inactifs regroupés en bas de la page Traitements diff --git a/docs/DEVELOPPEMENT.md b/docs/DEVELOPPEMENT.md index 24b40b0..6ecfc51 100644 --- a/docs/DEVELOPPEMENT.md +++ b/docs/DEVELOPPEMENT.md @@ -60,7 +60,7 @@ Règles du portage, fixées au départ : | Choix | Décision | Pourquoi | |---|---|---| -| Langage | JavaScript ES modules natifs (pas de TS, pas de build) | zéro chaîne de compilation → le déploiement = copier le dossier ; les types critiques sont documentés en JSDoc et épinglés par 184 tests | +| Langage | JavaScript ES modules natifs (pas de TS, pas de build) | zéro chaîne de compilation → le déploiement = copier le dossier ; les types critiques sont documentés en JSDoc et épinglés par 194 tests | | Framework UI | Aucun — DOM via un helper `el()` (`ui/components.js`) | même philosophie que la v1 Android (pas de ViewModel/DI) ; une seule abstraction à connaître | | Rendu graphique | Canvas 2D natif (portage de `CurveChart.kt`) | pas de lib de charts : fidélité du rendu, zéro dépendance | | Persistance | `localStorage` (clés préfixées `hormonetrack.`) | données petites (JSON) ; backend INJECTABLE → testable en Node (`store.setBackend(new Map())`) | @@ -263,6 +263,8 @@ HormoneTrack-web/ (dépôt séparé) │ ├── treatments-display.test.js ← treatmentsForDisplay (v1.11.0 : actifs │ │ d'abord, inactifs regroupés en bas — tri │ │ stable) +│ ├── doses-extras.test.js ← fix #70 garde prévision inactifs + +│ │ routeGlyph + labMarkersForDose (v1.12.0) │ ├── lab-trajectory-model.test.js ← LabTrajectoryModelTest.kt (v1.5.0 │ │ « Tracé labs » + v1.6.0 prolongation — │ │ passage exact, garde #61, horizon cutoff) @@ -408,7 +410,7 @@ Pièges rencontrés : |---|---|---|---| | Syntaxe | `node --check` | tous les modules ES | `bash scripts/check.sh` | | i18n | `scripts/i18n-check.mjs` | clés FR/EN synchronisées + clés utilisées | (dans check.sh) | -| Unitaires | `node --test` | **184 tests** : noyau PK (ESE analytique/TFS/WHS), calibration, backup, store, helpers, alertes, rappels, changelog, tracé labs, estrannaise-models/cloud, rounding, presets (v1.9.4), ordre d'affichage des traitements (v1.11.0) | `npm test` | +| Unitaires | `node --test` | **194 tests** : noyau PK (ESE analytique/TFS/WHS), calibration, backup, store, helpers, alertes, rappels, changelog, tracé labs, estrannaise-models/cloud, rounding, presets (v1.9.4), ordre d'affichage des traitements (v1.11.0), garde prévision inactifs + extras Doses (v1.12.0) | `npm test` | | E2E navigateur | playwright-core + build Firefox | app réelle : rendu, canvas peint (pixels), navigation, dialog changelog, langue | `npm run e2e` | | Smoke HTTP | curl | ressources clés en 200 | `check.sh --with-serve` | diff --git a/js/data/models.js b/js/data/models.js index 2b85cf6..f711487 100644 --- a/js/data/models.js +++ b/js/data/models.js @@ -229,3 +229,25 @@ export function newLabResult(init = {}) { export function treatmentsForDisplay(treatments) { return [...treatments].sort((a, b) => Number(b.isActive) - Number(a.isActive)); } + +/** + * GLYPHE de FORME DE PRISE (v1.12.0, miroir de `routeIcon` — + * `ui/screens/DosesScreen.kt`, même KDoc) : l'app web n'a pas d'icônes + * Material → émojis, même mapping : ORAL = pilule 💊 ; IM/SC = seringue 💉 + * (glyphe partagé, la page Doses différencie par le texte de la ligne) ; + * GEL = goutte 💧 ; PATCH = sparadrap 🩹 ; OTHER/inconnu = compte-gouttes + * 🧴. PUR → testable en node (tests/doses-extras.test.js). + * + * @param {string|null} route + * @returns {string} émoji + */ +export function routeGlyph(route) { + switch (route) { + case 'ORAL': return '💊'; + case 'INJECTION_IM': + case 'INJECTION_SUBCUT': return '💉'; + case 'TRANSDERMAL_GEL': return '💧'; + case 'TRANSDERMAL_PATCH': return '🩹'; + default: return '🧴'; + } +} diff --git a/js/pk/pk-engine.js b/js/pk/pk-engine.js index 14e0147..1cd25d9 100644 --- a/js/pk/pk-engine.js +++ b/js/pk/pk-engine.js @@ -512,6 +512,14 @@ export function computeCurve(treatments, doseLogs, startMs, endMs, stepMs, tConf export function generateForecastDoses(treatment, allDoseLogs, toMs, nowMs = Date.now()) { const intervalDays = treatment.forecastIntervalDays; if (intervalDays === null || intervalDays === undefined || intervalDays <= 0.0) return []; + // ⚠️ FIX #70 (v1.12.0, miroir du Kotlin) : un traitement INACTIF ne + // projette PLUS de créneaux — l'archivage arrête les prédictions + // d'injections futures (graphique Prévision, extension de fenêtre, + // horizon LabTiming). Cohérent avec les rappels (nextReminderFireFor + // garde déjà !isActive). ⚠️ §6.bis inchangé : l'INACTIF reste simulé + // pour son HISTORIQUE (computeCurve ne passe pas ici) — seul le FUTUR + // s'arrête. + if (!treatment.isActive) return []; const intervalMs = Math.trunc(intervalDays * 24.0 * HOUR_MS); if (intervalMs <= 0) return []; diff --git a/js/ui/chart.js b/js/ui/chart.js index 3d8c4ac..30f5c4c 100644 --- a/js/ui/chart.js +++ b/js/ui/chart.js @@ -171,7 +171,10 @@ export function renderChart(container, ctx, { preserveState = false } = {}) { } // ── Horizon de prévision (12 × plus grande Posologie, borné 30 j–1 an) ──── + // ⚠️ FIX #70 (v1.12.0, miroir du Kotlin) : SEULS les traitements ACTIFS + // comptent — la Posologie d'un archivé n'active plus le chip Prévision. const maxIntervalDays = treatments + .filter((tr) => tr.isActive) .map((tr) => tr.forecastIntervalDays) .filter((x) => x !== null && x !== undefined) .reduce((m, x) => Math.max(m, x), 0) || null; diff --git a/js/ui/doses.js b/js/ui/doses.js index e6d5284..26d6429 100644 --- a/js/ui/doses.js +++ b/js/ui/doses.js @@ -15,8 +15,9 @@ */ import { store } from '../data/store.js'; +import { routeGlyph, routeLabelKey } from '../data/models.js'; import { t } from '../util/i18n.js'; -import { formatDose, formatTime, formatLongDate, formatISODate, HrtDuration } from '../util/format.js'; +import { formatDose, formatTime, formatLongDate, formatISODate, HrtDuration, labMarkersForDose } from '../util/format.js'; import { el, assistChip, confirmDialog, fab } from './components.js'; import { openDoseDialog } from './dialogs.js'; @@ -91,7 +92,17 @@ export function renderDoses(container) { col.appendChild(el('div', { class: 'group-header' }, dateKey)); for (const log of logs) { const tr = treatmentMap.get(log.treatmentId); + // v1.12.0 : marqueurs « prise de sang le même jour » (↑ avant / ↓ + // après) — miroir labMarkersForDose de DosesScreen.kt + const markers = labMarkersForDose(store.getLabResults(), log.timestamp); const item = el('div', { class: 'list-item', onclick: () => openDoseDialog({ treatments, preselected: null, existing: log }) }); + // v1.12.0 : GLYPHE de forme de prise à gauche (💊 oral, 💉 injection + // IM/SC, 💧 gel, 🩹 patch, 🧴 autre — cf routeGlyph dans models.js) + item.appendChild(el('span', { + class: 'sub', + style: 'align-self:center;font-size:1.1em;', + title: tr ? t(routeLabelKey(tr.route)) : '', + }, routeGlyph(tr ? tr.route : null))); const main = el('div', { class: 'main' }); main.appendChild(el('div', { class: 'title' }, `${tr ? tr.name : '?'} · ${formatDose(log.doseAmount)} ${tr ? tr.doseUnit : ''}`)); @@ -107,12 +118,28 @@ export function renderDoses(container) { if (log.notes && log.notes.trim() !== '') sub.appendChild(el('span', {}, log.notes)); main.appendChild(sub); item.appendChild(main); + const side = el('div', { class: 'row', style: 'align-items:center;gap:2px;' }); + // Encart latéral « prise de sang le même jour » (title = phrase + // complète pour l'accessibilité, miroir des contentDescription) + if (markers.before) { + side.appendChild(el('span', { + class: 'sub', style: 'color:var(--accent,#d6589e);', + title: t('lab_before_dose'), + }, '🧪↑')); + } + if (markers.after) { + side.appendChild(el('span', { + class: 'sub', style: 'color:var(--accent,#d6589e);', + title: t('lab_after_dose'), + }, '🧪↓')); + } const del = el('button', { class: 'icon-btn', 'aria-label': t('delete') }, '🗑'); del.addEventListener('click', (e) => { e.stopPropagation(); // ne pas déclencher l'édition confirmDialog(t('delete'), t('confirm_delete'), () => store.deleteDoseLog(log.id)); }); - item.appendChild(del); + side.appendChild(del); + item.appendChild(side); col.appendChild(item); } } diff --git a/js/ui/settings.js b/js/ui/settings.js index 85f53b6..8505f30 100644 --- a/js/ui/settings.js +++ b/js/ui/settings.js @@ -31,7 +31,7 @@ import { el, showDialog, field, switchRow, filterChip, toast } from './component * (APK + web au MÊME numéro, tags appariés `vX.Y.Z` + `vX.Y.Z-web`). * v1.4.10 = portage initial de l'Android v1.4.10. */ -export const WEB_VERSION = '1.11.0'; +export const WEB_VERSION = '1.12.0'; /** Parse un champ numérique FR (virgule → point) ; null si vide/invalide. */ function parseNumOrNull(text) { diff --git a/js/util/format.js b/js/util/format.js index d3b3ddb..57965d9 100644 --- a/js/util/format.js +++ b/js/util/format.js @@ -240,3 +240,32 @@ export function labNotesForDisplay(group) { if (distinctPairs.length === 1) return [distinctPairs[0][1]]; return distinctPairs.map(([marker, text]) => `${marker} : ${text}`); } + +/** + * Marqueurs d'ANALYSE DE SANG pour UNE dose (v1.12.0, miroir fidèle de + * `ui/screens/DosesScreen.kt` — même KDoc). Une analyse compte si elle + * tombe le MÊME JOUR calendaire LOCAL que la dose ; une analyse exactement + * À l'heure de la dose compte comme APRÈS (convention documentée). + * + * PUR → testable en node (tests/doses-extras.test.js). + * + * @param {Array} labs LabResult[] + * @param {number} doseMs + * @param {string} [timeZone=null] IANA (défaut : locale du navigateur) + * @returns {{before: boolean, after: boolean}} + */ +export function labMarkersForDose(labs, doseMs, timeZone = null) { + // Comparaison de JOURS CALENDAIRES : 'fr-CA' rend YYYY-MM-DD (CLDR/ISO) — + // honore `timeZone` quand fourni, sinon la locale du navigateur/node. + const opts = timeZone ? { timeZone } : {}; + const day = new Date(doseMs).toLocaleDateString('fr-CA', opts); + let before = false; + let after = false; + for (const lab of labs) { + if (new Date(lab.timestamp).toLocaleDateString('fr-CA', opts) === day) { + if (lab.timestamp < doseMs) before = true; + else after = true; + } + } + return { before, after }; +} diff --git a/js/util/i18n.js b/js/util/i18n.js index 47b4f03..1e7a88e 100644 --- a/js/util/i18n.js +++ b/js/util/i18n.js @@ -64,6 +64,8 @@ const EN = { // Labs edit_which_lab: 'Which entry to edit?', confirm_delete_blood_draw: 'Delete the whole blood draw ({0})?', + lab_before_dose: 'Blood test earlier the same day, before this dose', + lab_after_dose: 'Blood test later the same day, after this dose', // Traitements inactive: 'inactive', inactive_section: 'Inactive treatments', @@ -273,6 +275,8 @@ const FR = { confirm_delete_treatment: 'Supprimer ce traitement ? Son historique de doses sera supprimé aussi (les analyses sont conservées).', edit_which_lab: 'Quelle entrée modifier ?', confirm_delete_blood_draw: 'Supprimer la prise de sang entière ({0}) ?', + lab_before_dose: 'Prise de sang plus tôt le même jour, avant cette dose', + lab_after_dose: 'Prise de sang plus tard le même jour, après cette dose', inactive: 'inactive', inactive_section: 'Traitements inactifs', pk_absorption: 'Tmax', diff --git a/tests/doses-extras.test.js b/tests/doses-extras.test.js new file mode 100644 index 0000000..360770f --- /dev/null +++ b/tests/doses-extras.test.js @@ -0,0 +1,116 @@ +/** + * Tests des ajouts v1.12.0 à la page Doses (miroir de DosesExtrasTest.kt + + * ForecastDosesGuardTest.kt) : + * - FIX #70 : un traitement INACTIF ne projette PLUS de créneaux + * (generateForecastDoses) — l'historique reste simulé (§6.bis) ; + * - routeGlyph : un émoji PAR forme de prise (IM/SC partagent la seringue + * par design — différenciées par le texte de la ligne côté web) ; + * - labMarkersForDose : marqueurs « prise de sang le même jour » — + * avant/après la dose, jour calendaire LOCAL, frontière minuit. + * + * Déterministes (instants construits via Date locale, hors changement + * d'heure — même discipline que misc.test.js). + */ + +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { generateForecastDoses } from '../js/pk/pk-engine.js'; +import { routeGlyph, treatmentsForDisplay } from '../js/data/models.js'; +import { labMarkersForDose } from '../js/util/format.js'; +import { makeTreatment, makeDose, DAY_MS, HOUR_MS } from './helpers.js'; + +const NOW = 1_790_000_000_000; + +describe('forecast guard for inactive treatments (fix #70, v1.12.0)', () => { + test('un inactif à Posologie + historique ne projette plus rien', () => { + const archived = makeTreatment({ id: 1, isActive: false, forecastIntervalDays: 7 }); + const doses = [ + makeDose(NOW - 8 * DAY_MS, 5, { treatmentId: 1 }), + makeDose(NOW - DAY_MS, 5, { treatmentId: 1 }), + ]; + assert.equal(generateForecastDoses(archived, doses, NOW + 30 * DAY_MS, NOW).length, 0); + }); + + test('un actif jumeau projette toujours (comportement inchangé)', () => { + const active = makeTreatment({ id: 2, isActive: true, forecastIntervalDays: 7 }); + const doses = [ + makeDose(NOW - 8 * DAY_MS, 5, { treatmentId: 2 }), + makeDose(NOW - DAY_MS, 5, { treatmentId: 2 }), + ]; + const slots = generateForecastDoses(active, doses, NOW + 30 * DAY_MS, NOW); + assert.ok(slots.length > 0, 'un actif à Posologie projette'); + assert.equal(slots[0].timestamp, NOW - DAY_MS + 7 * DAY_MS); + }); + + test('archiver un traitement actif arrête ses créneaux futurs', () => { + const treatment = makeTreatment({ id: 3, isActive: true, forecastIntervalDays: 7 }); + const doses = [makeDose(NOW - DAY_MS, 5, { treatmentId: 3 })]; + const before = generateForecastDoses(treatment, doses, NOW + 30 * DAY_MS, NOW); + const after = generateForecastDoses( + { ...treatment, isActive: false }, doses, NOW + 30 * DAY_MS, NOW, + ); + assert.ok(before.length > 0); + assert.equal(after.length, 0); + }); +}); + +describe('route glyphs (v1.12.0)', () => { + test('un glyphe par forme (IM/SC partagent la seringue par design)', () => { + const oral = routeGlyph('ORAL'); + const im = routeGlyph('INJECTION_IM'); + const sc = routeGlyph('INJECTION_SUBCUT'); + const gel = routeGlyph('TRANSDERMAL_GEL'); + const patch = routeGlyph('TRANSDERMAL_PATCH'); + const other = routeGlyph('OTHER'); + assert.notEqual(oral, im); + assert.notEqual(oral, gel); + assert.notEqual(im, gel); + assert.notEqual(gel, patch); + assert.notEqual(patch, other); + assert.equal(im, sc, 'IM/SC : même seringue (texte de ligne distinct côté web)'); + }); + + test('route inconnue/null → glyphe OTHER', () => { + assert.equal(routeGlyph(null), routeGlyph('OTHER')); + assert.equal(routeGlyph(undefined), routeGlyph('OTHER')); + }); +}); + +describe('lab markers per dose (v1.12.0)', () => { + // Jour calendaire LOCAL de node (hors changement d'heure) + const noon = new Date(2026, 8, 27, 12, 0).getTime(); // 27 sept 2026 12:00 local + + const lab = (atMs) => ({ marker: 'E2', value: 100, unit: 'pg/mL', timestamp: atMs }); + + test('labs avant ET après la dose le même jour → les deux marqueurs', () => { + const m = labMarkersForDose([lab(noon - HOUR_MS), lab(noon + HOUR_MS)], noon); + assert.equal(m.before, true); + assert.equal(m.after, true); + }); + + test('lab d\'un autre jour ne marque jamais', () => { + const m = labMarkersForDose([lab(noon - 24 * HOUR_MS)], noon); + assert.equal(m.before, false); + assert.equal(m.after, false); + }); + + test('frontière minuit : 23:50 la veille ≠ jour suivant', () => { + const lateEvening = new Date(2026, 8, 26, 23, 50).getTime(); + const earlyMorning = new Date(2026, 8, 27, 0, 10).getTime(); + const m = labMarkersForDose([lab(lateEvening)], earlyMorning); + assert.equal(m.before, false); + assert.equal(m.after, false); + }); + + test('lab exactement à l\'heure de la dose compte comme APRÈS (convention)', () => { + const m = labMarkersForDose([lab(noon)], noon); + assert.equal(m.before, false); + assert.equal(m.after, true); + }); + + test('aucun lab → aucun marqueur', () => { + const m = labMarkersForDose([], noon); + assert.equal(m.before, false); + assert.equal(m.after, false); + }); +});