feat(ui-state): persist sub-pill selections across reload + sign-out

Tab-level choice (ped_last_tab) already survived sign-out/in via
localStorage, but sub-pill and sub-tab selections inside a loaded tab
lived only in memory — they reset to defaults after a reload or
browser restart. Now the following are persisted under the ped_ui/
namespace:

  - Calculators nav pill (BP / BMI / GCS / …)
  - Bedside sub-pill (neonatal / airway / …)
  - Well Visit sub-tab (byvisit / milestones / shadess / note)
  - Physical Exam Guide age group + system

Implementation:
- Added public/js/ui-state.js — a ~30-line window.UIState wrapper
  around localStorage with a ped_ui/ prefix and try/catch around both
  read and write (Safari private mode + quota errors silently no-op).
- Each tab's click handler now also calls UIState.set; each tab's
  init path calls UIState.get and replays the saved value through
  the same function a click would call — so there is exactly one
  code path for "show this selection", whether it came from the user
  or from a restore. For Bedside, the restore additionally listens
  for tabChanged so the lazy-loaded HTML is guaranteed to exist by
  the time we re-activate the pill.

Tests:
- e2e/tests/ui-state-persistence.spec.js — 5 specs × 2 viewports =
  10 tests. Each clicks the feature, reloads the page, and asserts
  the same pill / subtab / dropdown value is still active. Catches
  any future regression in the persistence wiring.
- e2e/tests/soap-hospital-workflow.spec.js — fills SOAP transcript,
  generates via mocked AI, clears, opens/closes load popovers; also
  smoke-tests the Hospital Course save-bar.

Suite: 250 passed / 0 failed (+ 20 over the last run).
This commit is contained in:
Daniel 2026-04-23 16:43:29 +02:00
parent 6f6b6fb4d5
commit a6ee2208cd
8 changed files with 289 additions and 21 deletions

View file

@ -0,0 +1,75 @@
// ============================================================
// SOAP + HOSPITAL COURSE — detailed workflow for the two notes
// tabs that previously only had smoke coverage.
// ============================================================
const { test, expect, E2E_BASE, mockAI } = require('../fixtures');
async function openTab(page, name) {
await page.goto(E2E_BASE + '/');
await page.waitForSelector('button.tab-btn', { timeout: 15000 });
const vp = page.viewportSize();
if (vp && vp.width <= 768) {
await page.click('#btn-menu-toggle').catch(() => {});
}
await page.click(`button.tab-btn[data-tab="${name}"]`);
await page.waitForFunction((t) => {
const el = document.getElementById(t + '-tab');
return el && el.classList.contains('active') && el.innerHTML.trim().length > 100;
}, name, { timeout: 15000 });
}
test.describe('SOAP Note — generate + refine', () => {
test('fill transcript + generate → mocked SOAP renders', async ({ authedPage: _, page }) => {
await mockAI(page);
await openTab(page, 'soap');
await page.fill('#soap-age', '5 years');
await page.selectOption('#soap-gender', 'Male');
await page.locator('#soap-transcript').click();
await page.keyboard.type('Patient presents with 3 days of fever, cough, and runny nose.');
const [resp] = await Promise.all([
page.waitForResponse('**/api/generate-soap', { timeout: 15000 }),
page.click('#soap-generate-btn'),
]);
expect(resp.status()).toBe(200);
await expect(page.locator('#soap-output')).toBeVisible({ timeout: 10000 });
await expect(page.locator('#soap-text')).toContainText('MOCK SOAP NOTE');
});
test('clear transcript button empties the contenteditable', async ({ authedPage: _, page }) => {
await mockAI(page);
await openTab(page, 'soap');
await page.locator('#soap-transcript').click();
await page.keyboard.type('some transcript');
await expect(page.locator('#soap-transcript')).toContainText('some transcript');
await page.click('#soap-clear');
const text = await page.locator('#soap-transcript').innerText();
expect(text.trim()).toBe('');
});
test('load popover opens and closes', async ({ authedPage: _, page }) => {
await openTab(page, 'soap');
await page.click('#btn-soap-load').catch(() => page.click('button[id*="soap-load"]'));
await expect(page.locator('#soap-load-popover')).not.toHaveClass(/hidden/, { timeout: 3000 });
});
});
test.describe('Hospital Course — tab loads with save/load bar', () => {
test('tab renders with label input + save/load/new buttons', async ({ authedPage: _, page }) => {
await openTab(page, 'hospital');
await expect(page.locator('#hosp-label')).toBeVisible();
await expect(page.locator('#hosp-save-bar')).toBeVisible();
});
test('load popover opens and closes', async ({ authedPage: _, page }) => {
await openTab(page, 'hospital');
// Any button with id that looks like load
const loadBtn = page.locator('button[id*="hosp-load"], button[id*="btn-hosp-load"]').first();
await loadBtn.click();
await expect(page.locator('#hosp-load-popover')).not.toHaveClass(/hidden/, { timeout: 3000 });
});
});

