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:
parent
1957b73e3c
commit
f5951e1806
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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"]`
|
||||||
|
|||||||
@ -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
154
js/pk/lab-timing.js
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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 })));
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
97
tests/lab-timing.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user