HormoneTrack-web/js/pk/alerts.js
Siphonight 491efef8eb v1.4.10 — portage initial de l'app Android v1.4.10
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.
2026-09-08 14:34:58 +02:00

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 || {});
}