v1.8.1 : fix stabilisation (changements récents vus) + encart Accueil (miroir Android)

- lab-timing.js : computeRegimeStartMs — le régime courant = séquence
  terminale de doses à (ester effectif, dose, écart inter-doses) constants
  (écart comparé EXACTEMENT) : tout changement récent de dose/ester/
  posologie réinitialise la stabilisation. L'ancien proxy (1ʳᵉ dose du
  traitement) disait « stabilisée depuis février » malgré des changements
  récents (remontée v1.8.0). L'encart porte l'ESTER EFFECTIF de la
  dernière dose (override compris).
- home.js : encart « Prochaine prise de sang (suggestion) » sur l'ACCUEIL
  (demande) — creux daté + créneau associé + mention de stabilisation
  seulement si le régime n'était pas déjà stable.
- 3 nouveaux tests miroirs (155 verts) : dose/intervalle/ester → reset
  épinglé. WEB_VERSION 1.8.1. CHANGELOG [1.8.1], README.
This commit is contained in:
Siphonight 2026-09-17 19:47:49 +02:00
parent f5951e1806
commit 61683ba5c4
7 changed files with 147 additions and 13 deletions

View File

@ -17,11 +17,11 @@
> Fie-toi toujours à tes analyses de sang et aux consignes de ton
> endocrinologue.
- **Statut** : web v1.8.0 — parité fonctionnelle avec l'Android v1.8.0
(recommandation de prochaine prise de sang ; l'auto-backup journalier
Android reste structurellement non porté, cf §12 — l'export manuel couvre
la donnée) ·
**152 tests verts** · E2E navigateur ✅ · conteneur testé en
- **Statut** : web v1.8.1 — parité fonctionnelle avec l'Android v1.8.1
(recommandation de prochaine prise de sang, stabilisation sensible aux
changements récents ; l'auto-backup journalier Android reste
structurellement non porté, cf §12 — l'export manuel couvre la donnée) ·
**155 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)

View File

@ -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.8.1] — 2026-09-17
### Corrigé — la stabilisation ignorait les changements récents (dose/ester/intervalle)
- **Bug remonté** : la recommandation disait « stabilisée depuis février »
alors que l'ester, le dosage ET la posologie venaient de changer —
l'ancien calcul partait de la **1ʳᵉ dose du traitement** (proxy aveugle
aux changements).
- **Fix** : le régime courant = la **séquence terminale de doses** à
(ester effectif, dose mg, **écart inter-doses**) constants — tout
changement récent réinitialise la stabilisation : la recommandation saute
au premier creux **après** la stabilisation du nouveau régime (règle des
~5 demi-vies conservée). L'encart porte l'ester **effectif** de la
dernière dose (override compris).
- **Nouveau : encart sur l'Accueil** (demande) — la suggestion de
prochaine prise de sang est désormais aussi visible directement sur la
page d'accueil (creux daté + créneau associé, mention de stabilisation
si le régime n'était pas déjà stable).
- 3 nouveaux tests miroirs (155 au total) : changement de dose / de
posologie (intervalle) / d'ester → réinitialisation épinglée.
## [1.8.0] — 2026-09-17
### Prochaine prise de sang recommandée — portage de l'Android v1.8.0 (versions sync)

View File

