v1.13.0 (web) : reco prédictive — valeur E2 au creux, régime poolé, cible de creux — miroir Android
- 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.
This commit is contained in:
parent
0e9a5672fb
commit
902d521094
12
README.md
12
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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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<string,number>} [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';
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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' });
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user