Independent education · Sources and limitations documented
Portfolio / Smart NOA simulation

Software simulation

Source snapshot · 5 November 2026

simulator/ui/patient-form.js

// Step 1 — Patient form (fictional adult patients only).
import { h, clear, announce } from './dom.js';
import { validatePatient, LIMITS } from '../engine/patient.js';
import { CONDITION_GROUPS } from './labels.js';
import { EXAMPLE_PATIENTS } from '../data/scenarios.js';

const getPath = (o, p) => p.split('.').reduce((a, k) => (a == null ? undefined : a[k]), o);
function setPath(o, p, v) {
  const ks = p.split('.'); let a = o;
  for (const k of ks.slice(0, -1)) { if (a[k] == null || typeof a[k] !== 'object') a[k] = {}; a = a[k]; }
  if (v === undefined) delete a[ks[ks.length - 1]]; else a[ks[ks.length - 1]] = v;
}
const fid = (path) => `pf-${path.replace(/\./g, '-')}`;

const FIELD_LABELS = {
  sex: 'Sex', ageYears: 'Age', heightCm: 'Height', weightKg: 'Weight', asa: 'ASA physical status', emergency: 'Emergency',
  'baseline.hr': 'Heart rate', 'baseline.sbp': 'Systolic BP', 'baseline.dbp': 'Diastolic BP', 'baseline.spo2': 'SpO2', 'baseline.rr': 'Respiratory rate',
  egfr: 'eGFR', 'airway.mallampati': 'Mallampati class', 'stopBang.neckCm': 'Neck circumference',
};

