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.
145 lines
6.1 KiB
JavaScript
145 lines
6.1 KiB
JavaScript
/**
|
|
* ─────────────────────────────────────────────────────────────────────────────
|
|
* Seuils d'alerte configurables (portage de `pk/Alerts.kt`, v1.4.2).
|
|
*
|
|
* Limites HAUTE et BASSE définies par l'utilisatrice pour l'estradiol (pg/mL)
|
|
* et la testostérone (ng/mL), évaluées contre le TAUX ESTIMÉ ACTUEL (dernier
|
|
* point de courbe = le niveau « ≈ X pg/mL » affiché sur l'accueil — pas une
|
|
* mesure de labo).
|
|
*
|
|
* Philosophie :
|
|
* - **opt-in** : aucun seuil par défaut (l'app n'invente pas de normes
|
|
* médicales — tout champ laissé vide désactive l'alerte correspondante) ;
|
|
* - PUR (aucune dépendance DOM/storage) → testable en Node ;
|
|
* - les courbes restent des ESTIMATIONS pharmacocinétiques : l'avertissement
|
|
* est indicatif, libellé en ce sens dans l'UI.
|
|
*
|
|
* Validation de cohérence : si haut ET bas sont définis, il faut haut > bas
|
|
* (sinon l'évaluation serait ambiguë) — vérifié à la saisie dans Paramètres
|
|
* avec feedback, et défensivement ici (isCoherent).
|
|
* ─────────────────────────────────────────────────────────────────────────────
|
|
*/
|
|
|
|
/** Niveaux de dépassement. */
|
|
export const Level = { HIGH: 'HIGH', LOW: 'LOW' };
|
|
|
|
/**
|
|
* Seuils de l'utilisatrice (persistés dans le store). null = pas de limite
|
|
* pour cette valeur → jamais d'alerte dessus.
|
|
*/
|
|
export class Thresholds {
|
|
/**
|
|
* @param {number|null} [e2High=null] Limite HAUTE E2 (pg/mL)
|
|
* @param {number|null} [e2Low=null] Limite BASSE E2 (pg/mL)
|
|
* @param {number|null} [tHigh=null] Limite HAUTE T (ng/mL)
|
|
* @param {number|null} [tLow=null] Limite BASSE T (ng/mL)
|
|
*/
|
|
constructor(e2High = null, e2Low = null, tHigh = null, tLow = null) {
|
|
this.e2High = e2High;
|
|
this.e2Low = e2Low;
|
|
this.tHigh = tHigh;
|
|
this.tLow = tLow;
|
|
}
|
|
|
|
/** Cohérence : une limite haute doit être strictement au-dessus de la basse. */
|
|
isCoherent() {
|
|
return (this.e2High === null || this.e2Low === null || this.e2High > this.e2Low)
|
|
&& (this.tHigh === null || this.tLow === null || this.tHigh > this.tLow);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Évalue UNE valeur contre ses limites.
|
|
*
|
|
* - `value > high` → HIGH ; `value < low` → LOW (STRICT : la valeur
|
|
* exactement à la limite ne déclenche rien — éviter les alertes
|
|
* « clignotantes » sur la précision d'affichage) ;
|
|
* - limite null → jamais d'alerte sur ce côté ;
|
|
* - un seul verdict par appel : HIGH prime LOW si la configuration était
|
|
* incohérente (haut < bas) — défensif, la saisie interdit ce cas.
|
|
*
|
|
* @param {number} value
|
|
* @param {number|null} low
|
|
* @param {number|null} high
|
|
* @returns {[string, number]|null} [niveau, limite franchie] ou null
|
|
*/
|
|
export function evaluate(value, low, high) {
|
|
if (high !== null && high !== undefined && value > high) return [Level.HIGH, high];
|
|
if (low !== null && low !== undefined && value < low) return [Level.LOW, low];
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Évalue le niveau actuel (E2 pg/mL + T ng/mL) contre tous les seuils.
|
|
*
|
|
* @param {number} currentE2
|
|
* @param {number} currentT
|
|
* @param {Thresholds} thresholds
|
|
* @returns {{marker:string, level:string, value:number, limit:number, unit:string}[]}
|
|
* Alertes déclenchées (vide = tout va bien / rien de configuré).
|
|
* Ordre stable : E2 d'abord, puis T.
|
|
*/
|
|
export function evaluateAll(currentE2, currentT, thresholds) {
|
|
const out = [];
|
|
const e2 = evaluate(currentE2, thresholds.e2Low, thresholds.e2High);
|
|
if (e2) out.push({ marker: 'E2', level: e2[0], value: currentE2, limit: e2[1], unit: 'pg/mL' });
|
|
const t = evaluate(currentT, thresholds.tLow, thresholds.tHigh);
|
|
if (t) out.push({ marker: 'T', level: t[0], value: currentT, limit: t[1], unit: 'ng/mL' });
|
|
return out;
|
|
}
|
|
|
|
// ── État de notification (v1.4.2) — anti-spam des vérifications périodiques ──
|
|
|
|
/**
|
|
* Sérialise l'état des alertes déjà NOTIFIÉES pour la persistance
|
|
* (localStorage) : `"E2:HIGH;T:LOW"`. Chaîne vide = rien de notifié.
|
|
* (Côté Android, l'encodage est stocké en DataStore entre les vérifications
|
|
* du worker ; côté web, entre les cycles de la boucle de vérification.)
|
|
*
|
|
* @param {{marker:string, level:string}[]} alerts
|
|
* @returns {string}
|
|
*/
|
|
export function encodeState(alerts) {
|
|
return alerts.map((a) => `${a.marker}:${a.level}`).join(';');
|
|
}
|
|
|
|
/**
|
|
* Décodage de l'état persisté (format encodeState) → map marqueur → niveau.
|
|
* Résistant aux entrées malformées (segments sans « : », niveaux inconnus).
|
|
*
|
|
* @param {string|null} encoded
|
|
* @returns {Object<string,string>} ex. { E2: 'HIGH', T: 'LOW' }
|
|
*/
|
|
export function parseState(encoded) {
|
|
const out = {};
|
|
if (!encoded) return out;
|
|
for (const part of String(encoded).split(';')) {
|
|
if (!part.includes(':')) continue;
|
|
const idx = part.indexOf(':');
|
|
const marker = part.slice(0, idx);
|
|
const level = part.slice(idx + 1);
|
|
if ((level === Level.HIGH || level === Level.LOW) && marker) out[marker] = level;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Décide si une NOTIFICATION doit être envoyée pour cette vérification
|
|
* (anti-spam de la boucle périodique — 15 min côté Android, 5 min côté web) :
|
|
*
|
|
* - current vide → **false** (pas de notif de « retour à la normale » ;
|
|
* l'appelant efface l'état persisté pour permettre la re-notification
|
|
* au PROCHAIN franchissement) ;
|
|
* - current identique au dernier état notifié → **false** (l'écart
|
|
* continue, pas de re-notif à chaque cycle) ;
|
|
* - nouveau franchissement OU changement de niveau (H↔L) → **true**.
|
|
*
|
|
* @param {Object<string,string>} current État évalué à CETTE vérification
|
|
* @param {Object<string,string>|null} lastNotified État persisté de la dernière notification
|
|
* @returns {boolean}
|
|
*/
|
|
export function shouldNotify(current, lastNotified) {
|
|
if (Object.keys(current).length === 0) return false;
|
|
return JSON.stringify(current) !== JSON.stringify(lastNotified || {});
|
|
}
|