v1.9.7 (web) : tolérance ± 24 h de l'écart inter-doses — miroir Android

La règle de stabilisation comparait l'écart inter-doses EXACTEMENT à
l'écart précédent : un log 30 min plus tard cassait le régime et
repoussait la stabilisation de 5 × t½ à chaque injection (creux fuyant).

Fix : chaque écart doit rester dans l'intervalle de Posologie ± 24 h
(GAP_TOLERANCE_MS, fenêtre vs l'intervalle THÉORIQUE) — des logs à 6,8 j
puis 7,2 j ne se déstabilisent plus en cascade ; un vrai changement de
créneau reste hors fenêtre.

Tests : « interval change » ré-épinglé (+21 j) + 2 tests de tolérance
miroirs (12 verts lab-timing). WEB_VERSION 1.9.7 + CHANGELOG/README.
This commit is contained in:
Siphonight 2026-09-21 22:20:21 +02:00
parent 048a76e540
commit ab8926bf36
5 changed files with 68 additions and 13 deletions

View File

@ -17,12 +17,12 @@
> 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.9.6 — parité fonctionnelle avec l'Android v1.9.6 - **Statut** : web v1.9.7 — parité fonctionnelle avec l'Android v1.9.7
(modèle Estrannaise analytique + nuage d'incertitude MCMC exclusif ESE ; (modèle Estrannaise analytique + nuage d'incertitude MCMC exclusif ESE ;
presets des 6 esters injectables ESE ; l'auto-backup journalier Android presets des 6 esters injectables ESE ; l'auto-backup journalier Android
reste structurellement non porté, cf §12 — l'export manuel couvre la reste structurellement non porté, cf §12 — l'export manuel couvre la
donnée) · donnée) ·
**178 tests verts** · E2E navigateur ✅ · conteneur testé en **180 tests verts** · E2E navigateur ✅ · conteneur testé en
configuration durcie ✅ · lint/i18n ✅ 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)

View File

@ -5,6 +5,20 @@
> 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.9.7] — 2026-09-20
### Corrigé — la suggestion de prise de sang « change tout le temps » à chaque injection
- Miroir de l'Android v1.9.7 (remontée + données réelles) : la règle de
stabilisation comparait l'écart inter-doses **EXACTEMENT** à l'écart
précédent — un log 30 min plus tard cassait le régime et repoussait la
stabilisation de 5 × t½ à chaque injection (creux recommandé fuyant).
- **Fix (tolérance)** : chaque écart doit rester dans **l'intervalle de
Posologie ± 24 h** (`GAP_TOLERANCE_MS`, fenêtre vs l'intervalle
THÉORIQUE). Un vrai changement de créneau (2 j au lieu de 7 j) reste
hors fenêtre. Tests : « interval change » ré-épinglé (+21 j) + 2 tests
de tolérance miroirs (12 verts lab-timing).
## [1.9.6] — 2026-09-20 ## [1.9.6] — 2026-09-20
### Modifié — la carte de l'accueil liste TOUTES les prochaines prises ### Modifié — la carte de l'accueil liste TOUTES les prochaines prises

View File

