v1.5.0 : « Tracé labs » — portage complet de l'Android v1.5.0 (versions sync)

Miroir strict de pk/LabTrajectoryModel.kt (Android v1.5.0) :
- js/pk/lab-trajectory-model.js : courbe hybride M(t)×ρ(t) ancrée sur les
  labs — passage EXACT garanti, ρ log-linéaire, garde #61 (lab hors
  fenêtre d'action pas un ancrage), E2 seul, fenêtre [1er ; dernier lab],
  < 2 labs → vide, indépendante de la calibration (sinon double
  correction). JAMAIS dans levelAt/alertes.
- UI : chip 'Tracé labs' (off par défaut) dans la rangée modèles +
  scroll horizontal CONFINÉ à la rangée (miroir du fix Android) ;
  couleur #c2185b dashed ; leçon #63 Android-miroir — clé 'LAB' SKIPPÉE
  de la boucle légende (sinon légende TFS dupliquée).
- 11 tests miroirs de LabTrajectoryModelTest.kt (132 verts total).
- WEB_VERSION = 1.5.0 + CHANGELOG [1.5.0] + docs (§4/§8, README).

Détail d'exécution épingle: les deux problèmes du portage ont été
attrapés par node --check/E2E avant release (imports ESM faux —
labIsSignificant vit dans pk-calibration.js, pas pk-engine.js).
This commit is contained in:
Siphonight 2026-09-11 16:17:36 +02:00
parent e078650045
commit dfef8e90ef
12 changed files with 330 additions and 8 deletions

View File

