Siphonight f5951e1806 v1.8.0 : recommandation de prochaine prise de sang (portage Android, miroir)
- js/pk/lab-timing.js : miroir fidèle de pk/LabTiming.kt — creux prévisionnel
  (minimum de la courbe E2 entre 2 injections), saut au premier creux
  STABILISÉ (5 × t½ terminale : TFS/WHS analytiques, Estrannaise lue dans
  la table via le nouveau pkProfileStore.terminalHalfLifeDays — refactor
  de la pente d'extrapolation de sample(), zéro duplication), filtres
  honnêtes (injectable actif + Posologie requis, creux ≥ now+6 h et jamais
  déjà mesuré, horizon borné → null), shouldSuggestPosology (invite quand
  un injectable actif est sans Posologie — demande v1.8.0).
- labs.js : encart « Prochaine prise de sang (suggestion) » ou invite,
  mutuellement exclusifs (miroir carte Android).
- i18n FR/EN (labrec_*), WEB_VERSION 1.8.0, assertions E2E.
- tests/lab-timing.test.js : 7 tests miroirs (152 verts au total) + E2E
  (encart rendu : titre, creux daté, créneau, stabilisation).
- Doc : §4 arbre, §8 suite + E2E, CHANGELOG [1.8.0], README.
2026-09-17 18:46:51 +02:00

153 lines
6.7 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, labNotesForDisplay } from '../util/format.js';
import { nextBloodDrawRecommendation, shouldSuggestPosology } 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();
const rec = nextBloodDrawRecommendation(treatments, doseLogs, labs);
if (rec) {
const card = el('div', { class: 'card' });
card.appendChild(el('div', { class: 'title' }, t('labrec_section')));
card.appendChild(el('div', { class: 'main' }, [
el('div', {}, t('labrec_when', formatDateTime(rec.troughMs))),
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')),
]));
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() }],
});
}