HormoneTrack-web/js/ui/treatment-editor.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

289 lines
14 KiB
JavaScript

/**
* ─────────────────────────────────────────────────────────────────────────────
* Éditeur de traitement (portage de `ui/screens/TreatmentEditorScreen.kt`).
*
* - presets (PKPresets, 22 entrées) pré-remplissent tout (création) ;
* - champs conditionnels : ester + modèle PK si injection, Bateman sinon ;
* - 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 } 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 simple (miroir Android — EV/EU/EEn dans l'éditeur ;
// les 7 esters sont proposables dans l'OVERRIDE par dose du DoseDialog)
const esterOptions = ['EV', 'EU', 'EEN'].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é)
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; }));
} 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();
}