From 902d521094c1bcdaad56f204e7db8b09b144b1e6 Mon Sep 17 00:00:00 2001 From: Siphonight Date: Wed, 7 Oct 2026 09:58:52 +0200 Subject: [PATCH] =?UTF-8?q?v1.13.0=20(web)=20:=20reco=20pr=C3=A9dictive=20?= =?UTF-8?q?=E2=80=94=20valeur=20E2=20au=20creux,=20r=C3=A9gime=20pool?= =?UTF-8?q?=C3=A9,=20cible=20de=20creux=20=E2=80=94=20miroir=20Android?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - predictedE2 : valeur brute au creux × facteur du ester (auto-calibration ON requise — sinon rien, une valeur brute serait trompeuse) ; rendu coloré face à la cible dans labs.js + home.js. - pooledRegimeDoses : les doses de tous les traitements partageant (ester effectif, mg) forment UNE séquence — le re-parenting d'historique (« 6d-old ») ne redémarre plus la stabilisation. Garde-fous : ester ≠ jamais poolé, dose ≠ exclue. - Cible de creux E2 (opt-in) : store + carte settings + backup (rétrocompatible — un backup ancien n'efface pas la cible locale) + i18n FR/EN. ⚠️ DISTINCT des seuils d'alerte : jamais de notification. - 15 tests (pooling ×3, statut cible, round-trip backup) ; leçon E2E : bump WEB_VERSION sans section CHANGELOG = dialog vide. 199 tests + E2E verts (check.sh). WEB_VERSION alignée sur 1.13.0. --- README.md | 12 +++--- docs/CHANGELOG.md | 21 +++++++++++ docs/DEVELOPPEMENT.md | 15 +++++--- js/data/backup.js | 3 ++ js/data/store.js | 2 + js/pk/index.js | 2 +- js/pk/lab-timing.js | 77 ++++++++++++++++++++++++++++++++++++-- js/ui/home.js | 24 +++++++++++- js/ui/labs.js | 37 +++++++++++++++--- js/ui/settings.js | 56 +++++++++++++++++++++++++-- js/util/i18n.js | 18 +++++++++ tests/backup.test.js | 2 + tests/doses-extras.test.js | 70 ++++++++++++++++++++++++++++++++++ 13 files changed, 314 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 693240b..4b226e6 100644 --- a/README.md +++ b/README.md @@ -17,13 +17,15 @@ > Fie-toi toujours à tes analyses de sang et aux consignes de ton > endocrinologue. -- **Statut** : web v1.12.0 — parité fonctionnelle avec l'Android v1.12.0 - sur la logique miroirée (fix #70 : un traitement INACTIF ne projette plus - de créneaux — graphique Prévision ; page Doses : glyphes de forme de - prise 💊💉💧🩹🧴 et encarts « prise de sang le même jour » 🧪↑/🧪↓) ; +- **Statut** : web v1.13.0 — parité fonctionnelle avec l'Android v1.13.0 + sur la logique miroirée (reco de prise de sang : **valeur E2 attendue au + creux** quand l'auto-calibration est ON + **régime poolé** — un + re-parenting d'historique ne redémarre plus la stabilisation + **cible de + creux** opt-in colorant la prédiction, jamais de notification ; le fix + #70 et les extras Doses de la v1.12 restent couverts) ; l'auto-backup journalier et les notifications Android restent structurellement non portés, cf §12 · - **194 tests verts** · E2E navigateur ✅ · conteneur testé en + **199 tests verts** · E2E navigateur ✅ · conteneur testé en configuration durcie ✅ · lint/i18n ✅ - **Journal des versions web** : [docs/CHANGELOG.md](docs/CHANGELOG.md) - **Releases** : [gitea.cloudyfy.fr/Siphonight/HormoneTrack-web/releases](https://gitea.cloudyfy.fr/Siphonight/HormoneTrack-web/releases) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 305595c..c29547d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,6 +5,27 @@ > section la plus récente EN PREMIER ; ce fichier est lu par le dialog > « Nouveautés » de l'app web (fetch `docs/CHANGELOG.md`). +## [1.13.0] — 2026-09-30 + +### Ajouté — recommandation de prise de sang : valeur prédite, régime poolé, cible de creux + +- **Valeur E2 attendue au creux recommandé** : la carte affiche désormais + « ≈ X pg/mL » quand l'auto-calibration fournit ses facteurs (sinon rien — + une valeur brute non calibrée serait trompeuse). Miroir de + `LabRecommendation.predictedE2`. +- **Régime poolé** (`pooledRegimeDoses`, miroir du Kotlin) : les doses de + tous les traitements partageant (ester effectif, mg) forment UNE séquence + — un re-parenting d'historique (« … - old » créé a posteriori) ne + redémarre plus la stabilisation. Garde-fous : ester ≠ → jamais poolé ; + dose ≠ → exclue (le trou qui suit casse la séquence, conservateur). +- **Cible de creux E2** (opt-in, Paramètres) : référence personnelle pour la + prochaine prise de sang — la valeur prédite est colorée dans/hors cible. + ⚠️ DISTINCT des seuils d'alerte (surveillance continue + notifications) : + la cible ne notifie jamais. Voyage dans le backup (champs optionnels, + rétrocompatibles). + +Vérifié : 198 tests + E2E verts (`check.sh`). + ## [1.12.0] — 2026-09-29 ### Corrigé — un traitement inactif à Posologie projetait encore ses injections (#70) diff --git a/docs/DEVELOPPEMENT.md b/docs/DEVELOPPEMENT.md index e1247f7..dcb9bbb 100644 --- a/docs/DEVELOPPEMENT.md +++ b/docs/DEVELOPPEMENT.md @@ -60,7 +60,7 @@ Règles du portage, fixées au départ : | Choix | Décision | Pourquoi | |---|---|---| -| Langage | JavaScript ES modules natifs (pas de TS, pas de build) | zéro chaîne de compilation → le déploiement = copier le dossier ; les types critiques sont documentés en JSDoc et épinglés par 194 tests | +| Langage | JavaScript ES modules natifs (pas de TS, pas de build) | zéro chaîne de compilation → le déploiement = copier le dossier ; les types critiques sont documentés en JSDoc et épinglés par 199 tests | | Framework UI | Aucun — DOM via un helper `el()` (`ui/components.js`) | même philosophie que la v1 Android (pas de ViewModel/DI) ; une seule abstraction à connaître | | Rendu graphique | Canvas 2D natif (portage de `CurveChart.kt`) | pas de lib de charts : fidélité du rendu, zéro dépendance | | Persistance | `localStorage` (clés préfixées `hormonetrack.`) | données petites (JSON) ; backend INJECTABLE → testable en Node (`store.setBackend(new Map())`) | @@ -265,6 +265,7 @@ HormoneTrack-web/ (dépôt séparé) │ │ stable) │ ├── doses-extras.test.js ← fix #70 garde prévision inactifs + │ │ routeGlyph + labMarkersForDose (v1.12.0) +│ │ + régime poolé + statut cible (v1.13.0) │ ├── lab-trajectory-model.test.js ← LabTrajectoryModelTest.kt (v1.5.0 │ │ « Tracé labs » + v1.6.0 prolongation — │ │ passage exact, garde #61, horizon cutoff) @@ -410,7 +411,7 @@ Pièges rencontrés : |---|---|---|---| | Syntaxe | `node --check` | tous les modules ES | `bash scripts/check.sh` | | i18n | `scripts/i18n-check.mjs` | clés FR/EN synchronisées + clés utilisées | (dans check.sh) | -| Unitaires | `node --test` | **194 tests** : noyau PK (ESE analytique/TFS/WHS), calibration, backup, store, helpers, alertes, rappels, changelog, tracé labs, estrannaise-models/cloud, rounding, presets (v1.9.4), ordre d'affichage des traitements (v1.11.0), garde prévision inactifs + extras Doses (v1.12.0) | `npm test` | +| Unitaires | `node --test` | **199 tests** : noyau PK (ESE analytique/TFS/WHS), calibration, backup, store, helpers, alertes, rappels, changelog, tracé labs, estrannaise-models/cloud, rounding, presets (v1.9.4), ordre d'affichage des traitements (v1.11.0), garde prévision inactifs + extras Doses (v1.12.0), régime poolé + prédiction creux + statut cible (v1.13.0) | `npm test` | | E2E navigateur | playwright-core + build Firefox | app réelle : rendu, canvas peint (pixels), navigation, dialog changelog, langue | `npm run e2e` | | Smoke HTTP | curl | ressources clés en 200 | `check.sh --with-serve` | @@ -454,10 +455,12 @@ launch) — d'où le build dédié dans le cache Playwright, hors dépôt. `lastAnchorMs`. - **lab-timing** : v1.8.0 « prochaine prise de sang recommandée » (miroir du Kotlin, §7.11 Android) : creux = niveau PRÉ-INJECTION du créneau - (**fix #68 v1.10.0** — l'ancien minimum de la fenêtre entière tombait - juste après l'injection précédente pour les esters à montée lente comme - l'EEn ; pour EV le point pré-injection EST le minimum de fenêtre), - saut au premier creux STABILISÉ (5 × t½ — TFS/WHS + (**fix #68 v1.10.0**), **régime POOLÉ entre traitements identiques** + (**v1.13.0** — `pooledRegimeDoses`, le re-parenting d'historique ne + redémarre plus la stabilisation), **prédiction E2 au creux** + (`predictedE2`, seulement si calibration fournie) et **statut cible** + (`troughTargetStatus` — jamais de notification, distinct des seuils + d'alerte), saut au premier creux STABILISÉ (5 × t½ — TFS/WHS analytiques, Estrannaise lue dans la table via terminalHalfLifeDays), filtres (≥ now+6 h, > dernière prise, Posologie requise), invite « renseigne une Posologie », oral seul → null. diff --git a/js/data/backup.js b/js/data/backup.js index 7d3d8bf..e914c30 100644 --- a/js/data/backup.js +++ b/js/data/backup.js @@ -64,6 +64,9 @@ export function buildBackupJson(collections, tConfig, userSettings = null) { alertE2Low: userSettings.alertE2Low ?? null, alertTHigh: userSettings.alertTHigh ?? null, alertTLow: userSettings.alertTLow ?? null, + // v1.13.0 : cible de creux (champs optionnels — rétrocompatibles) + troughTargetLow: userSettings.troughTargetLow ?? null, + troughTargetHigh: userSettings.troughTargetHigh ?? null, }; } return JSON.stringify(data); diff --git a/js/data/store.js b/js/data/store.js index 68e34e6..1d68947 100644 --- a/js/data/store.js +++ b/js/data/store.js @@ -226,6 +226,8 @@ class WebStore { alertE2Low: null, alertTHigh: null, alertTLow: null, + troughTargetLow: null, // cible de creux (v1.13.0, opt-in — référence de lab, JAMAIS de notification) + troughTargetHigh: null, chartTimezone: null, // fuseau de LECTURE du graphique (null = auto) changelogSeenVersion: null, // dialog « Nouveautés » alertNotifiedState: null, // anti-spam des notifications d'alerte diff --git a/js/pk/index.js b/js/pk/index.js index f68a6f5..aed3af4 100644 --- a/js/pk/index.js +++ b/js/pk/index.js @@ -13,7 +13,7 @@ export { computeLabAnchoredCurve, ratioAt, extensionHorizonEndMs } from './lab-t export * as LabTiming from './lab-timing.js'; export * as EstrannaiseModels from './estrannaise-models.js'; export { sample as estrannaiseSample, hasModel as estrannaiseHasModel } from './estrannaise-models.js'; -export { nextBloodDrawRecommendation, shouldSuggestPosology } from './lab-timing.js'; +export { nextBloodDrawRecommendation, shouldSuggestPosology, troughTargetStatus } from './lab-timing.js'; export { compute as computeEstrannaiseCloud, DEFAULT_NB_CURVES } from './estrannaise-cloud.js'; export * from './chart-helpers.js'; export * as Alerts from './alerts.js'; diff --git a/js/pk/lab-timing.js b/js/pk/lab-timing.js index e6196ee..761ced8 100644 --- a/js/pk/lab-timing.js +++ b/js/pk/lab-timing.js @@ -62,11 +62,14 @@ const HOUR_MS = 3600000; * @param {object[]} doseLogs toutes les doses * @param {object[]} labs toutes les prises de sang existantes * @param {number} [nowMs=Date.now()] + * @param {Object} [scalePerEster=null] facteurs d'échelle PAR + * ESTER (v1.13.0, issus de l'auto-calibration quand elle est activée) — + * alimente `predictedE2` ; absent → `predictedE2 = null` (carte sans valeur) * @returns {{troughMs:number, injectionMs:number, ester:string, * terminalHalfLifeDays:number, regimeStartMs:number, stabilizedAtMs:number, - * wasAlreadyStabilized:boolean}|null} + * wasAlreadyStabilized:boolean, predictedE2:number|null}|null} */ -export function nextBloodDrawRecommendation(treatments, doseLogs, labs, nowMs = Date.now()) { +export function nextBloodDrawRecommendation(treatments, doseLogs, labs, nowMs = Date.now(), scalePerEster = null) { // ── 1) Traitement PORTEUR : E2 + injectable + actif + Posologie ─────────── const carrier = treatments.find((t) => t.type === 'ESTRADIOL' @@ -87,7 +90,12 @@ export function nextBloodDrawRecommendation(treatments, doseLogs, labs, nowMs = // ── 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); + // v1.13.0 : régime POOLÉ (cf pooledRegimeDoses) — le re-parenting + // d'historique (ex. « 6d - old » créé a posteriori, remontée v1.12.0) + // ne doit pas faire croire à un nouveau régime. + const regimeStartMs = computeRegimeStartMs( + carrier, pooledRegimeDoses(carrier, doseLogs, treatments), + ); 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 @@ -146,6 +154,15 @@ export function nextBloodDrawRecommendation(treatments, doseLogs, labs, nowMs = regimeStartMs, stabilizedAtMs, wasAlreadyStabilized: stabilizedAtMs <= nowMs, + // v1.13.0 : E2 attendue au creux = valeur brute du modèle × facteur + // du ester actif (lookup insensible à la casse) ; null sans calibration + predictedE2: (() => { + if (!scalePerEster) return null; + const entry = Object.entries(scalePerEster).find( + ([k]) => String(k).toUpperCase() === activeEster, + ); + return entry ? trough.e2 * entry[1] : null; + })(), }; } windowStart = slot.timestamp; @@ -235,3 +252,57 @@ export function shouldSuggestPosology(treatments) { && (t.forecastIntervalDays ?? 0) <= 0 ); } + +/** + * SÉQUENCE DE RÉGIME POOLÉE (v1.13.0, miroir fidèle du Kotlin — même KDoc) : + * toutes les doses de TOUS les traitements partageant l'ester effectif du + * porteur ET son mg exact (± eps), triées par timestamp. Un re-parenting + * d'historique (« … - old ») ne doit plus redémarrer la stabilisation. + * Garde-fous : ester ≠ → pas de mutualisation ; dose ≠ → exclue (le trou + * qui suit casse la séquence — conservateur). + * + * @param {object} carrier + * @param {object[]} allDoseLogs + * @param {object[]} allTreatments + * @returns {object[]} triée par timestamp + */ +export function pooledRegimeDoses(carrier, allDoseLogs, allTreatments) { + let last = null; + for (const d of allDoseLogs) { + if (d.treatmentId === carrier.id && (last === null || d.timestamp > last.timestamp)) last = d; + } + if (!last) return []; + const activeEster = String(doseEster(carrier, last)).toUpperCase(); + const amount = last.doseAmount; + const ownerById = new Map(allTreatments.map((t) => [t.id, t])); + const out = []; + for (const d of allDoseLogs) { + const owner = ownerById.get(d.treatmentId); + if (!owner) continue; + // Ester EFFECTIF via le traitement PROPRIÉTAIRE (override compris) + if (String(doseEster(owner, d)).toUpperCase() !== activeEster) continue; + if (Math.abs(d.doseAmount - amount) >= 1e-6) continue; + out.push(d); + } + return out.sort((a, b) => a.timestamp - b.timestamp); +} + +/** + * Statut d'une valeur par rapport à la CIBLE DE CREUX personnelle (v1.13.0, + * miroir du Kotlin — même KDoc). ⚠️ DISTINCT des seuils d'alerte (alerts.js, + * §9.bis) : les alertes surveillent le niveau estimé EN CONTINU et + * notifient ; la cible de creux est une RÉFÉRENCE DE LAB, évaluée en + * lecture seule sur la carte, JAMAIS de notification. + * + * @param {number|null} predictedE2 + * @param {number|null} low + * @param {number|null} high + * @returns {'IN_TARGET'|'BELOW'|'ABOVE'|null} null si prédiction ou cible absente + */ +export function troughTargetStatus(predictedE2, low, high) { + if (predictedE2 === null || predictedE2 === undefined) return null; + if (low === null || low === undefined || high === null || high === undefined) return null; + if (predictedE2 < low) return 'BELOW'; + if (predictedE2 > high) return 'ABOVE'; + return 'IN_TARGET'; +} diff --git a/js/ui/home.js b/js/ui/home.js index 7ea647b..c87c634 100644 --- a/js/ui/home.js +++ b/js/ui/home.js @@ -18,7 +18,7 @@ * ───────────────────────────────────────────────────────────────────────────── */ -import { computeCurve, autoCalibrated, nextReminderFireFor, Alerts, TConfig, pointHoursBefore, nextBloodDrawRecommendation } from '../pk/index.js'; +import { computeCurve, autoCalibrated, nextReminderFireFor, Alerts, TConfig, pointHoursBefore, nextBloodDrawRecommendation, troughTargetStatus } from '../pk/index.js'; import { store } from '../data/store.js'; import { t } from '../util/i18n.js'; import { formatDose, formatValue, formatDateTime, formatWeekdayTime, nextDoseLineText } from '../util/format.js'; @@ -138,12 +138,32 @@ export function renderHome(container, ctx) { // l'accueil (demande v1.8.1) : creux daté + créneau associé + mention de // stabilisation SEULEMENT si le régime n'était pas déjà stable (sinon le // texte est identique à chaque ouverture). Caché si non calculable. - const labRec = nextBloodDrawRecommendation(treatments, doseLogs, labResults, nowMs); + // v1.13.0 : valeur E2 ATTENDUE au creux (seulement si l'auto-calibration + // fournit ses facteurs) + statut face à la cible de creux (lecture seule, + // jamais de notification — distinct des seuils d'alerte). + const labRec = nextBloodDrawRecommendation( + treatments, doseLogs, labResults, nowMs, effectiveAuto ? effectiveAuto.esterScales : null, + ); + const troughTarget = store.getSettings().troughTargetLow !== null + && store.getSettings().troughTargetHigh !== null + ? { low: store.getSettings().troughTargetLow, high: store.getSettings().troughTargetHigh } + : null; if (labRec) { const recCard = el('div', { class: 'card variant' }); recCard.appendChild(el('div', { class: 'label' }, t('labrec_section'))); recCard.appendChild(el('div', { style: 'font-weight:600;' }, t('labrec_when', formatWeekdayTime(labRec.troughMs)))); + if (labRec.predictedE2 !== null && labRec.predictedE2 !== undefined) { + const status = troughTargetStatus( + labRec.predictedE2, troughTarget ? troughTarget.low : null, troughTarget ? troughTarget.high : null, + ); + const statusText = status === 'IN_TARGET' ? ` — ${t('labrec_in_target')}` + : status !== null ? ` — ${t('labrec_out_of_target')}` : ''; + const statusColor = status === 'IN_TARGET' ? 'var(--primary,#4f5bd5)' + : status !== null ? 'var(--accent,#d6589e)' : ''; + recCard.appendChild(el('div', { style: statusColor ? `color:${statusColor};` : '' }, + t('labrec_predicted', formatDose(Math.round(labRec.predictedE2))) + statusText)); + } recCard.appendChild(el('div', {}, t('labrec_before_injection', labRec.ester, formatWeekdayTime(labRec.injectionMs)))); if (!labRec.wasAlreadyStabilized) { diff --git a/js/ui/labs.js b/js/ui/labs.js index 88adc08..5748437 100644 --- a/js/ui/labs.js +++ b/js/ui/labs.js @@ -14,8 +14,8 @@ import { store } from '../data/store.js'; import { t } from '../util/i18n.js'; -import { formatLabValue, formatDateTime, labNotesForDisplay } from '../util/format.js'; -import { nextBloodDrawRecommendation, shouldSuggestPosology } from '../pk/index.js'; +import { formatLabValue, formatDateTime, formatDose, labNotesForDisplay } from '../util/format.js'; +import { nextBloodDrawRecommendation, shouldSuggestPosology, troughTargetStatus, autoCalibrated } from '../pk/index.js'; import { el, confirmDialog, fab, showDialog } from './components.js'; import { openLabDialog } from './dialogs.js'; @@ -64,19 +64,46 @@ export function renderLabs(container) { // découvrable). Les deux encarts sont mutuellement exclusifs. const treatments = store.getTreatments(); const doseLogs = store.getDoseLogs(); - const rec = nextBloodDrawRecommendation(treatments, doseLogs, labs); + // v1.13.0 : auto-calibration → facteurs d'échelle pour la prédiction + // « ≈ X pg/mL au creux » (null si l'option est OFF — même pattern home.js) + const settings = store.getSettings(); + const effectiveAuto = settings.autoCalibrate + ? autoCalibrated(treatments, doseLogs, labs, store.getTConfig(), null) + : null; + const rec = nextBloodDrawRecommendation( + treatments, doseLogs, labs, Date.now(), effectiveAuto ? effectiveAuto.esterScales : null, + ); + // Cible de creux (v1.13.0) : colore la prédiction (lecture seule) + const troughTarget = settings.troughTargetLow !== null && settings.troughTargetHigh !== null + ? { low: settings.troughTargetLow, high: settings.troughTargetHigh } + : null; if (rec) { const card = el('div', { class: 'card' }); card.appendChild(el('div', { class: 'title' }, t('labrec_section'))); - card.appendChild(el('div', { class: 'main' }, [ + const mainLines = [ el('div', {}, t('labrec_when', formatDateTime(rec.troughMs))), + ]; + // Prédiction E2 + statut cible (v1.13.0 — lecture seule, jamais de notif) + if (rec.predictedE2 !== null && rec.predictedE2 !== undefined) { + const status = troughTargetStatus( + rec.predictedE2, troughTarget ? troughTarget.low : null, troughTarget ? troughTarget.high : null, + ); + const statusText = status === 'IN_TARGET' ? ` — ${t('labrec_in_target')}` + : status !== null ? ` — ${t('labrec_out_of_target')}` : ''; + const statusColor = status === 'IN_TARGET' ? 'var(--primary,#4f5bd5)' + : status !== null ? 'var(--accent,#d6589e)' : ''; + mainLines.push(el('div', { style: statusColor ? `color:${statusColor};` : '' }, + t('labrec_predicted', formatDose(Math.round(rec.predictedE2))) + statusText)); + } + mainLines.push( el('div', {}, t('labrec_before_injection', rec.ester, formatDateTime(rec.injectionMs))), el('div', { class: 'legend-muted' }, rec.wasAlreadyStabilized ? t('labrec_stabilized', rec.ester, formatDateTime(rec.stabilizedAtMs)) : t('labrec_not_stabilized', rec.ester, formatDateTime(rec.stabilizedAtMs))), el('div', { class: 'legend-muted' }, t('labrec_disclaimer')), - ])); + ); + card.appendChild(el('div', { class: 'main' }, mainLines)); col.appendChild(card); } else if (shouldSuggestPosology(treatments)) { const card = el('div', { class: 'card' }); diff --git a/js/ui/settings.js b/js/ui/settings.js index 8505f30..38f2083 100644 --- a/js/ui/settings.js +++ b/js/ui/settings.js @@ -31,7 +31,7 @@ import { el, showDialog, field, switchRow, filterChip, toast } from './component * (APK + web au MÊME numéro, tags appariés `vX.Y.Z` + `vX.Y.Z-web`). * v1.4.10 = portage initial de l'Android v1.4.10. */ -export const WEB_VERSION = '1.12.0'; +export const WEB_VERSION = '1.13.0'; /** Parse un champ numérique FR (virgule → point) ; null si vide/invalide. */ function parseNumOrNull(text) { @@ -185,6 +185,46 @@ export function renderSettings(container) { }, t('save'))); col.appendChild(alertCard); + // ── Cible de creux E2 (v1.13.0, opt-in) ──────────────────────────────── + // ⚠️ DISTINCT des seuils d'alerte (carte au-dessus) : référence de lab + // personnelle (« je veux être dans [basse, haute] au creux recommandé »), + // lecture seule sur la carte reco — JAMAIS de notification (miroir + // TroughTargetCard.kt). + const targetCard = el('div', { class: 'card' }); + targetCard.appendChild(el('h3', {}, t('trough_target_section'))); + targetCard.appendChild(el('div', { class: 'hint' }, t('trough_target_hint'))); + const ttLowField = field({ label: t('trough_target_low'), type: 'number', value: settings.troughTargetLow === null ? '' : String(settings.troughTargetLow), attrs: { step: 'any', inputmode: 'decimal' } }); + const ttHighField = field({ label: t('trough_target_high'), type: 'number', value: settings.troughTargetHigh === null ? '' : String(settings.troughTargetHigh), attrs: { step: 'any', inputmode: 'decimal' } }); + const ttRow = el('div', { class: 'row' }); + ttRow.appendChild(ttLowField); + ttRow.appendChild(ttHighField); + targetCard.appendChild(ttRow); + const ttError = el('div', { class: 'error-text', style: 'display:none;' }); + targetCard.appendChild(ttError); + targetCard.appendChild(el('button', { + class: 'btn', + onclick: () => { + const low = parseNumOrNull(ttLowField.input.value); + const high = parseNumOrNull(ttHighField.input.value); + if ((low === null) !== (high === null)) { + // Un seul champ rempli = saisie incomplète (pas de « demi-cible ») + ttError.textContent = t('trough_target_incomplete'); + ttError.style.display = ''; + } else if (low !== null && high !== null && high <= low) { + ttError.textContent = t('trough_target_invalid'); + ttError.style.display = ''; + } else { + ttError.style.display = 'none'; + // null/null = désactivation (setTroughTarget efface, miroir Android) + store.setSettings({ + troughTargetLow: low, troughTargetHigh: high, + }); + toast(t('saved')); + } + }, + }, t('save'))); + col.appendChild(targetCard); + // ── Fuseau du graphique (v1.4.5) ─────────────────────────────────────── const tzCard = el('div', { class: 'card' }); tzCard.appendChild(el('h3', {}, t('timezone_section'))); @@ -341,13 +381,23 @@ export function renderSettings(container) { store.setTConfig(data.tConfig); // ⚠️ Paramètres du backup (null = backup v1 → ne rien toucher) if (data.settings) { - store.setSettings({ + const patch = { autoCalibrate: data.settings.autoCalibrate ?? false, alertE2High: data.settings.alertE2High ?? null, alertE2Low: data.settings.alertE2Low ?? null, alertTHigh: data.settings.alertTHigh ?? null, alertTLow: data.settings.alertTLow ?? null, - }); + }; + // v1.13.0 : cible de creux — restaurée SEULEMENT si le backup + // en porte une (champs non nuls ET cohérents) ; un backup + // ancien ne doit pas effacer la cible locale (miroir Android) + if (data.settings.troughTargetLow !== null && data.settings.troughTargetLow !== undefined + && data.settings.troughTargetHigh !== null && data.settings.troughTargetHigh !== undefined + && data.settings.troughTargetHigh > data.settings.troughTargetLow) { + patch.troughTargetLow = data.settings.troughTargetLow; + patch.troughTargetHigh = data.settings.troughTargetHigh; + } + store.setSettings(patch); if (data.settings.language) { store.setSettings({ language: data.settings.language }); } diff --git a/js/util/i18n.js b/js/util/i18n.js index 1e7a88e..8f91e1c 100644 --- a/js/util/i18n.js +++ b/js/util/i18n.js @@ -66,6 +66,15 @@ const EN = { confirm_delete_blood_draw: 'Delete the whole blood draw ({0})?', lab_before_dose: 'Blood test earlier the same day, before this dose', lab_after_dose: 'Blood test later the same day, after this dose', + labrec_predicted: 'Expected at this trough: ≈ {0} pg/mL', + labrec_in_target: 'within your target', + labrec_out_of_target: 'outside your target', + trough_target_section: 'Trough target (blood draw)', + trough_target_hint: 'Personal reference for the recommended blood draw: the predicted trough value is colored against this band. Distinct from the alert thresholds above — those watch your estimated level continuously and can notify you; this one never does.', + trough_target_low: 'Target low (pg/mL)', + trough_target_high: 'Target high (pg/mL)', + trough_target_incomplete: 'Fill both fields — or leave both empty to disable.', + trough_target_invalid: 'Target high must be greater than target low.', // Traitements inactive: 'inactive', inactive_section: 'Inactive treatments', @@ -277,6 +286,15 @@ const FR = { confirm_delete_blood_draw: 'Supprimer la prise de sang entière ({0}) ?', lab_before_dose: 'Prise de sang plus tôt le même jour, avant cette dose', lab_after_dose: 'Prise de sang plus tard le même jour, après cette dose', + labrec_predicted: 'Attendu à ce creux : ≈ {0} pg/mL', + labrec_in_target: 'dans ta cible', + labrec_out_of_target: 'hors de ta cible', + trough_target_section: 'Cible de creux (prise de sang)', + trough_target_hint: 'Référence personnelle pour la prochaine prise de sang : la valeur de creux prédite est colorée par rapport à cette bande. Distinct des seuils d\u0027alerte ci-dessus — ceux-là surveillent ton niveau estimé en continu et peuvent te notifier ; celui-ci jamais.', + trough_target_low: 'Cible basse (pg/mL)', + trough_target_high: 'Cible haute (pg/mL)', + trough_target_incomplete: 'Remplis les deux champs — ou laisse les deux vides pour désactiver.', + trough_target_invalid: 'La cible haute doit être supérieure à la cible basse.', inactive: 'inactive', inactive_section: 'Traitements inactifs', pk_absorption: 'Tmax', diff --git a/tests/backup.test.js b/tests/backup.test.js index 49166eb..a74b447 100644 --- a/tests/backup.test.js +++ b/tests/backup.test.js @@ -46,9 +46,11 @@ describe('format BackupData v2 (compatibilité Android)', () => { assert.equal(parsed.labResults.length, 1); assert.deepEqual(parsed.tConfig, tConfig); // champs plats UserSettings exactement comme Gson + // (v1.13.0 : la cible de creux voyage aussi — champs optionnels) assert.deepEqual(parsed.settings, { language: 'fr', autoCalibrate: true, alertE2High: 400, alertE2Low: 50, alertTHigh: 1.2, alertTLow: null, + troughTargetLow: null, troughTargetHigh: null, }); // fidélité des entités (champs Gson/Kotlin) assert.equal(parsed.treatments[0].calendarEventId, null); diff --git a/tests/doses-extras.test.js b/tests/doses-extras.test.js index 360770f..7dabec2 100644 --- a/tests/doses-extras.test.js +++ b/tests/doses-extras.test.js @@ -114,3 +114,73 @@ describe('lab markers per dose (v1.12.0)', () => { assert.equal(m.after, false); }); }); + +// ── v1.13.0 : régime poolé + prédiction au creux + statut cible ──────────── +import { pooledRegimeDoses, troughTargetStatus } from '../js/pk/lab-timing.js'; +import { buildBackupJson, parseBackupJson } from '../js/data/backup.js'; + +describe('pooled regime doses (v1.13.0, miroir LabTimingV113Test)', () => { + test('le pool traverse la frontière de re-parenting (même ester, même mg)', () => { + const active = makeTreatment({ id: 1, isActive: true, forecastIntervalDays: 7 }); + const old = makeTreatment({ id: 10, isActive: false, forecastIntervalDays: 6 }); + const all = [ + makeDose(NOW - 50 * DAY_MS, 5, { treatmentId: 1 }), + makeDose(NOW - 43 * DAY_MS, 5, { treatmentId: 1 }), + makeDose(NOW - 36 * DAY_MS, 5, { treatmentId: 10 }), + makeDose(NOW - 29 * DAY_MS, 5, { treatmentId: 10 }), + makeDose(NOW - 22 * DAY_MS, 5, { treatmentId: 1 }), + makeDose(NOW - 15 * DAY_MS, 5, { treatmentId: 1 }), + makeDose(NOW - 8 * DAY_MS, 5, { treatmentId: 1 }), + ]; + const pooled = pooledRegimeDoses(active, all, [active, old]); + assert.equal(pooled.length, 7, 'le pool traverse la frontière t1/t10'); + }); + + test('ester différent → jamais poolé (EV inactif hors du régime EEn)', () => { + const active = makeTreatment({ id: 1, isActive: true, esterType: 'EEN' }); + const inactiveEv = makeTreatment({ id: 2, isActive: false, esterType: 'EV' }); + const all = [ + makeDose(NOW - 20 * DAY_MS, 5, { treatmentId: 2 }), + makeDose(NOW - 8 * DAY_MS, 5, { treatmentId: 1 }), + ]; + assert.equal(pooledRegimeDoses(active, all, [active, inactiveEv]).length, 1); + }); + + test('dose de mg différent → exclue (le 8 mg casse le régime)', () => { + const active = makeTreatment({ id: 1, isActive: true, forecastIntervalDays: 7 }); + const all = [ + makeDose(NOW - 28 * DAY_MS, 5, { treatmentId: 1 }), + makeDose(NOW - 21 * DAY_MS, 5, { treatmentId: 1 }), + makeDose(NOW - 14 * DAY_MS, 5, { treatmentId: 1 }), + makeDose(NOW - 7 * DAY_MS, 8, { treatmentId: 1 }), + makeDose(NOW, 5, { treatmentId: 1 }), + ]; + const pooled = pooledRegimeDoses(active, all, [active]); + assert.equal(pooled.length, 4, 'le 8 mg est exclu du pool'); + }); +}); + +describe('trough target status (v1.13.0)', () => { + test('matrice in/below/above + bornes incluses + null', () => { + assert.equal(troughTargetStatus(175, 150, 200), 'IN_TARGET'); + assert.equal(troughTargetStatus(140, 150, 200), 'BELOW'); + assert.equal(troughTargetStatus(220, 150, 200), 'ABOVE'); + assert.equal(troughTargetStatus(150, 150, 200), 'IN_TARGET'); + assert.equal(troughTargetStatus(200, 150, 200), 'IN_TARGET'); + assert.equal(troughTargetStatus(null, 150, 200), null); + assert.equal(troughTargetStatus(175, null, 200), null); + assert.equal(troughTargetStatus(175, 150, null), null); + }); +}); + +describe('backup round-trip trough target (v1.13.0)', () => { + test('les champs voyagent et restent absents quand non définis', () => { + const userSettings = { language: 'fr', troughTargetLow: 150, troughTargetHigh: 200 }; + const parsed = parseBackupJson(buildBackupJson({ treatments: [], doseLogs: [], labResults: [] }, { base: 6.0, floor: 0.2, k: 0.19 }, userSettings)); + assert.equal(parsed.settings.troughTargetLow, 150); + assert.equal(parsed.settings.troughTargetHigh, 200); + const parsed2 = parseBackupJson(buildBackupJson({ treatments: [], doseLogs: [], labResults: [] }, { base: 6.0, floor: 0.2, k: 0.19 }, { language: 'en' })); + assert.equal(parsed2.settings.troughTargetLow, null); + assert.equal(parsed2.settings.troughTargetHigh, null); + }); +});