From a6ee2208cd71e1e66909b3acd73edd45f7fed509 Mon Sep 17 00:00:00 2001 From: Daniel Date: Thu, 23 Apr 2026 16:43:29 +0200 Subject: [PATCH] feat(ui-state): persist sub-pill selections across reload + sign-out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- e2e/tests/soap-hospital-workflow.spec.js | 75 ++++++++++++++++++++ e2e/tests/ui-state-persistence.spec.js | 89 ++++++++++++++++++++++++ public/index.html | 1 + public/js/bedside/sub-nav.js | 39 +++++++++-- public/js/calculators.js | 22 ++++-- public/js/peGuide.js | 39 ++++++++--- public/js/ui-state.js | 35 ++++++++++ public/js/wellVisit.js | 10 ++- 8 files changed, 289 insertions(+), 21 deletions(-) create mode 100644 e2e/tests/soap-hospital-workflow.spec.js create mode 100644 e2e/tests/ui-state-persistence.spec.js create mode 100644 public/js/ui-state.js diff --git a/e2e/tests/soap-hospital-workflow.spec.js b/e2e/tests/soap-hospital-workflow.spec.js new file mode 100644 index 0000000..cfa0244 --- /dev/null +++ b/e2e/tests/soap-hospital-workflow.spec.js @@ -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 }); + }); +}); diff --git a/e2e/tests/ui-state-persistence.spec.js b/e2e/tests/ui-state-persistence.spec.js new file mode 100644 index 0000000..53c98d0 --- /dev/null +++ b/e2e/tests/ui-state-persistence.spec.js @@ -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 }); + }); +}); diff --git a/public/index.html b/public/index.html index 3ede480..271e216 100644 --- a/public/index.html +++ b/public/index.html @@ -443,6 +443,7 @@ + diff --git a/public/js/bedside/sub-nav.js b/public/js/bedside/sub-nav.js index 841d0ed..11e7d15 100644 --- a/public/js/bedside/sub-nav.js +++ b/public/js/bedside/sub-nav.js @@ -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(); }); } diff --git a/public/js/calculators.js b/public/js/calculators.js index 9e92fdc..07f474f 100644 --- a/public/js/calculators.js +++ b/public/js/calculators.js @@ -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 diff --git a/public/js/peGuide.js b/public/js/peGuide.js index e3346a7..f59dbde 100644 --- a/public/js/peGuide.js +++ b/public/js/peGuide.js @@ -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); diff --git a/public/js/ui-state.js b/public/js/ui-state.js new file mode 100644 index 0000000..9dc9d40 --- /dev/null +++ b/public/js/ui-state.js @@ -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, + }; +})(); diff --git a/public/js/wellVisit.js b/public/js/wellVisit.js index a485306..451512f 100644 --- a/public/js/wellVisit.js +++ b/public/js/wellVisit.js @@ -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"