- 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.
219 lines
11 KiB
JavaScript
219 lines
11 KiB
JavaScript
/**
|
||
* ─────────────────────────────────────────────────────────────────────────────
|
||
* É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);
|
||
}
|