Spaces:
Sleeping
Sleeping
| /* ============================================================================= | |
| * Author: Rick Escher | |
| * Project: SailingMedAdvisor | |
| * Context: Google HAI-DEF Framework | |
| * Models: Google MedGemmas | |
| * Program: Kaggle Impact Challenge | |
| * ========================================================================== */ | |
| /* | |
| File: static/js/main.js | |
| Author notes: Application orchestration and global UI utilities. | |
| Key Responsibilities: | |
| - Tab navigation and content switching | |
| - Collapsible section management (queries, headers, details) | |
| - Sidebar state persistence and synchronization | |
| - Application initialization and data preloading | |
| - Medical chest global search across all inventories | |
| - LocalStorage state restoration | |
| Architecture Overview: | |
| --------------------- | |
| main.js acts as the conductor for the single-page application, coordinating: | |
| 1. **Tab System**: 5 main tabs with lazy loading | |
| - Chat: AI consultation interface | |
| - Medical Chest: Pharmacy inventory (preloaded for performance) | |
| - Crew Health & Log: Medical records and history | |
| - Vessel & Crew Info: Demographics and documents | |
| - Onboard Equipment: Medical equipment and consumables | |
| - Settings: Configuration and offline mode | |
| 2. **Collapsible Sections**: 3 different toggle patterns | |
| - toggleSection(): Standard sections (most common) | |
| - toggleDetailSection(): Detail panels with special handling | |
| - toggleCrewSection(): Crew cards with accordion behavior | |
| 3. **Sidebar Management**: Context-sensitive help/reference | |
| - Collapsed/expanded state persists across sessions | |
| - Auto-syncs with collapsible section states | |
| - Shows/hides relevant content per active tab | |
| 4. **Initialization Strategy**: Staggered loading for performance | |
| - Immediate: Chat tab (default landing) | |
| - Preload: Medical Chest (frequent access) | |
| - On-demand: Other tabs load when opened | |
| - Concurrent: Crew data, settings, history loaded together | |
| 5. **Global Search**: Unified search across all inventories | |
| - Searches pharmaceuticals, equipment, consumables | |
| - Scope filtering (all/pharma/equipment/consumables) | |
| - Grouped results by category | |
| - Expandable result sections | |
| Data Loading Flow: | |
| ----------------- | |
| ``` | |
| Page Load → ensureCrewData() → Promise.all([ | |
| /api/data/patients, | |
| /api/data/history, | |
| /api/data/settings | |
| ]) → loadCrewData() → Render UI | |
| Tab Switch → showTab() → | |
| if Chat: updateUI(), restoreCollapsibleState() | |
| if CrewMedical: ensureCrewData() | |
| if OnboardEquipment: loadEquipment() | |
| if Settings: loadSettingsUI(), loadCrewCredentials() | |
| ``` | |
| LocalStorage Keys: | |
| - sailingmed:sidebarCollapsed: Sidebar state (1=collapsed, 0=expanded) | |
| - sailingmed:lastOpenCrew: Last opened crew card ID | |
| - sailingmed:skipLastChat: Flag to skip restoring last chat | |
| - [headerId]: Per-section collapsed state | |
| Integration Points: | |
| - crew.js: loadCrewData() renders crew lists | |
| - pharmacy.js: preloadPharmacy(), loadPharmacy() | |
| - equipment.js: loadEquipment() | |
| - settings.js: loadSettingsUI(), loadCrewCredentials() | |
| - chat.js: updateUI(), refreshPromptPreview() | |
| */ | |
| // ============================================================================ | |
| // STATE MANAGEMENT | |
| // ============================================================================ | |
| const SIDEBAR_STATE_KEY = 'sailingmed:sidebarCollapsed'; | |
| const renderAssistantMarkdownMain = (window.Utils && window.Utils.renderAssistantMarkdown) | |
| ? window.Utils.renderAssistantMarkdown | |
| : (txt) => (window.marked && typeof window.marked.parse === 'function') | |
| ? window.marked.parse(txt || '', { gfm: true, breaks: true }) | |
| : (window.escapeHtml ? window.escapeHtml(txt || '') : String(txt || '')).replace(/\n/g, '<br>'); | |
| let globalSidebarCollapsed = false; // Sidebar collapse state (synced across all tabs) | |
| let crewDataLoaded = false; // Prevent duplicate crew data loads | |
| let crewDataPromise = null; // Promise for concurrent load protection | |
| let loadDataInFlight = null; // Shared promise so concurrent refreshes collapse into one request | |
| let cachedPatientsRoster = null; // Last successful /api/data/patients payload for lightweight refreshes | |
| let consultationLogAutoOpened = false; // One-time auto-open for Ethan consultation log section | |
| let ethanAutoOpenHoldTimer = null; // Keep pending auto-open alive briefly to survive late rerenders | |
| const LAST_PATIENT_KEY_MAIN = 'sailingmed:lastPatient'; | |
| const CREW_OPTIONS_CACHE_KEY = 'sailingmed:crewOptionsCache'; | |
| const COLLAPSIBLE_PREF_SCHEMA_KEY = 'sailingmed:collapsiblePrefSchema'; | |
| const COLLAPSIBLE_PREF_SCHEMA_VERSION = '2'; | |
| let startupCriticalInitDone = false; // Prevent duplicate critical bootstrap runs | |
| let startupDeferredInitDone = false; // Prevent duplicate deferred bootstrap runs | |
| let startupCrewReadyPromise = null; // Shared crew-ready promise across startup phases | |
| /** | |
| * Attempt one-time auto-open behavior for Ethan's Consultation Log section. | |
| * Returns true once Ethan's section has been successfully opened. | |
| */ | |
| function attemptEthanConsultationLogAutoOpen() { | |
| if (consultationLogAutoOpened) return true; | |
| if (typeof window.autoOpenEthanConsultationLog !== 'function') return false; | |
| const opened = !!window.autoOpenEthanConsultationLog(); | |
| if (opened) { | |
| consultationLogAutoOpened = true; | |
| } | |
| return opened; | |
| } | |
| // Crew renderer calls this when it applies the pending Ethan auto-open. | |
| window.__SMA_MARK_ETHAN_AUTO_OPENED = function markEthanAutoOpenCompleted() { | |
| consultationLogAutoOpened = true; | |
| }; | |
| /** | |
| * migrateCollapsiblePrefs: function-level behavior note for maintainers. | |
| * Keep this block synchronized with implementation changes. | |
| */ | |
| function migrateCollapsiblePrefs() { | |
| try { | |
| const current = localStorage.getItem(COLLAPSIBLE_PREF_SCHEMA_KEY); | |
| if (current === COLLAPSIBLE_PREF_SCHEMA_VERSION) return; | |
| // Legacy values were stored inverted; normalize once. | |
| ['query-form-open', 'triage-pathway-open'].forEach((key) => { | |
| const raw = localStorage.getItem(key); | |
| if (raw === 'true') localStorage.setItem(key, 'false'); | |
| else if (raw === 'false') localStorage.setItem(key, 'true'); | |
| }); | |
| localStorage.setItem(COLLAPSIBLE_PREF_SCHEMA_KEY, COLLAPSIBLE_PREF_SCHEMA_VERSION); | |
| } catch (err) { /* ignore */ } | |
| } | |
| /** | |
| * getCrewFullNameFast: function-level behavior note for maintainers. | |
| * Keep this block synchronized with implementation changes. | |
| */ | |
| function getCrewFullNameFast(crew) { | |
| const first = crew && typeof crew.firstName === 'string' ? crew.firstName.trim() : ''; | |
| const last = crew && typeof crew.lastName === 'string' ? crew.lastName.trim() : ''; | |
| const full = `${first} ${last}`.trim(); | |
| if (full) return full; | |
| if (crew && typeof crew.name === 'string' && crew.name.trim()) return crew.name.trim(); | |
| return 'Unnamed Crew'; | |
| } | |
| /** | |
| * Fast-path dropdown population so the Chat crew selector is usable | |
| * immediately after splash/login transition. | |
| * | |
| * This intentionally avoids rendering full crew/history UI and only updates | |
| * `#p-select` while the rest of loadData() continues in the background. | |
| */ | |
| function populateCrewSelectFast(patients) { | |
| if (!Array.isArray(patients)) return; | |
| const select = document.getElementById('p-select'); | |
| if (!select) return; | |
| let storedValue = ''; | |
| try { | |
| storedValue = localStorage.getItem(LAST_PATIENT_KEY_MAIN) || ''; | |
| } catch (err) { /* ignore */ } | |
| const currentValue = select.value || ''; | |
| const preferredValue = currentValue || storedValue; | |
| const frag = document.createDocumentFragment(); | |
| const defaultOpt = document.createElement('option'); | |
| defaultOpt.value = ''; | |
| defaultOpt.textContent = 'Unnamed Crew Member'; | |
| frag.appendChild(defaultOpt); | |
| patients.forEach((crew) => { | |
| const opt = document.createElement('option'); | |
| opt.value = String((crew && crew.id) || ''); | |
| opt.textContent = getCrewFullNameFast(crew); | |
| frag.appendChild(opt); | |
| }); | |
| select.replaceChildren(frag); | |
| if (preferredValue && Array.from(select.options).some((opt) => opt.value === preferredValue)) { | |
| select.value = preferredValue; | |
| return; | |
| } | |
| if (preferredValue) { | |
| const byName = Array.from(select.options).find((opt) => opt.textContent === preferredValue); | |
| select.value = byName ? (byName.value || '') : ''; | |
| return; | |
| } | |
| select.value = ''; | |
| } | |
| /** | |
| * cacheCrewOptionsFast: persist lightweight crew selector options for instant hydration. | |
| */ | |
| function cacheCrewOptionsFast(patients) { | |
| if (!Array.isArray(patients)) return; | |
| const compact = patients | |
| .map((crew) => { | |
| const id = String((crew && crew.id) || '').trim(); | |
| if (!id) return null; | |
| const label = getCrewFullNameFast(crew); | |
| return { id, label }; | |
| }) | |
| .filter(Boolean); | |
| if (!compact.length) return; | |
| try { | |
| localStorage.setItem(CREW_OPTIONS_CACHE_KEY, JSON.stringify(compact)); | |
| } catch (err) { /* ignore */ } | |
| } | |
| /** | |
| * hydrateCrewSelectFromCache: render cached crew options before network completes. | |
| */ | |
| function hydrateCrewSelectFromCache() { | |
| try { | |
| const raw = localStorage.getItem(CREW_OPTIONS_CACHE_KEY); | |
| if (!raw) return false; | |
| const parsed = JSON.parse(raw); | |
| if (!Array.isArray(parsed) || !parsed.length) return false; | |
| populateCrewSelectFast(parsed.map((entry) => ({ | |
| id: entry && entry.id, | |
| name: entry && entry.label, | |
| }))); | |
| return true; | |
| } catch (err) { | |
| return false; | |
| } | |
| } | |
| /** | |
| * getMedicalChestRenderCount: function-level behavior note for maintainers. | |
| * Keep this block synchronized with implementation changes. | |
| */ | |
| function getMedicalChestRenderCount() { | |
| const pharmaCount = document.querySelectorAll('#pharmacy-list .history-item').length; | |
| const equipmentCount = document.querySelectorAll('#equipment-list .history-item').length; | |
| const consumableCount = document.querySelectorAll('#consumables-list .history-item').length; | |
| return pharmaCount + equipmentCount + consumableCount; | |
| } | |
| async function runMedicalChestLoadCycle() { | |
| const loaders = []; | |
| if (typeof loadEquipment === 'function') { | |
| loaders.push(Promise.resolve(loadEquipment())); | |
| } | |
| if (typeof loadPharmacy === 'function') { | |
| loaders.push(Promise.resolve(loadPharmacy())); | |
| } | |
| if (!loaders.length) return; | |
| await Promise.allSettled(loaders); | |
| } | |
| async function ensureMedicalChestLoaded() { | |
| await runMedicalChestLoadCycle(); | |
| if (getMedicalChestRenderCount() > 0) return; | |
| try { | |
| const [invRes, toolsRes] = await Promise.all([ | |
| fetch('/api/data/inventory', { credentials: 'same-origin' }), | |
| fetch('/api/data/tools', { credentials: 'same-origin' }), | |
| ]); | |
| const [invData, toolsData] = await Promise.all([ | |
| invRes.ok ? invRes.json() : Promise.resolve([]), | |
| toolsRes.ok ? toolsRes.json() : Promise.resolve([]), | |
| ]); | |
| const hasBackendData = (Array.isArray(invData) && invData.length > 0) | |
| || (Array.isArray(toolsData) && toolsData.length > 0); | |
| if (hasBackendData) { | |
| console.warn('Medical Chest rendered empty; retrying load once.'); | |
| await new Promise((resolve) => setTimeout(resolve, 300)); | |
| await runMedicalChestLoadCycle(); | |
| } | |
| } catch (err) { | |
| console.warn('Medical Chest retry probe failed:', err); | |
| } | |
| } | |
| /** | |
| * Set sidebar collapsed state across all sidebars. | |
| * | |
| * Sidebar States: | |
| * - Expanded: Shows context help, reference content | |
| * - Collapsed: Hides sidebar, maximizes main content area | |
| * | |
| * UI Changes: | |
| * 1. Adds/removes 'collapsed' class to all .page-sidebar elements | |
| * 2. Updates toggle button text ("Context ←" / "Context →") | |
| * 3. Adjusts page body layout classes | |
| * 4. Persists state to localStorage | |
| * | |
| * Applied Globally: | |
| * All sidebars on the page sync to same state for consistency. | |
| * | |
| * @param {boolean} collapsed - True to collapse, false to expand | |
| */ | |
| function setSidebarState(collapsed) { | |
| globalSidebarCollapsed = !!collapsed; | |
| try { localStorage.setItem(SIDEBAR_STATE_KEY, globalSidebarCollapsed ? '1' : '0'); } catch (err) { /* ignore */ } | |
| document.querySelectorAll('.page-sidebar').forEach((sidebar) => { | |
| sidebar.classList.toggle('collapsed', globalSidebarCollapsed); | |
| const button = sidebar.querySelector('.sidebar-toggle'); | |
| if (button) button.textContent = globalSidebarCollapsed ? 'Context ←' : 'Context →'; | |
| const body = sidebar.closest('.page-body'); | |
| if (body) { | |
| body.classList.toggle('sidebar-open', !globalSidebarCollapsed); | |
| body.classList.toggle('sidebar-collapsed', globalSidebarCollapsed); | |
| } | |
| }); | |
| } | |
| /** | |
| * Toggle standard collapsible section visibility. | |
| * | |
| * Standard Pattern: | |
| * Header with .detail-icon (▸/▾) and body that expands/collapses. | |
| * | |
| * Special Behaviors: | |
| * 1. Crew Sort Control: Shows only when crew list expanded | |
| * 2. Triage Sample Selector: Shows only when expanded AND in advanced/developer mode | |
| * 3. Sidebar Sync: Updates related sidebar sections if data-sidebar-id present | |
| * 4. State Persistence: Saves to localStorage if data-pref-key present | |
| * | |
| * Used For: | |
| * - Query form sections | |
| * - Settings sections | |
| * - Crew list headers | |
| * - Equipment sections | |
| * | |
| * @param {HTMLElement} el - Header element to toggle | |
| */ | |
| function toggleSection(el) { | |
| const body = el.nextElementSibling; | |
| const icon = el.querySelector('.detail-icon'); | |
| const isExpanded = body.style.display === "block"; | |
| const nextExpanded = !isExpanded; | |
| body.style.display = nextExpanded ? "block" : "none"; | |
| if (icon) icon.textContent = nextExpanded ? "▾" : "▸"; | |
| // Show/hide crew sort control only when crew list expanded | |
| const sortWrap = el.querySelector('#crew-sort-wrap'); | |
| if (sortWrap) { | |
| sortWrap.style.display = nextExpanded ? "flex" : "none"; | |
| } | |
| if (el.dataset && el.dataset.sidebarId) { | |
| syncSidebarSections(el.dataset.sidebarId, nextExpanded); | |
| } | |
| if (el.dataset && el.dataset.prefKey) { | |
| try { localStorage.setItem(el.dataset.prefKey, nextExpanded.toString()); } catch (err) { /* ignore */ } | |
| } | |
| if (el.id === 'query-form-header') { | |
| if (typeof window.handleStartPanelToggle === 'function') { | |
| window.handleStartPanelToggle(nextExpanded); | |
| } else if (typeof window.updateStartPanelTitle === 'function') { | |
| window.updateStartPanelTitle(); | |
| } | |
| } | |
| } | |
| /** | |
| * Toggle detail section with prompt preview special handling. | |
| * | |
| * Similar to toggleSection but with additional logic for: | |
| * - Prompt refresh inline button visibility (advanced/developer mode only) | |
| * - ARIA attributes for accessibility (aria-expanded) | |
| * | |
| * Used For: | |
| * - Prompt preview/editor panel (chat.js) | |
| * - Other detail panels requiring ARIA support | |
| * | |
| * @param {HTMLElement} el - Header element to toggle | |
| */ | |
| function toggleDetailSection(el) { | |
| const body = el.nextElementSibling; | |
| const icon = el.querySelector('.detail-icon'); | |
| const isExpanded = body.style.display === "block"; | |
| const nextExpanded = !isExpanded; | |
| body.style.display = nextExpanded ? "block" : "none"; | |
| icon.textContent = nextExpanded ? "▾" : "▸"; | |
| // Handle prompt refresh inline visibility | |
| const refreshInline = document.getElementById('prompt-refresh-inline'); | |
| if (refreshInline && el.id === 'prompt-preview-header') { | |
| const isAdvanced = document.body.classList.contains('mode-advanced') || document.body.classList.contains('mode-developer'); | |
| refreshInline.style.display = nextExpanded && isAdvanced ? 'flex' : 'none'; | |
| el.setAttribute('aria-expanded', nextExpanded ? 'true' : 'false'); | |
| } | |
| } | |
| /** | |
| * Toggle crew card with accordion behavior. | |
| * | |
| * Crew Card Features: | |
| * 1. Icon: Changes ▸ (collapsed) ↔ ▾ (expanded) | |
| * 2. Action Buttons: Shows/hides based on state | |
| * 3. Accordion Groups: Collapses siblings in same group when opening | |
| * 4. Sidebar Sync: Updates related sidebar content | |
| * 5. Last Opened: Remembers last opened crew for reload restoration | |
| * | |
| * Accordion Behavior (Pharmacy): | |
| * When opening a medication card in pharmacy, other cards in the same | |
| * collapse-group automatically close to keep UI clean and focused. | |
| * | |
| * Used For: | |
| * - Crew medical cards (crew.js) | |
| * - Pharmacy medication cards (pharmacy.js) | |
| * - Equipment cards (equipment.js) | |
| * | |
| * @param {HTMLElement} el - Crew card header element | |
| */ | |
| function toggleCrewSection(el) { | |
| const body = el.nextElementSibling; | |
| const icon = el.querySelector('.toggle-label'); | |
| const actionBtns = el.querySelectorAll('.history-action-btn'); | |
| const isExpanded = body.style.display === "block"; | |
| body.style.display = isExpanded ? "none" : "block"; | |
| icon.textContent = isExpanded ? "▸" : "▾"; | |
| actionBtns.forEach(btn => { btn.style.visibility = isExpanded ? "hidden" : "visible"; }); | |
| if (el.dataset && el.dataset.sidebarId) { | |
| syncSidebarSections(el.dataset.sidebarId, !isExpanded); | |
| } | |
| // If this header participates in a collapse group, close siblings in the same group when opening | |
| const group = el.querySelector('.toggle-label')?.dataset?.collapseGroup || el.dataset.collapseGroup; | |
| if (!isExpanded && group) { | |
| const container = el.closest('#pharmacy-list') || el.parentElement; | |
| if (container) { | |
| container.querySelectorAll(`.toggle-label[data-collapse-group="${group}"]`).forEach(lbl => { | |
| const header = lbl.closest('.col-header'); | |
| if (!header || header === el) return; | |
| const b = header.nextElementSibling; | |
| if (b && b.style.display !== "none") { | |
| b.style.display = "none"; | |
| lbl.textContent = ">"; | |
| const btns = header.querySelectorAll('.history-action-btn'); | |
| btns.forEach(btn => { btn.style.visibility = "hidden"; }); | |
| } | |
| }); | |
| } | |
| } | |
| // Remember last opened crew card so we can restore after reloads (e.g., after uploads) | |
| if (!isExpanded) { | |
| try { | |
| const parent = el.closest('.collapsible[data-crew-id]'); | |
| if (parent) { | |
| const crewId = parent.getAttribute('data-crew-id'); | |
| localStorage.setItem('sailingmed:lastOpenCrew', crewId || ''); | |
| } | |
| } catch (err) { /* ignore */ } | |
| } | |
| } | |
| /** | |
| * Toggle sidebar collapsed state. | |
| * | |
| * Triggered by sidebar toggle button. Applies state globally to all | |
| * sidebars on the page via setSidebarState(). | |
| * | |
| * @param {HTMLElement} btn - Toggle button element | |
| */ | |
| function toggleSidebar(btn) { | |
| const sidebar = btn.closest ? btn.closest('.page-sidebar') : btn; | |
| if (!sidebar) return; | |
| // Toggle global state and apply to all sidebars | |
| const nextCollapsed = !globalSidebarCollapsed; | |
| setSidebarState(nextCollapsed); | |
| } | |
| /** | |
| * Sync sidebar section visibility with main content sections. | |
| * | |
| * When main content section opens/closes, matching sidebar sections | |
| * (identified by data-sidebar-section attribute) show/hide accordingly. | |
| * | |
| * Example: | |
| * ```html | |
| * <div data-sidebar-id="crew-medical">Crew Medical</div> | |
| * <!-- Opens... --> | |
| * <div data-sidebar-section="crew-medical">Related help content</div> | |
| * <!-- ^ This shows in sidebar --> | |
| * ``` | |
| * | |
| * @param {string} sectionId - Section identifier to sync | |
| * @param {boolean} isOpen - True if section is open | |
| */ | |
| function syncSidebarSections(sectionId, isOpen) { | |
| if (!sectionId) return; | |
| document.querySelectorAll(`[data-sidebar-section="${sectionId}"]`).forEach(sec => { | |
| if (isOpen) { | |
| sec.classList.remove('hidden'); | |
| } else { | |
| sec.classList.add('hidden'); | |
| } | |
| }); | |
| } | |
| /** | |
| * Initialize sidebar state on page load. | |
| * | |
| * Initialization Process: | |
| * 1. Restores collapsed state from localStorage | |
| * 2. Applies state to all sidebars | |
| * 3. Syncs sidebar sections with main content collapsible states | |
| * | |
| * Called once during startup initialization. | |
| */ | |
| function initSidebarSync() { | |
| try { | |
| const saved = localStorage.getItem(SIDEBAR_STATE_KEY); | |
| globalSidebarCollapsed = saved === '1'; | |
| } catch (err) { /* ignore */ } | |
| setSidebarState(globalSidebarCollapsed); | |
| document.querySelectorAll('[data-sidebar-id]').forEach(header => { | |
| const body = header.nextElementSibling; | |
| const isOpen = body && body.style.display === 'block'; | |
| syncSidebarSections(header.dataset.sidebarId, isOpen); | |
| }); | |
| } | |
| /** | |
| * Ensure crew data is loaded with concurrency protection. | |
| * | |
| * Loading Strategy: | |
| * - First call: Initiates loadData(), sets promise | |
| * - Concurrent calls: Return existing promise (no duplicate loads) | |
| * - Subsequent calls after load: Return immediately (cached flag) | |
| * | |
| * Protects against race conditions when multiple tabs/functions | |
| * request crew data simultaneously. | |
| * | |
| * @returns {Promise<void>} Resolves when crew data loaded | |
| */ | |
| async function ensureCrewData() { | |
| if (crewDataLoaded) return; | |
| if (crewDataPromise) return crewDataPromise; | |
| crewDataPromise = loadData() | |
| .then(() => { crewDataLoaded = true; crewDataPromise = null; }) | |
| .catch((err) => { crewDataPromise = null; throw err; }); | |
| return crewDataPromise; | |
| } | |
| /** | |
| * Navigate to a tab and initialize its content. | |
| * | |
| * Tab Switching Process: | |
| * 1. Hides all content sections | |
| * 2. Removes 'active' class from all tabs | |
| * 3. Shows target content section | |
| * 4. Adds 'active' class to clicked tab | |
| * 5. Updates banner controls visibility | |
| * 6. Loads tab-specific data/UI | |
| * | |
| * Tab-Specific Initialization: | |
| * | |
| * **Chat Tab:** | |
| * - Updates UI (mode, privacy state) | |
| * - Ensures crew data loaded (for patient selector) | |
| * - Loads context sidebar | |
| * - Restores collapsible state | |
| * - Prefetches prompt preview | |
| * | |
| * **Settings Tab:** | |
| * - Loads settings UI | |
| * - Loads crew credentials | |
| * - Loads workspace switcher | |
| * - Loads context sidebar | |
| * | |
| * **CrewMedical / VesselCrewInfo Tabs:** | |
| * - Ensures crew data loaded | |
| * - Loads vessel data (VesselCrewInfo only) | |
| * - Loads context sidebar | |
| * | |
| * **OnboardEquipment Tab:** | |
| * - Loads equipment list | |
| * - Preloads pharmacy data | |
| * - Loads context sidebar | |
| * | |
| * @param {Event} e - Click event | |
| * @param {string} n - Tab name/ID to show | |
| */ | |
| async function showTab(trigger, n) { | |
| document.querySelectorAll('.content').forEach(c=>c.style.display='none'); | |
| document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active')); | |
| document.getElementById(n).style.display='flex'; | |
| const clickedTab = trigger?.currentTarget | |
| || (trigger && trigger.nodeType === 1 ? trigger : null) | |
| || document.querySelector(`.tab[onclick*="'${n}'"]`); | |
| if (clickedTab) { | |
| clickedTab.classList.add('active'); | |
| } | |
| toggleBannerControls(n); | |
| if (n === 'Chat') updateUI(); | |
| if(n === 'Settings') { | |
| if (typeof loadSettingsUI === 'function') { | |
| await loadSettingsUI(); | |
| } | |
| if (typeof loadCrewCredentials === 'function') { | |
| loadCrewCredentials(); | |
| } | |
| if (typeof loadWorkspaceSwitcher === 'function') { | |
| loadWorkspaceSwitcher(); | |
| } | |
| loadContext('Settings'); | |
| } else if(n === 'CrewMedical' || n === 'VesselCrewInfo') { | |
| try { | |
| await ensureCrewData(); | |
| if (n === 'VesselCrewInfo' && typeof ensureVesselLoaded === 'function') { | |
| await ensureVesselLoaded(); | |
| } | |
| if (n === 'CrewMedical' && !consultationLogAutoOpened && typeof window.autoOpenEthanConsultationLog === 'function') { | |
| // Keep this pending until the renderer confirms success so late | |
| // rerenders cannot immediately collapse the target section. | |
| window.__SMA_PENDING_ETHAN_AUTO_OPEN = true; | |
| if (ethanAutoOpenHoldTimer) { | |
| clearTimeout(ethanAutoOpenHoldTimer); | |
| } | |
| ethanAutoOpenHoldTimer = setTimeout(() => { | |
| window.__SMA_PENDING_ETHAN_AUTO_OPEN = false; | |
| ethanAutoOpenHoldTimer = null; | |
| }, 5000); | |
| requestAnimationFrame(() => { | |
| try { | |
| attemptEthanConsultationLogAutoOpen(); | |
| } catch (err) { | |
| console.warn('Unable to auto-open Ethan consultation log section:', err); | |
| } | |
| }); | |
| setTimeout(() => { | |
| try { attemptEthanConsultationLogAutoOpen(); } catch (err) { /* ignore */ } | |
| }, 250); | |
| } | |
| } catch (err) { | |
| console.warn('Tab load data failed:', err); | |
| } | |
| loadContext(n); | |
| } else if (n === 'OnboardEquipment') { | |
| await ensureMedicalChestLoaded(); | |
| loadContext(n); | |
| } | |
| if (n === 'Chat') { | |
| try { | |
| await ensureCrewData(); | |
| } catch (err) { | |
| console.warn('Chat crew load failed:', err); | |
| } | |
| loadContext('Chat'); | |
| if (typeof window.syncStartPanelWithConsultationState === 'function') { | |
| window.syncStartPanelWithConsultationState(); | |
| } else { | |
| restoreCollapsibleState('query-form-header', true); | |
| } | |
| restoreCollapsibleState('triage-pathway-header', false); | |
| // Prefetch prompt preview so it is ready when expanded | |
| if (typeof refreshPromptPreview === 'function') { | |
| refreshPromptPreview(); | |
| } | |
| } | |
| } | |
| // Ensure inline onclick handlers can always resolve this function. | |
| window.showTab = showTab; | |
| /** | |
| * Load crew, history, and settings data from server. | |
| * | |
| * Loading Strategy: | |
| * - Concurrent fetch of history/settings on every call | |
| * - Optional patient roster reuse for lightweight refresh paths | |
| * - Patients: Hard requirement, fails if unavailable | |
| * - History: Soft requirement, continues if fails (empty array) | |
| * - Settings: Optional, uses defaults if unavailable | |
| * | |
| * Concurrency Strategy: | |
| * - If a refresh is already in flight, concurrent callers share that promise | |
| * unless `options.force === true` | |
| * | |
| * Lightweight Refresh Mode: | |
| * - `options.skipPatients === true` reuses cached roster when available | |
| * - This keeps post-chat refreshes fast while still updating history/settings | |
| * | |
| * Error Handling: | |
| * - History parse failure: Warns and continues with [] | |
| * - Settings parse failure: Warns and continues with {} | |
| * - Patients failure: Throws error and shows graceful UI fallback | |
| * | |
| * Race Condition Protection: | |
| * If loadCrewData not yet available (script still loading), retries | |
| * after 150ms to allow crew.js to finish initializing. | |
| * | |
| * Side Effects: | |
| * - Sets window.CACHED_SETTINGS for global access | |
| * - Calls loadCrewData() to render crew UI | |
| * - Sets crewDataLoaded flag | |
| * - Updates patient selector dropdown | |
| * | |
| * Fallback UI on Error: | |
| * - Shows "Unable to load crew data" message | |
| * - Provides "Unnamed Crew" option in selectors | |
| * - Prevents cascading errors | |
| * | |
| * @throws {Error} If patients data unavailable or malformed | |
| */ | |
| async function loadData(options = {}) { | |
| const opts = { | |
| skipPatients: false, | |
| force: false, | |
| forcePatients: false, | |
| ...(options && typeof options === 'object' ? options : {}), | |
| }; | |
| if (!opts.force && loadDataInFlight) { | |
| return loadDataInFlight; | |
| } | |
| loadDataInFlight = (async () => { | |
| try { | |
| const historyResPromise = fetch('/api/data/history', { credentials: 'same-origin' }) | |
| .catch((err) => { | |
| console.warn('History request failed before response; continuing without history.', err); | |
| return null; | |
| }); | |
| const settingsResPromise = fetch('/api/data/settings', { credentials: 'same-origin' }) | |
| .catch((err) => { | |
| console.warn('Settings request failed before response; using defaults.', err); | |
| return null; | |
| }); | |
| const shouldFetchPatients = !opts.skipPatients | |
| || opts.forcePatients | |
| || !Array.isArray(cachedPatientsRoster) | |
| || !cachedPatientsRoster.length; | |
| let data = Array.isArray(cachedPatientsRoster) ? cachedPatientsRoster : []; | |
| if (shouldFetchPatients) { | |
| // Prioritize patient roster fetch so #p-select is usable as early as possible. | |
| const patientsResPromise = fetch('/api/data/patients', { credentials: 'same-origin' }); | |
| const patientOptionsPromise = fetch('/api/patients/options', { credentials: 'same-origin' }) | |
| .then(async (res) => { | |
| if (!res.ok) return null; | |
| const optionsData = await res.json(); | |
| return Array.isArray(optionsData) ? optionsData : null; | |
| }) | |
| .then((optionsData) => { | |
| if (Array.isArray(optionsData) && optionsData.length) { | |
| populateCrewSelectFast(optionsData); | |
| cacheCrewOptionsFast(optionsData); | |
| } | |
| return optionsData; | |
| }) | |
| .catch((err) => { | |
| console.warn('Patient options request failed before response; falling back to full patients payload.', err); | |
| return null; | |
| }); | |
| const res = await patientsResPromise; | |
| if (!res.ok) { | |
| if (!Array.isArray(cachedPatientsRoster) || !cachedPatientsRoster.length) { | |
| throw new Error(`Patients request failed: ${res.status}`); | |
| } | |
| console.warn('Patients request failed; reusing cached roster. Status:', res.status); | |
| data = cachedPatientsRoster; | |
| } else { | |
| data = await res.json(); | |
| if (!Array.isArray(data)) throw new Error('Unexpected patients data format'); | |
| cachedPatientsRoster = data; | |
| populateCrewSelectFast(data); | |
| cacheCrewOptionsFast(data); | |
| } | |
| // Ensure fast options request has settled; failures are already handled. | |
| await patientOptionsPromise; | |
| } else { | |
| // Reuse cached roster for lightweight refreshes (e.g., after chat completion). | |
| populateCrewSelectFast(data); | |
| } | |
| const [historyRes, settingsRes] = await Promise.all([historyResPromise, settingsResPromise]); | |
| if (!settingsRes || !settingsRes.ok) console.warn('Settings request failed:', settingsRes ? settingsRes.status : 'network'); | |
| // Parse history, but never block crew rendering if it fails | |
| let history = []; | |
| if (historyRes && historyRes.ok) { | |
| try { | |
| const parsedHistory = await historyRes.json(); | |
| history = Array.isArray(parsedHistory) ? parsedHistory : []; | |
| } catch (err) { | |
| console.warn('History parse failed; continuing without history.', err); | |
| history = []; | |
| } | |
| } else { | |
| console.warn('History request failed; continuing without history. Status:', historyRes ? historyRes.status : 'network'); | |
| } | |
| // Parse settings (optional) | |
| let settings = {}; | |
| try { | |
| settings = (settingsRes && settingsRes.ok) ? await settingsRes.json() : {}; | |
| } catch (err) { | |
| console.warn('Settings parse failed, using defaults.', err); | |
| } | |
| window.CACHED_SETTINGS = settings || {}; | |
| // Ensure crew renderer is ready; retry briefly if the script is still loading | |
| if (typeof loadCrewData !== 'function') { | |
| console.warn('loadCrewData missing; retrying shortly…'); | |
| setTimeout(() => { | |
| if (typeof loadCrewData === 'function') { | |
| loadCrewData(data, history, settings || {}); | |
| } else { | |
| console.error('loadCrewData still missing after retry.'); | |
| } | |
| }, 150); | |
| return; | |
| } | |
| loadCrewData(data, history, settings || {}); | |
| crewDataLoaded = true; | |
| } catch (err) { | |
| console.error('Failed to load crew data', err); | |
| window.CACHED_SETTINGS = window.CACHED_SETTINGS || {}; | |
| // Gracefully clear UI to avoid JS errors | |
| const pSelect = document.getElementById('p-select'); | |
| if (pSelect) pSelect.innerHTML = '<option value=\"\">Unnamed Crew Member</option>'; | |
| const medicalContainer = document.getElementById('crew-medical-list'); | |
| if (medicalContainer) medicalContainer.innerHTML = `<div style="color:#666;">Unable to load crew data. ${err.message}</div>`; | |
| const infoContainer = document.getElementById('crew-info-list'); | |
| if (infoContainer) infoContainer.innerHTML = `<div style="color:#666;">Unable to load crew data. ${err.message}</div>`; | |
| } finally { | |
| loadDataInFlight = null; | |
| } | |
| })(); | |
| return loadDataInFlight; | |
| } | |
| /** | |
| * Show/hide tab-specific banner controls. | |
| * | |
| * Banner Control Groups: | |
| * | |
| * **Chat Tab:** | |
| * - Mode selector (triage/inquiry) | |
| * - Privacy toggle (logging on/off) | |
| * - Patient selector | |
| * | |
| * **Crew Health & Log Tab:** | |
| * - Export all medical records button | |
| * | |
| * **Vessel & Crew Info Tab:** | |
| * - Export crew CSV button (for border crossings) | |
| * - Export immigration zip button (crew + vessel package) | |
| * | |
| * Other tabs: All banner controls hidden | |
| * | |
| * @param {string} activeTab - Current active tab name | |
| */ | |
| function toggleBannerControls(activeTab) { | |
| const triageControls = document.getElementById('banner-controls-triage'); | |
| const crewControls = document.getElementById('banner-controls-crew'); | |
| const medExportAll = document.getElementById('crew-med-export-all-btn'); | |
| const crewCsvBtn = document.getElementById('crew-csv-btn'); | |
| const immigrationZipBtn = document.getElementById('crew-immigration-zip-btn'); | |
| if (triageControls) triageControls.style.display = activeTab === 'Chat' ? 'flex' : 'none'; | |
| if (crewControls) crewControls.style.display = (activeTab === 'CrewMedical' || activeTab === 'VesselCrewInfo') ? 'flex' : 'none'; | |
| if (medExportAll) medExportAll.style.display = activeTab === 'CrewMedical' ? 'inline-flex' : 'none'; | |
| if (crewCsvBtn) crewCsvBtn.style.display = activeTab === 'VesselCrewInfo' ? 'inline-flex' : 'none'; | |
| if (immigrationZipBtn) immigrationZipBtn.style.display = activeTab === 'VesselCrewInfo' ? 'inline-flex' : 'none'; | |
| } | |
| window.toggleBannerControls = toggleBannerControls; | |
| /** | |
| * Application initialization on page load. | |
| * | |
| * Initialization Sequence: | |
| * 1. **Crew Data**: Load immediately (used by multiple tabs) | |
| * 2. **Medical Chest**: Preload for fast access | |
| * - preloadPharmacy(): Loads inventory | |
| * - loadWhoMedsFromServer(): Loads WHO reference list | |
| * - ensurePharmacyLabels(): Loads user labels | |
| * - loadPharmacy(): Pre-renders pharmacy UI | |
| * 3. **Chat UI**: Initialize with updateUI() | |
| * 4. **Banner Controls**: Show Chat tab controls | |
| * 5. **Sidebar**: Initialize and restore state | |
| * 6. **Query Form**: Restore collapsed state | |
| * 7. **Last Chat**: Restore previous session view | |
| * | |
| * Preloading Strategy: | |
| * Medical Chest is preloaded because: | |
| * - Frequently accessed (medications needed for consultations) | |
| * - Large dataset (better to load early than wait on tab switch) | |
| * - Improves perceived performance | |
| * | |
| * Error Handling: | |
| * All preload operations use .catch() to prevent blocking page load | |
| * if individual components fail. | |
| * | |
| * Called By: Browser on page load completion | |
| */ | |
| function runCriticalStartup() { | |
| if (startupCriticalInitDone) return; | |
| startupCriticalInitDone = true; | |
| migrateCollapsiblePrefs(); | |
| // Use cached lightweight crew options immediately to avoid a blank selector | |
| // while network requests are still in flight. | |
| hydrateCrewSelectFromCache(); | |
| startupCrewReadyPromise = ensureCrewData().catch((err) => { | |
| console.warn('ensureCrewData failed during critical boot:', err); | |
| }); | |
| updateUI(); | |
| toggleBannerControls('Chat'); | |
| initSidebarSync(); | |
| if (typeof window.syncStartPanelWithConsultationState === 'function') { | |
| window.syncStartPanelWithConsultationState(); | |
| } else { | |
| restoreCollapsibleState('query-form-header', true); | |
| } | |
| restoreCollapsibleState('triage-pathway-header', false); | |
| } | |
| function runDeferredStartup() { | |
| if (startupDeferredInitDone) return; | |
| startupDeferredInitDone = true; | |
| const preloadMedicalChest = () => { | |
| // Preload Medical Chest after crew dropdown hydration to prioritize chat readiness. | |
| if (typeof preloadPharmacy === 'function') { | |
| preloadPharmacy().catch((err) => console.warn('preloadPharmacy failed:', err)); | |
| } | |
| if (typeof loadWhoMedsFromServer === 'function') { | |
| loadWhoMedsFromServer().catch((err) => console.warn('preload WHO meds failed:', err)); | |
| } | |
| if (typeof ensurePharmacyLabels === 'function') { | |
| ensurePharmacyLabels().catch((err) => console.warn('preload pharmacy labels failed:', err)); | |
| } | |
| if (typeof loadPharmacy === 'function') { | |
| loadPharmacy(); // pre-warm Medical Chest so list is ready when tab opens | |
| } | |
| }; | |
| const crewReady = startupCrewReadyPromise || ensureCrewData().catch((err) => { | |
| console.warn('ensureCrewData failed during deferred boot:', err); | |
| }); | |
| crewReady.finally(() => { | |
| if (typeof window.requestIdleCallback === 'function') { | |
| window.requestIdleCallback(preloadMedicalChest, { timeout: 800 }); | |
| } else { | |
| setTimeout(preloadMedicalChest, 0); | |
| } | |
| }); | |
| restoreLastChatView(); | |
| } | |
| document.addEventListener('DOMContentLoaded', runCriticalStartup); | |
| window.addEventListener('load', runDeferredStartup); | |
| // If scripts are injected late, run startup paths immediately as needed. | |
| if (document.readyState === 'interactive' || document.readyState === 'complete') { | |
| runCriticalStartup(); | |
| } | |
| if (document.readyState === 'complete') { | |
| runDeferredStartup(); | |
| } | |
| // Ensure loadCrewData exists before any calls (safety for race conditions) | |
| if (typeof window.loadCrewData !== 'function') { | |
| console.error('window.loadCrewData is not defined at main.js load time.'); | |
| } | |
| /** | |
| * Restore collapsible section state from localStorage. | |
| * | |
| * Restoration Process: | |
| * 1. Looks for stored state in localStorage (by header ID or data-pref-key) | |
| * 2. Applies stored state or uses default if not found | |
| * 3. Updates icon (▸/▾) | |
| * 4. Special handling for prompt preview (ARIA + refresh button) | |
| * | |
| * Special Cases: | |
| * | |
| * **Prompt Preview Header:** | |
| * - Updates aria-expanded attribute | |
| * - Shows/hides prompt refresh inline button | |
| * | |
| * Use Cases: | |
| * - Query form: Restore expanded/collapsed state | |
| * - Prompt preview: Restore editor visibility | |
| * - Settings sections: Restore user preferences | |
| * | |
| * @param {string} headerId - ID of header element | |
| * @param {boolean} defaultOpen - Default state if no stored preference | |
| */ | |
| function restoreCollapsibleState(headerId, defaultOpen = true) { | |
| const header = document.getElementById(headerId); | |
| if (!header) return; | |
| const body = header.nextElementSibling; | |
| if (!body) return; | |
| let isOpen = defaultOpen; | |
| const key = header.dataset?.prefKey || headerId; | |
| try { | |
| const stored = localStorage.getItem(key); | |
| if (stored !== null) { | |
| isOpen = stored === 'true'; | |
| } | |
| } catch (err) { /* ignore */ } | |
| body.style.display = isOpen ? 'block' : 'none'; | |
| const icon = header.querySelector('.detail-icon'); | |
| if (icon) icon.textContent = isOpen ? '▾' : '▸'; | |
| if (headerId === 'prompt-preview-header') { | |
| const refreshInline = document.getElementById('prompt-refresh-inline'); | |
| if (refreshInline) refreshInline.style.display = isOpen ? 'flex' : 'none'; | |
| header.setAttribute('aria-expanded', isOpen ? 'true' : 'false'); | |
| } | |
| } | |
| /** | |
| * Load context sidebar content for tab. | |
| * | |
| * Legacy Function: | |
| * Previously loaded remote context content. Now sidebars are static HTML, | |
| * so this is a no-op but kept for compatibility. | |
| * | |
| * @param {string} tabName - Tab name (no longer used) | |
| * @deprecated Sidebars are now static HTML | |
| */ | |
| async function loadContext(tabName) { | |
| // Sidebars are now fully static HTML; no remote context to fetch. | |
| return; | |
| } | |
| /** | |
| * Restore the most recent chat session on page load. | |
| * | |
| * Restoration Process: | |
| * 1. Checks display element is empty (fresh load) | |
| * 2. Checks skipLastChat flag (user may have cleared it) | |
| * 3. Fetches history from server | |
| * 4. Gets most recent entry | |
| * 5. Attempts active session restore via chat.js helper | |
| * 6. Falls back to read-only rendering if active restore is unavailable | |
| * | |
| * Display Format: | |
| * - Title: "Last Session — [Patient] ([Date])" | |
| * - Query section | |
| * - Response section | |
| * | |
| * Use Cases: | |
| * - User returns to page: See previous consultation | |
| * - Page refresh: Maintain context | |
| * - Quick reference: Check recent advice | |
| * | |
| * Skip Conditions: | |
| * - User cleared display (skipLastChat=1) | |
| * - Display already has content (manual chat run) | |
| * - No history available | |
| * - History fetch fails | |
| * | |
| * Integration: | |
| * Respects skipLastChat flag set when starting a new consultation clears prior | |
| * on-screen results. | |
| */ | |
| async function restoreLastChatView() { | |
| try { | |
| const display = document.getElementById('display'); | |
| if (!display || display.children.length > 0) return; | |
| try { | |
| const skip = localStorage.getItem('sailingmed:skipLastChat'); | |
| if (skip === '1') return; | |
| } catch (err) { /* ignore */ } | |
| const res = await fetch('/api/data/history', { credentials: 'same-origin' }); | |
| if (!res.ok) return; | |
| const history = await res.json(); | |
| if (!Array.isArray(history) || history.length === 0) return; | |
| const toTimestamp = (entry) => { | |
| if (!entry || typeof entry !== 'object') return Number.NEGATIVE_INFINITY; | |
| const raw = entry.updated_at || entry.date || ''; | |
| if (!raw) return Number.NEGATIVE_INFINITY; | |
| const normalized = raw.includes('T') ? raw : raw.replace(' ', 'T'); | |
| const ts = Date.parse(normalized); | |
| return Number.isNaN(ts) ? Number.NEGATIVE_INFINITY : ts; | |
| }; | |
| const sortedHistory = history.slice().sort((a, b) => toTimestamp(b) - toTimestamp(a)); | |
| const last = sortedHistory[0]; | |
| if (!last) return; | |
| if (typeof window.restoreHistoryEntrySession === 'function') { | |
| try { | |
| const restored = window.restoreHistoryEntrySession(last, { | |
| focusInput: false, | |
| forceLoggingOn: true, | |
| notifyRestored: false, | |
| allowTakeover: false, | |
| }); | |
| if (restored) return; | |
| } catch (err) { | |
| console.warn('Failed to restore active last chat session', err); | |
| } | |
| } | |
| const parseTranscript = (entry) => { | |
| if (!entry) return { messages: [] }; | |
| if (entry.response && typeof entry.response === 'object' && Array.isArray(entry.response.messages)) { | |
| return { messages: entry.response.messages, meta: entry.response.meta || {} }; | |
| } | |
| if (typeof entry.response === 'string' && entry.response.trim().startsWith('{')) { | |
| try { | |
| const parsed = JSON.parse(entry.response); | |
| if (parsed && Array.isArray(parsed.messages)) { | |
| return { messages: parsed.messages, meta: parsed.meta || {} }; | |
| } | |
| } catch (err) { /* ignore */ } | |
| } | |
| return { messages: [] }; | |
| }; | |
| const transcript = parseTranscript(last); | |
| if (transcript.messages.length && typeof window.renderTranscript === 'function') { | |
| display.innerHTML = ` | |
| <div class="response-block" style="border-left-color:var(--inquiry);"> | |
| <div style="font-weight:800; margin-bottom:6px;">Last Consultation (read-only) — ${last.patient || 'Unknown'} (${last.date || ''})</div> | |
| <div style="font-size:12px; color:#555;">Use the Consultation Log to restore and continue this session.</div> | |
| </div> | |
| `; | |
| window.renderTranscript(transcript.messages, { append: true }); | |
| return; | |
| } | |
| const parse = (txt) => renderAssistantMarkdownMain(txt || ''); | |
| const responseHtml = parse(last.response || ''); | |
| const queryHtml = parse(last.query || ''); | |
| display.innerHTML = ` | |
| <div class="response-block"> | |
| <div style="font-weight:800; margin-bottom:6px;">Last Session — ${last.patient || 'Unknown'} (${last.date || ''})</div> | |
| <div style="margin-bottom:8px;"><strong>Query:</strong><br>${queryHtml}</div> | |
| <div><strong>Response:</strong><br>${responseHtml}</div> | |
| </div> | |
| `; | |
| } catch (err) { | |
| console.warn('Failed to restore last chat view', err); | |
| } finally { | |
| if (typeof window.syncStartPanelWithConsultationState === 'function') { | |
| window.syncStartPanelWithConsultationState(); | |
| } | |
| } | |
| } | |
| /** | |
| * Search across all medical inventories. | |
| * | |
| * Search Scope: | |
| * - **all**: Pharmaceuticals + Equipment + Consumables | |
| * - **pharma**: Medications only | |
| * - **equipment**: Durable medical equipment only | |
| * - **consumables**: Single-use supplies only | |
| * | |
| * Search Fields: | |
| * | |
| * **Pharmaceuticals:** | |
| * - Generic name, brand name | |
| * - Indication, dosage | |
| * - Storage location, notes | |
| * | |
| * **Equipment/Consumables:** | |
| * - Item name | |
| * - Storage location | |
| * - Notes, quantity | |
| * | |
| * Results Display: | |
| * - Grouped by category (Pharmaceuticals, Equipment, Consumables) | |
| * - Collapsible result sections | |
| * - Shows count per category | |
| * - Item details (title, detail, storage location) | |
| * | |
| * Use Cases: | |
| * - "Where is the amoxicillin?" | |
| * - "Do we have any splints?" | |
| * - "What's in Medical Bag 3?" | |
| * - "Find all antibiotics" | |
| * | |
| * Performance: | |
| * Concurrent fetch of inventory and tools data for fast results. | |
| * Case-insensitive search for better UX. | |
| */ | |
| async function searchMedicalChest() { | |
| const input = document.getElementById('medchest-search-input'); | |
| const scopeSel = document.getElementById('medchest-search-scope'); | |
| const resultsBox = document.getElementById('medchest-search-results'); | |
| if (!input || !scopeSel || !resultsBox) return; | |
| const q = (input.value || '').trim().toLowerCase(); | |
| const scope = scopeSel.value || 'all'; | |
| if (!q) { | |
| resultsBox.innerHTML = '<div style="color:#b71c1c;">Enter a search term.</div>'; | |
| return; | |
| } | |
| resultsBox.innerHTML = '<div style="color:#555;">Searching…</div>'; | |
| try { | |
| const wantPharma = scope === 'all' || scope === 'pharma'; | |
| const wantEquip = scope === 'all' || scope === 'equipment' || scope === 'consumables'; | |
| const [invData, toolsData] = await Promise.all([ | |
| wantPharma ? fetch('/api/data/inventory', { credentials: 'same-origin' }).then(r => r.json()) : Promise.resolve([]), | |
| wantEquip ? fetch('/api/data/tools', { credentials: 'same-origin' }).then(r => r.json()) : Promise.resolve([]), | |
| ]); | |
| const results = []; | |
| if (wantPharma && Array.isArray(invData)) { | |
| invData.forEach(m => { | |
| const hay = [m.genericName, m.brandName, m.primaryIndication, m.standardDosage, m.storageLocation, m.notes].join(' ').toLowerCase(); | |
| if (hay.includes(q)) { | |
| results.push({ section: 'Pharmaceuticals', title: m.genericName || m.brandName || 'Medication', detail: m.strength || '', extra: m.storageLocation || '' }); | |
| } | |
| }); | |
| } | |
| if (wantEquip && Array.isArray(toolsData)) { | |
| toolsData.forEach(t => { | |
| const hay = [t.name, t.storageLocation, t.notes, t.quantity].join(' ').toLowerCase(); | |
| const isConsumable = (t.type || '').toLowerCase() === 'consumable'; | |
| const sec = isConsumable ? 'Consumables' : 'Equipment'; | |
| if ((scope === 'consumables' && !isConsumable) || (scope === 'equipment' && isConsumable)) return; | |
| if (hay.includes(q)) { | |
| results.push({ section: sec, title: t.name || 'Item', detail: t.quantity || '', extra: t.storageLocation || '' }); | |
| } | |
| }); | |
| } | |
| if (!results.length) { | |
| resultsBox.innerHTML = '<div style="color:#2c3e50;">No matches found.</div>'; | |
| return; | |
| } | |
| const grouped = results.reduce((acc, r) => { | |
| acc[r.section] = acc[r.section] || []; | |
| acc[r.section].push(r); | |
| return acc; | |
| }, {}); | |
| let html = ''; | |
| Object.keys(grouped).forEach(sec => { | |
| const list = grouped[sec]; | |
| html += ` | |
| <div style="margin-bottom:10px; border:1px solid #d8e2f5; border-radius:8px;"> | |
| <div style="padding:8px 10px; background:#eef3ff; cursor:pointer; font-weight:700;" onclick="toggleSearchResults(this)"> | |
| ${sec} — ${list.length} match(es) | |
| <span style="float:right;">▾</span> | |
| </div> | |
| <div class="medchest-search-results-body" style="padding:8px 10px; display:none; background:#fff;"> | |
| ${list.map(item => ` | |
| <div style="padding:6px 0; border-bottom:1px solid #eee;"> | |
| <div style="font-weight:700;">${item.title}</div> | |
| <div style="font-size:12px; color:#444;">${item.detail || ''}</div> | |
| <div style="font-size:12px; color:#666;">${item.extra || ''}</div> | |
| </div> | |
| `).join('')} | |
| </div> | |
| </div> | |
| `; | |
| }); | |
| resultsBox.innerHTML = html; | |
| } catch (err) { | |
| resultsBox.innerHTML = `<div style="color:#b71c1c;">Search failed: ${err.message}</div>`; | |
| } | |
| } | |
| /** | |
| * Toggle search result section visibility. | |
| * | |
| * Each category (Pharmaceuticals, Equipment, Consumables) is a | |
| * collapsible section. This toggles individual sections. | |
| * | |
| * @param {HTMLElement} headerEl - Result category header element | |
| */ | |
| function toggleSearchResults(headerEl) { | |
| const body = headerEl.nextElementSibling; | |
| if (!body) return; | |
| const isShown = body.style.display === 'block'; | |
| body.style.display = isShown ? 'none' : 'block'; | |
| const arrow = headerEl.querySelector('span'); | |
| if (arrow) arrow.textContent = isShown ? '▸' : '▾'; | |
| } | |
| // expose for inline handlers | |
| window.searchMedicalChest = searchMedicalChest; | |
| window.toggleSearchResults = toggleSearchResults; | |
| // | |
| // MAINTENANCE NOTE | |
| // Historical auto-generated note blocks were removed because they were repetitive and | |
| // obscured real logic changes during review. Keep focused comments close to behavior- | |
| // critical code paths (UI state hydration, async fetch lifecycle, and mode-gated | |
| // controls) so maintenance remains actionable. | |