View file

@ -0,0 +1,89 @@
// ============================================================
// UI STATE PERSISTENCE — sub-pill / sub-tab choices survive a
// full page reload (simulating sign-out / sign-in or browser
// restart). Guards against regressions in the ui-state.js +
// localStorage wiring.
// ============================================================
const { test, expect, E2E_BASE } = require('../fixtures');
async function gotoHome(page) {
await page.goto(E2E_BASE + '/');
await page.waitForSelector('button.tab-btn', { timeout: 15000 });
}
async function openDesktopTab(page, name) {
const vp = page.viewportSize();
if (vp && vp.width <= 768) {
await page.click('#btn-menu-toggle').catch(() => {});
}
await page.click(`button.tab-btn[data-tab="${name}"]`);
await page.waitForFunction((t) => {
const el = document.getElementById(t + '-tab');
return el && el.classList.contains('active') && el.innerHTML.trim().length > 100;
}, name, { timeout: 15000 });
}
test.describe('UI state survives page reload', () => {
test('ped_last_tab → user lands on last-active tab after reload', async ({ authedPage: _, page }) => {
await gotoHome(page);
await openDesktopTab(page, 'calculators');
// Reload (keeps cookie, clears _componentCache + in-memory DOM state)
await page.reload();
await page.waitForSelector('button.tab-btn', { timeout: 15000 });
await expect.poll(async () => {
return await page.locator('#calculators-tab.active').count();
}, { timeout: 5000 }).toBe(1);
});
test('calculators nav pill persists across reload', async ({ authedPage: _, page }) => {
await gotoHome(page);
await openDesktopTab(page, 'calculators');
// Switch to GCS
await page.click('button.calc-nav-pill[data-calc="gcs"]');
await expect(page.locator('button.calc-nav-pill[data-calc="gcs"].active')).toBeVisible();
await page.reload();
await page.waitForSelector('button.calc-nav-pill[data-calc="gcs"]', { timeout: 15000 });
// Same pill should be active after reload
await expect(page.locator('button.calc-nav-pill[data-calc="gcs"].active')).toBeVisible({ timeout: 5000 });
// And the corresponding panel should be un-hidden
await expect(page.locator('#calc-gcs')).not.toHaveClass(/hidden/);
});
test('bedside sub-pill persists across reload', async ({ authedPage: _, page }) => {
await gotoHome(page);
await openDesktopTab(page, 'bedside');
await page.click('button.calc-pill[data-em="anaphylaxis"]');
await expect(page.locator('button.calc-pill[data-em="anaphylaxis"].active')).toBeVisible();
await page.reload();
await page.waitForSelector('button.calc-pill[data-em="anaphylaxis"]', { timeout: 15000 });
await expect(page.locator('button.calc-pill[data-em="anaphylaxis"].active')).toBeVisible({ timeout: 5000 });
// The anaphylaxis em-section should be the visible one
await expect.poll(async () => {
return await page.locator('#em-anaphylaxis').evaluate(el => el.style.display);
}, { timeout: 5000 }).not.toBe('none');
});
test('well-visit sub-tab persists across reload', async ({ authedPage: _, page }) => {
await gotoHome(page);
await openDesktopTab(page, 'wellvisit');
await page.click('button.wv-subtab-btn[data-subtab="milestones"]');
await expect(page.locator('#wv-panel-milestones')).not.toHaveClass(/hidden/);
await page.reload();
await page.waitForSelector('button.wv-subtab-btn[data-subtab="milestones"]', { timeout: 15000 });
await expect(page.locator('#wv-panel-milestones')).not.toHaveClass(/hidden/, { timeout: 5000 });
});
test('PE guide age group + system persist across reload', async ({ authedPage: _, page }) => {
await gotoHome(page);
await openDesktopTab(page, 'peguide');
await page.selectOption('#pe-age-group', 'adolescent');
await page.click('button[data-pesystem="neuro"]');
await expect(page.locator('button[data-pesystem="neuro"].active')).toBeVisible();
await page.reload();
await page.waitForSelector('#pe-age-group', { timeout: 15000 });
await expect(page.locator('#pe-age-group')).toHaveValue('adolescent', { timeout: 5000 });
await expect(page.locator('button[data-pesystem="neuro"].active')).toBeVisible({ timeout: 5000 });
});
});