@ -34,7 +34,7 @@
* ─────────────────────────────────────────────────────────────────────────────
*/
import { computeCurve, generateForecastDoses, TConfig, usesProfileModel } from './pk-engine.js';
import { computeCurve, generateForecastDoses, TConfig, usesProfileModel, doseEster } from './pk-engine.js';
import { model as tfsModel } from './transfem-science-models.js';
import { model as whsahModel } from './whsah-models.js';
import * as pkProfileStore from './pk-profile-store.js';
@ -83,8 +83,15 @@ 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 = Math.min(...myDoses.map((d) => d.timestamp));
const regimeStartMs = computeRegimeStartMs(carrier, myDoses);
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
let lastDose = myDoses[0];
for (const d of myDoses) {
if (d.timestamp > lastDose.timestamp) lastDose = d;
}
const activeEster = String(doseEster(carrier, lastDose)).toUpperCase();
// ── 4) Créneaux prévisionnels : assez pour couvrir la stabilisation ───────
const intervalDays = carrier.forecastIntervalDays;
@ -123,7 +130,7 @@ export function nextBloodDrawRecommendation(treatments, doseLogs, labs, nowMs =
return {
troughMs: trough.timestamp,
injectionMs: slot.timestamp,
ester: carrier.esterType,
ester: activeEster,
terminalHalfLifeDays: tHalfDays,
regimeStartMs,
stabilizedAtMs,
@ -144,6 +151,53 @@ export function nextBloodDrawRecommendation(treatments, doseLogs, labs, nowMs =
* @param {object[]} treatments
* @returns {boolean}
*/
/**
* DÉBUT DU RÉGIME COURANT (v1.8.1 — correction du proxy v1.8.0, miroir du
* Kotlin). Critique remontée : « changé d'ester, de dosage ET de posologie,
* et l'app me disait stabilisée depuis février » — l'ancien proxy (1ʳᵉ dose
* du traitement) ne voyait aucun de ces changements.
*
* NOUVELLE RÈGLE : le régime courant = la séquence terminale de doses où
* (a) l'ESTER EFFECTIF et la DOSE (mg) sont identiques à la dose la plus
* récente, ET (b) l'ÉCART entre doses consécutives est constant (= l'écart
* des deux doses les plus récentes). On remonte depuis la dose la plus
* récente tant que ces conditions tiennent.
*
* Pourquoi l'écart fait partie du régime : 7 j → 2 j → 9 j (posologie
* modifiée/irrégulière) change le creux — conservateur assumé : une série
* d'intervalles chaotiques maintient la carte « non stabilisée », ce qui est
* pharmacocinétiquement vrai (le trough n'est comparable que sur un
* intervalle régulier). Comparaison EXACTE (au ms) : 7 j ± 1 h casse le
* régime, reflétant un vrai changement de créneau.
*
* @private
* @param {object} carrier traitement porteur
* @param {object[]} myDoses doses du porteur (ordre indifférent)
* @returns {number} timestamp de la 1ʳᵉ dose du régime courant
*/
function computeRegimeStartMs(carrier, myDoses) {
const sorted = myDoses.slice().sort((a, b) => a.timestamp - b.timestamp);
let idx = sorted.length - 1;
let regimeStart = sorted[idx].timestamp;
let current = sorted[idx];
// Écart EXIGÉ pour remonter : celui des deux doses les plus récentes
let gapExpected = -1;
const DOSE_EPS = 1e-6;
while (idx > 0) {
const prev = sorted[idx - 1];
const gap = current.timestamp - prev.timestamp;
const sameMarker = String(doseEster(carrier, current)) === String(doseEster(carrier, prev))
&& Math.abs(current.doseAmount - prev.doseAmount) < DOSE_EPS;
const sameGap = gapExpected < 0 || gap === gapExpected;
if (!sameMarker || !sameGap) break;
gapExpected = gap;
regimeStart = prev.timestamp;
current = prev;
idx--;
}
return regimeStart;
}
export function shouldSuggestPosology(treatments) {
return treatments.some((t) =>
t.type === 'ESTRADIOL'

View File

@ -18,10 +18,10 @@
* ─────────────────────────────────────────────────────────────────────────────
*/
import { computeCurve, autoCalibrated, nextReminderFireFor, Alerts, TConfig, pointHoursBefore } from '../pk/index.js';
import { computeCurve, autoCalibrated, nextReminderFireFor, Alerts, TConfig, pointHoursBefore, nextBloodDrawRecommendation } from '../pk/index.js';
import { store } from '../data/store.js';
import { t } from '../util/i18n.js';
import { formatDose, formatValue, formatTime, formatWeekdayTime, HrtDuration } from '../util/format.js';
import { formatDose, formatValue, formatTime, formatWeekdayTime, formatDateTime, HrtDuration } from '../util/format.js';
import { el } from './components.js';
import { drawChart } from './chart-canvas.js';
import { openDoseDialog } from './dialogs.js';
@ -138,6 +138,26 @@ export function renderHome(container, ctx) {
col.appendChild(card);
}
// ── Recommandation de prochaine prise de sang (v1.8.1) ───────────────────
// Même calcul que la page Analyses (pk/LabTiming.js) — encart COMPACT sur
// 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);
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))));
recCard.appendChild(el('div', {},
t('labrec_before_injection', labRec.ester, formatWeekdayTime(labRec.injectionMs))));
if (!labRec.wasAlreadyStabilized) {
recCard.appendChild(el('div', { class: 'legend-muted' },
t('labrec_not_stabilized', labRec.ester, formatDateTime(new Date(labRec.stabilizedAtMs)))));
}
col.appendChild(recCard);
}
// ── Log rapide : chips des traitements ACTIFS uniquement (saisie) ────────
const quickCard = el('div', { class: 'card variant' });
quickCard.appendChild(el('h3', {}, t('logged_today')));

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

