From f5951e1806a4bf2e1d54f91d51faad16b6d5d035 Mon Sep 17 00:00:00 2001 From: Siphonight Date: Thu, 17 Sep 2026 18:46:51 +0200 Subject: [PATCH] v1.8.0 : recommandation de prochaine prise de sang (portage Android, miroir) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - js/pk/lab-timing.js : miroir fidèle de pk/LabTiming.kt — creux prévisionnel (minimum de la courbe E2 entre 2 injections), saut au premier creux STABILISÉ (5 × t½ terminale : TFS/WHS analytiques, Estrannaise lue dans la table via le nouveau pkProfileStore.terminalHalfLifeDays — refactor de la pente d'extrapolation de sample(), zéro duplication), filtres honnêtes (injectable actif + Posologie requis, creux ≥ now+6 h et jamais déjà mesuré, horizon borné → null), shouldSuggestPosology (invite quand un injectable actif est sans Posologie — demande v1.8.0). - labs.js : encart « Prochaine prise de sang (suggestion) » ou invite, mutuellement exclusifs (miroir carte Android). - i18n FR/EN (labrec_*), WEB_VERSION 1.8.0, assertions E2E. - tests/lab-timing.test.js : 7 tests miroirs (152 verts au total) + E2E (encart rendu : titre, creux daté, créneau, stabilisation). - Doc : §4 arbre, §8 suite + E2E, CHANGELOG [1.8.0], README. --- README.md | 8 +- docs/CHANGELOG.md | 21 ++++++ docs/DEVELOPPEMENT.md | 11 +++ js/pk/index.js | 2 + js/pk/lab-timing.js | 154 ++++++++++++++++++++++++++++++++++++++ js/pk/pk-profile-store.js | 74 +++++++++++++----- js/ui/labs.js | 30 ++++++++ js/ui/settings.js | 2 +- js/util/i18n.js | 18 +++++ scripts/e2e.mjs | 13 +++- tests/lab-timing.test.js | 97 ++++++++++++++++++++++++ 11 files changed, 406 insertions(+), 24 deletions(-) create mode 100644 js/pk/lab-timing.js create mode 100644 tests/lab-timing.test.js diff --git a/README.md b/README.md index 13e6ea1..ccae573 100644 --- a/README.md +++ b/README.md @@ -17,11 +17,11 @@ > Fie-toi toujours à tes analyses de sang et aux consignes de ton > endocrinologue. -- **Statut** : web v1.7.1 — parité fonctionnelle avec l'Android v1.7.1 - (unités des axes + fix notes des analyses ; l'auto-backup journalier - Android est structurellement non porté, cf §12 — l'export manuel couvre +- **Statut** : web v1.8.0 — parité fonctionnelle avec l'Android v1.8.0 + (recommandation de prochaine prise de sang ; l'auto-backup journalier + Android reste structurellement non porté, cf §12 — l'export manuel couvre la donnée) · - **145 tests verts** · E2E navigateur ✅ · conteneur testé en + **152 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 1f54e4e..1489497 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,6 +5,27 @@ > 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.8.0] — 2026-09-17 + +### Prochaine prise de sang recommandée — portage de l'Android v1.8.0 (versions sync) + +- **Nouvel encart sur la page Analyses** : « Prochaine prise de sang + (suggestion) » — quand un traitement E2 **injectable actif a une + Posologie**, l'app déduit de ses courbes **le creux estimé juste avant + l'injection suivante** (heure exacte + créneau associé + mention « ou + simplement la veille »), **au premier creux où l'ester est stabilisé** + (~5 demi-vies terminales après le dernier changement — on saute les + creux « de transition »). +- `js/pk/lab-timing.js` : miroir fidèle du Kotlin (créneaux + generateForecastDoses, courbe E2 fine sans calibration, filtres + maintenant/+6 h/dernière prise/stabilisation). Le modèle Estrannaise lit + sa demi-vie dans les tables (`pkProfileStore.terminalHalfLifeDays`, + refactor miroir), TFS/WHSAH analytiquement. +- **Sans Posologie** sur l'injectable actif : encart remplacé par l'invite + « renseigne une Posologie… » (demande v1.8.0) — le lien est découvrable. +- 7 tests miroirs (152 au total) + **scénario E2E** (encart rendu sur #labs + avec la démo EEn 7 j : creux daté, créneau, stabilisation). + ## [1.7.1] — 2026-09-16 ### Corrigé — une seule note sur deux s'affichait dans les Analyses diff --git a/docs/DEVELOPPEMENT.md b/docs/DEVELOPPEMENT.md index 3c4d86a..899703a 100644 --- a/docs/DEVELOPPEMENT.md +++ b/docs/DEVELOPPEMENT.md @@ -243,6 +243,9 @@ HormoneTrack-web/ (dépôt séparé) │ ├── pk-engine.test.js ← PharmacokineticEngineTest.kt (+ #35, §6.bis) │ ├── calibration.test.js ← CalibrationPerModel/ScaleFactorWhsahRepro/ │ │ V120Features (fixes #60/#61, périodes d'ester) +│ ├── lab-timing.test.js ← LabTimingTest.kt (v1.8.0 : prochaine +│ │ prise de sang recommandée — creux avant +│ │ créneau, saut de stabilisation, gardes) │ ├── lab-notes.test.js ← labNotesForDisplay (v1.7.1 : notes d'une │ │ prise E2+T — les DEUX distinctes affichées) │ ├── lab-trajectory-model.test.js ← LabTrajectoryModelTest.kt (v1.5.0 @@ -432,6 +435,12 @@ launch) — d'où le build dédié dans le cache Playwright, hors dépôt. null), dose EV loguée après le dernier lab → pic de prolongation, garde #61 sur la prolongation, flag off = v1.5.0 bit-compatible, `lastAnchorMs`. +- **lab-timing** : v1.8.0 « prochaine prise de sang recommandée » (miroir + du Kotlin, §7.11 Android) : creux = minimum de la courbe prévisionnelle + entre 2 injections, saut au premier creux STABILISÉ (5 × t½ — TFS/WHS + analytiques, Estrannaise lue dans la table via terminalHalfLifeDays), + filtres (≥ now+6 h, > dernière prise, Posologie requise), invite + « renseigne une Posologie », oral seul → null. - **backup** : round-trip v2, rétrocompat v1, écrasement + IDs conservés, **imports des exports Android réels** (data-driven, hors dépôt). - **chart-helpers** : cumul fractionnaire du pan (#62), minuit local (#55), @@ -474,6 +483,8 @@ launch) — d'où le build dédié dans le cache Playwright, hors dépôt. vérifie que le chip reste sélectionné ET la légende affichée ; puis navigation (quitter → revenir) et vérifie le RESET aux défauts (miroir du `remember` Android quitté). +- **v1.8.0 : encart « prochaine prise de sang »** sur #labs (démo EEn 7 j) + — titre, creux daté, créneau d'injection associé, mention de stabilisation ; - vérifie l'**absence d'erreur console/page** sur toute la session ; - captures dans `/tmp/hrt-web-shots/` (inspection visuelle). - ⚠️ Course au screenshot évitée par le marqueur `body[data-ready="1"]` diff --git a/js/pk/index.js b/js/pk/index.js index d9848cd..6cf9e3e 100644 --- a/js/pk/index.js +++ b/js/pk/index.js @@ -10,6 +10,8 @@ export * from './pk-reminders.js'; export * from './pk-extrema.js'; export * as LabTrajectoryModel from './lab-trajectory-model.js'; export { computeLabAnchoredCurve, ratioAt, extensionHorizonEndMs } from './lab-trajectory-model.js'; +export * as LabTiming from './lab-timing.js'; +export { nextBloodDrawRecommendation, shouldSuggestPosology } from './lab-timing.js'; export * from './chart-helpers.js'; export * as Alerts from './alerts.js'; export * as PKProfileStore from './pk-profile-store.js'; diff --git a/js/pk/lab-timing.js b/js/pk/lab-timing.js new file mode 100644 index 0000000..b777e05 --- /dev/null +++ b/js/pk/lab-timing.js @@ -0,0 +1,154 @@ +/** + * ───────────────────────────────────────────────────────────────────────────── + * RECOMMANDATION DE PROCHAINE PRISE DE SANG (v1.8.0, miroir fidèle de + * `pk/LabTiming.kt` Android) — affichée dans la page Analyses + * (« Prochaine analyse recommandée »). + * + * PRINCIPE PHARMACOCINÉTIQUE (choix validé avec l'utilisatrice) : + * 1. le **creux (trough) juste avant l'injection suivante** est le moment le + * plus informatif et reproductible pour un ester injectable (le pic varie + * énormément, le creux reflète le niveau de fond et l'accumulation) ; + * 2. un creux n'est interprétable que si le régime est **stabilisé** — règle + * des 5 demi-vies terminales (≈ 97 % de l'équilibre) ; + * 3. **ON SAUTE donc au premier creux STABILISÉ** (décision v1.8.0). + * + * CALCUL (déduit de ce que l'app connaît déjà) : + * - traitement porteur : ESTRADIOL + injectable + ACTIF + Posologie + * (`forecastIntervalDays > 0`) — sinon null (la page Analyses affiche + * alors l'invite « renseigne une Posologie ») ; + * - t½ terminale : analytique TFS/WHSAH (`terminalHalfLifeDays`), lue dans + * la TABLE pour Estrannaise (`pkProfileStore.terminalHalfLifeDays`) ; + * - début du régime courant = 1ʳᵉ dose du traitement actif — PROXY assumé : + * l'app ne trace pas l'historique des éditions de Posologie/dose ; + * - creux = minimum local de la courbe E2 PRÉVISIONNELLE (doses réelles + + * créneaux generateForecastDoses) entre deux injections — la FORME suffit + * (calcul sans calibration : le facteur ne déplace pas le minimum) ; + * - filtres : creux ≥ maintenant + 6 h, creux > dernière prise de sang + * (jamais recommander un creux déjà mesuré), créneau ≥ stabilisation. + * + * LIMITES ASSUMÉES (doc web §7.11/§12) : le creux est une estimation du + * modèle (disclaimer affiché) ; pas de Posologie → pas de recommandation ; + * stabilisation hors horizon (ester ultra-long) → null (carte cachée). + * + * PUR (aucun DOM) → testable en Node (tests/lab-timing.test.js). + * ───────────────────────────────────────────────────────────────────────────── + */ + +import { computeCurve, generateForecastDoses, TConfig, usesProfileModel } from './pk-engine.js'; +import { model as tfsModel } from './transfem-science-models.js'; +import { model as whsahModel } from './whsah-models.js'; +import * as pkProfileStore from './pk-profile-store.js'; + +/** Règle de stabilisation : 5 demi-vies terminales (≈ 97 % de l'équilibre). */ +export const STABILIZATION_HALF_LIVES = 5; + +/** Horizon max de créneaux explorés (ester ultra-long → null, carte cachée). */ +export const MAX_FORECAST_INTERVALS = 12; + +/** Un creux à moins de 6 h n'est pas exploitable (organisation d'une prise). */ +export const MIN_HORIZON_HOURS = 6; + +const HOUR_MS = 3600000; + +/** + * Calcule la prochaine prise de sang recommandée, ou `null` si rien n'est + * calculable honnêtement (l'appelant cache l'encart sur null, et peut + * afficher l'invite « renseigne une Posologie » via [shouldSuggestPosology]). + * + * @param {object[]} treatments TOUS les traitements (actifs ET inactifs, §6.bis) + * @param {object[]} doseLogs toutes les doses + * @param {object[]} labs toutes les prises de sang existantes + * @param {number} [nowMs=Date.now()] + * @returns {{troughMs:number, injectionMs:number, ester:string, + * terminalHalfLifeDays:number, regimeStartMs:number, stabilizedAtMs:number, + * wasAlreadyStabilized:boolean}|null} + */ +export function nextBloodDrawRecommendation(treatments, doseLogs, labs, nowMs = Date.now()) { + // ── 1) Traitement PORTEUR : E2 + injectable + actif + Posologie ─────────── + const carrier = treatments.find((t) => + t.type === 'ESTRADIOL' + && t.isActive + && usesProfileModel(t) + && (t.forecastIntervalDays ?? 0) > 0 + ); + if (!carrier) return null; + + // ── 2) t½ terminale de l'ester (analytique ou lue dans la table) ────────── + let tHalfDays = null; + if (carrier.pkModel === 'TFS') tHalfDays = tfsModel(carrier.esterType)?.terminalHalfLifeDays ?? null; + else if (carrier.pkModel === 'WHS') tHalfDays = whsahModel(carrier.esterType)?.terminalHalfLifeDays ?? null; + else if (carrier.pkModel === 'ESE') tHalfDays = pkProfileStore.terminalHalfLifeDays(carrier.esterType, 'ESE'); + if (tHalfDays === null || tHalfDays === undefined) return null; + + // ── 3) Début du régime courant + date de stabilisation ──────────────────── + const myDoses = doseLogs.filter((d) => d.treatmentId === carrier.id); + if (myDoses.length === 0) return null; + const regimeStartMs = Math.min(...myDoses.map((d) => d.timestamp)); + const stabilizedAtMs = regimeStartMs + Math.round(STABILIZATION_HALF_LIVES * tHalfDays * 24 * HOUR_MS); + + // ── 4) Créneaux prévisionnels : assez pour couvrir la stabilisation ─────── + const intervalDays = carrier.forecastIntervalDays; + const intervalsNeeded = Math.min( + MAX_FORECAST_INTERVALS, + Math.max(3, Math.ceil((STABILIZATION_HALF_LIVES * tHalfDays) / intervalDays) + 1), + ); + const horizonMs = nowMs + Math.round(intervalsNeeded * intervalDays * 24 * HOUR_MS); + const slots = generateForecastDoses(carrier, doseLogs, horizonMs, nowMs); + if (slots.length === 0) return null; + + // ── 5) Courbe E2 fine (FORME brute — sans calibration) ──────────────────── + const curve = computeCurve( + treatments, doseLogs.concat(slots), nowMs, slots[slots.length - 1].timestamp, HOUR_MS, + new TConfig(), {}, + ); + + // ── 6) Premier creux STABILISÉ jamais mesuré ────────────────────────────── + const lastLabMs = labs.length ? Math.max(...labs.map((l) => l.timestamp)) : 0; + const minTroughMs = nowMs + MIN_HORIZON_HOURS * HOUR_MS; + let windowStart = nowMs; + for (const slot of slots) { + // Creux du créneau = minimum de la courbe entre l'injection précédente + // (ou maintenant) et ce créneau + const window = curve.filter((p) => p.timestamp > windowStart && p.timestamp < slot.timestamp); + let trough = null; + for (const p of window) { + if (trough === null || p.e2 < trough.e2) trough = p; + } + if ( + trough !== null + && trough.timestamp >= minTroughMs + && trough.timestamp > lastLabMs + && slot.timestamp >= stabilizedAtMs // sauter au premier creux stabilisé + ) { + return { + troughMs: trough.timestamp, + injectionMs: slot.timestamp, + ester: carrier.esterType, + terminalHalfLifeDays: tHalfDays, + regimeStartMs, + stabilizedAtMs, + wasAlreadyStabilized: stabilizedAtMs <= nowMs, + }; + } + windowStart = slot.timestamp; + } + return null; // stabilisation hors horizon (ester ultra-long) → carte cachée +} + +/** + * Invite d'AFFICHAGE (v1.8.0, demande explicite) : existe-t-il un traitement + * E2 injectable ACTIF SANS Posologie ? Si oui, la page Analyses suggère de la + * renseigner pour recevoir des recommandations. Ne s'affiche QUE si aucune + * recommandation n'est calculable. + * + * @param {object[]} treatments + * @returns {boolean} + */ +export function shouldSuggestPosology(treatments) { + return treatments.some((t) => + t.type === 'ESTRADIOL' + && t.isActive + && usesProfileModel(t) + && (t.forecastIntervalDays ?? 0) <= 0 + ); +} diff --git a/js/pk/pk-profile-store.js b/js/pk/pk-profile-store.js index 7e1ab2c..3aa7fc8 100644 --- a/js/pk/pk-profile-store.js +++ b/js/pk/pk-profile-store.js @@ -137,24 +137,8 @@ export function sample(ester, mod, dtHours) { // plate) → on part du dernier point encore ≥ 1 % du pic, avec le TAUX // logarithmique moyen des 48 h précédentes (jamais avant le pic) — // extrapolation EXPONENTIELLE, donc jamais croissante. - let peakIdx = 0; - let peakV = 0; - for (let idx = 0; idx < arr.length; idx++) { - if (arr[idx] > peakV) { - peakV = arr[idx]; - peakIdx = idx; - } - } - if (peakV <= 0) return 0.0; - let j = lastIdx; - while (j > 0 && arr[j] < peakV * 0.01) j--; + const [j, rate] = terminalDecayParameters(arr) ?? [0, 0.0]; if (j <= 0) return 0.0; - const WINDOW = 48; - const j0 = Math.max(peakIdx, j - WINDOW); - let rate = 0.0; - if (j > j0) { - rate = Math.log(Math.max(arr[j], 1e-12) / Math.max(arr[j0], 1e-12)) / (j - j0); - } return arr[j] * Math.exp(rate * (dtHours - j)); } @@ -165,6 +149,62 @@ export function sample(ester, mod, dtHours) { return arr[i0] + (arr[i0 + 1] - arr[i0]) * frac; } +/** + * Paramètres de DÉCROISSANCE TERMINALE d'une table (v1.8.0 — partagés par + * [sample] et [terminalHalfLifeDays], miroir du Kotlin) : dernier point + * ≥ 1 % du pic + pente log-linéaire /h sur les 48 h précédentes (jamais + * avant le pic). + * + * @private + * @param {number[]} arr table horaire + * @returns {[number, number]|null} [index j, pente /h] — null si ni pic ni + * queue exploitable + */ +function terminalDecayParameters(arr) { + let peakIdx = 0; + let peakV = 0; + for (let idx = 0; idx < arr.length; idx++) { + if (arr[idx] > peakV) { + peakV = arr[idx]; + peakIdx = idx; + } + } + if (peakV <= 0) return null; + let j = arr.length - 1; + while (j > 0 && arr[j] < peakV * 0.01) j--; + if (j <= 0) return null; + const WINDOW = 48; + const j0 = Math.max(peakIdx, j - WINDOW); + if (j <= j0) return null; + const rate = Math.log(Math.max(arr[j], 1e-12) / Math.max(arr[j0], 1e-12)) / (j - j0); + return [j, rate]; +} + +/** + * Demi-vie TERMINALE (JOURS) estimée depuis la table horaire (v1.8.0, miroir + * du Kotlin) : t½ = ln2 ÷ |pente terminale| — cf [terminalDecayParameters]. + * + * Pourquoi : la recommandation de prochaine prise de sang (js/pk/lab-timing.js) + * juge de la STABILISATION du régime via 5 × t½ — et le modèle Estrannaise + * (tables ODS) n'a pas de forme close : sa t½ se LIT dans la table (les + * modèles TFS/WHSAH exposent la leur analytiquement). + * + * @param {string} ester Clé ester ("EV"…) + * @param {string} mod "ESE" | "TFS" + * @returns {number|null} t½ en jours, ou null si pas de décroissance + * terminale exploitable (ester inconnu, pic nul, pas de queue) + */ +export function terminalHalfLifeDays(ester, mod) { + if (!profiles) return null; + const suffix = MODEL_SUFFIX[mod]; + if (!suffix) return 0.0; + const arr = lookup(`${ester}_${suffix}`); + if (!arr) return null; + const decay = terminalDecayParameters(arr); + if (!decay || decay[1] >= 0) return null; + return Math.log(2) / -decay[1] / 24.0; // pente /h → t½ en jours +} + /** @returns {boolean} true si le store a été initialisé (asset chargé). */ export function isInitialized() { return profiles !== null; diff --git a/js/ui/labs.js b/js/ui/labs.js index 8788ec1..88adc08 100644 --- a/js/ui/labs.js +++ b/js/ui/labs.js @@ -15,6 +15,7 @@ import { store } from '../data/store.js'; import { t } from '../util/i18n.js'; import { formatLabValue, formatDateTime, labNotesForDisplay } from '../util/format.js'; +import { nextBloodDrawRecommendation, shouldSuggestPosology } from '../pk/index.js'; import { el, confirmDialog, fab, showDialog } from './components.js'; import { openLabDialog } from './dialogs.js'; @@ -55,6 +56,35 @@ export function renderLabs(container) { const col = el('div', {}); container.appendChild(col); + // ── Recommandation de prochaine prise de sang (v1.8.0) ─────────────────── + // Miroir de la carte Android : calculée par le moteur (pk/LabTiming.js) sur + // TOUS les traitements + toutes les doses (§6.bis). Si le calcul n'est pas + // possible mais qu'un injectable E2 actif est SANS Posologie : invite à la + // renseigner (demande v1.8.0 — rendre le lien Posologie → recommandation + // découvrable). Les deux encarts sont mutuellement exclusifs. + const treatments = store.getTreatments(); + const doseLogs = store.getDoseLogs(); + const rec = nextBloodDrawRecommendation(treatments, doseLogs, labs); + if (rec) { + const card = el('div', { class: 'card' }); + card.appendChild(el('div', { class: 'title' }, t('labrec_section'))); + card.appendChild(el('div', { class: 'main' }, [ + el('div', {}, t('labrec_when', formatDateTime(rec.troughMs))), + el('div', {}, t('labrec_before_injection', rec.ester, formatDateTime(rec.injectionMs))), + el('div', { class: 'legend-muted' }, + rec.wasAlreadyStabilized + ? t('labrec_stabilized', rec.ester, formatDateTime(rec.stabilizedAtMs)) + : t('labrec_not_stabilized', rec.ester, formatDateTime(rec.stabilizedAtMs))), + el('div', { class: 'legend-muted' }, t('labrec_disclaimer')), + ])); + col.appendChild(card); + } else if (shouldSuggestPosology(treatments)) { + const card = el('div', { class: 'card' }); + card.appendChild(el('div', { class: 'title' }, t('labrec_section'))); + card.appendChild(el('div', { class: 'main legend-muted' }, t('labrec_needs_posology'))); + col.appendChild(card); + } + if (grouped.length === 0) { col.appendChild(el('p', {}, t('no_data'))); container.appendChild(fab(() => openLabDialog({ existing: null }))); diff --git a/js/ui/settings.js b/js/ui/settings.js index 286546e..785ecdf 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.7.1'; +export const WEB_VERSION = '1.8.0'; /** Parse un champ numérique FR (virgule → point) ; null si vide/invalide. */ function parseNumOrNull(text) { diff --git a/js/util/i18n.js b/js/util/i18n.js index 550272a..1decaa1 100644 --- a/js/util/i18n.js +++ b/js/util/i18n.js @@ -181,6 +181,15 @@ const EN = { show_labs: 'Labs', show_forecast: 'Forecast', show_extrema: 'Peaks & troughs', + // v1.8.0 : recommandation de prochaine prise de sang (creux stabilisé — + // miroir strings.xml Android ; t() remplace {0}/{1} par les args) + labrec_section: 'Next blood draw (suggested)', + labrec_when: 'At the estimated trough: {0}', + labrec_before_injection: 'Just before your {0} injection of {1} — or simply the day before that slot.', + labrec_stabilized: 'Your {0} has been stable since {1} (~5 terminal half-lives).', + labrec_not_stabilized: 'Your {0} was not stable until {1} (~5 terminal half-lives): this is the first reliable trough after your latest change.', + labrec_disclaimer: 'Estimation from the pharmacokinetic model, not medical advice — blood draw at the trough simply gives the most comparable result. Always follow your endocrinologist.', + labrec_needs_posology: 'Tip: set a Dosage interval on your active injectable E2 treatment (Treatments → edit) and this page will suggest when to do your next blood draw (at the trough, just before the injection).', show_lab_track: 'Lab track', // v1.6.0 : prolongation du tracé labs au-delà du dernier lab (opt-in, // actif seulement quand Lab track est ON — miroir strings.xml Android) @@ -365,6 +374,15 @@ const FR = { show_labs: 'Labs', show_forecast: 'Prévision', show_extrema: 'Pics & creux', + // v1.8.0 : recommandation de prochaine prise de sang (creux stabilisé — + // miroir strings.xml Android) + labrec_section: 'Prochaine prise de sang (suggestion)', + labrec_when: 'Au creux estimé : {0}', + labrec_before_injection: 'Juste avant ton injection de {0} du {1} — ou simplement la veille de ce créneau.', + labrec_stabilized: 'Ton {0} est stabilisé depuis le {1} (~5 demi-vies terminales).', + labrec_not_stabilized: 'Ton {0} n\'était pas stabilisé avant le {1} (~5 demi-vies terminales) : c\'est le premier creux fiable depuis ton dernier changement.', + labrec_disclaimer: 'Estimation du modèle pharmacocinétique, pas un avis médical — la prise au creux donne simplement le résultat le plus comparable. Suis toujours ton endocrinologue.', + labrec_needs_posology: 'Astuce : renseigne une Posologie (intervalle entre prises) sur ton traitement E2 injectable actif (Traitements → éditer) et cette page suggérera quand faire ta prochaine prise de sang (au creux, juste avant l\'injection).', show_lab_track: 'Tracé labs', // v1.6.0 : prolongation du tracé labs au-delà du dernier lab (opt-in, // actif seulement quand Tracé labs est ON — miroir strings.xml Android) diff --git a/scripts/e2e.mjs b/scripts/e2e.mjs index 1262c08..41fcebf 100644 --- a/scripts/e2e.mjs +++ b/scripts/e2e.mjs @@ -131,7 +131,7 @@ async function main() { ok(dialog !== null, 'dialog « Nouveautés » au premier lancement (markdown rendu)'); if (dialog) { const dlgText = await dialog.textContent(); - ok(dlgText.includes('Version 1.7.1'), 'dialog : section de la version courante'); + ok(dlgText.includes('Version 1.8.0'), 'dialog : section de la version courante'); const hasRenderedMd = await dialog.$('.md-body h3, .md-body strong'); ok(hasRenderedMd !== null, 'dialog : markdown RENDU (titres/gras), pas de brut'); await page.click('.dialog .actions button'); @@ -278,6 +278,15 @@ async function main() { 'Labs : note E2 affichée (préfixée)'); ok(text.includes('T : DHT : 12 ng/dL'), 'Labs : note T affichée (préfixée) — les deux notes coexistent'); + // v1.8.0 : encart « prochaine prise de sang recommandée » — la démo a un + // EEn actif à Posologie 7 j → l'encart DOIT être là (creux + créneau + note) + ok(text.includes('Prochaine prise de sang (suggestion)'), + 'Labs : encart recommandation de prochaine prise de sang rendu'); + ok(/Au creux estimé : \d{2}\/\d{2}\/\d{4}/.test(text), + 'Labs : creux estimé daté dans l\'encart'); + ok(text.includes('Juste avant ton injection de EEN'), + 'Labs : créneau d\'injection associé mentionné'); + ok(text.includes('stabilisé'), 'Labs : mention de stabilisation présente'); await page.goto(`${base}/#treatments`, { waitUntil: 'load' }); await page.waitForTimeout(300); @@ -289,7 +298,7 @@ async function main() { await page.waitForTimeout(300); text = await page.textContent('main.content'); ok(text.includes('Langue'), 'Paramètres : carte langue'); - ok(text.includes('Version 1.7.1 (web)'), 'Paramètres : version affichée'); + ok(text.includes('Version 1.8.0 (web)'), 'Paramètres : version affichée'); ok(text.includes('100 % local'), 'Paramètres : note vie privée'); await page.screenshot({ path: join(SHOTS, 'e2e-settings.png') }); diff --git a/tests/lab-timing.test.js b/tests/lab-timing.test.js new file mode 100644 index 0000000..c758f91 --- /dev/null +++ b/tests/lab-timing.test.js @@ -0,0 +1,97 @@ +/** + * Tests de la RECOMMANDATION DE PROCHAINE PRISE DE SANG (v1.8.0 — miroir de + * `LabTimingTest.kt` Android, `js/pk/lab-timing.js`) : + * - creux recommandé JUSTE AVANT le créneau d'injection associé ; + * - régime non stabilisé → saut au premier creux stabilisé (5 × t½) ; + * - régime stabilisé → premier creux (`wasAlreadyStabilized = true`) ; + * - sans Posologie / sans injectable E2 → null (+ invite Posologie) ; + * - prise récente → creux SUIVANT (jamais un creux déjà mesuré). + */ + +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { nextBloodDrawRecommendation, shouldSuggestPosology } from '../js/pk/lab-timing.js'; +import { makeTreatment, makeDose, makeLab } from './helpers.js'; + +const DAY_MS = 86_400_000; +const HOUR_MS = 3_600_000; +/** Maintenant FIXE : les tests sont déterministes. */ +const NOW = 1_790_000_000_000; + +/** Traitement EEn TFS actif, Posologie 7 j (fixture de l'utilisatrice). */ +function eenTfs(posologyDays = 7.0) { + return makeTreatment({ + name: 'EEn', type: 'ESTRADIOL', route: 'INJECTION_IM', + doseAmount: 5.0, doseUnit: 'mg', esterType: 'EEN', pkModel: 'TFS', + forecastIntervalDays: posologyDays, isActive: true, + }); +} + +/** Doses hebdomadaires sur [weeks] semaines (la plus récente à NOW − 7 j). */ +function weeklyDoses(weeks) { + const out = []; + for (let k = weeks; k >= 1; k--) { + out.push(makeDose(NOW - k * 7 * DAY_MS, 5.0)); + } + return out; +} + +const lab = (ts) => makeLab('E2', 200, 'pg/mL', ts); + +describe('LabTiming (miroir Android v1.8.0)', () => { + test('le creux est trouvé juste avant le créneau d injection associé', () => { + const rec = nextBloodDrawRecommendation( + [eenTfs()], weeklyDoses(9), [], NOW, + ); + assert.ok(rec, 'une recommandation est calculable'); + assert.equal(rec.injectionMs, NOW + 7 * DAY_MS); + const gap = rec.injectionMs - rec.troughMs; + assert.ok(gap > 0 && gap <= 2 * HOUR_MS, `creux juste avant l'injection (écart ${gap} ms)`); + assert.equal(rec.wasAlreadyStabilized, true); + }); + + test('non stabilisé - saute au premier creux STABILISÉ', () => { + // Une seule dose il y a 7 j : EEn TFS t½ = ln2/0.15291485 ≈ 4,53 j → + // stabilisation ≈ NOW + 15,7 j → créneaux +7/+14 trop tôt, +21 premier stable + const rec = nextBloodDrawRecommendation([eenTfs()], weeklyDoses(1), [], NOW); + assert.equal(rec.injectionMs, NOW + 21 * DAY_MS); + assert.equal(rec.wasAlreadyStabilized, false); + }); + + test('déjà stabilisé - premier creux, wasAlreadyStabilized à true', () => { + const rec = nextBloodDrawRecommendation([eenTfs()], weeklyDoses(9), [], NOW); + assert.equal(rec.injectionMs, NOW + 7 * DAY_MS); + assert.equal(rec.wasAlreadyStabilized, true); + assert.ok(rec.stabilizedAtMs < NOW); + }); + + test('prise de sang récente → recommande le creux SUIVANT', () => { + const recentLab = lab(NOW + 7 * DAY_MS - 30 * 60_000); // 30 min avant le créneau + const rec = nextBloodDrawRecommendation( + [eenTfs()], weeklyDoses(9), [recentLab], NOW, + ); + assert.equal(rec.injectionMs, NOW + 14 * DAY_MS); + }); + + test('sans Posologie → null', () => { + assert.equal( + nextBloodDrawRecommendation([eenTfs(null)], weeklyDoses(9), [], NOW), + null, + ); + }); + + test('oral seul → null (et pas d\'invite Posologie)', () => { + const oral = makeTreatment({ + name: 'CPA', type: 'ANTI_ANDROGEN', route: 'ORAL', + doseAmount: 12.5, doseUnit: 'mg', esterType: 'NONE', + forecastIntervalDays: 2.0, isActive: true, + }); + assert.equal(nextBloodDrawRecommendation([oral], weeklyDoses(9), [], NOW), null); + assert.equal(shouldSuggestPosology([oral]), false); + }); + + test('invite Posologie seulement pour un injectable E2 sans Posologie', () => { + assert.equal(shouldSuggestPosology([eenTfs(null)]), true); + assert.equal(shouldSuggestPosology([eenTfs(7.0)]), false); + }); +});