HormoneTrack-web/js/ui/dialogs.js
Siphonight be4d26b3e5 v1.9.5 : revue de code — garde cutoffHours ESE, portage optimisations v1.8.2, perf canvas, docs alignées
Corrigé (audit) :
- cutoffHours : garde hasModel pour ESE (parité Kotlin) — un backup
  importé ESE + ester non couvert levait TypeError → écran vide
- min/max en une passe : les spreads Math.min(...allPoints) levaient
  RangeError au-delà de ~1e5 arguments (Nuage × zoom max) et allouaient
  par frame
- init MCMC non bloquante : data-ready garantit désormais « asset chargé »

Perf (portage v1.8.2 Android, fin de la dérive de miroir) :
- prepareE2Context/e2AtCtx : doses groupées, cutoffs précalculés, Bateman
  paresseux ; computeCurve ne recalcule plus cutoffHours par point
- résultat identique (175 tests verts)

E2E : version lue via data-version (anti-bug v1.9.3)

Docs/commentaires : pk-engine (ESE analytique), pk-calibration (JSDoc
arrondi), dialogs (ESE=6), lab-timing (import mort, JSDoc orpheline),
home (COLORS), pk-profile-store (test-only), pk_profiles.json exclu du
zip (export-ignore, −548 Ko)
2026-09-20 12:55:51 +02:00

245 lines
9.3 KiB
JavaScript

