Portage navigateur complet de l'app Android HormoneTrack v1.4.10 : - moteur PK fidèle (Estrannaise tables ODS, Transfem Science V3C, WHSAH fit Mona, Bateman) — paramètres identiques, invariants Android préservés (fixes #19-#23, #35, #52-#62), 121 tests Node épinglés - calibration par période d'ester et par modèle affiché (#60/#61) - UI 6 écrans + éditeur, CurveChart Canvas (zoom/pan fractionnaire, prévision, pics/creux, fuseau configurable), rappels web (Notification API), seuils d'alerte, i18n FR/EN - sauvegarde JSON v2 compatible Android bidirectionnelle (rétrocompat v1) - 100 % local : localStorage, aucun serveur applicatif, aucune télémétrie - processus : scripts/check.sh (syntaxe, i18n, tests, E2E navigateur, smoke HTTP), docs séparées (README + DEVELOPPEMENT + CHANGELOG) - dépôt GIT SÉPARÉ de l'Android : historique 100 % propre, versions alignées sur l'Android porté, changelogs indépendants Non porté (impossible dans un navigateur, documenté §12) : agenda récurrent, notifications onglet fermé, montre.
475 lines
20 KiB
JavaScript
475 lines
20 KiB
JavaScript
/**
|
||
* ─────────────────────────────────────────────────────────────────────────────
|
||
* PharmacokineticEngine (partie 1/4 : noyau) — portage web de
|
||
* `pk/PharmacokineticEngine.kt`. Le reste est découpé en modules :
|
||
* - pk-calibration.js : auto/manuelle par ester/période/modèle (#60/#61) ;
|
||
* - pk-reminders.js : prochain déclenchement (grille Posologie, #52) ;
|
||
* - pk-extrema.js : détection pics/creux (triangles du graphique).
|
||
*
|
||
* Chaque injection contribue `dose_mg × profil(dt)` où `profil` est la
|
||
* réponse normalisée (pg/mL par mg) ; les contributions se superposent.
|
||
* Trois modèles superposables :
|
||
* - **Estrannaise (ESE)** = tables horaires de l'asset (ODS) ;
|
||
* - **Transfem Science (TFS)** = forme close V3C de la méta-analyse (7 esters) ;
|
||
* - **WHSAH (WHS)** = fit « license-free » de Mona (6 esters, PEP non couvert).
|
||
* Gel/patch/oral → modèle de Bateman paramétrable.
|
||
*
|
||
* ⚠️ INVARIANTS à préserver (doc Android §14 — bugs à ne pas réintroduire) :
|
||
* - `computeKa` : bisection DANS LE BON SENS (#19) ;
|
||
* - tables ESE : modèle strict + extrapolation terminale ≥ 1 % du pic (#20/#21) ;
|
||
* - `convertTToNgMl` appliquée à la calibration ET au rendu (#23) ;
|
||
* - `generateForecastDoses` : créneaux passés SAUTÉS (#35).
|
||
*
|
||
* PUR : aucune dépendance au DOM ni au localStorage → testable en Node.
|
||
* ─────────────────────────────────────────────────────────────────────────────
|
||
*/
|
||
|
||
import * as TransfemScienceModels from './transfem-science-models.js';
|
||
import * as WhsahModels from './whsah-models.js';
|
||
import * as PKProfileStore from './pk-profile-store.js';
|
||
|
||
/** Une heure en ms. */
|
||
export const HOUR_MS = 3600000;
|
||
|
||
/** Un point de courbe : E2 en pg/mL, T en ng/mL. */
|
||
export class LevelPoint {
|
||
/** @param {number} timestamp Epoch ms · @param {number} e2 pg/mL · @param {number} t ng/mL */
|
||
constructor(timestamp, e2, t) {
|
||
this.timestamp = timestamp;
|
||
this.e2 = e2;
|
||
this.t = t;
|
||
}
|
||
}
|
||
|
||
/** Réglages du modèle T empirique (mêmes défauts que TConfig.kt). */
|
||
export class TConfig {
|
||
/** @param {number} [base=6.0] T de base (ng/mL) à E2 ≈ 0 · @param {number} [floor=0.2] plancher · @param {number} [k=0.19] constante d'inhibition */
|
||
constructor(base = 6.0, floor = 0.2, k = 0.19) {
|
||
this.base = base;
|
||
this.floor = floor;
|
||
this.k = k;
|
||
}
|
||
}
|
||
|
||
/** Constantes d'ester (miroir de `Esters.kt` — mêmes clés que les backups JSON). */
|
||
export const Esters = {
|
||
NONE: 'NONE', EV: 'EV', EU: 'EU', EEN: 'EEN',
|
||
EB: 'EB', EC: 'EC', ECS: 'ECS', PEP: 'PEP',
|
||
};
|
||
|
||
/** Constantes de modèles PK (miroir de `PKModels.kt`). */
|
||
export const PKModels = { ESTRANNAISE: 'ESE', TRANSFEM_SCIENCE: 'TFS', WHSAH: 'WHS' };
|
||
|
||
/**
|
||
* Propriété DÉRIVÉE (miroir du getter Kotlin `usesProfileModel`) : le
|
||
* traitement a un PROFIL PK s'il est injectable (IM/SC) ET avec un ester.
|
||
* En JS ce n'est pas une propriété stockée (objets JSON plats, mêmes champs
|
||
* que Gson) — le moteur la recalcule à la demande.
|
||
*
|
||
* @param {object} treatment
|
||
* @returns {boolean}
|
||
*/
|
||
export function usesProfileModel(treatment) {
|
||
return (treatment.route === 'INJECTION_IM' || treatment.route === 'INJECTION_SUBCUT')
|
||
&& treatment.esterType !== Esters.NONE;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Bateman (gel / patch / oral)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Résout `ln(ka/ke) = (ka−ke)·Tmax` par bisection (50 itérations).
|
||
*
|
||
* ⚠️ BUG #19 (épinglé depuis la session 1) : eq(mid) DÉCROÎT en mid et
|
||
* s'annule en ka > ke → `eq > 0` signifie que la racine est AU-DESSUS de
|
||
* mid ⇒ `lo = mid` (l'ancienne bisection inversée convergeait vers un ka
|
||
* énorme, pic à ~0 h au lieu de Tmax).
|
||
*
|
||
* @param {number} tHalfHours Demi-vie d'élimination (h)
|
||
* @param {number} tMaxHours Temps au pic souhaité (h)
|
||
* @returns {number} ka (h⁻¹)
|
||
*/
|
||
export function computeKa(tHalfHours, tMaxHours) {
|
||
if (tMaxHours <= 0 || tHalfHours <= 0) return 1.0;
|
||
const ke = Math.log(2.0) / tHalfHours;
|
||
if (tMaxHours < 0.01) return ke * 100.0;
|
||
let lo = ke * 1.001;
|
||
let hi = ke * 1000.0;
|
||
for (let i = 0; i < 50; i++) {
|
||
const mid = (lo + hi) / 2.0;
|
||
const eq = Math.log(mid / ke) - (mid - ke) * tMaxHours;
|
||
if (eq > 0) lo = mid; else hi = mid; // direction corrigée (bug #19)
|
||
}
|
||
return (lo + hi) / 2.0;
|
||
}
|
||
|
||
/**
|
||
* Paramètres (ke, ka) d'un traitement Bateman, bornes défensives incluses.
|
||
* @param {object} treatment Treatment (data/models.js)
|
||
* @returns {{ke:number, ka:number}}
|
||
*/
|
||
export function batemanParams(treatment) {
|
||
const ke = Math.log(2.0) / Math.max(treatment.eliminationHalfLifeHours, 0.01);
|
||
const ka = computeKa(
|
||
Math.max(treatment.eliminationHalfLifeHours, 0.01),
|
||
Math.max(treatment.absorptionHours, 0.01),
|
||
);
|
||
return { ke, ka };
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Contribution d'une dose
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Ester EFFECTIF d'une dose : override par injection (`dose.esterType`) ou
|
||
* ester par défaut du traitement — permet de switcher d'ester d'une
|
||
* injection à l'autre (comme dans le tableur d'origine).
|
||
*
|
||
* @param {object} treatment
|
||
* @param {object} dose DoseLog
|
||
* @returns {string} clé ester ("EV"…, "NONE" si aucun)
|
||
*/
|
||
export function doseEster(treatment, dose) {
|
||
return dose.esterType ?? treatment.esterType;
|
||
}
|
||
|
||
/**
|
||
* Contribution d'UNE dose au niveau d'E2 à queryTimeMs (pg/mL), tous modèles :
|
||
* 1. traitement à PROFIL PK (injection + ester ≠ NONE) :
|
||
* - TFS + ester couvert par le V3C → TransfemScienceModels ;
|
||
* - WHSAH + ester couvert par le fit Mona → WhsahModels ;
|
||
* - sinon → tables ODS via PKProfileStore (ester sans modèle → 0) ;
|
||
* 2. sinon → Bateman (gel / patch / oral / custom).
|
||
*
|
||
* `modelOverride` force ESE/TFS/WHS pour CE calcul (le graphique dessine les
|
||
* trois modèles côte à côte depuis le même traitement) ; Bateman n'est pas
|
||
* concerné (les séries y sont identiques).
|
||
*
|
||
* @param {object} treatment
|
||
* @param {object} dose
|
||
* @param {number} queryTimeMs
|
||
* @param {{ke:number,ka:number}|null} [bateman=null] Params précalculés (cache)
|
||
* @param {string|null} [modelOverride=null] "ESE" | "TFS" | "WHS"
|
||
* @returns {number} pg/mL
|
||
*/
|
||
export function concentrationOfDose(treatment, dose, queryTimeMs, bateman = null, modelOverride = null) {
|
||
const dtH = (queryTimeMs - dose.timestamp) / 3600000.0;
|
||
if (dtH <= 0.0) return 0.0;
|
||
|
||
const mg = dose.doseAmount;
|
||
if (mg <= 0.0) return 0.0;
|
||
|
||
if (usesProfileModel(treatment)) {
|
||
const ester = doseEster(treatment, dose);
|
||
if (ester !== Esters.NONE) {
|
||
const mod = modelOverride || treatment.pkModel;
|
||
// Dispatch parallèle des 3 modèles (aucun ne remplace un autre) :
|
||
// TFS (V3C méta-analyse) puis WHSAH (fit Mona), fallback tables ODS.
|
||
if (mod === PKModels.TRANSFEM_SCIENCE && TransfemScienceModels.hasModel(ester)) {
|
||
return TransfemScienceModels.sample(ester, dtH) * mg;
|
||
}
|
||
if (mod === PKModels.WHSAH && WhsahModels.hasModel(ester)) {
|
||
return WhsahModels.sample(ester, dtH) * mg;
|
||
}
|
||
return PKProfileStore.sample(ester, mod, dtH) * mg;
|
||
}
|
||
}
|
||
|
||
// Fallback Bateman (gel / patch / oral / custom)
|
||
const p = bateman || batemanParams(treatment);
|
||
const diff = p.ka - p.ke;
|
||
const a = mg * treatment.bioavailabilityFraction;
|
||
// Cas dégénéré ka≈ke : limite analytique (sinon division par ~0)
|
||
const c = Math.abs(diff) < 1e-3
|
||
? a * p.ke * dtH * Math.exp(-p.ke * dtH)
|
||
: (a * p.ka / diff) * (Math.exp(-p.ke * dtH) - Math.exp(-p.ka * dtH));
|
||
return Math.max(0.0, c);
|
||
}
|
||
|
||
/**
|
||
* Fenêtre de contribution d'un traitement en heures (au-delà : la dose est
|
||
* ignorée par les boucles d'agrégation — performance).
|
||
* - TFS/WHS : 10 demi-vies TERMINALES du fit (ex. EV 30 j, PEP 284 j) ;
|
||
* - tables ODS : longueur de table (8001 h) ;
|
||
* - Bateman : 30 × t½. Toujours ≥ 24 h.
|
||
*
|
||
* @param {object} treatment
|
||
* @returns {number} heures
|
||
*/
|
||
export function cutoffHours(treatment) {
|
||
let profileH = 0.0;
|
||
if (usesProfileModel(treatment)) {
|
||
const mod = treatment.pkModel;
|
||
if (mod === PKModels.WHSAH && WhsahModels.hasModel(treatment.esterType)) {
|
||
profileH = WhsahModels.model(treatment.esterType).terminalHalfLifeDays * 24.0 * 10.0;
|
||
} else if (mod === PKModels.TRANSFEM_SCIENCE && TransfemScienceModels.hasModel(treatment.esterType)) {
|
||
profileH = TransfemScienceModels.model(treatment.esterType).terminalHalfLifeDays * 24.0 * 10.0;
|
||
} else {
|
||
profileH = PKProfileStore.profileLength(treatment.esterType, mod);
|
||
}
|
||
}
|
||
const batemanH = usesProfileModel(treatment) ? 0.0 : 30.0 * treatment.eliminationHalfLifeHours;
|
||
const cut = Math.max(profileH, batemanH);
|
||
return cut >= 24.0 ? cut : 24.0;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Niveaux agrégés
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Niveau E2 total à tMs : somme des contributions de toutes les doses E2
|
||
* (traitements ESTRADIOL uniquement), chacune multipliée par SON facteur
|
||
* d'échelle — `scalePerEster[ester]` si présent (calibration par période
|
||
* d'ester, v1.2.1), sinon `scaleFactor` stocké du traitement.
|
||
*
|
||
* ⚠️ TOUS les traitements sont passés ici, actifs comme INACTIFS :
|
||
* « isActive » est un drapeau administratif (plus de nouvelles doses),
|
||
* JAMAIS un filtre de données (bug v1.2.4 — l'historique EV d'un traitement
|
||
* inactivé doit rester simulé, cf régression #3).
|
||
*
|
||
* @param {object[]} treatments
|
||
* @param {object[]} doseLogs
|
||
* @param {number} tMs Instant d'évaluation (epoch ms)
|
||
* @param {string|null} [modelOverride=null]
|
||
* @param {Object<string,number>|null} [scalePerEster=null]
|
||
* @returns {number} pg/mL
|
||
*/
|
||
export function e2At(treatments, doseLogs, tMs, modelOverride = null, scalePerEster = null) {
|
||
const batemanCache = new Map();
|
||
let total = 0.0;
|
||
for (const treatment of treatments) {
|
||
if (treatment.type !== 'ESTRADIOL') continue; // AA/progestatifs → 0 en E2
|
||
if (!batemanCache.has(treatment.id)) {
|
||
batemanCache.set(treatment.id, batemanParams(treatment));
|
||
}
|
||
for (const dose of doseLogs) {
|
||
if (dose.treatmentId !== treatment.id || dose.timestamp > tMs) continue;
|
||
const dtH = (tMs - dose.timestamp) / 3600000.0;
|
||
if (dtH > cutoffHours(treatment)) continue;
|
||
const c = concentrationOfDose(treatment, dose, tMs, batemanCache.get(treatment.id), modelOverride);
|
||
if (c > 0.0) {
|
||
const scale = (scalePerEster && scalePerEster[doseEster(treatment, dose)]) ?? treatment.scaleFactor;
|
||
total += c * scale;
|
||
}
|
||
}
|
||
}
|
||
return total;
|
||
}
|
||
|
||
/**
|
||
* Modèle T EMPIRIQUE (non publié — étiqueté « estimation » partout) :
|
||
* `T = floor + (base − floor) / (1 + k·E2)` en ng/mL.
|
||
*
|
||
* @param {number} e2Level E2 calibrée (pg/mL)
|
||
* @param {TConfig} config
|
||
* @returns {number} ng/mL
|
||
*/
|
||
export function testosteroneAt(e2Level, config) {
|
||
if (e2Level <= 0.0) return config.base;
|
||
return config.floor + (config.base - config.floor) / (1.0 + config.k * e2Level);
|
||
}
|
||
|
||
/**
|
||
* Normalisation des labs T vers ng/mL — appliquée à la calibration ET au
|
||
* rendu du graphique (sinon l'axe T est faux d'un facteur 100, bug #23 :
|
||
* labs 33/44 ng/dL). Branche défensive pg/mL ÷ 1000 : un lab saisi avec une
|
||
* unité aberrante (« 38 pg/mL », bug #26) ne doit pas écraser l'axe T.
|
||
*
|
||
* @param {number} value Valeur du lab
|
||
* @param {string} unit Unité saisie (ng/mL, ng/dL, ng/L, nmol/L…)
|
||
* @returns {number} ng/mL
|
||
*/
|
||
export function convertTToNgMl(value, unit) {
|
||
const u = String(unit).toLowerCase().replace(/\s/g, '');
|
||
if (u.includes('dl')) return value / 100.0;
|
||
if (u.includes('nmol')) return value * 0.2884;
|
||
if (u.includes('pg')) return value / 1000.0;
|
||
if (u.includes('µg') || u.includes('μg')) return value / 1000.0;
|
||
if (u.includes('ng/l') || u.endsWith('/l')) return value / 1000.0;
|
||
return value; // ng/mL (ou unité inconnue : valeur brute)
|
||
}
|
||
|
||
/**
|
||
* L'ester « actif » à l'instant t = celui de la DERNIÈRE dose E2 ≤ t
|
||
* (une prise de sang / un point de courbe reflète l'injection qui précède).
|
||
* Utilisé pour choisir le k de la courbe T (k par période d'ester, v1.2.3).
|
||
*
|
||
* @param {object[]} treatments
|
||
* @param {object[]} doseLogs
|
||
* @param {number} tMs
|
||
* @returns {string|null} clé ester, ou null (avant la 1ʳᵉ dose / ester NONE)
|
||
*/
|
||
export function activeEsterAt(treatments, doseLogs, tMs) {
|
||
const estrogenIds = new Set(treatments.filter((tr) => tr.type === 'ESTRADIOL').map((tr) => tr.id));
|
||
let last = null;
|
||
for (const d of doseLogs) {
|
||
if (d.timestamp <= tMs && estrogenIds.has(d.treatmentId)) {
|
||
if (last === null || d.timestamp > last.timestamp) last = d;
|
||
}
|
||
}
|
||
if (!last) return null;
|
||
const tr = treatments.find((t) => t.id === last.treatmentId);
|
||
const ester = doseEster(tr, last);
|
||
return ester !== Esters.NONE ? ester : null;
|
||
}
|
||
|
||
/**
|
||
* Niveau combiné (E2 + T) à un instant donné — la notification d'alerte et la
|
||
* carte « niveau actuel » de l'accueil partagent CE calcul (même calibration :
|
||
* `scalePerEster` + `tKPerEster`, cf §9.bis Android).
|
||
*
|
||
* @param {object[]} treatments
|
||
* @param {object[]} doseLogs
|
||
* @param {number} tMs
|
||
* @param {TConfig} tConfig
|
||
* @param {Object<string,number>|null} [tKPerEster=null] k T par ester (période active)
|
||
* @param {Object<string,number>|null} [scalePerEster=null]
|
||
* @returns {LevelPoint}
|
||
*/
|
||
export function levelAt(treatments, doseLogs, tMs, tConfig, tKPerEster = null, scalePerEster = null) {
|
||
const e2 = e2At(treatments, doseLogs, tMs, null, scalePerEster);
|
||
const active = activeEsterAt(treatments, doseLogs, tMs);
|
||
const k = (active && tKPerEster && tKPerEster[active]) ?? tConfig.k;
|
||
return new LevelPoint(tMs, e2, testosteroneAt(e2, new TConfig(tConfig.base, tConfig.floor, k)));
|
||
}
|
||
|
||
/** Niveau actuel (now par défaut) — enveloppe de levelAt. */
|
||
export function currentLevel(treatments, doseLogs, tConfig, tKPerEster = null, nowMs = Date.now(), scalePerEster = null) {
|
||
return levelAt(treatments, doseLogs, nowMs, tConfig, tKPerEster, scalePerEster);
|
||
}
|
||
|
||
/**
|
||
* Courbe complète entre startMs et endMs (grille régulière stepMs, défaut 1 h).
|
||
* - départ clampé à la 1ʳᵉ dose (rien avant) ;
|
||
* - k de la T suivant l'ester ACTIF à chaque point (curseur sur les doses triées) ;
|
||
* - modèle via `modelOverride`, calibration via `scalePerEster` + `tKPerEster`.
|
||
*
|
||
* @param {object[]} treatments
|
||
* @param {object[]} doseLogs
|
||
* @param {number} startMs
|
||
* @param {number} endMs
|
||
* @param {number} [stepMs=HOUR_MS]
|
||
* @param {TConfig} tConfig
|
||
* @param {object} [opts={}] { modelOverride, scalePerEster, tKPerEster }
|
||
* @returns {LevelPoint[]}
|
||
*/
|
||
export function computeCurve(treatments, doseLogs, startMs, endMs, stepMs, tConfig, opts = {}) {
|
||
if (stepMs === undefined) stepMs = HOUR_MS;
|
||
const { modelOverride = null, scalePerEster = null, tKPerEster = null } = opts;
|
||
if (treatments.length === 0 || doseLogs.length === 0 || endMs <= startMs) return [];
|
||
|
||
const relevantTreatments = treatments.filter(
|
||
(tr) => tr.type === 'ESTRADIOL' && doseLogs.some((d) => d.treatmentId === tr.id),
|
||
);
|
||
if (relevantTreatments.length === 0) return [];
|
||
|
||
const earliestDose = doseLogs.reduce((m, d) => Math.min(m, d.timestamp), Infinity);
|
||
const searchStart = Math.max(startMs, earliestDose);
|
||
|
||
const batemanCache = new Map();
|
||
relevantTreatments.forEach((tr) => batemanCache.set(tr.id, batemanParams(tr)));
|
||
|
||
// Doses triées pour suivre l'« ester actif » le long de la grille (k de T)
|
||
const sortedEstrogenDoses = doseLogs
|
||
.filter((d) => relevantTreatments.some((tr) => tr.id === d.treatmentId))
|
||
.slice()
|
||
.sort((a, b) => a.timestamp - b.timestamp);
|
||
let doseCursor = 0;
|
||
let activeEster = null;
|
||
|
||
const points = [];
|
||
let t = searchStart;
|
||
while (t <= endMs) {
|
||
let e2 = 0.0;
|
||
// Avance le curseur : la dose à t définit l'ester actif
|
||
while (doseCursor < sortedEstrogenDoses.length && sortedEstrogenDoses[doseCursor].timestamp <= t) {
|
||
const d = sortedEstrogenDoses[doseCursor];
|
||
const tr = relevantTreatments.find((x) => x.id === d.treatmentId);
|
||
activeEster = doseEster(tr, d);
|
||
doseCursor++;
|
||
}
|
||
const k = (activeEster && tKPerEster && tKPerEster[activeEster]) ?? tConfig.k;
|
||
for (const treatment of relevantTreatments) {
|
||
const cutoff = cutoffHours(treatment);
|
||
const p = batemanCache.get(treatment.id);
|
||
for (const dose of doseLogs) {
|
||
if (dose.treatmentId !== treatment.id || dose.timestamp > t) continue;
|
||
const dtH = (t - dose.timestamp) / 3600000.0;
|
||
if (dtH > cutoff) continue;
|
||
const c = concentrationOfDose(treatment, dose, t, p, modelOverride);
|
||
if (c > 0.0) {
|
||
const scale = (scalePerEster && scalePerEster[doseEster(treatment, dose)]) ?? treatment.scaleFactor;
|
||
e2 += c * scale;
|
||
}
|
||
}
|
||
}
|
||
points.push(new LevelPoint(t, e2, testosteroneAt(e2, new TConfig(tConfig.base, tConfig.floor, k))));
|
||
t += stepMs;
|
||
}
|
||
return points;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Prévision
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Génère les doses PRÉVISIONNELLES d'un traitement, à partir de sa Posologie
|
||
* (`forecastIntervalDays`, en jours) et de la dernière dose réellement
|
||
* enregistrée.
|
||
*
|
||
* Règles :
|
||
* - intervalle null ou ≤ 0 → aucune prévision ;
|
||
* - la première dose projetée suit EXACTEMENT l'intervalle après la dernière
|
||
* dose réelle (le rythme reste sous le contrôle de l'utilisatrice) ;
|
||
* - dose = standard du traitement, ester = override de la dernière injection ;
|
||
* - ⚠️ les créneaux déjà PASSÉS sont sautés au rythme configuré (#35, cas
|
||
* d'un OUBLI : simuler un créneau passé = faux pic dans l'historique) ;
|
||
* un RETARD décale naturellement toute la prévision (comportement voulu).
|
||
*
|
||
* Les doses retournées ne sont JAMAIS persistées : elles alimentent
|
||
* computeCurve et les marqueurs du graphique quand le chip est actif.
|
||
*
|
||
* @param {object} treatment
|
||
* @param {object[]} allDoseLogs
|
||
* @param {number} toMs Fin de la fenêtre de projection
|
||
* @param {number} [nowMs=Date.now()]
|
||
* @returns {object[]} DoseLog[] (objets purs, jamais en base)
|
||
*/
|
||
export function generateForecastDoses(treatment, allDoseLogs, toMs, nowMs = Date.now()) {
|
||
const intervalDays = treatment.forecastIntervalDays;
|
||
if (intervalDays === null || intervalDays === undefined || intervalDays <= 0.0) return [];
|
||
const intervalMs = Math.trunc(intervalDays * 24.0 * HOUR_MS);
|
||
if (intervalMs <= 0) return [];
|
||
|
||
let last = null;
|
||
for (const d of allDoseLogs) {
|
||
if (d.treatmentId === treatment.id && d.timestamp <= nowMs) {
|
||
if (last === null || d.timestamp > last.timestamp) last = d;
|
||
}
|
||
}
|
||
if (!last) return [];
|
||
|
||
const forecast = [];
|
||
let t = last.timestamp + intervalMs;
|
||
// ⚠️ Oubli d'une injection : le premier créneau théorique tombe dans le
|
||
// PASSÉ → on avance au premier créneau STRICTEMENT FUTUR, au rythme
|
||
// configuré (bug #35).
|
||
while (t <= nowMs) t += intervalMs;
|
||
while (t <= toMs) {
|
||
forecast.push({
|
||
id: 0, // jamais persisté
|
||
treatmentId: treatment.id,
|
||
timestamp: t,
|
||
doseAmount: treatment.doseAmount,
|
||
notes: null,
|
||
esterType: doseEster(treatment, last),
|
||
});
|
||
t += intervalMs;
|
||
}
|
||
return forecast;
|
||
}
|