/** * ───────────────────────────────────────────────────────────────────────────── * 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 ; fix * #68 v1.10.0 : le creux = niveau PRÉ-INJECTION du créneau — l'ancien * minimum de fenêtre entière tombait juste après l'injection PRÉCÉDENTE * pour les esters à montée lente, date antérieure à la stabilisation). * * 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 pour les trois modèles (`terminalHalfLifeDays` * — v1.9.0 : Estrannaise analytique aussi, plus aucune lecture de table) ; * - 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 * as EstrannaiseModels from './estrannaise-models.js'; import { model as tfsModel } from './transfem-science-models.js'; import { model as whsahModel } from './whsah-models.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; // v1.9.0 : ESE analytique (estrannaise.js) — t½ terminale analytique else if (carrier.pkModel === 'ESE') tHalfDays = EstrannaiseModels.model(carrier.esterType)?.terminalHalfLifeDays ?? null; 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) { // ⚠️ FIX #68 (v1.10.0, miroir du Kotlin) : le creux = niveau // PRÉ-INJECTION du créneau (dernier point de courbe avant l'injection — // principe 1, « le moment le plus comparable »). L'ancien MINIMUM DE LA // FENÊTRE ENTIÈRE tombait au DÉBUT de la fenêtre pour les esters à // montée lente (EEn : pic ~J+5 ≈ intervalle 7 j → le minimum local est // le creux d'absorption, quelques heures après l'injection PRÉCÉDENTE) : // la date proposée précédait la stabilisation affichée sur la MÊME // carte, et le texte « juste avant ton injection du … » était faux de // plusieurs jours (remontée v1.9.8). Pour les esters à t½ courte (EV), // le point pré-injection EST le minimum de fenêtre : inchangé. let trough = null; for (const p of curve) { if (p.timestamp > windowStart && p.timestamp < slot.timestamp) trough = p; } if ( trough !== null && trough.timestamp >= minTroughMs && trough.timestamp > lastLabMs && trough.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 } /** * 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). * * v1.9.7 (miroir du Kotlin, remontée : « la suggestion change tout le temps * à chaque injection si l'injection n'est pas faite pile à la même heure ») : * la comparaison EXACTE cassait le régime pour un log à 12:30 au lieu de * 12:00 — chaque écart doit rester dans l'INTERVALLE DE POSOLOGIE ± 24 h * (« je m'injecte le même jour, à l'heure près »). La fenêtre se réfère à * l'intervalle THÉORIQUE, pas à l'écart précédent : des logs à 6,8 j puis * 7,2 j ne se déstabilisent plus en cascade. Un vrai changement (2 j au lieu * de 7 j) reste hors fenêtre. * * @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]; // Fenêtre d'écart = INTERVALLE DE POSOLOGIE ± 24 h (la Posologie est un // prérequis de la carte : absente → on s'arrête à la dernière dose). const intervalDays = carrier.forecastIntervalDays; if (!intervalDays || intervalDays <= 0) return regimeStart; const intervalMs = Math.round(intervalDays * 24 * HOUR_MS); const gapMin = intervalMs - GAP_TOLERANCE_MS; const gapMax = intervalMs + GAP_TOLERANCE_MS; 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; // gap > 0 : un doublon de log n'est jamais un régime const sameGap = gap > 0 && gap >= gapMin && gap <= gapMax; if (!sameMarker || !sameGap) break; regimeStart = prev.timestamp; current = prev; idx--; } return regimeStart; } /** Tolérance de l'écart inter-doses (v1.9.7, miroir GAP_TOLERANCE_MS Kotlin) : * ± 24 h autour de l'intervalle de Posologie — un log à 12:30 au lieu de * 12:00 ne repousse plus la stabilisation de 5 × t½. */ const GAP_TOLERANCE_MS = 24 * HOUR_MS; /** * 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 ); }