diff --git a/README.md b/README.md index ccae573..b51f4ad 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 1489497..edc0fd9 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.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) diff --git a/js/pk/lab-timing.js b/js/pk/lab-timing.js index b777e05..14da394 100644 --- a/js/pk/lab-timing.js +++ b/js/pk/lab-timing.js @@ -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' diff --git a/js/ui/home.js b/js/ui/home.js index dd8875e..42bdc3c 100644 --- a/js/ui/home.js +++ b/js/ui/home.js @@ -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'))); diff --git a/js/ui/settings.js b/js/ui/settings.js index 785ecdf..077d1b2 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.8.0'; +export const WEB_VERSION = '1.8.1'; /** Parse un champ numérique FR (virgule → point) ; null si vide/invalide. */ function parseNumOrNull(text) { diff --git a/scripts/e2e.mjs b/scripts/e2e.mjs index 41fcebf..ac93ab3 100644 --- a/scripts/e2e.mjs +++ b/scripts/e2e.mjs @@ -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') }); diff --git a/tests/lab-timing.test.js b/tests/lab-timing.test.js index c758f91..1484950 100644 --- a/tests/lab-timing.test.js +++ b/tests/lab-timing.test.js @@ -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); + }); });