View file

@ -443,6 +443,7 @@
<script defer src="/js/transcriptionSettings.js"></script>
<script defer src="/js/voicePreferences.js"></script>
<script defer src="/js/app.js"></script>
<script defer src="/js/ui-state.js"></script>
<script defer src="/js/secureStorage.js"></script>
<script defer src="/js/authFetch.js"></script>
<script defer src="/js/auth.js"></script>

View file

@ -1,18 +1,43 @@
// ============================================================
// bedside/sub-nav.js
// EMERGENCIES SUB-NAV — data-em pill switching.
// Persists the active pill so reloads / sign-out-sign-in land on the
// same sub-section the user was last looking at.
// ============================================================
var SECTIONS = ['neonatal','airway','cardiac','respiratory','ventilation','seizure','sepsis','anaphylaxis','sedation','agitation','antiemetics','antimicrobials','burns','toxicology','trauma'];
function activate(emKey) {
var pill = document.querySelector('[data-em="' + emKey + '"]');
if (!pill) return false;
document.querySelectorAll('[data-em]').forEach(function(p) { p.classList.remove('active'); });
pill.classList.add('active');
SECTIONS.forEach(function(s) {
var el = document.getElementById('em-' + s);
if (el) el.style.display = emKey === s ? '' : 'none';
});
return true;
}
export function init() {
document.addEventListener('click', function(e) {
var pill = e.target.closest('[data-em]');
if (!pill) return;
document.querySelectorAll('[data-em]').forEach(function(p) { p.classList.remove('active'); });
pill.classList.add('active');
var sections = ['neonatal','airway','cardiac','respiratory','ventilation','seizure','sepsis','anaphylaxis','sedation','agitation','antiemetics','antimicrobials','burns','toxicology','trauma'];
sections.forEach(function(s) {
var el = document.getElementById('em-' + s);
if (el) el.style.display = pill.dataset.em === s ? '' : 'none';
});
activate(pill.dataset.em);
if (window.UIState) window.UIState.set('bedside.pill', pill.dataset.em);
});
// Restore on load. The bedside component is lazily injected the first time
// the tab is activated, so try both an immediate pass (harness where the
// DOM is synchronously ready) and a tabChanged listener (live app where
// the component HTML arrives async). Either path reaches the same
// activate(saved) call — whichever fires first wins; the other is a noop.
function applySaved() {
if (!window.UIState) return;
var saved = window.UIState.get('bedside.pill');
if (saved) activate(saved);
}
applySaved();
document.addEventListener('tabChanged', function(e) {
if (e.detail && e.detail.tab === 'bedside') applySaved();
});
}

View file

