Siphonight 902d521094 v1.13.0 (web) : reco prédictive — valeur E2 au creux, régime poolé, cible de creux — miroir Android
- predictedE2 : valeur brute au creux × facteur du ester (auto-calibration
  ON requise — sinon rien, une valeur brute serait trompeuse) ; rendu
  coloré face à la cible dans labs.js + home.js.
- pooledRegimeDoses : les doses de tous les traitements partageant (ester
  effectif, mg) forment UNE séquence — le re-parenting d'historique
  (« 6d-old ») ne redémarre plus la stabilisation. Garde-fous : ester ≠
  jamais poolé, dose ≠ exclue.
- Cible de creux E2 (opt-in) : store + carte settings + backup
  (rétrocompatible — un backup ancien n'efface pas la cible locale) +
  i18n FR/EN. ⚠️ DISTINCT des seuils d'alerte : jamais de notification.
- 15 tests (pooling ×3, statut cible, round-trip backup) ; leçon E2E :
  bump WEB_VERSION sans section CHANGELOG = dialog vide.

199 tests + E2E verts (check.sh). WEB_VERSION alignée sur 1.13.0.
2026-10-07 09:58:52 +02:00

219 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* ─────────────────────────────────────────────────────────────────────────────
* Écran Accueil (portage de `ui/screens/HomeScreen.kt`).
*
* - carte « niveau actuel » : E2 ≈ X pg/mL + T ≈ Y ng/mL + delta vs 6 h
* (v1.4.3, fix #53 : point le plus PROCHE de −6 h via pointHoursBefore) ;
* - cartes d'alerte si un seuil est franchi (v1.4.2, évaluées sur le
* DERNIER point de courbe = le niveau affiché) ;
* - carte « prochaine dose » : heures/min sous 24 h, JOURS au-delà (v1.4.1),
* grille Posologie via le moteur (fix #52) ;
* - chips de log rapide (traitements ACTIFS uniquement — l'inactivation ne
* retire JAMAIS un traitement de la simulation, seulement de la saisie) ;
* - mini-chart 24 h cliquable → écran Graphiques.
*
* ⚠️ La simulation reçoit TOUS les traitements (actifs ET inactifs) : bug
* v1.2.4 — « isActive » est un drapeau administratif, jamais un filtre de
* données (cf régression #3).
* ─────────────────────────────────────────────────────────────────────────────
*/
import { computeCurve, autoCalibrated, nextReminderFireFor, Alerts, TConfig, pointHoursBefore, nextBloodDrawRecommendation, troughTargetStatus } from '../pk/index.js';
import { store } from '../data/store.js';
import { t } from '../util/i18n.js';
import { formatDose, formatValue, formatDateTime, formatWeekdayTime, nextDoseLineText } from '../util/format.js';
import { el } from './components.js';
import { drawChart, COLORS } from './chart-canvas.js';
import { openDoseDialog } from './dialogs.js';
const HOUR_MS = 3600000;
/**
* Rend l'écran accueil dans [container].
* @param {HTMLElement} container
* @param {object} ctx { nowMs, openChart, openTreatment }
*/
export function renderHome(container, ctx) {
const treatments = store.getTreatments(); // TOUS (actifs + inactifs)
const activeTreatments = treatments.filter((tr) => tr.isActive);
const doseLogs = store.getDoseLogs();
const labResults = store.getLabResults();
const tConfig = new TConfig(store.getTConfig().base, store.getTConfig().floor, store.getTConfig().k);
const settings = store.getSettings();
const nowMs = ctx.nowMs;
// Auto-calibration (v1.2.1) : facteurs PAR PÉRIODE D'ESTER + k T, recalculés
// à la volée depuis les labs. null = option désactivée (échelles stockées).
// Home calibre avec le modèle STOCKÉ (pas d'override — le graphique, lui,
// calcule une calibration PAR modèle affiché, fix #60).
let effectiveAuto = null;
if (settings.autoCalibrate) {
effectiveAuto = autoCalibrated(treatments, doseLogs, labResults, tConfig, null);
}
const effTreatments = effectiveAuto ? effectiveAuto.treatments : treatments;
const effTConfig = effectiveAuto ? effectiveAuto.tConfig : tConfig;
const scalePerEster = effectiveAuto ? effectiveAuto.esterScales : null;
const tKPerEster = effectiveAuto ? effectiveAuto.tKPerEster : null;
// Courbe 24 h (grille horaire — le mini-chart de l'accueil n'a pas besoin
// du pas adaptatif du graphique complet)
const curve = computeCurve(
effTreatments, doseLogs, nowMs - 24 * HOUR_MS, nowMs, HOUR_MS, effTConfig,
{ scalePerEster, tKPerEster },
);
const col = el('div', {});
// ── Carte « niveau actuel » ──────────────────────────────────────────────
const now = curve[curve.length - 1] || null;
// v1.4.3 (fix #53) : le point de comparaison « il y a 6 h » est celui le
// plus PROCHE de −6 h (l'ancien code prenait le PREMIER point de la
// fenêtre 24 h → le delta affiché était en réalité un delta 24 h).
const sixHAgo = pointHoursBefore(curve, 6.0);
const levelCard = el('div', { class: 'card primary' });
levelCard.appendChild(el('div', { class: 'label' }, t('current_level')));
if (!now) {
levelCard.appendChild(el('div', {}, t('no_data_short')));
} else {
levelCard.appendChild(el('div', { class: 'row' }, [
el('span', { class: 'big-number' }, `≈ ${formatValue(now.e2)}`),
el('span', { class: 'unit' }, ' pg/mL'),
el('span', { class: 't-inline' }, ` T ≈ ${formatValue(now.t)} ng/mL`),
]));
if (sixHAgo) {
const delta = now.e2 - sixHAgo.e2;
const arrow = delta >= 0 ? '↗' : '↘';
levelCard.appendChild(el('div', { class: delta >= 0 ? 'delta-up' : 'delta-down' },
t('delta_6h', arrow, formatValue(Math.abs(delta)))));
}
}
col.appendChild(levelCard);
// ── Cartes d'alerte (v1.4.2) : taux ESTIMÉ actuel vs seuils configurés ────
if (now) {
const thresholds = new Alerts.Thresholds(
settings.alertE2High, settings.alertE2Low, settings.alertTHigh, settings.alertTLow,
);
const alerts = Alerts.evaluateAll(now.e2, now.t, thresholds);
if (alerts.length) {
const alertCard = el('div', { class: 'card error' });
alertCard.appendChild(el('h3', {}, t('alert_card_title')));
for (const a of alerts) {
alertCard.appendChild(el('div', {},
a.level === 'HIGH'
? t('alert_high', a.marker, formatDose(a.value), a.unit, formatDose(a.limit))
: t('alert_low', a.marker, formatDose(a.value), a.unit, formatDose(a.limit))));
}
alertCard.appendChild(el('div', { class: 'small' }, t('alert_disclaimer')));
col.appendChild(alertCard);
}
}
// ── Carte « prochaines doses » (grille Posologie via le moteur, fix #52) ──
// v1.9.6 (demande) : la carte liste TOUTES les prochaines prises, UNE
// ligne par traitement TRIÉES par prochaine prise (helper pur
// nextDoseLineText, miroir du Kotlin nextDoseLine) — l'ancienne carte ne
// montrait que la plus proche, et une prise quotidienne (CPA) masquait
// totalement le rappel de l'injection hebdomadaire. Le titre passe au
// pluriel dès qu'il y a au moins deux lignes ; la 1ʳᵉ ligne (la plus
// proche) reste mise en avant (font-weight 600).
const fires = [];
for (const tr of treatments) {
const fire = nextReminderFireFor(tr, doseLogs, nowMs);
if (fire !== null) fires.push([tr, fire]);
}
if (fires.length) {
fires.sort((a, b) => a[1] - b[1]);
const card = el('div', { class: 'card' });
card.appendChild(el('div', { class: 'label' }, t(fires.length === 1 ? 'next_dose' : 'next_doses')));
fires.forEach(([treatment, next], idx) => {
card.appendChild(el('div', { style: idx === 0 ? 'font-weight:600;' : '' },
nextDoseLineText(nowMs, next, treatment.name)));
});
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.
// v1.13.0 : valeur E2 ATTENDUE au creux (seulement si l'auto-calibration
// fournit ses facteurs) + statut face à la cible de creux (lecture seule,
// jamais de notification — distinct des seuils d'alerte).
const labRec = nextBloodDrawRecommendation(
treatments, doseLogs, labResults, nowMs, effectiveAuto ? effectiveAuto.esterScales : null,
);
const troughTarget = store.getSettings().troughTargetLow !== null
&& store.getSettings().troughTargetHigh !== null
? { low: store.getSettings().troughTargetLow, high: store.getSettings().troughTargetHigh }
: null;
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))));
if (labRec.predictedE2 !== null && labRec.predictedE2 !== undefined) {
const status = troughTargetStatus(
labRec.predictedE2, troughTarget ? troughTarget.low : null, troughTarget ? troughTarget.high : null,
);
const statusText = status === 'IN_TARGET' ? ` — ${t('labrec_in_target')}`
: status !== null ? ` — ${t('labrec_out_of_target')}` : '';
const statusColor = status === 'IN_TARGET' ? 'var(--primary,#4f5bd5)'
: status !== null ? 'var(--accent,#d6589e)' : '';
recCard.appendChild(el('div', { style: statusColor ? `color:${statusColor};` : '' },
t('labrec_predicted', formatDose(Math.round(labRec.predictedE2))) + statusText));
}
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')));
const chipRow = el('div', { class: 'chip-row' });
for (const tr of activeTreatments) {
chipRow.appendChild(el('span', {
class: 'chip',
onclick: () => openDoseDialog({ treatments: activeTreatments, preselected: tr, existing: null }),
}, `${tr.name} · ${formatDose(tr.doseAmount)}${tr.doseUnit}`));
}
chipRow.appendChild(el('button', {
class: 'chip',
onclick: () => openDoseDialog({ treatments: activeTreatments, preselected: null, existing: null }),
}, '+'));
quickCard.appendChild(chipRow);
col.appendChild(quickCard);
// ── Mini-chart 24 h cliquable → écran Graphiques ─────────────────────────
const chartCard = el('div', { class: 'card', style: 'cursor:pointer;', onclick: ctx.openChart });
chartCard.appendChild(el('h3', {}, t('home_chart_title')));
const canvas = el('canvas', { class: 'chart', style: 'height:180px;' });
chartCard.appendChild(canvas);
// Labs limités à la fenêtre de la courbe (miroir filterInRange)
if (curve.length >= 2) {
const a = curve[0].timestamp;
const b = curve[curve.length - 1].timestamp;
const e2Labs = labResults.filter((l) => String(l.marker).toUpperCase() === 'E2' && l.timestamp >= a && l.timestamp <= b);
const tLabs = labResults.filter((l) => String(l.marker).toUpperCase() === 'T' && l.timestamp >= a && l.timestamp <= b);
// Dessin différé : le canvas doit être dans le DOM pour connaître sa taille
requestAnimationFrame(() => {
drawChart(canvas, [{ points: curve, e2Style: { color: COLORS.chartE2, dashed: false }, tStyle: { color: COLORS.chartT, dashed: false } }],
e2Labs, tLabs, { showT: true, showLabs: true, showExtrema: false, nowMs }, [], [], null);
});
}
chartCard.appendChild(el('div', { class: 'legend-text', style: `color:${COLORS.chartE2};` }, t('home_legend_e2')));
chartCard.appendChild(el('div', { class: 'legend-text', style: `color:${COLORS.chartT};` }, t('home_legend_t')));
chartCard.appendChild(el('div', { class: 'legend-muted' }, t('open_charts_hint')));
col.appendChild(chartCard);
col.appendChild(el('div', { class: 'hint' }, t('disclaimer')));
container.appendChild(col);
}