@ -159,8 +159,16 @@ export function nextBloodDrawRecommendation(treatments, doseLogs, labs, nowMs =
* modifiée/irrégulière) change le creux — conservateur assumé : une série * 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 * d'intervalles chaotiques maintient la carte « non stabilisée », ce qui est
* pharmacocinétiquement vrai (le trough n'est comparable que sur un * pharmacocinétiquement vrai (le trough n'est comparable que sur un
* intervalle régulier). Comparaison EXACTE (au ms) : 7 j ± 1 h casse le * intervalle régulier).
* régime, reflétant un vrai changement de créneau. *
* v1.9.7 (miroir du Kotlin, remontée : « la suggestion change tout le temps
* à chaque injection si l'injection n'est pas faite pile à la même heure ») :
* la comparaison EXACTE cassait le régime pour un log à 12:30 au lieu de
* 12:00 — chaque écart doit rester dans l'INTERVALLE DE POSOLOGIE ± 24 h
* (« je m'injecte le même jour, à l'heure près »). La fenêtre se réfère à
* l'intervalle THÉORIQUE, pas à l'écart précédent : des logs à 6,8 j puis
* 7,2 j ne se déstabilisent plus en cascade. Un vrai changement (2 j au lieu
* de 7 j) reste hors fenêtre.
* *
* @private * @private
* @param {object} carrier traitement porteur * @param {object} carrier traitement porteur
@ -172,17 +180,22 @@ function computeRegimeStartMs(carrier, myDoses) {
let idx = sorted.length - 1; let idx = sorted.length - 1;
let regimeStart = sorted[idx].timestamp; let regimeStart = sorted[idx].timestamp;
let current = sorted[idx]; let current = sorted[idx];
// Écart EXIGÉ pour remonter : celui des deux doses les plus récentes // Fenêtre d'écart = INTERVALLE DE POSOLOGIE ± 24 h (la Posologie est un
let gapExpected = -1; // prérequis de la carte : absente → on s'arrête à la dernière dose).
const intervalDays = carrier.forecastIntervalDays;
if (!intervalDays || intervalDays <= 0) return regimeStart;
const intervalMs = Math.round(intervalDays * 24 * HOUR_MS);
const gapMin = intervalMs - GAP_TOLERANCE_MS;
const gapMax = intervalMs + GAP_TOLERANCE_MS;
const DOSE_EPS = 1e-6; const DOSE_EPS = 1e-6;
while (idx > 0) { while (idx > 0) {
const prev = sorted[idx - 1]; const prev = sorted[idx - 1];
const gap = current.timestamp - prev.timestamp; const gap = current.timestamp - prev.timestamp;
const sameMarker = String(doseEster(carrier, current)) === String(doseEster(carrier, prev)) const sameMarker = String(doseEster(carrier, current)) === String(doseEster(carrier, prev))
&& Math.abs(current.doseAmount - prev.doseAmount) < DOSE_EPS; && Math.abs(current.doseAmount - prev.doseAmount) < DOSE_EPS;
const sameGap = gapExpected < 0 || gap === gapExpected; // gap > 0 : un doublon de log n'est jamais un régime
const sameGap = gap > 0 && gap >= gapMin && gap <= gapMax;
if (!sameMarker || !sameGap) break; if (!sameMarker || !sameGap) break;
gapExpected = gap;
regimeStart = prev.timestamp; regimeStart = prev.timestamp;
current = prev; current = prev;
idx--; idx--;
@ -190,6 +203,11 @@ function computeRegimeStartMs(carrier, myDoses) {
return regimeStart; return regimeStart;
} }
/** Tolérance de l'écart inter-doses (v1.9.7, miroir GAP_TOLERANCE_MS Kotlin) :
* ± 24 h autour de l'intervalle de Posologie — un log à 12:30 au lieu de
* 12:00 ne repousse plus la stabilisation de 5 × t½. */
const GAP_TOLERANCE_MS = 24 * HOUR_MS;
/** /**
* Invite d'AFFICHAGE (v1.8.0, demande explicite) : existe-t-il un traitement * Invite d'AFFICHAGE (v1.8.0, demande explicite) : existe-t-il un traitement
* E2 injectable ACTIF SANS Posologie ? Si oui, la page Analyses suggère de la * E2 injectable ACTIF SANS Posologie ? Si oui, la page Analyses suggère de la

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.9.6'; export const WEB_VERSION = '1.9.7';
/** 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

@ -113,12 +113,35 @@ describe('LabTiming (miroir Android v1.8.0)', () => {
}); });
test('changement d\'intervalle récent réinitialise la stabilisation', () => { 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 // 9 semaines à 7 j, puis une dose 2 j après la précédente : l'écart de
// dernières : l'écart terminal devient 2 j → régime fondé sur cette dose // 2 j (48 h) est HORS de la fenêtre Posologie ± 24 h (v1.9.7, [6 j ;
// (celle à NOW−14 j avait un écart de 5 j ≠ 2 j → hors régime) // 8 j] pour 7 j) → le régime s'arrête à la dose NOW−7 j (un vrai
// changement de créneau reste un changement — miroir Android)
const doses = weeklyDoses(9).concat([makeDose(NOW - 9 * DAY_MS, 5.0)]); const doses = weeklyDoses(9).concat([makeDose(NOW - 9 * DAY_MS, 5.0)]);
const rec = nextBloodDrawRecommendation([eenTfs()], doses, [], NOW); const rec = nextBloodDrawRecommendation([eenTfs()], doses, [], NOW);
assert.equal(rec.injectionMs, NOW + 14 * DAY_MS); // régimeStart = NOW−7 j → stabilisation ≈ NOW+15,7 j → 1ᵉʳ créneau : +21 j
assert.equal(rec.injectionMs, NOW + 21 * DAY_MS);
assert.equal(rec.wasAlreadyStabilized, false);
});
test('le flou d\'heure de log ne réinitialise PAS la stabilisation (v1.9.7)', () => {
// REMONTÉE v1.9.7 (miroir Android) : « la suggestion change tout le
// temps à chaque injection si l'injection n'est pas faite pile à la
// même heure » — chaque écart reste dans Posologie ± 24 h → le régime
// remonte la séquence entière (63 j) → déjà stabilisée.
const jitterHours = [2, -3, 5, -1, 23, -23, 1, -0.5, 0.75];
const doses = jitterHours.map((jh, i) =>
makeDose(NOW - (63 - 7 * i) * DAY_MS + jh * HOUR_MS, 5.0));
const rec = nextBloodDrawRecommendation([eenTfs()], doses, [], NOW);
assert.equal(rec.wasAlreadyStabilized, true);
});
test('un écart au-delà de 24 h de tolérance réinitialise (v1.9.7)', () => {
// 8 écarts réguliers, puis un retard de 25 h sur le 7 j : hors fenêtre
// → le régime ne remonte pas avant la dose en retard → non stabilisée
const doses = weeklyDoses(9).map((d, i) =>
i === 7 ? makeDose(NOW - 14 * DAY_MS + 25 * HOUR_MS, 5.0) : d);
const rec = nextBloodDrawRecommendation([eenTfs()], doses, [], NOW);
assert.equal(rec.wasAlreadyStabilized, false); assert.equal(rec.wasAlreadyStabilized, false);
}); });