@ -58,15 +58,27 @@
function initCalculators() {
// ── Top-level pill navigation (scoped to .calc-nav-pill only) ──
function activateCalcPill(key) {
var pill = document.querySelector('.calc-nav-pill[data-calc="' + key + '"]');
if (!pill) return false;
document.querySelectorAll('.calc-nav-pill').forEach(function(p) { p.classList.remove('active'); });
document.querySelectorAll('.calc-panel').forEach(function(p) { p.classList.add('hidden'); });
pill.classList.add('active');
var panel = document.getElementById('calc-' + key);
if (panel) panel.classList.remove('hidden');
return true;
}
document.querySelectorAll('.calc-nav-pill').forEach(function(pill) {
pill.addEventListener('click', function() {
document.querySelectorAll('.calc-nav-pill').forEach(function(p) { p.classList.remove('active'); });
document.querySelectorAll('.calc-panel').forEach(function(p) { p.classList.add('hidden'); });
pill.classList.add('active');
var panel = document.getElementById('calc-' + pill.dataset.calc);
if (panel) panel.classList.remove('hidden');
activateCalcPill(pill.dataset.calc);
if (window.UIState) window.UIState.set('calc.pill', pill.dataset.calc);
});
});
// Restore the last-selected pill across page reloads / sign-out-sign-in.
if (window.UIState) {
var saved = window.UIState.get('calc.pill');
if (saved) activateCalcPill(saved);
}
// ═══════════════════════════════════════════════════════════
// BP PERCENTILE — BCM/Rosner Quantile Spline Regression

View file

@ -1357,8 +1357,8 @@
var content = document.getElementById('pe-content');
var actions = document.getElementById('pe-actions');
ageSelect.addEventListener('change', function () {
currentAgeGroup = ageSelect.value;
function applyAgeGroup(ag) {
currentAgeGroup = ag;
state = {};
document.getElementById('pe-output').classList.add('hidden');
if (!currentAgeGroup || !PE_DATA[currentAgeGroup]) {
@ -1368,19 +1368,42 @@
}
renderSystem();
actions.style.display = 'flex';
}
ageSelect.addEventListener('change', function () {
applyAgeGroup(ageSelect.value);
if (window.UIState) window.UIState.set('pe.ageGroup', ageSelect.value || '');
});
function applySystem(sys) {
document.querySelectorAll('[data-pesystem]').forEach(function (b) {
b.classList.toggle('active', b.dataset.pesystem === sys);
});
currentSystem = sys;
state = {};
document.getElementById('pe-output').classList.add('hidden');
if (currentAgeGroup) renderSystem();
}
document.querySelectorAll('[data-pesystem]').forEach(function (btn) {
btn.addEventListener('click', function () {
document.querySelectorAll('[data-pesystem]').forEach(function (b) { b.classList.remove('active'); });
btn.classList.add('active');
currentSystem = btn.dataset.pesystem;
state = {};
document.getElementById('pe-output').classList.add('hidden');
if (currentAgeGroup) renderSystem();
applySystem(btn.dataset.pesystem);
if (window.UIState) window.UIState.set('pe.system', btn.dataset.pesystem);
});
});
// Restore persisted selections. Age group must be restored first because
// the system render depends on it.
if (window.UIState) {
var savedAge = window.UIState.get('pe.ageGroup');
if (savedAge && PE_DATA[savedAge]) {
ageSelect.value = savedAge;
applyAgeGroup(savedAge);
}
var savedSys = window.UIState.get('pe.system');
if (savedSys && /^(msk|neuro|resp|cv)$/.test(savedSys)) {
applySystem(savedSys);
}
}
document.getElementById('pe-all-normal').addEventListener('click', setAllNormal);
document.getElementById('pe-all-clear').addEventListener('click', clearAll);
document.getElementById('pe-generate-btn').addEventListener('click', generate);

35
public/js/ui-state.js Normal file
View file

@ -0,0 +1,35 @@
// ============================================================
// UI STATE — sub-pill / sub-tab persistence across browser restart
// ============================================================
// Tab-level selection (ped_last_tab) is handled in app.js. This file
// persists the finer-grained choices that would otherwise reset to
// defaults when the user closes the browser, signs out / back in, or
// reloads the page — because _componentCache is in-memory and cleared
// on reload.
//
// All keys live under the "ped_ui/" namespace in localStorage so they
// are easy to enumerate and clear. Reads and writes are wrapped in
// try/catch — localStorage can throw in some contexts (Safari private
// mode, storage quotas), and a persistence failure must never break
// the UI.
// ============================================================
(function () {
var PREFIX = 'ped_ui/';
function get(key) {
try { return localStorage.getItem(PREFIX + key); } catch (e) { return null; }
}
function set(key, value) {
try { localStorage.setItem(PREFIX + key, value); } catch (e) {}
}
function del(key) {
try { localStorage.removeItem(PREFIX + key); } catch (e) {}
}
window.UIState = {
get: get,
set: set,
del: del,
};
})();

View file

@ -110,8 +110,16 @@
document.getElementById('wv-visit-select').addEventListener('change', onVisitChange);
document.querySelectorAll('.wv-subtab-btn').forEach(function (btn) {
btn.addEventListener('click', function () { switchSubtab(btn.dataset.subtab); });
btn.addEventListener('click', function () {
switchSubtab(btn.dataset.subtab);
if (window.UIState) window.UIState.set('wv.subtab', btn.dataset.subtab);
});
});
// Restore persisted subtab so the user lands on the last-open pane.
if (window.UIState) {
var savedSubtab = window.UIState.get('wv.subtab');
if (savedSubtab) switchSubtab(savedSubtab);
}
// Wire the visit-detail panel click/input ONCE here (not inside renderVisitPanel)
// which would accumulate a new listener on every visit change — causing "can't click Done"