HormoneTrack-web/js/ui/dialogs.js
Siphonight 491efef8eb v1.4.10 — portage initial de l'app Android v1.4.10
Portage navigateur complet de l'app Android HormoneTrack v1.4.10 :
- moteur PK fidèle (Estrannaise tables ODS, Transfem Science V3C,
  WHSAH fit Mona, Bateman) — paramètres identiques, invariants Android
  préservés (fixes #19-#23, #35, #52-#62), 121 tests Node épinglés
- calibration par période d'ester et par modèle affiché (#60/#61)
- UI 6 écrans + éditeur, CurveChart Canvas (zoom/pan fractionnaire,
  prévision, pics/creux, fuseau configurable), rappels web
  (Notification API), seuils d'alerte, i18n FR/EN
- sauvegarde JSON v2 compatible Android bidirectionnelle (rétrocompat v1)
- 100 % local : localStorage, aucun serveur applicatif, aucune télémétrie
- processus : scripts/check.sh (syntaxe, i18n, tests, E2E navigateur,
  smoke HTTP), docs séparées (README + DEVELOPPEMENT + CHANGELOG)
- dépôt GIT SÉPARÉ de l'Android : historique 100 % propre, versions
  alignées sur l'Android porté, changelogs indépendants

Non porté (impossible dans un navigateur, documenté §12) : agenda
récurrent, notifications onglet fermé, montre.
2026-09-08 14:34:58 +02:00

244 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 (TFS=7, WHS=6, ESE=3).
*
* 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();
},
},
],
});
}