/** * ───────────────────────────────────────────────────────────────────────────── * Écran Analyses (portage de `ui/screens/LabsScreen.kt`, v1.2.2). * * - prise de sang E2 + T en UNE entrée → affichage GROUPÉ par timestamp : * « E2 306 pg/mL · T 44 ng/dL » côte à côte (E2 avant T, autres ensuite) ; * - tap ligne → sélecteur E2/T si paire, édition unitaire pré-remplie ; * - suppression = la prise ENTIÈRE (les labs sont prélevés ensemble — choix * de design confirmé côté Android) ; * - les labos T peuvent être en ng/mL, ng/dL, ng/L, nmol/L (conversion au * rendu et à la calibration — bug #23). * ───────────────────────────────────────────────────────────────────────────── */ import { store } from '../data/store.js'; import { t } from '../util/i18n.js'; import { formatLabValue, formatDateTime, formatDose, labNotesForDisplay } from '../util/format.js'; import { nextBloodDrawRecommendation, shouldSuggestPosology, troughTargetStatus, autoCalibrated } from '../pk/index.js'; import { el, confirmDialog, fab, showDialog } from './components.js'; import { openLabDialog } from './dialogs.js'; /** * Regroupe les labs par timestamp (une « prise de sang » = E2 + T prises au * même moment). Ordre d'affichage dans un groupe : E2 avant T, autres * marqueurs ensuite. Tri par date décroissante. (Miroir groupLabsForDisplay — * exporté pour les tests.) * * @param {object[]} labs * @returns {Array<[number, object[]]>} (timestamp, labs du groupe) */ export function groupLabsForDisplay(labs) { const byTs = new Map(); for (const lab of labs) { if (!byTs.has(lab.timestamp)) byTs.set(lab.timestamp, []); byTs.get(lab.timestamp).push(lab); } const order = (marker) => { const m = String(marker).toUpperCase(); if (m === 'E2') return 0; if (m === 'T') return 1; return 2; }; return [...byTs.entries()] .map(([ts, group]) => [ts, group.slice().sort((a, b) => order(a.marker) - order(b.marker))]) .sort((a, b) => b[0] - a[0]); } /** * Rend l'écran Analyses. * @param {HTMLElement} container */ export function renderLabs(container) { const labs = store.getLabResults(); const grouped = groupLabsForDisplay(labs); const col = el('div', {}); container.appendChild(col); // ── Recommandation de prochaine prise de sang (v1.8.0) ─────────────────── // Miroir de la carte Android : calculée par le moteur (pk/LabTiming.js) sur // TOUS les traitements + toutes les doses (§6.bis). Si le calcul n'est pas // possible mais qu'un injectable E2 actif est SANS Posologie : invite à la // renseigner (demande v1.8.0 — rendre le lien Posologie → recommandation // découvrable). Les deux encarts sont mutuellement exclusifs. const treatments = store.getTreatments(); const doseLogs = store.getDoseLogs(); // v1.13.0 : auto-calibration → facteurs d'échelle pour la prédiction // « ≈ X pg/mL au creux » (null si l'option est OFF — même pattern home.js) const settings = store.getSettings(); const effectiveAuto = settings.autoCalibrate ? autoCalibrated(treatments, doseLogs, labs, store.getTConfig(), null) : null; const rec = nextBloodDrawRecommendation( treatments, doseLogs, labs, Date.now(), effectiveAuto ? effectiveAuto.esterScales : null, ); // Cible de creux (v1.13.0) : colore la prédiction (lecture seule) const troughTarget = settings.troughTargetLow !== null && settings.troughTargetHigh !== null ? { low: settings.troughTargetLow, high: settings.troughTargetHigh } : null; if (rec) { const card = el('div', { class: 'card' }); card.appendChild(el('div', { class: 'title' }, t('labrec_section'))); const mainLines = [ el('div', {}, t('labrec_when', formatDateTime(rec.troughMs))), ]; // Prédiction E2 + statut cible (v1.13.0 — lecture seule, jamais de notif) if (rec.predictedE2 !== null && rec.predictedE2 !== undefined) { const status = troughTargetStatus( rec.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)' : ''; mainLines.push(el('div', { style: statusColor ? `color:${statusColor};` : '' }, t('labrec_predicted', formatDose(Math.round(rec.predictedE2))) + statusText)); } mainLines.push( el('div', {}, t('labrec_before_injection', rec.ester, formatDateTime(rec.injectionMs))), el('div', { class: 'legend-muted' }, rec.wasAlreadyStabilized ? t('labrec_stabilized', rec.ester, formatDateTime(rec.stabilizedAtMs)) : t('labrec_not_stabilized', rec.ester, formatDateTime(rec.stabilizedAtMs))), el('div', { class: 'legend-muted' }, t('labrec_disclaimer')), ); card.appendChild(el('div', { class: 'main' }, mainLines)); col.appendChild(card); } else if (shouldSuggestPosology(treatments)) { const card = el('div', { class: 'card' }); card.appendChild(el('div', { class: 'title' }, t('labrec_section'))); card.appendChild(el('div', { class: 'main legend-muted' }, t('labrec_needs_posology'))); col.appendChild(card); } if (grouped.length === 0) { col.appendChild(el('p', {}, t('no_data'))); container.appendChild(fab(() => openLabDialog({ existing: null }))); return; } for (const [ts, group] of grouped) { // Affichage côte à côte : « E2 306 pg/mL · T 45 ng/dL » const headline = group .map((lab) => `${String(lab.marker).toUpperCase()} ${formatLabValue(lab.value)} ${lab.unit}`) .join(' · '); const item = el('div', { class: 'list-item' }); const main = el('div', { class: 'main' }); main.appendChild(el('div', { class: 'title' }, headline)); main.appendChild(el('div', { class: 'sub' }, formatDateTime(ts))); // v1.7.1 : TOUTES les notes distinctes du groupe (préfixées du marqueur // quand elles diffèrent) — l'ancien code ne prenait que la PREMIÈRE note // non vide du groupe (« une note sur deux » : la note T disparaissait de // l'affichage quand la note E2 existait aussi, bien qu'elle fût // sauvegardée). Même fix que labNotesForDisplay côté Android. for (const note of labNotesForDisplay(group)) { main.appendChild(el('div', { class: 'sub' }, note)); } item.appendChild(main); // Tap → sélecteur E2/T si paire, édition directe sinon item.addEventListener('click', () => { if (group.length > 1) openChooser(group); else openLabDialog({ existing: group[0] }); }); const del = el('button', { class: 'icon-btn', 'aria-label': t('delete') }, '🗑'); del.addEventListener('click', (e) => { e.stopPropagation(); // Suppression = la prise de sang ENTIÈRE (confirm nommant les valeurs) const named = group.map((l) => `${String(l.marker).toUpperCase()} ${formatLabValue(l.value)}`).join(' · '); confirmDialog(t('delete'), t('confirm_delete_blood_draw', named), () => { for (const lab of group) store.deleteLabResult(lab.id); }); }); item.appendChild(del); col.appendChild(item); } container.appendChild(fab(() => openLabDialog({ existing: null }))); } /** Sélecteur « quelle entrée modifier ? » quand la prise contient E2 + T. */ function openChooser(group) { const body = el('div', {}); for (const lab of group) { body.appendChild(el('button', { class: 'btn text', onclick: () => { dlg.close(); openLabDialog({ existing: lab }); }, }, `${String(lab.marker).toUpperCase()} · ${formatLabValue(lab.value)} ${lab.unit}`)); } const dlg = showDialog({ title: t('edit_which_lab'), body, actions: [{ label: t('cancel'), className: 'btn text', onClick: () => dlg.close() }], }); }