export function renderPatient(ctx, main) {
  const { store, lib } = ctx;
  const model = store.patientInput;
  const touched = new Set();
  let showAll = false;

  const errEls = new Map();
  const numberField = (path, label, unit, { step = 'any', hint = null, optional = false } = {}) => {
    const lim = LIMITS[path.split('.').pop()] || null;
    const id = fid(path); const errId = `${id}-err`; const hintId = `${id}-hint`;
    const v = getPath(model, path);
    const input = h('input', { type: 'number', inputmode: 'decimal', id, class: 'input input--num', step, min: lim ? lim[0] : null, max: lim ? lim[1] : null,
      value: v === undefined || v === null || Number.isNaN(v) ? '' : v, 'aria-describedby': `${hintId} ${errId}`, dataset: { path } });
    input.addEventListener('input', () => {
      const raw = input.value.trim();
      setPath(model, path, raw === '' ? (optional ? undefined : null) : Number(raw));
      touched.add(path); changed();
    });
    input.addEventListener('blur', () => { touched.add(path); refreshErrors(); });
    const err = h('p', { class: 'field-error', id: errId, hidden: true });
    errEls.set(path, { err, input });
    return h('div', { class: 'field' },
      h('label', { for: id, class: 'label' }, label, unit ? h('span', { class: 'unit' }, ` (${unit})`) : null, optional ? h('span', { class: 'muted' }, ' — optional') : null),
      input,
      h('p', { class: 'hint', id: hintId }, hint || (lim ? `Allowed ${lim[0]}–${lim[1]}` : '')),
      err);
  };
  const checkbox = (path, label, hint = null) => {
    const id = fid(path);
    const input = h('input', { type: 'checkbox', id, checked: !!getPath(model, path), dataset: { path } });
    input.addEventListener('change', () => { setPath(model, path, input.checked); changed(); });
    return h('div', { class: 'check' }, input, h('label', { for: id }, label, hint ? h('span', { class: 'muted' }, ` — ${hint}`) : null));
  };
  const listChecks = (field, items, legendText, groupFilter = null) => {
    const set = new Set(model[field] || []);
    const boxes = items.filter((it) => !groupFilter || it.group === groupFilter).map((it) => {
      const id = `pf-${field}-${it.id}`;
      const input = h('input', { type: 'checkbox', id, checked: set.has(it.id), dataset: { list: field, item: it.id } });
      input.addEventListener('change', () => {
        const cur = new Set(model[field] || []);
        if (input.checked) cur.add(it.id); else cur.delete(it.id);
        model[field] = items.map((x) => x.id).filter((x) => cur.has(x));
        changed();
      });
      return h('div', { class: 'check' }, input, h('label', { for: id }, it.label));
    });
    if (!boxes.length) return null;
    return h('fieldset', { class: 'fieldset fieldset--checks' }, h('legend', {}, legendText), h('div', { class: 'check-grid' }, boxes));
  };

  // --- live derived preview
  const preview = h('p', { class: 'preview', id: 'pf-preview', 'aria-live': 'polite' });
  const updatePreview = () => {
    const b = model.baseline || {}; const ok = (x) => typeof x === 'number' && Number.isFinite(x);
    const parts = [];
    if (ok(b.sbp) && ok(b.dbp) && b.dbp < b.sbp) parts.push(`MAP ${Math.round(b.dbp + (b.sbp - b.dbp) / 3)} mmHg`);
    if (ok(model.weightKg) && ok(model.heightCm)) parts.push(`BMI ${(model.weightKg / (model.heightCm / 100) ** 2).toFixed(1)} kg/m²`);
    preview.textContent = parts.length ? `Derived: ${parts.join(' · ')} (full assessment on the next step)` : '';
  };

  const errorSummary = h('div', { class: 'error-summary', id: 'pf-error-summary', tabindex: '-1', hidden: true, role: 'alert' });

  function currentErrors() {
    const v = validatePatient(model, { library: lib });
    const byField = new Map();
    for (const e of v.errors) {
      const val = getPath(model, e.field);
      const msg = val === null || val === undefined || (typeof val === 'number' && Number.isNaN(val)) ? 'Required — enter a number.' : e.message.replace(/\s*\(got [^)]*\)\s*$/, '').replace(/^must/, 'Must');
      if (!byField.has(e.field)) byField.set(e.field, msg);
    }
    return { ok: v.ok, byField };
  }
  function refreshErrors() {
    const { byField } = currentErrors();
    for (const [path, { err, input }] of errEls) {
      const msg = byField.get(path);
      const show = msg && (showAll || touched.has(path));
      err.hidden = !show; err.textContent = show ? msg : '';
      if (show) input.setAttribute('aria-invalid', 'true'); else input.removeAttribute('aria-invalid');
    }
    if (showAll && byField.size) {
      clear(errorSummary).append(h('h3', {}, `Please correct ${byField.size} ${byField.size === 1 ? 'field' : 'fields'}`),
        h('ul', {}, [...byField].map(([f, m]) => h('li', {}, errEls.has(f) ? h('a', { href: `#${fid(f)}`, dataset: { focus: fid(f) } }, `${FIELD_LABELS[f] || f}: ${m}`) : `${FIELD_LABELS[f] || f}: ${m}`))));
      errorSummary.hidden = false;
    } else errorSummary.hidden = true;
    return byField;
  }
  errorSummary.addEventListener('click', (e) => { const a = e.target.closest('[data-focus]'); if (a) { e.preventDefault(); document.getElementById(a.dataset.focus).focus(); } });

  function changed() { ctx.patientChanged(); updatePreview(); refreshErrors(); ctx.refreshStepper(); }

  // --- examples
  const exSel = h('select', { id: 'pf-example', class: 'input' },
    h('option', { value: '' }, 'Choose a fictional example…'),
    EXAMPLE_PATIENTS.map((e) => h('option', { value: e.id, selected: store.exampleId === e.id }, e.label)));
  const exBtn = h('button', { type: 'button', class: 'btn', id: 'pf-load-example' }, 'Load example case');
  exBtn.addEventListener('click', () => {
    if (!exSel.value) { exSel.focus(); announce('Choose an example first.'); return; }
    ctx.loadExample(exSel.value);
    ctx.rerender();
    announce(`Example case loaded: ${EXAMPLE_PATIENTS.find((e) => e.id === exSel.value).label}. Patient and matching anaesthetic plan filled in.`);
  });

  const sexField = h('fieldset', { class: 'fieldset fieldset--inline' }, h('legend', {}, 'Sex (for weight scalars and scores)'),
    ['female', 'male'].map((s) => {
      const id = `pf-sex-${s}`;
      const r = h('input', { type: 'radio', name: 'pf-sex', id, value: s, checked: model.sex === s });
      r.addEventListener('change', () => { if (r.checked) { model.sex = s; changed(); } });
      return h('div', { class: 'check' }, r, h('label', { for: id }, s === 'female' ? 'Female' : 'Male'));
    }));

  const asaSel = h('select', { id: fid('asa'), class: 'input', 'aria-describedby': 'pf-asa-hint' }, [1, 2, 3, 4, 5].map((n) => h('option', { value: n, selected: model.asa === n }, `ASA ${n}`)));
  asaSel.addEventListener('change', () => { model.asa = Number(asaSel.value); changed(); });
  const mallSel = h('select', { id: fid('airway.mallampati'), class: 'input' }, [1, 2, 3, 4].map((n) => h('option', { value: n, selected: model.airway && model.airway.mallampati === n }, `Class ${n}`)));
  mallSel.addEventListener('change', () => { setPath(model, 'airway.mallampati', Number(mallSel.value)); changed(); });

  const cont = h('button', { type: 'submit', class: 'btn btn--primary', id: 'pf-continue' }, 'Continue to assessment →');
  const form = h('form', { class: 'patient-form', novalidate: true, 'aria-labelledby': 'step-title' },
    errorSummary,
    h('section', { class: 'card' }, h('h3', {}, 'Demographics'),
      h('div', { class: 'grid grid--4' },
        sexField,
        numberField('ageYears', 'Age', 'years', { step: '1', hint: 'Adults 18–90 y only' }),
        numberField('heightCm', 'Height', 'cm'),
        numberField('weightKg', 'Weight', 'kg'),
        h('div', { class: 'field' }, h('label', { class: 'label', for: fid('asa') }, 'ASA physical status'), asaSel, h('p', { class: 'hint', id: 'pf-asa-hint' }, '1 (healthy) to 5')),
        h('div', { class: 'field field--check' }, checkbox('emergency', 'Emergency procedure', 'adds aspiration-risk flag')),
      )),
    h('section', { class: 'card' }, h('h3', {}, 'Baseline vital signs & renal function'),
      h('div', { class: 'grid grid--6' },
        numberField('baseline.hr', 'Heart rate', '/min'),
        numberField('baseline.sbp', 'Systolic BP', 'mmHg'),
        numberField('baseline.dbp', 'Diastolic BP', 'mmHg'),
        numberField('baseline.spo2', 'SpO2 (room air)', '%'),
        numberField('baseline.rr', 'Respiratory rate', '/min'),
        numberField('egfr', 'eGFR', 'mL/min/1.73 m²'),
      ), preview),
    h('section', { class: 'card' }, h('h3', {}, 'Airway'),
      h('div', { class: 'grid grid--4' },
        h('div', { class: 'field' }, h('label', { class: 'label', for: fid('airway.mallampati') }, 'Mallampati class'), mallSel),
        h('div', { class: 'field field--check' },
          checkbox('airway.difficultHistory', 'Previous difficult airway'),
          checkbox('airway.limitedMouthOpening', 'Limited mouth opening'),
          checkbox('airway.limitedNeck', 'Limited neck movement')))),
    h('section', { class: 'card' }, h('h3', {}, 'Conditions'),
      h('p', { class: 'muted' }, 'Conditions drive contraindication lockouts/warnings and illustrative patient modifiers.'),
      h('div', { class: 'grid grid--2' }, CONDITION_GROUPS.map(([g, label]) => listChecks('conditions', lib.CONDITIONS, label, g)),
        listChecks('conditions', lib.CONDITIONS.filter((c) => !CONDITION_GROUPS.some(([g]) => g === c.group)), 'Other'))),
    h('section', { class: 'card' }, h('h3', {}, 'Allergies & current medications'),
      h('div', { class: 'grid grid--2' }, listChecks('allergies', lib.ALLERGIES, 'Allergies'), listChecks('medications', lib.MEDICATIONS, 'Medications'))),
    h('section', { class: 'card' }, h('h3', {}, 'PONV risk factors (Apfel) & STOP-Bang'),
      h('div', { class: 'grid grid--2' },
        h('fieldset', { class: 'fieldset' }, h('legend', {}, 'Apfel factors (sex is taken from above)'),
          checkbox('ponvHistory', 'History of PONV or motion sickness'),
          checkbox('smoker', 'Current smoker'),
          checkbox('postopOpioidsExpected', 'Postoperative opioids expected')),
        h('fieldset', { class: 'fieldset' }, h('legend', {}, 'STOP-Bang items (BMI, age and sex are derived)'),
          checkbox('stopBang.snoring', 'Snores loudly'),
          checkbox('stopBang.tired', 'Tired / sleepy in the daytime'),
          checkbox('stopBang.observedApnea', 'Observed apnoea during sleep'),
          checkbox('stopBang.pressure', 'Treated for high blood pressure', 'also counted if “Uncontrolled hypertension” is ticked'),
          numberField('stopBang.neckCm', 'Neck circumference', 'cm', { optional: true, hint: 'Leave blank if not measured' })))),
    h('div', { class: 'actions' }, cont),
  );
  form.addEventListener('submit', (e) => {
    e.preventDefault();
    showAll = true;
    const errs = refreshErrors();
    if (errs.size) {
      errorSummary.focus();
      announce(`${errs.size} field${errs.size === 1 ? '' : 's'} need correcting.`, true);
      return;
    }
    ctx.go(2);
  });

  clear(main).append(
    h('div', { class: 'step-head' },
      h('h2', { id: 'step-title', tabindex: '-1' }, '1 · Patient'),
      h('p', { class: 'muted' }, 'Enter a fictional adult patient (18–90 y). Pregnancy, paediatrics, cardiac bypass and neuraxial/regional techniques are not modelled.')),
    h('section', { class: 'card card--accent' },
      h('h3', {}, 'Load an example case'),
      h('p', { class: 'muted' }, 'Loads a fictional patient and the matching example plan (doses are filled from the drug library at load time).'),
      h('div', { class: 'inline-form' }, h('label', { for: 'pf-example', class: 'label' }, 'Example case'), exSel, exBtn)),
    form);
  updatePreview();
  refreshErrors();
}

export function defaultPatientInput() {
  const p = EXAMPLE_PATIENTS[0].patient;
  return JSON.parse(JSON.stringify(p));
}
Source and scope

Published from Smart-NOA-Controller/simulator/ui/patient-form.js at revision 6e5b205e3843. Full source text is retained; HTML presentation does not establish independent clinical review. In-memory simulation with no patient, monitor or pump interface; not clinically validated.