/** * ───────────────────────────────────────────────────────────────────────────── * É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 } from '../pk/index.js'; import { store } from '../data/store.js'; import { t } from '../util/i18n.js'; import { formatDose, formatValue, formatTime, formatWeekdayTime, formatDateTime, HrtDuration } 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 « prochaine dose » (grille Posologie via le moteur, fix #52) ─── 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 [nextTreatment, next] = fires[0]; const deltaMs = next - nowMs; const h = Math.floor(deltaMs / HOUR_MS); const m = Math.floor((deltaMs % HOUR_MS) / 60000); const card = el('div', { class: 'card' }); card.appendChild(el('div', { class: 'label' }, t('next_dose'))); if (h >= 24) { // v1.4.1 : au-delà de 24 h, afficher en JOURS + jour de semaine const [days, remH] = HrtDuration.daysAndHours(h); card.appendChild(el('div', { style: 'font-weight:600;' }, t('next_dose_days', days, remH, formatWeekdayTime(next), nextTreatment.name))); } else if (h > 0) { card.appendChild(el('div', { style: 'font-weight:600;' }, `${h}h${String(m).padStart(2, '0')} · ${formatTime(next)} (${nextTreatment.name})`)); } else { card.appendChild(el('div', { style: 'font-weight:600;' }, `${m} min · ${formatTime(next)} (${nextTreatment.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. 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'))); 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); }