View File

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

View File

@ -94,4 +94,43 @@ describe('LabTiming (miroir Android v1.8.0)', () => {
assert.equal(shouldSuggestPosology([eenTfs(null)]), true);
assert.equal(shouldSuggestPosology([eenTfs(7.0)]), false);
});
// ── v1.8.1 : le régime courant voit les CHANGEMENTS RÉCENTS ──────────────
// Critique v1.8.0 : « changé d'ester, de dosage ET de posologie, et l'app
// me disait stabilisée depuis février » — l'ancien proxy (1ʳᵉ dose du
// traitement) ne détectait rien. NOUVELLE règle : le régime = séquence
// terminale à (ester, dose, écart inter-doses) constants ; tout changement
// récent réinitialise la stabilisation.
test('changement de dose récent réinitialise la stabilisation', () => {
// 9 semaines à 5 mg, puis 3 mg il y a 2 j : le régime (3 mg) n'a que
// 2 j → non stabilisé → créneau ≥ NOW−2 j + ~22,7 j ≈ NOW+26 j (le 1ᵉʳ
// créneau part de la DERNIÈRE dose réelle, pas de NOW)
const doses = weeklyDoses(9).concat([makeDose(NOW - 2 * DAY_MS, 3.0)]);
const rec = nextBloodDrawRecommendation([eenTfs()], doses, [], NOW);
assert.equal(rec.injectionMs, NOW + 26 * DAY_MS);
assert.equal(rec.wasAlreadyStabilized, false);
});
test('changement d\'intervalle récent réinitialise la stabilisation', () => {
// 9 semaines à 7 j, puis une dose 2 j après la plus ancienne des deux
// dernières : l'écart terminal devient 2 j → régime fondé sur cette dose
// (celle à NOW−14 j avait un écart de 5 j ≠ 2 j → hors régime)
const doses = weeklyDoses(9).concat([makeDose(NOW - 9 * DAY_MS, 5.0)]);
const rec = nextBloodDrawRecommendation([eenTfs()], doses, [], NOW);
assert.equal(rec.injectionMs, NOW + 14 * DAY_MS);
assert.equal(rec.wasAlreadyStabilized, false);
});
test('override d\'ester récent réinitialise la stabilisation (ester effectif)', () => {
// Même dose (5 mg), même écart (7 j), mais la dernière dose est un
// override EV : switch d'ester = nouveau régime + l'ester EFFECTIF
// (EV, pas EEN) alimente la carte
const doses = weeklyDoses(9).concat([
Object.assign(makeDose(NOW, 5.0), { esterType: 'EV' }),
]);
const rec = nextBloodDrawRecommendation([eenTfs()], doses, [], NOW);
assert.equal(rec.ester, 'EV');
assert.equal(rec.wasAlreadyStabilized, false);
});
});