- 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.
180 lines
8.2 KiB
JavaScript
180 lines
8.2 KiB
JavaScript
/**
|
|
* ─────────────────────────────────────────────────────────────────────────────
|
|
* É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() }],
|
|
});
|
|
}
|