- lab-timing.js : computeRegimeStartMs — le régime courant = séquence terminale de doses à (ester effectif, dose, écart inter-doses) constants (écart comparé EXACTEMENT) : tout changement récent de dose/ester/ posologie réinitialise la stabilisation. L'ancien proxy (1ʳᵉ dose du traitement) disait « stabilisée depuis février » malgré des changements récents (remontée v1.8.0). L'encart porte l'ESTER EFFECTIF de la dernière dose (override compris). - home.js : encart « Prochaine prise de sang (suggestion) » sur l'ACCUEIL (demande) — creux daté + créneau associé + mention de stabilisation seulement si le régime n'était pas déjà stable. - 3 nouveaux tests miroirs (155 verts) : dose/intervalle/ester → reset épinglé. WEB_VERSION 1.8.1. CHANGELOG [1.8.1], README.
209 lines
10 KiB
JavaScript
209 lines
10 KiB
JavaScript
/**
|
|
* ─────────────────────────────────────────────────────────────────────────────
|
|
* 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, doseEster } 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 = computeRegimeStartMs(carrier, myDoses);
|
|
const stabilizedAtMs = regimeStartMs + Math.round(STABILIZATION_HALF_LIVES * tHalfDays * 24 * HOUR_MS);
|
|
// Ester EFFECTIF de la dernière dose (override compris) : c'est lui qui
|
|
// gouverne les creux futurs et le texte de la carte
|
|
let lastDose = myDoses[0];
|
|
for (const d of myDoses) {
|
|
if (d.timestamp > lastDose.timestamp) lastDose = d;
|
|
}
|
|
const activeEster = String(doseEster(carrier, lastDose)).toUpperCase();
|
|
|
|
// ── 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: activeEster,
|
|
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}
|
|
*/
|
|
/**
|
|
* DÉBUT DU RÉGIME COURANT (v1.8.1 — correction du proxy v1.8.0, miroir du
|
|
* Kotlin). Critique remontée : « changé d'ester, de dosage ET de posologie,
|
|
* et l'app me disait stabilisée depuis février » — l'ancien proxy (1ʳᵉ dose
|
|
* du traitement) ne voyait aucun de ces changements.
|
|
*
|
|
* NOUVELLE RÈGLE : le régime courant = la séquence terminale de doses où
|
|
* (a) l'ESTER EFFECTIF et la DOSE (mg) sont identiques à la dose la plus
|
|
* récente, ET (b) l'ÉCART entre doses consécutives est constant (= l'écart
|
|
* des deux doses les plus récentes). On remonte depuis la dose la plus
|
|
* récente tant que ces conditions tiennent.
|
|
*
|
|
* Pourquoi l'écart fait partie du régime : 7 j → 2 j → 9 j (posologie
|
|
* modifiée/irrégulière) change le creux — conservateur assumé : une série
|
|
* d'intervalles chaotiques maintient la carte « non stabilisée », ce qui est
|
|
* pharmacocinétiquement vrai (le trough n'est comparable que sur un
|
|
* intervalle régulier). Comparaison EXACTE (au ms) : 7 j ± 1 h casse le
|
|
* régime, reflétant un vrai changement de créneau.
|
|
*
|
|
* @private
|
|
* @param {object} carrier traitement porteur
|
|
* @param {object[]} myDoses doses du porteur (ordre indifférent)
|
|
* @returns {number} timestamp de la 1ʳᵉ dose du régime courant
|
|
*/
|
|
function computeRegimeStartMs(carrier, myDoses) {
|
|
const sorted = myDoses.slice().sort((a, b) => a.timestamp - b.timestamp);
|
|
let idx = sorted.length - 1;
|
|
let regimeStart = sorted[idx].timestamp;
|
|
let current = sorted[idx];
|
|
// Écart EXIGÉ pour remonter : celui des deux doses les plus récentes
|
|
let gapExpected = -1;
|
|
const DOSE_EPS = 1e-6;
|
|
while (idx > 0) {
|
|
const prev = sorted[idx - 1];
|
|
const gap = current.timestamp - prev.timestamp;
|
|
const sameMarker = String(doseEster(carrier, current)) === String(doseEster(carrier, prev))
|
|
&& Math.abs(current.doseAmount - prev.doseAmount) < DOSE_EPS;
|
|
const sameGap = gapExpected < 0 || gap === gapExpected;
|
|
if (!sameMarker || !sameGap) break;
|
|
gapExpected = gap;
|
|
regimeStart = prev.timestamp;
|
|
current = prev;
|
|
idx--;
|
|
}
|
|
return regimeStart;
|
|
}
|
|
|
|
export function shouldSuggestPosology(treatments) {
|
|
return treatments.some((t) =>
|
|
t.type === 'ESTRADIOL'
|
|
&& t.isActive
|
|
&& usesProfileModel(t)
|
|
&& (t.forecastIntervalDays ?? 0) <= 0
|
|
);
|
|
}
|