HormoneTrack-web/js/ui/treatment-editor.js
Siphonight f79a180e36 v1.9.4 (web) : presets ESE étendus à 6 esters + éditeur dynamique + portage v1.9.0 complété
- presets.js : EC huile (5 mg IM), EB (1 mg IM), EUCS suspension
  cristalline (10 mg SC) — 25 entrées, champs Bateman informatifs
- pk-engine.js : Esters.EUCS manquant (portage v1.9.0 incomplet — le
  preset référençait undefined)
- models.js : choicesForModel ESE = 6 esters (parité Android v1.9.0 ;
  était resté sur la couverture des tables ODS à 3) + KDoc à jour
- treatment-editor.js : liste des esters = choicesForModel(model) —
  rebascule sur EV si l'ester sort de la couverture au changement de
  modèle (garde anti 0 pg/mL) + re-render du formulaire
- tests/presets.test.js (nouveau, 6) : couverture 6 esters ESE, unicité,
  i18n EN/FR, TFS 7 / WHSAH 6 inchangés, choicesForModel aligné dispatchs
- versions 1.9.4 (settings + e2e) + CHANGELOG/§4/§8/README
2026-09-19 23:29:40 +02:00

305 lines
15 KiB
JavaScript

/**
* ─────────────────────────────────────────────────────────────────────────────
* Éditeur de traitement (portage de `ui/screens/TreatmentEditorScreen.kt`).
*
* - presets (PKPresets, 25 entrées) pré-remplissent tout (création) —
* v1.9.4 : le modèle ESE offre ses 6 esters injectables (EV/EU/EEn/EC/
* EB/EUCS), au même niveau que les presets TFS (7) et WHSAH (6) ;
* - champs conditionnels : ester + modèle PK si injection, Bateman sinon —
* v1.9.4 : la liste des esters suit la couverture RÉELLE du modèle
* choisi (choicesForModel) et rebascule sur EV si l'ester courant
* n'y est plus (garde anti « 0 pg/mL ») ;
* - carte Calibration : facteur manuel + bouton « Calibrer avec les
* analyses » (computeScaleFactor — médiane lab ÷ prédiction, garde #61) ;
* hint quand l'auto-calibration écrase le facteur à l'affichage (v1.4.9) ;
* - section Posologie (prévision, intervalle en jours) ;
* - carte Rappel : switch + heure → notifications navigateur (web) ;
* - section Agenda : AFFICHAGE informatif seulement (impossible dans un
* navigateur — pas de CalendarProvider) ;
* - switch Actif : drapeau administratif (§6.bis) — ne retire rien de la
* simulation ;
* - save → insert/update ; delete avec confirmation.
*
* ⚠️ Miroir du bug #51 (Android) : la reconstruction d'une entité pour un
* save doit repartir des champs NON ÉDITABLES — ici `createdAt` chargé et
* conservé (pas de calendarEventId côté web : toujours null).
* ─────────────────────────────────────────────────────────────────────────────
*/
import { store } from '../data/store.js';
import { newTreatment, isInjectionRoute, typeLabelKey, routeLabelKey, modelLabelKey, choicesForModel } from '../data/models.js';
import { all as allPresets } from '../pk/presets.js';
import { computeScaleFactor } from '../pk/pk-calibration.js';
import { t } from '../util/i18n.js';
import { formatDose } from '../util/format.js';
import { el, showDialog, field, dropdown, switchRow, confirmDialog } from './components.js';
/** Parse un champ numérique FR (virgule → point) ; défaut si vide/invalide. */
function parseNum(text, fallback) {
const v = Number(String(text).replace(',', '.'));
return Number.isFinite(v) ? v : fallback;
}
/**
* Rend l'éditeur.
* @param {HTMLElement} container
* @param {object} ctx { treatmentId: number (0 = nouveau), onDone: ()=>void }
*/
export function renderTreatmentEditor(container, ctx) {
const isNew = ctx.treatmentId <= 0;
const existing = isNew ? null : store.getTreatments().find((x) => x.id === ctx.treatmentId) || null;
// ── État du formulaire (pré-rempli depuis le traitement ou défauts) ──────
const st = {
name: existing ? existing.name : '',
type: existing ? existing.type : 'ESTRADIOL',
route: existing ? existing.route : 'INJECTION_IM',
ester: existing ? existing.esterType : 'EV',
model: existing ? existing.pkModel : 'ESE',
doseText: existing ? formatDose(existing.doseAmount) : '4',
unit: existing ? existing.doseUnit : 'mg',
tmaxText: existing ? formatDose(existing.absorptionHours) : '46',
thalfText: existing ? formatDose(existing.eliminationHalfLifeHours) : '100',
bioText: existing ? String(existing.bioavailabilityFraction) : '1',
scaleText: existing ? formatDose(existing.scaleFactor) : '1',
reminderEnabled: existing ? existing.reminderEnabled : false,
reminderHour: existing ? (existing.reminderHour ?? 12) : 12,
reminderMinute: existing ? (existing.reminderMinute ?? 0) : 0,
active: existing ? existing.isActive : true,
forecastEnabled: existing ? existing.forecastIntervalDays !== null && existing.forecastIntervalDays !== undefined : false,
forecastDaysText: existing && existing.forecastIntervalDays !== null ? formatDose(existing.forecastIntervalDays) : '',
// ⚠️ champs NON ÉDITABLES conservés (bug #51 côté Android) :
createdAt: existing ? existing.createdAt : Date.now(),
};
const settings = store.getSettings();
const col = el('div', {});
container.appendChild(col);
// Titre + retour
const header = el('div', { class: 'row' }, [
el('button', { class: 'btn text', onclick: ctx.onDone }, '←'),
el('h1', { style: 'font-size:1.15rem;' }, isNew ? t('add_treatment') : t('edit_treatment')),
]);
col.appendChild(header);
// ── Presets (création seulement) ─────────────────────────────────────────
if (isNew) {
const presetOptions = [{ value: '', label: t('select_preset') }, ...allPresets.map((p, i) => ({ value: String(i), label: t(p.nameKey) }))];
col.appendChild(dropdown(t('select_preset'), '', presetOptions, (v) => {
if (v === '') return;
const p = allPresets[Number(v)];
st.name = t(p.nameKey);
st.type = p.type;
st.route = p.route;
st.ester = p.esterType;
st.model = p.pkModel;
st.doseText = formatDose(p.defaultDoseAmount);
st.unit = p.defaultDoseUnit;
st.tmaxText = formatDose(p.absorptionHours);
st.thalfText = formatDose(p.eliminationHalfLifeHours);
st.bioText = String(p.bioavailabilityFraction);
rerenderForm();
}));
}
const form = el('div', {});
col.appendChild(form);
/** Reconstruit le formulaire (champs conditionnels route/type). */
function rerenderForm() {
form.textContent = '';
const nameField = field({ label: t('treatment_name'), value: st.name, onInput: (v) => { st.name = v; } });
form.appendChild(nameField);
const typeOptions = ['ESTRADIOL', 'ANTI_ANDROGEN', 'PROGESTOGEN', 'OTHER']
.map((x) => ({ value: x, label: t(typeLabelKey(x)) }));
form.appendChild(dropdown(t('treatment_type'), st.type, typeOptions, (v) => {
st.type = v;
rerenderForm(); // la carte calibration ne concerne qu'E2
}));
const routeOptions = Object.keys({
ORAL: 1, TRANSDERMAL_GEL: 1, TRANSDERMAL_PATCH: 1, INJECTION_IM: 1, INJECTION_SUBCUT: 1, OTHER: 1,
}).map((x) => ({ value: x, label: t(routeLabelKey(x)) }));
form.appendChild(dropdown(t('treatment_route'), st.route, routeOptions, (v) => {
st.route = v;
rerenderForm(); // champs ester/modèle vs Bateman
}));
if (isInjectionRoute(st.route)) {
// Ester : liste = couverture RÉELLE du modèle choisi (v1.9.4 —
// miroir Android Esters.choicesForModel) : ESE offre ses 6 esters
// injectables depuis l'éditeur, au lieu des seuls EV/EU/EEn de
// l'époque des tables ODS. L'override par dose du dialog garde le
// même helper (pas de divergence des deux listes).
const esterOptions = choicesForModel(st.model).map((x) => ({ value: x, label: x }));
form.appendChild(dropdown(t('ester'), st.ester, esterOptions, (v) => { st.ester = v; }));
// Modèle PK : 3 choix (v1.4.6 — WHSAH ajouté). Au changement de
// modèle, l'ester courant peut sortir de la couverture (ex. EUCS +
// TFS : le V3C n'a pas de paramètres EUCS → 0 pg/mL) : on rebascule
// sur le premier ester couvert (EV partout) puis on re-rend le
// formulaire pour reconstruire la liste des esters (v1.9.4).
const modelOptions = ['ESE', 'TFS', 'WHS'].map((x) => ({ value: x, label: t(modelLabelKey(x)) }));
form.appendChild(dropdown(t('pk_model'), st.model, modelOptions, (v) => {
st.model = v;
if (!choicesForModel(v).includes(st.ester)) st.ester = choicesForModel(v)[0];
rerenderForm();
}));
} else {
const row1 = el('div', { class: 'row' });
row1.appendChild(field({ label: t('pk_absorption'), type: 'number', value: st.tmaxText, onInput: (v) => { st.tmaxText = v; }, attrs: { step: 'any' } }));
row1.appendChild(field({ label: t('pk_halflife'), type: 'number', value: st.thalfText, onInput: (v) => { st.thalfText = v; }, attrs: { step: 'any' } }));
form.appendChild(row1);
form.appendChild(field({ label: t('pk_bioavail'), type: 'number', value: st.bioText, onInput: (v) => { st.bioText = v; }, attrs: { step: 'any', min: '0.01', max: '1' } }));
}
const doseRow = el('div', { class: 'row' });
doseRow.appendChild(field({ label: t('default_dose'), type: 'number', value: st.doseText, onInput: (v) => { st.doseText = v; }, attrs: { step: 'any', inputmode: 'decimal' } }));
doseRow.appendChild(field({ label: t('dose_unit'), value: st.unit, onInput: (v) => { st.unit = v; } }));
form.appendChild(doseRow);
// ── Calibration (E2 seulement — miroir du if type == ESTRADIOL) ────────
if (st.type === 'ESTRADIOL') {
const calCard = el('div', { class: 'card' });
calCard.appendChild(el('h3', {}, t('calibration_title')));
calCard.appendChild(field({ label: t('scale_factor'), type: 'number', value: st.scaleText, onInput: (v) => { st.scaleText = v; }, attrs: { step: 'any', inputmode: 'decimal' } }));
calCard.appendChild(el('div', { class: 'hint' }, t('calibration_hint')));
if (settings.autoCalibrate) {
// v1.4.9 : quand l'auto est ON, le facteur manuel est IGNORÉ à
// l'affichage des courbes — l'expliciter (confusion « j'ai calibré
// mais rien ne change »)
calCard.appendChild(el('div', { class: 'hint', style: 'color:var(--teal);' }, t('calibration_auto_override')));
}
calCard.appendChild(el('button', {
class: 'btn tonal',
onclick: () => {
const tmp = buildTreatment(existing ? existing.id : 0, { scaleFactor: 1.0 });
if (!tmp) return;
const doses = store.getDoseLogs();
const labs = store.getLabResults().filter((l) => String(l.marker).toUpperCase() === 'E2');
const sf = computeScaleFactor(tmp, doses, labs);
if (sf !== null) {
st.scaleText = sf.toFixed(2);
rerenderForm();
}
},
}, t('calibrate_from_labs')));
form.appendChild(calCard);
}
// ── Posologie (prévision) ───────────────────────────────────────────────
const freqCard = el('div', { class: 'card' });
freqCard.appendChild(switchRow(t('freq_section'), st.forecastEnabled, (v) => {
st.forecastEnabled = v;
rerenderForm();
}));
if (st.forecastEnabled) {
freqCard.appendChild(field({ label: t('freq_interval'), type: 'number', value: st.forecastDaysText, onInput: (v) => { st.forecastDaysText = v; }, attrs: { step: 'any', inputmode: 'decimal', min: '0' } }));
freqCard.appendChild(el('div', { class: 'hint' }, t('freq_hint')));
}
form.appendChild(freqCard);
// ── Rappel (notifications navigateur — cf ui/reminders.js) ──────────────
const remCard = el('div', { class: 'card' });
remCard.appendChild(switchRow(t('reminder'), st.reminderEnabled, (v) => {
st.reminderEnabled = v;
rerenderForm();
}));
if (st.reminderEnabled) {
remCard.appendChild(field({
label: t('reminder_time'),
type: 'time',
value: `${String(st.reminderHour).padStart(2, '0')}:${String(st.reminderMinute).padStart(2, '0')}`,
onInput: (v) => {
const [h, m] = v.split(':').map(Number);
if (!Number.isNaN(h) && !Number.isNaN(m)) {
st.reminderHour = h;
st.reminderMinute = m;
}
},
}));
}
form.appendChild(remCard);
// ── Agenda : AFFICHAGE informatif (non portable dans un navigateur) ─────
const agendaCard = el('div', { class: 'card' });
agendaCard.appendChild(el('h3', {}, t('agenda_section')));
agendaCard.appendChild(el('div', { class: 'hint' }, t('agenda_hint')));
form.appendChild(agendaCard);
// ── Actif (drapeau administratif — §6.bis) ──────────────────────────────
form.appendChild(switchRow(t('active'), st.active, (v) => { st.active = v; }));
// ── Save ────────────────────────────────────────────────────────────────
form.appendChild(el('button', {
class: 'btn',
style: 'width:100%;margin-top:10px;',
onclick: () => {
const tr = buildTreatment(existing ? existing.id : 0, null);
if (!tr) return; // validation (nom + dose > 0)
store.upsertTreatment(tr);
ctx.onDone();
},
}, t('save')));
// ── Delete (édition seulement) ──────────────────────────────────────────
if (!isNew) {
form.appendChild(el('button', {
class: 'btn text danger',
style: 'width:100%;margin-top:6px;',
onclick: () => confirmDialog(
t('delete'),
t('confirm_delete_treatment'),
() => {
store.deleteTreatment(existing.id);
ctx.onDone();
},
),
}, t('delete')));
}
}
/**
* Construit le Treatment final depuis l'état du formulaire (miroir
* buildTreatment — validations : nom non vide, dose > 0 ; bio ∈ [0.01, 1] ;
* scale ≥ 0.01 ; prévision si activée avec intervalle > 0 ; ester NONE
* hors injection ; createdAt/(calendarEventId) conservés).
*/
function buildTreatment(id, overrides) {
const dose = parseNum(st.doseText, NaN);
if (st.name.trim() === '' || Number.isNaN(dose) || dose <= 0.0) return null;
const forecastInterval = st.forecastEnabled
? (parseNum(st.forecastDaysText, 0) > 0 ? parseNum(st.forecastDaysText, 0) : null)
: null;
const base = newTreatment({
id,
name: st.name.trim(),
type: st.type,
route: st.route,
doseAmount: dose,
doseUnit: st.unit.trim() === '' ? 'mg' : st.unit.trim(),
isActive: st.active,
// ester NONE hors injection (miroir du Kotlin)
esterType: isInjectionRoute(st.route) ? st.ester : 'NONE',
pkModel: st.model,
absorptionHours: parseNum(st.tmaxText, 4),
eliminationHalfLifeHours: parseNum(st.thalfText, 24),
bioavailabilityFraction: Math.min(Math.max(parseNum(st.bioText, 1), 0.01), 1),
scaleFactor: Math.max(parseNum(st.scaleText, 1), 0.01),
forecastIntervalDays: forecastInterval,
reminderEnabled: st.reminderEnabled,
reminderHour: st.reminderEnabled ? st.reminderHour : null,
reminderMinute: st.reminderEnabled ? st.reminderMinute : null,
// ⚠️ champ NON ÉDITABLE conservé (bug #51)
createdAt: st.createdAt,
calendarEventId: null, // toujours null côté web
});
return overrides ? { ...base, ...overrides } : base;
}
rerenderForm();
}