v1.6.0 (web, UI + E2E) : chip Prolonger, split LAB/LABX, avertissement, WEB_VERSION 1.6.0

- chart.js : chip « Prolonger » (off par défaut, désactivé tant que
  Tracé labs est off), split série "LAB" (ancrée) / "LABX" (prolongée,
  rose atténué rgba(194,24,91,0.55)), légendes dédiées (clés LAB ET LABX
  skippées de la boucle — miroir leçon #63), AVERTISSEMENT « simple
  simulation, sans garantie de correspondre au réel, labs potentiellement
  erronés » affiché sous la légende tant que la prolongation est visible
  (condition factorisée : jamais de courbe prolongée sans avertissement).
- i18n : show_lab_track_extend, legend_lab_track_extend,
  lab_track_extend_warning (FR/EN synchronisées).
- E2E : scénario v1.6.0 (chip désactivé → activation → légende +
  avertissement → disparition à la désactivation) ; assertions version
  1.6.0.
This commit is contained in:
Siphonight 2026-09-12 23:51:20 +02:00
parent bd338decbf
commit 551d8fa29e
4 changed files with 122 additions and 8 deletions

View File

@ -76,6 +76,13 @@ export function renderChart(container, ctx) {
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) showLabTrack: false, // v1.5.0 — courbe ancrée sur les labs, OFF par défaut (choix v1.5.0)
// v1.6.0 : PROLONGATION du tracé labs au-delà du dernier lab (ρ du
// dernier lab constant × modèle, bornée par l'extinction du modèle —
// cf LabTrajectoryModel.extensionHorizonEndMs). OFF par défaut : la
// partie prolongée est une ESTIMATION non ancrée, elle doit être
// demandée explicitement ; le chip n'est actif que si Lab track est ON
// (miroir ChartScreen.kt).
showLabTrackExtend: false,
panResidualHours: 0.0, // résiduel fractionnaire du pan (fix #62) panResidualHours: 0.0, // résiduel fractionnaire du pan (fix #62)
togglesInitialized: false, togglesInitialized: false,
}; };
@ -208,18 +215,39 @@ export function renderChart(container, ctx) {
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) // 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(); })); modelRow.appendChild(filterChip(t('show_lab_track'), state.showLabTrack, () => { state.showLabTrack = !state.showLabTrack; redraw(); }));
// v1.6.0 : prolongation du tracé labs au-delà du dernier lab (off par
// défaut, SANS EFFET — donc désactivé — tant que Lab track est off :
// la prolongation n'existe que comme extension de CETTE courbe)
modelRow.appendChild(filterChip(t('show_lab_track_extend'), state.showLabTrackExtend, () => { state.showLabTrackExtend = !state.showLabTrackExtend; redraw(); }, !state.showLabTrack));
// ── Courbes par modèle sélectionné ───────────────────────────────────── // ── Courbes par modèle sélectionné ─────────────────────────────────────
const curves = []; const curves = [];
// ── v1.5.0 : courbe « Tracé labs » ─────────────────────────────────────── // ── v1.5.0 : courbe « Tracé labs » ───────────────────────────────────────
// Indépendante des toggles modèles (modèle STOCKÉ — sémantique Home) et // Indépendante des toggles modèles (modèle STOCKÉ — sémantique Home) et
// de la calibration (elle EST sa propre calibration continue — épinglé). // de la calibration (elle EST sa propre calibration continue — épinglé).
//
// ── v1.6.0 : split ancré / prolongé ──────────────────────────────────────
// Avec « Prolonger », la série est SPLITTÉE au dernier lab significatif
// (lastAnchorMs) : "LAB" = partie ancrée (passe par les labs), "LABX" =
// partie prolongée (M(t) × ρ_last — une estimation). Deux traits
// distincts (LABX en atténué) préservent l'honnêteté de l'affichage.
// ⚠️ Les DEUX clés doivent être SKIPPÉES par la boucle de légende
// (leçon #63 Android : sinon le catchall imprime la légende TFS en
// doublon) et avoir leurs entrées dédiées plus bas.
if (state.showLabTrack) { if (state.showLabTrack) {
const step = stepForRange(state.rangeHours); const step = stepForRange(state.rangeHours);
const anchored = computeLabAnchoredCurve( const labCurve = computeLabAnchoredCurve(
treatments, doseLogs, labResults, startMs, endMs, step, null, treatments, doseLogs, labResults, startMs, endMs, step, null,
state.showLabTrackExtend,
); );
curves.push(['LAB', anchored.points]); if (state.showLabTrackExtend && labCurve.lastAnchorMs !== null) {
const anchored = labCurve.points.filter((p) => p.timestamp <= labCurve.lastAnchorMs);
const extended = labCurve.points.filter((p) => p.timestamp > labCurve.lastAnchorMs);
curves.push(['LAB', anchored]);
if (extended.length > 0) curves.push(['LABX', extended]);
} else {
curves.push(['LAB', labCurve.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
@ -288,6 +316,18 @@ export function renderChart(container, ctx) {
// (série E2-only) et hors pics/creux (showExtrema=false) // (série E2-only) et hors pics/creux (showExtrema=false)
return { points: pts, e2Style: { color: COLORS.chartLabTrajectory, dashed: true }, tStyle: null, showExtrema: false }; return { points: pts, e2Style: { color: COLORS.chartLabTrajectory, dashed: true }, tStyle: null, showExtrema: false };
} }
if (model === 'LABX') {
// v1.6.0 : partie PROLONGÉE — même couleur ATTÉNUÉE (alpha 0,55,
// = #c2185b en rgba) pour distinguer l'estimation non ancrée de
// la partie qui passe par les labs. Mêmes exclusions (pas de T,
// pas d'extrema).
return {
points: pts,
e2Style: { color: 'rgba(194,24,91,0.55)', 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 };
}), }),
@ -310,9 +350,10 @@ 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 // ⚠️ Les clés 'LAB' (v1.5.0) et 'LABX' (v1.6.0) sont SKIPPÉES
// Android-miroir : sinon le `else` imprime la légende TFS en doublon) // (légendes dédiées plus bas, fix #63 Android-miroir : sinon le
if (model === 'LAB') continue; // catchall imprime la légende TFS en doublon)
if (model === 'LAB' || model === 'LABX') 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));
@ -335,6 +376,16 @@ export function renderChart(container, ctx) {
if (state.showLabTrack && curves.some(([m, pts]) => m === 'LAB' && pts.length >= 2)) { if (state.showLabTrack && curves.some(([m, pts]) => m === 'LAB' && pts.length >= 2)) {
legend.appendChild(legendLine(t('legend_lab_track'), COLORS.chartLabTrajectory)); legend.appendChild(legendLine(t('legend_lab_track'), COLORS.chartLabTrajectory));
} }
// v1.6.0 : légende de la partie PROLONGÉE + AVERTISSEMENT — seulement
// si la série "LABX" existe (chip Prolonger actif ET horizon atteint
// dans la fenêtre visible). Même condition pour les deux (factorisée) :
// il ne peut jamais y avoir de courbe prolongée sans son avertissement.
const labExtendVisible = state.showLabTrack && state.showLabTrackExtend
&& curves.some(([m, pts]) => m === 'LABX' && pts.length >= 2);
if (labExtendVisible) {
legend.appendChild(legendLine(t('legend_lab_track_extend'), 'rgba(194,24,91,0.55)'));
legend.appendChild(el('div', { class: 'legend-muted' }, t('lab_track_extend_warning')));
}
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.5.0'; export const WEB_VERSION = '1.6.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

@ -182,6 +182,10 @@ const EN = {
show_forecast: 'Forecast', show_forecast: 'Forecast',
show_extrema: 'Peaks & troughs', show_extrema: 'Peaks & troughs',
show_lab_track: 'Lab track', show_lab_track: 'Lab track',
// v1.6.0 : prolongation du tracé labs au-delà du dernier lab (opt-in,
// actif seulement quand Lab track est ON — miroir strings.xml Android)
show_lab_track_extend: 'Extend',
lab_track_extend_warning: '⚠ Simulation only, with no guarantee of matching reality: the extension extrapolates your model from your lab results — which can themselves be inaccurate. Trust your next blood test, not this curve.',
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)',
@ -193,6 +197,8 @@ const EN = {
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)', legend_lab_track: 'Lab-anchored curve — model shape forced through your labs (E2 only, between first and last lab)',
// v1.6.0 : légende de la partie PROLONGÉE du tracé labs (série "LABX")
legend_lab_track_extend: '-- Lab track extended past your last lab (model × last lab ratio — estimate, not anchored)',
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',
@ -360,6 +366,10 @@ const FR = {
show_forecast: 'Prévision', show_forecast: 'Prévision',
show_extrema: 'Pics & creux', show_extrema: 'Pics & creux',
show_lab_track: 'Tracé labs', show_lab_track: 'Tracé labs',
// v1.6.0 : prolongation du tracé labs au-delà du dernier lab (opt-in,
// actif seulement quand Tracé labs est ON — miroir strings.xml Android)
show_lab_track_extend: 'Prolonger',
lab_track_extend_warning: '⚠ Simple simulation, sans garantie de correspondre au réel : la prolongation extrapole ton modèle à partir de tes résultats de laboratoire — qui peuvent eux-mêmes être erronés. Fie-toi à ta prochaine prise de sang, pas à cette courbe.',
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)',
@ -371,6 +381,8 @@ const FR = {
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)', 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)',
// v1.6.0 : légende de la partie PROLONGÉE du tracé labs (série "LABX")
legend_lab_track_extend: '-- Tracé labs prolongé après ton dernier lab (modèle × ratio du dernier lab — estimation, plus ancrée)',
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

@ -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.5.0'), 'dialog : section de la version courante'); ok(dlgText.includes('Version 1.6.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');
@ -167,6 +167,57 @@ async function main() {
ok(legendText.includes('Glisser horizontalement'), 'hint panoramique'); ok(legendText.includes('Glisser horizontalement'), 'hint panoramique');
await page.screenshot({ path: join(SHOTS, 'e2e-chart.png') }); await page.screenshot({ path: join(SHOTS, 'e2e-chart.png') });
// ── 2.bis Tracé labs PROLONGÉ (v1.6.0) : chips, légende, avertissement ──
// La démo place le dernier lab à now − 7 j : on dézoome (24 h → 32 j)
// pour que la partie ANCRÉE soit dans la fenêtre, puis on active
// « Tracé labs » puis « Prolonger ». ⚠️ Chaque interaction re-rend le
// DOM (redraw) → re-chercher le chip avant CHAQUE clic. ⚠️ Les chips
// filterChip sont des <span class="chip"> (onclick inline), PAS des
// <button> — seuls − / + sont de vrais boutons ; « disabled » est
// simulé par style opacity:0.4 (pas d'attribut disabled).
console.log('── Tracé labs prolongé (#chart, v1.6.0) ──');
for (let i = 0; i < 5; i++) {
await page.evaluate(() => {
[...document.querySelectorAll('button.chip')].find((x) => x.textContent.trim() === '−')?.click();
});
}
await page.waitForTimeout(400);
// 1) Le chip « Prolonger » est DÉSACTIVÉ tant que « Tracé labs » est off
const extendDisabledBefore = await page.evaluate((lbl) => {
const b = [...document.querySelectorAll('.chip')].find((x) => x.textContent.trim() === lbl);
return b ? /opacity:\s*0\.4/.test(b.getAttribute('style') || '') : null;
}, 'Prolonger');
ok(extendDisabledBefore === true, 'chip « Prolonger » désactivé tant que Tracé labs est off');
// 2) Active Tracé labs → la légende ancrée apparaît (sans avertissement)
await page.evaluate((lbl) => {
[...document.querySelectorAll('.chip')].find((x) => x.textContent.trim() === lbl)?.click();
}, 'Tracé labs');
await page.waitForTimeout(400);
const labTrackText = await page.textContent('main.content');
ok(labTrackText.includes('E2 ancrée sur tes labs'), 'légende « Tracé labs » (ancrée) affichée');
ok(!labTrackText.includes('Simple simulation, sans garantie'),
'pas d\'avertissement tant que la prolongation est off');
// 3) Active « Prolonger » → légende prolongée + AVERTISSEMENT VISIBLE
await page.evaluate((lbl) => {
[...document.querySelectorAll('.chip')].find((x) => x.textContent.trim() === lbl)?.click();
}, 'Prolonger');
await page.waitForTimeout(400);
const extendText = await page.textContent('main.content');
ok(extendText.includes('Tracé labs prolongé'), 'légende de la partie prolongée affichée');
ok(extendText.includes('Simple simulation, sans garantie'),
'AVERTISSEMENT « simulation sans garantie » visible');
ok(extendText.includes('qui peuvent eux-mêmes être erronés'),
'avertissement : les résultats de lab peuvent être erronés');
// 4) Désactive « Prolonger » → avertissement ET légende prolongée disparaissent
await page.evaluate((lbl) => {
[...document.querySelectorAll('.chip')].find((x) => x.textContent.trim() === lbl)?.click();
}, 'Prolonger');
await page.waitForTimeout(400);
const offText = await page.textContent('main.content');
ok(!offText.includes('Tracé labs prolongé') && !offText.includes('Simple simulation, sans garantie'),
'désactivation → légende prolongée + avertissement disparaissent');
await page.screenshot({ path: join(SHOTS, 'e2e-chart-extend.png') });
// ── 3. Navigation : les 5 onglets + settings ──────────────────────────── // ── 3. Navigation : les 5 onglets + settings ────────────────────────────
console.log('── Navigation ──'); console.log('── Navigation ──');
await page.goto(`${base}/#doses`, { waitUntil: 'load' }); await page.goto(`${base}/#doses`, { waitUntil: 'load' });
@ -192,7 +243,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.5.0 (web)'), 'Paramètres : version affichée'); ok(text.includes('Version 1.6.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') });