@ -17,7 +17,7 @@
> Fie-toi toujours à tes analyses de sang et aux consignes de ton > Fie-toi toujours à tes analyses de sang et aux consignes de ton
> endocrinologue. > endocrinologue.
- **Statut** : web v1.4.10 — portage de l'Android v1.4.10 · **121 tests verts** · - **Statut** : web v1.5.0 — portage de l'Android v1.5.0 · **132 tests verts** ·
E2E navigateur ✅ · conteneur testé en configuration durcie ✅ · lint/i18n ✅ E2E navigateur ✅ · conteneur testé en configuration durcie ✅ · lint/i18n ✅
- **Journal des versions web** : [docs/CHANGELOG.md](docs/CHANGELOG.md) - **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) - **Releases** : [gitea.cloudyfy.fr/Siphonight/HormoneTrack-web/releases](https://gitea.cloudyfy.fr/Siphonight/HormoneTrack-web/releases)
@ -113,6 +113,8 @@ machine qui sert les fichiers.
(auto-calibration optionnelle, désactivée par défaut + bouton manuel). (auto-calibration optionnelle, désactivée par défaut + bouton manuel).
- **Seuils d'alerte** configurables (E2/T haut/bas) avec notification - **Seuils d'alerte** configurables (E2/T haut/bas) avec notification
navigateur et anti-spam. navigateur et anti-spam.
- **« Tracé labs » (v1.5.0)** : courbe hybride ancrée sur tes labs
(chip `Tracé labs`, off par défaut) — miroir strict de l'Android v1.5.0
- **Rappels** via les notifications du navigateur (actions « Loguer - **Rappels** via les notifications du navigateur (actions « Loguer
maintenant » / « Reporter 1 h ») — tant que la page est ouverte, cf maintenant » / « Reporter 1 h ») — tant que la page est ouverte, cf
[limites](#limites-vs-lapp-android). [limites](#limites-vs-lapp-android).

View File

@ -5,6 +5,24 @@
> section la plus récente EN PREMIER ; ce fichier est lu par le dialog > section la plus récente EN PREMIER ; ce fichier est lu par le dialog
> « Nouveautés » de l'app web (fetch `docs/CHANGELOG.md`). > « Nouveautés » de l'app web (fetch `docs/CHANGELOG.md`).
## [1.5.0] — 2026-09-11
### « Tracé labs » — portage de la version Android v1.5.0 (versions synchronisées)
- **Nouvelle courbe superposable** (chip `Tracé labs`, **off par défaut**)
: `courbe(t) = M(t) × ρ(t)` — le modèle PK garde la forme biologique
(Tmax, queues, accumulation), l'amplitude est recalée sur CHAQUE
prise de sang — **passage EXACT garanti** sur chaque lab, transition
log-linéaire d'un lab au suivant (jamais de saut vertical).
- Garde anti-ratés (#61) : un lab hors fenêtre d'action n'est pas un
ancrage ; E2 uniquement ; fenêtre bornée [1er ; dernier lab] — la
courbe s'arrête au dernier lab (pas d'extrapolation).
- Indépendante de la calibration (sa propre référence) ; hors
pics/creux ; n'entre JAMAIS dans niveau accueil/alertes.
- La rangée de chips du graphique défile horizontalement (4ᵉ chip —
miroir du fix Android v1.5.0).
- 11 tests miroirs de `LabTrajectoryModelTest.kt` (Android).
## [1.4.10] — 2026-09-08 ## [1.4.10] — 2026-09-08
Version initiale de la version web — portage navigateur de l'app Android Version initiale de la version web — portage navigateur de l'app Android

View File

@ -159,7 +159,11 @@ HormoneTrack-web/ (dépôt séparé)
│ │ ├── transfem-science-models.js V3C méta-analyse TFS (7 esters, params │ │ ├── transfem-science-models.js V3C méta-analyse TFS (7 esters, params
│ │ │ copiés à l'identique du Kotlin) │ │ │ copiés à l'identique du Kotlin)
│ │ ├── whsah-models.js fit WHSAH de Mona (6 esters, params identiques) │ │ ├── whsah-models.js fit WHSAH de Mona (6 esters, params identiques)
│ │ ├── alerts.js Alerts.kt : seuils opt-in, codec d'état, │ │ ├── lab-trajectory-model.js « Tracé labs » (v1.5.0) : courbe hybride
│ │ M(t)×ρ(t) ancrée sur les labs (miroir strict
│ │ de pk/LabTrajectoryModel.kt — garde #61,
│ │ ρ log-linéaire, fenêtre labs)
│ ├── alerts.js Alerts.kt : seuils opt-in, codec d'état,
│ │ │ shouldNotify (anti-spam) │ │ │ shouldNotify (anti-spam)
│ │ ├── presets.js PKPresets.kt (22 presets, clés i18n) │ │ ├── presets.js PKPresets.kt (22 presets, clés i18n)
│ │ ├── chart-helpers.kt fonctions pures de CurveChart.kt : │ │ ├── chart-helpers.kt fonctions pures de CurveChart.kt :
@ -391,6 +395,9 @@ launch) — d'où le build dédié dans le cache Playwright, hors dépôt.
- **calibration** : par période d'ester, **par modèle** (#60, chaque courbe - **calibration** : par période d'ester, **par modèle** (#60, chaque courbe
calibrée passe par le lab), garde #61 (labs hors fenêtre exclus des DEUX calibrée passe par le lab), garde #61 (labs hors fenêtre exclus des DEUX
pipelines — jamais de médiane contaminée), k T contre l'E2 CALIBRÉE. pipelines — jamais de médiane contaminée), k T contre l'E2 CALIBRÉE.
- **lab-trajectory-model** : v1.5.0 « Tracé labs » — miroir du Kotlin
(passage exact, ρ log-linéaire, garde #61, fenêtres, indépendance du
scaleFactor, modelOverride, doublons, bornes).
- **backup** : round-trip v2, rétrocompat v1, écrasement + IDs conservés, - **backup** : round-trip v2, rétrocompat v1, écrasement + IDs conservés,
**imports des exports Android réels** (data-driven, hors dépôt). **imports des exports Android réels** (data-driven, hors dépôt).
- **chart-helpers** : cumul fractionnaire du pan (#62), minuit local (#55), - **chart-helpers** : cumul fractionnaire du pan (#62), minuit local (#55),
@ -693,7 +700,7 @@ Structurels (plateforme), pas des bugs :
§16.ter de la doc mère). §16.ter de la doc mère).
--- ---
*Doc mise à jour le 8 sept. 2026 (web v1.4.10, dépôt séparé *Doc mise à jour le 11 sept. 2026 (web v1.5.0 — sync Android, dépôt séparé
HormoneTrack-web) — portage de l'Android v1.4.10, 121 tests verts + E2E, HormoneTrack-web) — portage de l'Android v1.4.10, 121 tests verts + E2E,
backup compatible bidirectionnel, historique propre (aucune donnée de backup compatible bidirectionnel, historique propre (aucune donnée de
santée, aucune dette de confidentialité), versions alignées sur santée, aucune dette de confidentialité), versions alignées sur

View File

@ -8,6 +8,8 @@ export * from './pk-engine.js';
export * from './pk-calibration.js'; export * from './pk-calibration.js';
export * from './pk-reminders.js'; export * from './pk-reminders.js';
export * from './pk-extrema.js'; export * from './pk-extrema.js';
export * as LabTrajectoryModel from './lab-trajectory-model.js';
export { computeLabAnchoredCurve, ratioAt } from './lab-trajectory-model.js';
export * from './chart-helpers.js'; export * from './chart-helpers.js';
export * as Alerts from './alerts.js'; export * as Alerts from './alerts.js';
export * as PKProfileStore from './pk-profile-store.js'; export * as PKProfileStore from './pk-profile-store.js';

View File

@ -0,0 +1,112 @@
/**
* ─────────────────────────────────────────────────────────────────────────────
* « Tracé labs » (version web de `pk/LabTrajectoryModel.kt`, v1.5.0) —
* courbe hybride ancrée sur les résultats de sang.
*
* DEMANDE (décision v1.5.0, débattue avec l'utilisatrice — cf doc de dev
* web §7.10/§3) : courbe « biologiquement plausible MAIS passant par tous
* les points ». Formule identique à l'Android :
*
* courbe(t) = M(t) × ρ(t)
*
* - **M(t)** = niveau du modèle PK BRUT (scaleFactor forcé à 1, modèle
* stocké ou modelOverride) — porte toute la physiologie entre les labs ;
* - **ρ_k = lab_k / M(t_k)** à chaque lab ; ENTRE deux labs consécutifs ρ
* interpole LOG-LINÉAIREMENT (morphing d'amplitude continu) ;
* - courbe(t_k) = lab_k EXACTEMENT (garantie par construction, épinglé).
*
* GARDES (invariants Android portés tels quels) :
* - garde anti-#61 (labIsSignificant) : lab hors fenêtre d'action PAS un ancrage ;
* - E2 uniquement (labs T trop rares + unités variées → bug #26) ;
* - fenêtre = [1er lab significatif ; dernier lab] ∩ fenêtre du graphique ;
* - < 2 labs significatifs → courbe vide ;
* - indépendante du scaleFactor stocké (sinon DOUBLE correction — épinglé).
*
* ⚠️ display-only : la série n'entre JAMAIS dans levelAt/alertes/reminders
* (invariant §9.bis Android transposé).
* ─────────────────────────────────────────────────────────────────────────────
*/
import { e2At } from './pk-engine.js';
import { labIsSignificant } from './pk-calibration.js';
/**
* Construit la courbe ancrée sur les labs.
*
* @param {object[]} treatments TOUS les traitements (actifs ET inactifs — §6.bis)
* @param {object[]} doseLogs Toutes les doses
* @param {object[]} labs Tous les LabResult (filtrés E2 ici)
* @param {number} startMs Fenêtre demandée (celle du graphique)
* @param {number} endMs
* @param {number} [stepMs=3600000] Pas d'échantillonnage (stepForRange)
* @param {string|null} [modelOverride=null]
* @returns {{points:{timestamp:number,e2:number,t:number}[], anchoredLabs:number}}
*/
export function computeLabAnchoredCurve(treatments, doseLogs, labs, startMs, endMs, stepMs = 3600000, modelOverride = null) {
if (startMs >= endMs) return { points: [], anchoredLabs: 0 };
const estrogenTreatments = treatments.filter((t) => t.type === 'ESTRADIOL');
if (estrogenTreatments.length === 0) return { points: [], anchoredLabs: 0 };
// Prédictions BRUTES : scaleFactor forcé à 1 (le ρ du lab EST le facteur)
const unscaled = estrogenTreatments.map((t) => ({ ...t, scaleFactor: 1.0 }));
// ── 1) Timeline des ANCRAGES (labs croissants + garde #61) ────────────────
const anchors = []; // (t_lab, ratio)
let maxPredictionSoFar = 0.0;
const e2Labs = labs
.filter((l) => String(l.marker).toUpperCase() === 'E2')
.slice()
.sort((a, b) => a.timestamp - b.timestamp);
for (const lab of e2Labs) {
const predicted = e2At(unscaled, doseLogs, lab.timestamp, modelOverride, null);
if (!labIsSignificant(predicted, maxPredictionSoFar)) continue;
anchors.push([lab.timestamp, lab.value / predicted]);
if (predicted > maxPredictionSoFar) maxPredictionSoFar = predicted;
}
if (anchors.length < 2) return { points: [], anchoredLabs: anchors.length };
const firstLabMs = anchors[0][0];
const lastLabMs = anchors[anchors.length - 1][0];
// ── 2) Fenêtre effective = [1er ; dernier lab] ∩ fenêtre demandée ─────────
const t0 = Math.max(startMs, firstLabMs);
const t1 = Math.min(endMs, lastLabMs);
if (t1 <= t0) return { points: [], anchoredLabs: anchors.length };
// ── 3) Courbe = M(t) × ρ(t) sur la grille (t = 0 : pas de courbe T) ───────
const points = [];
for (let t = t0; t <= t1; t += stepMs) {
const m = e2At(unscaled, doseLogs, t, modelOverride, null);
const rho = ratioAt(anchors, t);
points.push({ timestamp: t, e2: m * rho, t: 0.0 });
}
return { points, anchoredLabs: anchors.length };
}
/**
* ρ(t) : interpolation LOG-LINÉAIRE entre ancrages consécutifs
* (`anchors` trié croissant). Garanties : ρ(t_k) exactement, continuité
* multiplicative, monotone ; doublons de timestamp → le 2ᵉ ratio gagne
* (garde division par zéro) ; gardes défensives aux bornes.
*
* @param {[number, number][]} anchors (t_lab, ratio) trié croissant
* @param {number} tMs
* @returns {number}
*/
export function ratioAt(anchors, tMs) {
if (tMs <= anchors[0][0]) return anchors[0][1];
if (tMs >= anchors[anchors.length - 1][0]) return anchors[anchors.length - 1][1];
for (let i = 0; i < anchors.length - 1; i++) {
const [t0, r0] = anchors[i];
const [t1, r1] = anchors[i + 1];
if (tMs >= t0 && tMs <= t1) {
const span = t1 - t0;
if (span <= 0) return r1; // doublon de timestamp : 2ᵉ ratio gagne
const f = (tMs - t0) / span;
return Math.exp(Math.log(r0) + f * (Math.log(r1) - Math.log(r0)));
}
}
return anchors[anchors.length - 1][1]; // inatteignable — garde
}

View File

@ -36,10 +36,18 @@ export const COLORS = {
grid: '#cac4d0', grid: '#cac4d0',
label: '#6b7280', label: '#6b7280',
nowLine: '#2fa48d', nowLine: '#2fa48d',
chartLabTrajectory: '#c2185b', // v1.5.0 — Tracé labs (rose foncé)
forecastMarker: 'rgba(47,164,141,0.85)', forecastMarker: 'rgba(47,164,141,0.85)',
realDoseMarker: 'rgba(73,69,79,0.55)', realDoseMarker: 'rgba(73,69,79,0.55)',
}; };
/**
* Une série objet : { points, e2Style:{color,dashed}, tStyle|null, showExtrema }
* v1.5.0 : `showExtrema=false` → hors pics/creux (la série « Tracé labs »
* épouse l'interp ρ, pas la physiologie du modèle).
*/
/** /**
* Dessine le graphique dans un canvas. * Dessine le graphique dans un canvas.
* *
@ -195,7 +203,7 @@ export function drawChart(canvas, series, e2Labs, tLabs, options, forecastDoses
} }
// Pics/creux (v1.2.3/v1.2.6) : triangles + valeur estimée // Pics/creux (v1.2.3/v1.2.6) : triangles + valeur estimée
if (options.showExtrema) { if (options.showExtrema && (s.showExtrema !== false)) {
for (const ex of detectExtrema(s.points, (p) => p.e2, 2.0)) { for (const ex of detectExtrema(s.points, (p) => p.e2, 2.0)) {
drawExtremum(ctx, xOf(ex.timestamp), yE2(ex.value), ex, s.e2Style.color); drawExtremum(ctx, xOf(ex.timestamp), yE2(ex.value), ex, s.e2Style.color);
} }

View File

@ -26,6 +26,7 @@
import { import {
computeCurve, generateForecastDoses, autoCalibrated, TConfig, computeCurve, generateForecastDoses, autoCalibrated, TConfig,
computeLabAnchoredCurve,
} from '../pk/index.js'; } from '../pk/index.js';
import { import {
stepForRange, defaultModelToggles, panDeltaHours, clampPanHours, stepForRange, defaultModelToggles, panDeltaHours, clampPanHours,
@ -74,6 +75,7 @@ export function renderChart(container, ctx) {
showWhs: false, showWhs: false,
showForecast: false, showForecast: false,
showExtrema: false, showExtrema: false,
showLabTrack: false, // v1.5.0 — courbe ancrée sur les labs, OFF par défaut (choix v1.5.0)
panResidualHours: 0.0, // résiduel fractionnaire du pan (fix #62) panResidualHours: 0.0, // résiduel fractionnaire du pan (fix #62)
togglesInitialized: false, togglesInitialized: false,
}; };
@ -204,9 +206,21 @@ export function renderChart(container, ctx) {
modelRow.appendChild(filterChip(t('model_ese'), state.showEse, () => { state.showEse = !state.showEse; redraw(); })); modelRow.appendChild(filterChip(t('model_ese'), state.showEse, () => { state.showEse = !state.showEse; redraw(); }));
modelRow.appendChild(filterChip(t('model_tfs'), state.showTfs, () => { state.showTfs = !state.showTfs; redraw(); })); modelRow.appendChild(filterChip(t('model_tfs'), state.showTfs, () => { state.showTfs = !state.showTfs; redraw(); }));
modelRow.appendChild(filterChip(t('model_whs'), state.showWhs, () => { state.showWhs = !state.showWhs; redraw(); })); modelRow.appendChild(filterChip(t('model_whs'), state.showWhs, () => { state.showWhs = !state.showWhs; redraw(); }));
// v1.5.0 : courbe hybride ancrée sur les labs (off par défaut)
modelRow.appendChild(filterChip(t('show_lab_track'), state.showLabTrack, () => { state.showLabTrack = !state.showLabTrack; redraw(); }));
// ── Courbes par modèle sélectionné ───────────────────────────────────── // ── Courbes par modèle sélectionné ─────────────────────────────────────
const curves = []; const curves = [];
// ── v1.5.0 : courbe « Tracé labs » ───────────────────────────────────────
// Indépendante des toggles modèles (modèle STOCKÉ — sémantique Home) et
// de la calibration (elle EST sa propre calibration continue — épinglé).
if (state.showLabTrack) {
const step = stepForRange(state.rangeHours);
const anchored = computeLabAnchoredCurve(
treatments, doseLogs, labResults, startMs, endMs, step, null,
);
curves.push(['LAB', anchored.points]);
}
if (state.showEse || state.showTfs || state.showWhs) { if (state.showEse || state.showTfs || state.showWhs) {
// Home utilise le modèle stocké ; le graphique calibre chaque modèle // Home utilise le modèle stocké ; le graphique calibre chaque modèle
// avec SA prédiction (fix #60). Référence = auto du modèle TFS (les // avec SA prédiction (fix #60). Référence = auto du modèle TFS (les
@ -269,6 +283,11 @@ export function renderChart(container, ctx) {
drawChart( drawChart(
canvas, canvas,
curves.map(([model, pts]) => { curves.map(([model, pts]) => {
if (model === 'LAB') {
// v1.5.0 : Tracé labs = E2 rose foncé pointillé, PAS de courbe T
// (série E2-only) et hors pics/creux (showExtrema=false)
return { points: pts, e2Style: { color: COLORS.chartLabTrajectory, dashed: true }, tStyle: null, showExtrema: false };
}
const st = seriesStyles(model); const st = seriesStyles(model);
return { points: pts, e2Style: st.e2, tStyle: st.t }; return { points: pts, e2Style: st.e2, tStyle: st.t };
}), }),
@ -291,6 +310,9 @@ export function renderChart(container, ctx) {
// Légende dynamique (miroir du bloc Compose) // Légende dynamique (miroir du bloc Compose)
for (const [model] of curves) { for (const [model] of curves) {
// ⚠️ La clé 'LAB' est SKIPPÉE (légende dédiée plus bas, fix #63
// Android-miroir : sinon le `else` imprime la légende TFS en doublon)
if (model === 'LAB') continue;
const st = seriesStyles(model); const st = seriesStyles(model);
if (model === 'ESE') { if (model === 'ESE') {
legend.appendChild(legendLine(t('legend_ese_e2'), st.e2.color)); legend.appendChild(legendLine(t('legend_ese_e2'), st.e2.color));
@ -303,9 +325,16 @@ export function renderChart(container, ctx) {
if (state.showT) legend.appendChild(legendLine(t('legend_tfs_t'), st.t.color)); if (state.showT) legend.appendChild(legendLine(t('legend_tfs_t'), st.t.color));
} }
} }
// Légende modèles : la clé 'LAB' est SKIPPÉE (la boucle for-of ne
// gère que ESE/TFS/WHS — pas de catchall pour LAB, sinon légende
// dupliquée : miroir de la leçon #63 Android)
if (state.showLabs) legend.appendChild(legendLine(t('legend_labs'), COLORS.labDot)); if (state.showLabs) legend.appendChild(legendLine(t('legend_labs'), COLORS.labDot));
if (state.showForecast && horizonHours !== null) legend.appendChild(legendLine(t('legend_forecast_dose'), COLORS.nowLine)); if (state.showForecast && horizonHours !== null) legend.appendChild(legendLine(t('legend_forecast_dose'), COLORS.nowLine));
if (state.showExtrema) legend.appendChild(el('div', { class: 'legend-muted' }, t('legend_extrema'))); if (state.showExtrema) legend.appendChild(el('div', { class: 'legend-muted' }, t('legend_extrema')));
// v1.5.0 : légende dédiée « Tracé labs » (génère rien si la série vide)
if (state.showLabTrack && curves.some(([m, pts]) => m === 'LAB' && pts.length >= 2)) {
legend.appendChild(legendLine(t('legend_lab_track'), COLORS.chartLabTrajectory));
}
legend.appendChild(el('div', { class: 'legend-muted' }, t('drag_hint'))); legend.appendChild(el('div', { class: 'legend-muted' }, t('drag_hint')));
} }

View File

@ -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`). * (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. * v1.4.10 = portage initial de l'Android v1.4.10.
*/ */
export const WEB_VERSION = '1.4.10'; export const WEB_VERSION = '1.5.0';
/** Parse un champ numérique FR (virgule → point) ; null si vide/invalide. */ /** Parse un champ numérique FR (virgule → point) ; null si vide/invalide. */
function parseNumOrNull(text) { function parseNumOrNull(text) {

View File

@ -181,6 +181,7 @@ const EN = {
show_labs: 'Labs', show_labs: 'Labs',
show_forecast: 'Forecast', show_forecast: 'Forecast',
show_extrema: 'Peaks & troughs', show_extrema: 'Peaks & troughs',
show_lab_track: 'Lab track',
back_to_present: 'Back to present', back_to_present: 'Back to present',
legend_ese_e2: 'E2 — Estrannaise (blue)', legend_ese_e2: 'E2 — Estrannaise (blue)',
legend_ese_t: 'T — Estrannaise (pink)', legend_ese_t: 'T — Estrannaise (pink)',
@ -191,6 +192,7 @@ const EN = {
legend_labs: '● labs (orange) — circle = E2, square = T', legend_labs: '● labs (orange) — circle = E2, square = T',
legend_forecast_dose: 'Dashed line + ▼ = projected dose', legend_forecast_dose: 'Dashed line + ▼ = projected dose',
legend_extrema: '▲▼ = local peaks & troughs (with estimated values)', legend_extrema: '▲▼ = local peaks & troughs (with estimated values)',
legend_lab_track: 'Lab-anchored curve — model shape forced through your labs (E2 only, between first and last lab)',
drag_hint: 'Drag horizontally to pan (right = past, left = future with forecast). Pinch or − / + to zoom (6 h → 300 d).', drag_hint: 'Drag horizontally to pan (right = past, left = future with forecast). Pinch or − / + to zoom (6 h → 300 d).',
// Rappels (bannière + notification) // Rappels (bannière + notification)
reminder_notif_title: 'HormoneTrack — dose reminder', reminder_notif_title: 'HormoneTrack — dose reminder',
@ -357,6 +359,7 @@ const FR = {
show_labs: 'Labs', show_labs: 'Labs',
show_forecast: 'Prévision', show_forecast: 'Prévision',
show_extrema: 'Pics & creux', show_extrema: 'Pics & creux',
show_lab_track: 'Tracé labs',
back_to_present: 'Revenir à maintenant', back_to_present: 'Revenir à maintenant',
legend_ese_e2: 'E2 — Estrannaise (bleu)', legend_ese_e2: 'E2 — Estrannaise (bleu)',
legend_ese_t: 'T — Estrannaise (rose)', legend_ese_t: 'T — Estrannaise (rose)',
@ -367,6 +370,7 @@ const FR = {
legend_labs: '● labs (orange) — cercle = E2, carré = T', legend_labs: '● labs (orange) — cercle = E2, carré = T',
legend_forecast_dose: 'Ligne pointillée + ▼ = dose projetée', legend_forecast_dose: 'Ligne pointillée + ▼ = dose projetée',
legend_extrema: '▲▼ = pics/creux locaux (avec valeurs estimées)', legend_extrema: '▲▼ = pics/creux locaux (avec valeurs estimées)',
legend_lab_track: 'E2 ancrée sur tes labs — forme du modèle forcée de passer par chaque prise de sang (entre le 1er et le dernier lab)',
drag_hint: 'Glisser horizontalement pour panoramique (droite = passé, gauche = futur avec la prévision). Pincer ou − / + pour zoomer (6 h → 300 j).', drag_hint: 'Glisser horizontalement pour panoramique (droite = passé, gauche = futur avec la prévision). Pincer ou − / + pour zoomer (6 h → 300 j).',
reminder_notif_title: 'HormoneTrack — rappel de dose', reminder_notif_title: 'HormoneTrack — rappel de dose',
reminder_notif_body: '{0} · {1}{2} — prévu à {3}', reminder_notif_body: '{0} · {1}{2} — prévu à {3}',

View File

@ -1,6 +1,6 @@
{ {
"name": "hormonetrack-web", "name": "hormonetrack-web",
"version": "1.4.10", "version": "1.5.0",
"description": "HormoneTrack — version web (100% locale, localStorage). Portage navigateur de l'app Android.", "description": "HormoneTrack — version web (100% locale, localStorage). Portage navigateur de l'app Android.",
"private": true, "private": true,
"type": "module", "type": "module",

View File

@ -131,7 +131,7 @@ async function main() {
ok(dialog !== null, 'dialog « Nouveautés » au premier lancement (markdown rendu)'); ok(dialog !== null, 'dialog « Nouveautés » au premier lancement (markdown rendu)');
if (dialog) { if (dialog) {
const dlgText = await dialog.textContent(); const dlgText = await dialog.textContent();
ok(dlgText.includes('Version 1.4.10'), 'dialog : section de la version courante'); ok(dlgText.includes('Version 1.5.0'), 'dialog : section de la version courante');
const hasRenderedMd = await dialog.$('.md-body h3, .md-body strong'); const hasRenderedMd = await dialog.$('.md-body h3, .md-body strong');
ok(hasRenderedMd !== null, 'dialog : markdown RENDU (titres/gras), pas de brut'); ok(hasRenderedMd !== null, 'dialog : markdown RENDU (titres/gras), pas de brut');
await page.click('.dialog .actions button'); await page.click('.dialog .actions button');
@ -192,7 +192,7 @@ async function main() {
await page.waitForTimeout(300); await page.waitForTimeout(300);
text = await page.textContent('main.content'); text = await page.textContent('main.content');
ok(text.includes('Langue'), 'Paramètres : carte langue'); ok(text.includes('Langue'), 'Paramètres : carte langue');
ok(text.includes('Version 1.4.10 (web)'), 'Paramètres : version affichée'); ok(text.includes('Version 1.5.0 (web)'), 'Paramètres : version affichée');
ok(text.includes('100 % local'), 'Paramètres : note vie privée'); ok(text.includes('100 % local'), 'Paramètres : note vie privée');
await page.screenshot({ path: join(SHOTS, 'e2e-settings.png') }); await page.screenshot({ path: join(SHOTS, 'e2e-settings.png') });

View File

@ -0,0 +1,140 @@
/**
* Tests « Tracé labs » web (miroir de `LabTrajectoryModelTest.kt` Android)
* — mêmes garanties épinglées : passage exact, ρ log-linéaire, garde #61,
* fenêtre bornée aux labs, indépendance du scaleFactor.
*/
import { test, describe, before } from 'node:test';
import assert from 'node:assert/strict';
import {
initProfiles, makeTreatment, makeDose, makeLab, HOUR_MS, DAY_MS, assertClose,
} from './helpers.js';
import { e2At } from '../js/pk/pk-engine.js';
import {
computeLabAnchoredCurve, ratioAt,
} from '../js/pk/lab-trajectory-model.js';
/** Traitement injectable EEn/TFS 5 mg, une dose à t=0, scaleFactor paramétrable. */
function treatment(scaleFactor = 1.0) {
return makeTreatment({ scaleFactor });
}
function doses() {
return [makeDose(0, 5)];
}
const lab = (value, ts) => makeLab('E2', value, 'pg/mL', ts);
const tLab = (ts) => makeLab('T', 0.35, 'ng/mL', ts);
describe('LabTrajectoryModel web (miroir Android v1.5.0)', () => {
before(() => initProfiles());
test('passe exactement sur chaque lab', () => {
const t2 = 2 * DAY_MS;
const t7 = 7 * DAY_MS;
const labs = [lab(300, t2), lab(260, t7)];
const curve = computeLabAnchoredCurve([treatment()], doses(), labs, 0, 30 * DAY_MS, HOUR_MS);
assert.equal(curve.anchoredLabs, 2);
assertClose(curve.points.find((p) => p.timestamp === t2).e2, 300, 1e-6, 'ancrage lab 1');
assertClose(curve.points.find((p) => p.timestamp === t7).e2, 260, 1e-6, 'ancrage lab 2');
});
test('rho interpole en log entre deux labs (math exacte)', () => {
const t2 = 2 * DAY_MS;
const t7 = 7 * DAY_MS;
const tr = treatment();
// Labs DATA-DRIVEN (miroir du fix du test Kotlin : M(2 d) ≠ le pic de référence)
const m2 = e2At([tr], doses(), t2, null, null);
const m7 = e2At([tr], doses(), t7, null, null);
const labs = [lab(0.9 * m2, t2), lab(1.2 * m7, t7)];
// 1) La fonction PURE ratioAt interpole exactement
const pure = [[t2, 0.9], [t7, 1.2]];
const expectedMid = Math.exp((Math.log(0.9) + Math.log(1.2)) / 2.0);
assertClose(ratioAt(pure, t2), 0.9, 1e-12);
assertClose(ratioAt(pure, t7), 1.2, 1e-12);
assertClose(ratioAt(pure, (t2 + t7) / 2), expectedMid, 1e-9);
// 2) La courbe restitue le même ρ
const curve = computeLabAnchoredCurve([tr], doses(), labs, 0, 30 * DAY_MS, HOUR_MS);
const mid = curve.points[Math.trunc(curve.points.length / 2)];
const m = e2At([{ ...tr, scaleFactor: 1.0 }], doses(), mid.timestamp, null, null);
assertClose(mid.e2 / m, expectedMid, 1e-9);
});
test('un seul lab significatif donne une courbe vide', () => {
const curve = computeLabAnchoredCurve([treatment()], doses(),
[lab(200, 3 * DAY_MS)], 0, 30 * DAY_MS, HOUR_MS);
assert.equal(curve.points.length, 0);
assert.equal(curve.anchoredLabs, 1, 'le lab compte mais ne suffit pas');
});
test('garde 61 - un lab hors fenêtre d action n est pas un ancrage', () => {
const t2 = 2 * DAY_MS;
const t7 = 7 * DAY_MS;
const curve = computeLabAnchoredCurve([treatment()], doses(),
[lab(300, t2), lab(260, t7), lab(3000, 60 * DAY_MS)], 0, 90 * DAY_MS, HOUR_MS);
assert.equal(curve.anchoredLabs, 2, 'le lab tardif est rejeté');
assert.ok(curve.points.every((p) => p.e2 <= 600), 'aucun point ne suit la valeur absurde');
assert.ok(curve.points.every((p) => p.timestamp <= t7), 'fenêtre bornée au dernier lab significatif');
});
test('labs avant la première dose ne produisent aucun ancrage', () => {
const curve = computeLabAnchoredCurve([treatment()], doses(),
[lab(200, -10 * DAY_MS), lab(210, -5 * DAY_MS)], -15 * DAY_MS, 0, HOUR_MS);
assert.equal(curve.points.length, 0);
assert.equal(curve.anchoredLabs, 0);
});
test('la fenêtre du tracé est bornée par les labs et par la demande', () => {
const t2 = 2 * DAY_MS;
const t7 = 7 * DAY_MS;
const labs = [lab(160, t2), lab(140, t7)];
const wide = computeLabAnchoredCurve([treatment()], doses(), labs, 0, 60 * DAY_MS, HOUR_MS);
assert.ok(wide.points.length > 0);
assert.ok(wide.points.every((p) => p.timestamp >= t2 && p.timestamp <= t7));
const narrow = computeLabAnchoredCurve([treatment()], doses(), labs, 3 * DAY_MS, 5 * DAY_MS, HOUR_MS);
assert.ok(narrow.points.length > 0);
assert.ok(narrow.points.every((p) => p.timestamp >= 3 * DAY_MS && p.timestamp <= 5 * DAY_MS));
});
test('indépendant du scaleFactor stocké (sinon double correction)', () => {
const labs = [lab(300, 2 * DAY_MS), lab(260, 7 * DAY_MS)];
const a = computeLabAnchoredCurve([treatment(1.0)], doses(), labs, 0, 30 * DAY_MS, HOUR_MS);
const b = computeLabAnchoredCurve([treatment(0.55)], doses(), labs, 0, 30 * DAY_MS, HOUR_MS);
assert.deepEqual(a.points, b.points, 'M diffère mais ρ compense exactement');
});
test('modelOverride - chaque modèle ancre avec sa prédiction (sémantique 60)', () => {
// ESE vs WHS (ESE≈TFS quasi identiques par construction — seuil doc §8)
const trEv = { ...treatment(), esterType: 'EV', route: 'INJECTION_IM' };
const labs = [lab(320, 2 * DAY_MS), lab(240, 7 * DAY_MS)];
const ese = computeLabAnchoredCurve([trEv], doses(), labs, 0, 30 * DAY_MS, HOUR_MS, 'ESE');
const whs = computeLabAnchoredCurve([trEv], doses(), labs, 0, 30 * DAY_MS, HOUR_MS, 'WHS');
assertClose(ese.points.find((p) => p.timestamp === 2 * DAY_MS).e2, 320, 1e-6);
assertClose(whs.points.find((p) => p.timestamp === 2 * DAY_MS).e2, 320, 1e-6);
const midEse = ese.points[Math.trunc(ese.points.length / 2)].e2;
const midWhs = whs.points[Math.trunc(whs.points.length / 2)].e2;
const relDiff = Math.abs(midEse - midWhs) / Math.max(Math.abs(midEse), Math.abs(midWhs));
assert.ok(relDiff > 0.02, `empreinte intermédiaire ESE=${midEse} vs WHS=${midWhs}`);
});
test('les labs T sont ignorés (E2 uniquement)', () => {
const labs = [lab(300, 2 * DAY_MS), tLab(3 * DAY_MS), lab(240, 7 * DAY_MS)];
const curve = computeLabAnchoredCurve([treatment()], doses(), labs, 0, 30 * DAY_MS, HOUR_MS);
assert.equal(curve.anchoredLabs, 2);
});
test('doublons de timestamp - le deuxième ratio gagne', () => {
const t = 5 * DAY_MS;
const anchors = [[t, 1.0], [t, 2.0], [10 * DAY_MS, 3.0]];
assertClose(ratioAt(anchors, 10 * DAY_MS), 3.0, 1e-12);
const expected = Math.exp(Math.log(2) + (7 - 5) / 5 * (Math.log(3) - Math.log(2)));
assertClose(ratioAt(anchors, 7 * DAY_MS), expected, 1e-9);
});
test('ratioAt - gardes defensives aux bornes', () => {
const anchors = [[0, 1.0], [10, 2.0]];
assertClose(ratioAt(anchors, -5), 1.0, 1e-12);
assertClose(ratioAt(anchors, 100), 2.0, 1e-12);
});
});