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:
parent
6f6b6fb4d5
commit
a6ee2208cd
8 changed files with 289 additions and 21 deletions
75
e2e/tests/soap-hospital-workflow.spec.js
Normal file
75
e2e/tests/soap-hospital-workflow.spec.js
Normal 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 });
|
||||
});
|
||||
});
|
||||
89
e2e/tests/ui-state-persistence.spec.js
Normal file
89
e2e/tests/ui-state-persistence.spec.js
Normal 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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
35
public/js/ui-state.js
Normal 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,
|
||||
};
|
||||
})();
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue