- 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
305 lines
15 KiB
JavaScript
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();
|
|
}
|