/** * ───────────────────────────────────────────────────────────────────────────── * 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} 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} current État évalué à CETTE vérification * @param {Object|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 || {}); }