/**
* ─────────────────────────────────────────────────────────────────────────────
* Dialogs de saisie (portage de `ui/components/DoseDialog.kt` et
* `LabDialog.kt`).
*
* DoseDialog :
* - create (traitements ACTIFS proposés) / edit (TOUS les traitements —
* la dose éditée reste rattachable à son traitement même inactivé, §6.bis) ;
* - override d'ESTER par injection (comme le tableur d'origine) : la liste
* suit le MODÈLE du traitement via choicesForModel (v1.9.4 : TFS=7,
* WHSAH=6, ESE analytique=6).
*
* LabDialog (v1.2.2) : prise de sang E2 + T en UNE entrée (chacune
* optionnelle) → 1 ou 2 LabResult au même timestamp ; unités T suggérées.
* ─────────────────────────────────────────────────────────────────────────────
*/
import { choicesForModel, esterLabel, isInjectionRoute } from '../data/models.js';
import { store } from '../data/store.js';
import { t } from '../util/i18n.js';
import { formatDose } from '../util/format.js';
import { el, showDialog, field, dateTimeField } from './components.js';
/**
* Dialog dose (create ou edit).
*
* @param {object} opts
* treatments : liste proposée (actifs en création, tous en édition)
* preselected : Treatment présélectionné (chips accueil / rappel) ou null
* existing : DoseLog en édition, ou null (création)
*/
export function openDoseDialog({ treatments, preselected = null, existing = null }) {
const isEdit = !!existing;
if (treatments.length === 0) {
const dlgEmpty = showDialog({
title: t('add_dose'),
body: el('p', {}, t('no_treatment_hint')),
actions: [{ label: t('ok'), className: 'btn', onClick: () => dlgEmpty.close() }],
});
return;
}
// État du dialog
let selectedId = existing
? existing.treatmentId
: (preselected && treatments.some((x) => x.id === preselected.id)
? preselected.id
: (treatments[0] ? treatments[0].id : null));
const selected = () => treatments.find((x) => x.id === selectedId) || null;
let esterOverride = existing ? existing.esterType : null;
// ── construction du corps ────────────────────────────────────────────────
const body = el('div', {});
const treatmentSelect = field({
label: t('treatment_name'),
value: selectedId ? String(selectedId) : '',
options: treatments.map((tr) => ({ value: String(tr.id), label: `${tr.name} · ${formatDose(tr.doseAmount)} ${tr.doseUnit}` })),
onInput: (v) => {
selectedId = Number(v);
esterOverride = null; // miroir : changer de traitement réinitialise l'override
rebuildEster();
amount.input.value = formatDose(selected().doseAmount);
},
});
body.appendChild(treatmentSelect);
// Override d'ester (seulement si le traitement a un PROFIL PK)
const esterWrap = el('div', {});
body.appendChild(esterWrap);
function rebuildEster() {
esterWrap.textContent = '';
const tr = selected();
if (!tr) return;
// Propriété dérivée (miroir treatmentFlags) : injection + ester ≠ NONE
const usesProfileModel = isInjectionRoute(tr.route) && tr.esterType !== 'NONE';
if (!usesProfileModel) return;
const choices = [
{ value: '', label: t('ester_default', tr.esterType) },
...choicesForModel(tr.pkModel).map((e) => ({ value: e, label: esterLabel(e) })),
];
esterWrap.appendChild(field({
label: t('ester'),
value: esterOverride || '',
options: choices,
onInput: (v) => { esterOverride = v === '' ? null : v; },
}));
}
const amount = field({
label: `${t('dose_amount')} (${selected() ? selected().doseUnit : ''})`,
type: 'number',
value: formatDose(existing ? existing.doseAmount : (selected() ? selected().doseAmount : 0)),
attrs: { step: 'any', min: '0', inputmode: 'decimal' },
});
body.appendChild(amount);
const dt = dateTimeField(t('dose_time'), existing ? existing.timestamp : Date.now(), () => {});
body.appendChild(dt);
const notes = field({ label: t('dose_notes'), type: 'textarea', value: existing ? existing.notes || '' : '' });
body.appendChild(notes);
rebuildEster();
const dlg = showDialog({
title: isEdit ? t('edit_dose') : t('add_dose'),
body,
actions: [
{ label: t('cancel'), className: 'btn text', onClick: () => dlg.close() },
{
label: t('save'),
className: 'btn',
onClick: () => {
const tr = selected();
const value = Number(String(amount.input.value).replace(',', '.'));
const ts = dt.getMs();
if (!tr || Number.isNaN(value) || value <= 0 || ts === null) return; // garde de saisie
const data = {
treatmentId: tr.id,
timestamp: ts,
doseAmount: value,
notes: notes.input.value.trim() === '' ? null : notes.input.value,
esterType: esterOverride,
};
if (isEdit) {
store.upsertDoseLog({ ...existing, ...data });
} else {
store.upsertDoseLog(data);
}
dlg.close();
},
},
],
});
}
/**
* Dialog lab : prise de sang E2 + T en UNE entrée (chacune optionnelle).
*
* @param {object} opts { existing: LabResult|null }
* - create : deux sections optionnelles → 1 ou 2 LabResult au même timestamp ;
* - edit : une seule entrée (le regroupement E2+T reste intact).
*/
export function openLabDialog({ existing = null }) {
const isEdit = !!existing;
const body = el('div', {});
const dt = dateTimeField(t('lab_timestamp'), existing ? existing.timestamp : Date.now(), () => {});
body.appendChild(dt);
// Suggestions d'unités T (miroir LabDialog Android) : ng/mL, ng/dL, ng/L,
// nmol/L — la conversion vers ng/mL se fait à la calibration et au rendu.
const UNIT_OPTIONS = ['pg/mL', 'ng/mL', 'ng/dL', 'ng/L', 'nmol/L', 'mIU/L'];
let e2Input = null;
let e2Notes = null;
let tInput = null;
let tUnit = null;
if (isEdit) {
const isT = String(existing.marker).toUpperCase() === 'T';
const valueField = field({
label: isT ? t('lab_t_value') : t('lab_e2_value'),
type: 'number',
value: String(existing.value),
attrs: { step: 'any', inputmode: 'decimal' },
});
body.appendChild(valueField);
if (isT) {
tInput = valueField.input;
const unitSel = field({
label: t('dose_unit'),
value: existing.unit,
options: UNIT_OPTIONS.map((u) => ({ value: u, label: u })),
});
body.appendChild(unitSel);
tUnit = unitSel.input;
} else {
e2Input = valueField.input;
}
} else {
// Section E2 (optionnelle) — unité fixée pg/mL (marker E2 = estradiol)
const e2Field = field({ label: `${t('lab_e2_value')} (${t('lab_optional')})`, type: 'number', attrs: { step: 'any', inputmode: 'decimal' } });
body.appendChild(e2Field);
e2Input = e2Field.input;
// Section T (optionnelle) avec choix d'unité
const tField = field({ label: `${t('lab_t_value')} (${t('lab_optional')})`, type: 'number', attrs: { step: 'any', inputmode: 'decimal' } });
body.appendChild(tField);
tInput = tField.input;
const unitSel = field({
label: `${t('dose_unit')} — ${t('lab_units_hint')}`,
value: 'ng/mL',
options: UNIT_OPTIONS.map((u) => ({ value: u, label: u })),
});
body.appendChild(unitSel);
tUnit = unitSel.input;
}
const notes = field({ label: t('lab_notes'), type: 'textarea', value: existing ? existing.notes || '' : '' });
body.appendChild(notes);
const dlg = showDialog({
title: isEdit ? t('edit_which_lab') : t('add_lab'),
body,
actions: [
{ label: t('cancel'), className: 'btn text', onClick: () => dlg.close() },
{
label: t('save'),
className: 'btn',
onClick: () => {
const ts = dt.getMs();
if (ts === null) return;
const notesVal = notes.input.value.trim() === '' ? null : notes.input.value;
if (isEdit) {
const value = Number(String((tInput || e2Input).value).replace(',', '.'));
if (Number.isNaN(value)) return;
store.upsertLabResult({
...existing,
value,
unit: tInput ? tUnit.value : existing.unit,
timestamp: ts,
notes: notesVal,
});
} else {
const e2Val = String(e2Input.value).trim() === '' ? null : Number(e2Input.value.replace(',', '.'));
const tVal = String(tInput.value).trim() === '' ? null : Number(tInput.value.replace(',', '.'));
if (e2Val === null && tVal === null) return; // au moins une valeur
if (e2Val !== null && !Number.isNaN(e2Val)) {
store.upsertLabResult({ marker: 'E2', value: e2Val, unit: 'pg/mL', timestamp: ts, notes: notesVal });
}
if (tVal !== null && !Number.isNaN(tVal)) {
store.upsertLabResult({ marker: 'T', value: tVal, unit: tUnit.value, timestamp: ts, notes: notesVal });
}
}
dlg.close();
},
},
],
});
}