v1.8.0 : recommandation de prochaine prise de sang (portage Android, miroir)

- 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.
This commit is contained in:
Siphonight 2026-09-17 18:46:51 +02:00
parent 1957b73e3c
commit f5951e1806
11 changed files with 406 additions and 24 deletions

View File

@ -17,11 +17,11 @@
> Fie-toi toujours à tes analyses de sang et aux consignes de ton > Fie-toi toujours à tes analyses de sang et aux consignes de ton
> endocrinologue. > endocrinologue.
- **Statut** : web v1.7.1 — parité fonctionnelle avec l'Android v1.7.1 - **Statut** : web v1.8.0 — parité fonctionnelle avec l'Android v1.8.0
(unités des axes + fix notes des analyses ; l'auto-backup journalier (recommandation de prochaine prise de sang ; l'auto-backup journalier
Android est structurellement non porté, cf §12 — l'export manuel couvre Android reste structurellement non porté, cf §12 — l'export manuel couvre
la donnée) · la donnée) ·
**145 tests verts** · E2E navigateur ✅ · conteneur testé en **152 tests verts** · E2E navigateur ✅ · conteneur testé en
configuration durcie ✅ · lint/i18n ✅ configuration durcie ✅ · lint/i18n ✅
- **Journal des versions web** : [docs/CHANGELOG.md](docs/CHANGELOG.md) - **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) - **Releases** : [gitea.cloudyfy.fr/Siphonight/HormoneTrack-web/releases](https://gitea.cloudyfy.fr/Siphonight/HormoneTrack-web/releases)

View File

@ -5,6 +5,27 @@
> section la plus récente EN PREMIER ; ce fichier est lu par le dialog > section la plus récente EN PREMIER ; ce fichier est lu par le dialog
> « Nouveautés » de l'app web (fetch `docs/CHANGELOG.md`). > « 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 ## [1.7.1] — 2026-09-16
### Corrigé — une seule note sur deux s'affichait dans les Analyses ### Corrigé — une seule note sur deux s'affichait dans les Analyses

View File

@ -243,6 +243,9 @@ HormoneTrack-web/ (dépôt séparé)
│ ├── pk-engine.test.js ← PharmacokineticEngineTest.kt (+ #35, §6.bis) │ ├── pk-engine.test.js ← PharmacokineticEngineTest.kt (+ #35, §6.bis)
│ ├── calibration.test.js ← CalibrationPerModel/ScaleFactorWhsahRepro/ │ ├── calibration.test.js ← CalibrationPerModel/ScaleFactorWhsahRepro/
│ │ V120Features (fixes #60/#61, périodes d'ester) │ │ 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 │ ├── lab-notes.test.js ← labNotesForDisplay (v1.7.1 : notes d'une
│ │ prise E2+T — les DEUX distinctes affichées) │ │ prise E2+T — les DEUX distinctes affichées)
│ ├── lab-trajectory-model.test.js ← LabTrajectoryModelTest.kt (v1.5.0 │ ├── 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, 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, garde #61 sur la prolongation, flag off = v1.5.0 bit-compatible,
`lastAnchorMs`. `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, - **backup** : round-trip v2, rétrocompat v1, écrasement + IDs conservés,
**imports des exports Android réels** (data-driven, hors dépôt). **imports des exports Android réels** (data-driven, hors dépôt).
- **chart-helpers** : cumul fractionnaire du pan (#62), minuit local (#55), - **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 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 navigation (quitter → revenir) et vérifie le RESET aux défauts (miroir
du `remember` Android quitté). 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 ; - vérifie l'**absence d'erreur console/page** sur toute la session ;
- captures dans `/tmp/hrt-web-shots/` (inspection visuelle). - captures dans `/tmp/hrt-web-shots/` (inspection visuelle).
- ⚠️ Course au screenshot évitée par le marqueur `body[data-ready="1"]` - ⚠️ Course au screenshot évitée par le marqueur `body[data-ready="1"]`

View File

@ -10,6 +10,8 @@ export * from './pk-reminders.js';
export * from './pk-extrema.js'; export * from './pk-extrema.js';
export * as LabTrajectoryModel from './lab-trajectory-model.js'; export * as LabTrajectoryModel from './lab-trajectory-model.js';
export { computeLabAnchoredCurve, ratioAt, extensionHorizonEndMs } 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 * from './chart-helpers.js';
export * as Alerts from './alerts.js'; export * as Alerts from './alerts.js';
export * as PKProfileStore from './pk-profile-store.js'; export * as PKProfileStore from './pk-profile-store.js';

154
js/pk/lab-timing.js Normal file
View File

@ -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
);
}

View File

@ -137,24 +137,8 @@ export function sample(ester, mod, dtHours) {
// plate) → on part du dernier point encore ≥ 1 % du pic, avec le TAUX // 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) — // logarithmique moyen des 48 h précédentes (jamais avant le pic) —
// extrapolation EXPONENTIELLE, donc jamais croissante. // extrapolation EXPONENTIELLE, donc jamais croissante.
let peakIdx = 0; const [j, rate] = terminalDecayParameters(arr) ?? [0, 0.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--;
if (j <= 0) return 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)); 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; 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é). */ /** @returns {boolean} true si le store a été initialisé (asset chargé). */
export function isInitialized() { export function isInitialized() {
return profiles !== null; return profiles !== null;

View File

@ -15,6 +15,7 @@
import { store } from '../data/store.js'; import { store } from '../data/store.js';
import { t } from '../util/i18n.js'; import { t } from '../util/i18n.js';
import { formatLabValue, formatDateTime, labNotesForDisplay } from '../util/format.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 { el, confirmDialog, fab, showDialog } from './components.js';
import { openLabDialog } from './dialogs.js'; import { openLabDialog } from './dialogs.js';
@ -55,6 +56,35 @@ export function renderLabs(container) {
const col = el('div', {}); const col = el('div', {});
container.appendChild(col); 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) { if (grouped.length === 0) {
col.appendChild(el('p', {}, t('no_data'))); col.appendChild(el('p', {}, t('no_data')));
container.appendChild(fab(() => openLabDialog({ existing: null }))); container.appendChild(fab(() => openLabDialog({ existing: null })));

View File

@ -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`). * (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. * 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. */ /** Parse un champ numérique FR (virgule → point) ; null si vide/invalide. */
function parseNumOrNull(text) { function parseNumOrNull(text) {

View File

@ -181,6 +181,15 @@ const EN = {
show_labs: 'Labs', show_labs: 'Labs',
show_forecast: 'Forecast', show_forecast: 'Forecast',
show_extrema: 'Peaks & troughs', 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', show_lab_track: 'Lab track',
// v1.6.0 : prolongation du tracé labs au-delà du dernier lab (opt-in, // 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) // actif seulement quand Lab track est ON — miroir strings.xml Android)
@ -365,6 +374,15 @@ const FR = {
show_labs: 'Labs', show_labs: 'Labs',
show_forecast: 'Prévision', show_forecast: 'Prévision',
show_extrema: 'Pics & creux', 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', show_lab_track: 'Tracé labs',
// v1.6.0 : prolongation du tracé labs au-delà du dernier lab (opt-in, // 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) // actif seulement quand Tracé labs est ON — miroir strings.xml Android)

View File

@ -131,7 +131,7 @@ async function main() {
ok(dialog !== null, 'dialog « Nouveautés » au premier lancement (markdown rendu)'); ok(dialog !== null, 'dialog « Nouveautés » au premier lancement (markdown rendu)');
if (dialog) { if (dialog) {
const dlgText = await dialog.textContent(); 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'); const hasRenderedMd = await dialog.$('.md-body h3, .md-body strong');
ok(hasRenderedMd !== null, 'dialog : markdown RENDU (titres/gras), pas de brut'); ok(hasRenderedMd !== null, 'dialog : markdown RENDU (titres/gras), pas de brut');
await page.click('.dialog .actions button'); await page.click('.dialog .actions button');
@ -278,6 +278,15 @@ async function main() {
'Labs : note E2 affichée (préfixée)'); 'Labs : note E2 affichée (préfixée)');
ok(text.includes('T : DHT : 12 ng/dL'), ok(text.includes('T : DHT : 12 ng/dL'),
'Labs : note T affichée (préfixée) — les deux notes coexistent'); '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.goto(`${base}/#treatments`, { waitUntil: 'load' });
await page.waitForTimeout(300); await page.waitForTimeout(300);
@ -289,7 +298,7 @@ async function main() {
await page.waitForTimeout(300); await page.waitForTimeout(300);
text = await page.textContent('main.content'); text = await page.textContent('main.content');
ok(text.includes('Langue'), 'Paramètres : carte langue'); 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'); ok(text.includes('100 % local'), 'Paramètres : note vie privée');
await page.screenshot({ path: join(SHOTS, 'e2e-settings.png') }); await page.screenshot({ path: join(SHOTS, 'e2e-settings.png') });

97
tests/lab-timing.test.js Normal file
View File

@ -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);
});
});