/* ============================================================================= * Author: Rick Escher * Project: SailingMedAdvisor * Context: Google HAI-DEF Framework * Models: Google MedGemmas * Program: Kaggle Impact Challenge * ========================================================================== */ /* File: static/js/crew.js Author notes: Client-side controller for Crew & Vessel information management. I handle crew member profiles, medical histories, vaccine tracking, document uploads, vessel information, and integration with the chat system for patient selection. Key Responsibilities: - Crew member CRUD operations (add, edit, delete crew profiles) - Vaccine record management with customizable vaccine types - Document upload/storage for passport photos and identification - Medical history tracking and export functionality - Emergency contact management with copy-between-crew feature - Vessel information management with auto-save - Integration with chat system for patient/crew selection - Chat history tracking and display per crew member - Export capabilities for crew lists, medical histories, and individual records Data Flow: - Crew data stored in /data/patients.json via /api/data/patients endpoint - Vessel data stored in /data/vessel.json via /api/data/vessel endpoint - Chat history stored separately and grouped by patient for display - Documents stored as base64 in crew records (passportHeadshot, passportPage) Integration Points: - Chat system: Updates #p-select dropdown for patient selection - Settings: Uses customizable vaccine types from settings - Main.js: Called by loadData() for initial load and refreshes */ // Reuse the chat dropdown storage key without redefining the global constant const CREW_LAST_PATIENT_KEY = typeof LAST_PATIENT_KEY !== 'undefined' ? LAST_PATIENT_KEY : 'sailingmed:lastPatient'; const renderAssistantMarkdownCrew = (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, '
'); /** * Default vaccine types used when settings don't provide custom types. * These represent common vaccinations tracked for maritime crew health records. * Can be overridden via Settings → Vaccine Types configuration. */ const DEFAULT_VACCINE_TYPES = [ 'Diphtheria, Tetanus, and Pertussis (DTaP/Tdap)', 'Polio (IPV/OPV)', 'Measles, Mumps, Rubella (MMR)', 'HPV (Human Papillomavirus)', 'Influenza', 'Haemophilus influenzae type b (Hib)', 'Hepatitis B', 'Varicella (Chickenpox)', 'Pneumococcal (PCV)', 'Rotavirus', 'COVID-19', 'Yellow Fever', 'Typhoid', 'Hepatitis A', 'Japanese Encephalitis', 'Rabies', 'Cholera', ]; // In-memory caches for crew history tracking let historyStore = []; // Array of all chat history entries let historyStoreById = {}; // Map of history entries by ID for quick lookup /** * Calculate age from birthdate string. * * @param {string} birthdate - ISO date string (YYYY-MM-DD) * @returns {string} Age string in format " (XX yo)" or empty string if invalid * * @example * calculateAge("1990-05-15") // Returns " (35 yo)" in 2026 * calculateAge("") // Returns "" * calculateAge(null) // Returns "" */ function calculateAge(birthdate) { if (!birthdate) return ''; const today = new Date(); const birth = new Date(birthdate); let age = today.getFullYear() - birth.getFullYear(); const monthDiff = today.getMonth() - birth.getMonth(); if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) { age--; } return age >= 0 ? ` (${age} yo)` : ''; } /** * Toggle visibility of individual chat log entry (query/response pair). * Also shows/hides action buttons (Restore, Export, Delete) when expanded. * * @param {HTMLElement} el - The header element that was clicked */ function toggleLogEntry(el) { const body = el.nextElementSibling; const arrow = el.querySelector('.history-arrow'); const buttons = el.querySelectorAll('.history-entry-action'); const isExpanded = body.style.display === 'block'; body.style.display = isExpanded ? 'none' : 'block'; if (arrow) arrow.textContent = isExpanded ? '▸' : '▾'; buttons.forEach((btn) => { btn.style.visibility = isExpanded ? 'hidden' : 'visible'; }); } /** * parseHistoryTranscriptEntry: function-level behavior note for maintainers. * Keep this block synchronized with implementation changes. */ function parseHistoryTranscriptEntry(item) { if (!item) return { messages: [], meta: {} }; if (typeof item.response === 'string' && item.response.trim().startsWith('{')) { try { const parsed = JSON.parse(item.response); if (parsed && Array.isArray(parsed.messages)) { return { messages: parsed.messages, meta: parsed.meta || {} }; } } catch (err) { /* ignore */ } } return { messages: [], meta: {} }; } /** * renderTranscriptHtml: function-level behavior note for maintainers. * Keep this block synchronized with implementation changes. */ function renderTranscriptHtml(messages) { if (!messages || !messages.length) return ''; return messages .map((msg) => { if (!msg || typeof msg !== 'object') return ''; const role = (msg.role || msg.type || '').toString().toLowerCase(); const isUser = role === 'user'; const raw = msg.message || msg.content || ''; const content = (!isUser) ? renderAssistantMarkdownCrew(raw || '') : escapeHtml(raw || '').replace(/\\n/g, '
'); const metaParts = [isUser ? 'You' : 'MedGemma']; if (msg.model) metaParts.push(String(msg.model)); if (msg.ts) { const ts = new Date(msg.ts); if (!Number.isNaN(ts.getTime())) { metaParts.push(ts.toLocaleString()); } } if (msg.duration_ms != null) { const durMs = Number(msg.duration_ms); if (Number.isFinite(durMs) && durMs > 0) { const secs = durMs / 1000; metaParts.push(secs >= 10 ? `${Math.round(secs)}s` : `${secs.toFixed(1)}s`); } } let triageBlock = ''; const triageMeta = msg.triage_meta || msg.triageMeta; if (isUser && triageMeta && typeof triageMeta === 'object') { const lines = Object.entries(triageMeta) .filter(([, v]) => v) .map(([k, v]) => `${escapeHtml(k)}: ${escapeHtml(v)}`) .join('
'); if (lines) { triageBlock = `
Triage Intake
${lines}
`; } } return `
${escapeHtml(metaParts.join(' • '))}
${content}
${triageBlock}
`; }) .join(''); } /** * renderTranscriptText: function-level behavior note for maintainers. * Keep this block synchronized with implementation changes. */ function renderTranscriptText(messages) { if (!messages || !messages.length) return ''; const lines = []; messages.forEach((msg) => { if (!msg || typeof msg !== 'object') return; const role = (msg.role || msg.type || '').toString().toLowerCase(); const label = role === 'user' ? 'USER' : 'ASSISTANT'; const content = msg.message || msg.content || ''; if (!content) return; lines.push(`${label}: ${content}`); const triageMeta = msg.triage_meta || msg.triageMeta; if (role === 'user' && triageMeta && typeof triageMeta === 'object') { const metaLines = Object.entries(triageMeta) .filter(([, v]) => v) .map(([k, v]) => `- ${k}: ${v}`); if (metaLines.length) { lines.push('TRIAGE INTAKE:'); lines.push(...metaLines); } } lines.push(''); }); return lines.join('\\n').trim(); } /** * Export a single chat history entry as a text file. * * Creates a formatted text file with the query and response from a specific * chat log entry. Useful for sharing specific medical consultations or * keeping offline records of important interactions. * * @param {string} id - The unique ID of the history entry to export * * File Format: * ``` * Date: [ISO timestamp] * Patient: [Crew member name] * Title: [Optional title] * * Query: * [User's question/input] * * Response: * [AI's response] * ``` */ function exportHistoryItemById(id) { if (!id || !historyStoreById[id]) { alert('Unable to export: entry not found.'); return; } const item = historyStoreById[id]; const parsed = parseHistoryTranscriptEntry(item); const name = (item.patient || 'Unknown').replace(/[^a-z0-9]/gi, '_'); const date = (item.date || '').replace(/[^0-9T:-]/g, '_'); const filename = `history_${name}_${date || 'entry'}.txt`; const parts = []; parts.push(`Date: ${item.date || ''}`); parts.push(`Patient: ${item.patient || 'Unknown'}`); if (item.mode) parts.push(`Mode: ${item.mode}`); if (item.model) parts.push(`Last Model: ${item.model}`); if (item.duration_ms) parts.push(`Last Latency (ms): ${item.duration_ms}`); if (item.title) parts.push(`Title: ${item.title}`); parts.push(''); if (parsed.messages.length) { parts.push('Transcript:'); parts.push(renderTranscriptText(parsed.messages)); } else { parts.push('Query:'); parts.push(item.query || ''); parts.push(''); parts.push('Response:'); parts.push(item.response || ''); } const blob = new Blob([parts.join('\\n')], { type: 'text/plain' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); } /** * isDeveloperModeActive: function-level behavior note for maintainers. * Keep this block synchronized with implementation changes. */ function isDeveloperModeActive() { try { const bodyHasMode = !!document.body && document.body.classList.contains('mode-developer'); const htmlHasMode = document.documentElement.classList.contains('mode-developer'); if (bodyHasMode || htmlHasMode) return true; const modeSelect = document.getElementById('user_mode'); if (modeSelect && String(modeSelect.value || '').toLowerCase() === 'developer') return true; const cachedMode = window.CACHED_SETTINGS?.user_mode; if (String(cachedMode || '').toLowerCase() === 'developer') return true; const stored = localStorage.getItem('user_mode') || ''; return String(stored).toLowerCase() === 'developer'; } catch (err) { return false; } } /** * getHistoryEditableFields: function-level behavior note for maintainers. * Keep this block synchronized with implementation changes. */ function getHistoryEditableFields(item) { const parsed = parseHistoryTranscriptEntry(item); const transcriptMessages = parsed.messages || []; const firstUser = transcriptMessages.find((m) => (m?.role || '').toString().toLowerCase() === 'user'); const assistants = transcriptMessages.filter((m) => (m?.role || '').toString().toLowerCase() === 'assistant'); const lastAssistant = assistants.length ? assistants[assistants.length - 1] : null; return { query: (firstUser?.message || item?.query || item?.user_query || '').toString(), response: (lastAssistant?.message || item?.response || '').toString(), }; } /** * toggleHistoryEntryEditor: function-level behavior note for maintainers. * Keep this block synchronized with implementation changes. */ function toggleHistoryEntryEditor(id) { if (!id) return; if (!isDeveloperModeActive()) { alert('Consultation log editing is only available in Developer Mode.'); return; } const wrap = document.getElementById(`history-edit-wrap-${id}`); if (!wrap) return; const isOpen = wrap.style.display === 'block'; wrap.style.display = isOpen ? 'none' : 'block'; } async function saveHistoryItemById(id) { if (!id) return; if (!isDeveloperModeActive()) { alert('Consultation log editing is only available in Developer Mode.'); return; } const dateEl = document.getElementById(`history-edit-date-${id}`); const queryEl = document.getElementById(`history-edit-query-${id}`); const responseEl = document.getElementById(`history-edit-response-${id}`); const statusEl = document.getElementById(`history-edit-status-${id}`); const saveBtn = document.getElementById(`history-edit-save-${id}`); if (!dateEl || !queryEl || !responseEl) { alert('Unable to save: editor fields are missing.'); return; } const editedDate = (dateEl.value || '').trim(); const editedQuery = (queryEl.value || '').trim(); const editedResponse = (responseEl.value || '').trim(); if (!editedDate) { alert('Date is required.'); return; } if (!editedQuery) { alert('Query is required.'); return; } if (!editedResponse) { alert('Response is required.'); return; } try { if (statusEl) statusEl.textContent = 'Saving...'; if (saveBtn) saveBtn.disabled = true; const entryPath = `/api/history/${encodeURIComponent(id)}`; const res = await fetch(entryPath, { credentials: 'same-origin' }); if (!res.ok) throw new Error(res.statusText || 'Failed to load history entry'); const entry = await res.json(); if (!entry || typeof entry !== 'object') throw new Error('History payload is invalid'); entry.date = editedDate; entry.query = editedQuery; entry.user_query = editedQuery; entry.updated_at = new Date().toISOString(); let updatedResponse = editedResponse; if (typeof entry.response === 'string' && entry.response.trim().startsWith('{')) { try { const parsed = JSON.parse(entry.response); if (parsed && Array.isArray(parsed.messages)) { const messages = parsed.messages.map((m) => (m && typeof m === 'object' ? { ...m } : m)); const userIndex = messages.findIndex((m) => (m?.role || m?.type || '').toString().toLowerCase() === 'user'); if (userIndex >= 0) { messages[userIndex].message = editedQuery; } else { messages.unshift({ role: 'user', message: editedQuery, ts: new Date().toISOString() }); } let assistantIndex = -1; for (let i = messages.length - 1; i >= 0; i -= 1) { const role = (messages[i]?.role || messages[i]?.type || '').toString().toLowerCase(); if (role === 'assistant') { assistantIndex = i; break; } } if (assistantIndex >= 0) { messages[assistantIndex].message = editedResponse; } else { messages.push({ role: 'assistant', message: editedResponse, ts: new Date().toISOString(), model: entry.model || '', }); } if (parsed.meta && typeof parsed.meta === 'object') { parsed.meta.initial_query = editedQuery; if (!parsed.meta.date && editedDate) parsed.meta.date = editedDate; } parsed.messages = messages; updatedResponse = JSON.stringify(parsed); } } catch (err) { // Keep editedResponse as plain text if JSON parsing fails. } } entry.response = updatedResponse; const saveRes = await fetch(entryPath, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(entry), credentials: 'same-origin', }); if (!saveRes.ok) throw new Error(saveRes.statusText || 'Failed to save history entry'); if (statusEl) statusEl.textContent = 'Saved'; if (typeof loadData === 'function') { // History edits do not change crew roster records. await loadData({ skipPatients: true }); } } catch (err) { if (statusEl) statusEl.textContent = ''; alert(`Failed to save entry: ${err.message}`); } finally { if (saveBtn) saveBtn.disabled = false; } } /** * Delete a specific chat history entry with double confirmation. * * Implements a two-step confirmation process: * 1. Confirm dialog with crew member name * 2. Type "DELETE" prompt for final confirmation * * After deletion, reloads the entire history list to update UI. * * @param {string} id - The unique ID of the history entry to delete */ async function deleteHistoryItemById(id) { if (!id) { alert('Unable to delete: entry not found.'); return; } const label = historyStoreById[id]?.patient || 'entry'; const first = confirm(`Delete this log entry for ${label}?`); if (!first) return; const confirmText = prompt('Type DELETE to confirm deletion:'); if (confirmText !== 'DELETE') { alert('Deletion cancelled.'); return; } try { const res = await fetch(`/api/history/${encodeURIComponent(id)}`, { method: 'DELETE', credentials: 'same-origin', }); if (!res.ok) throw new Error(res.statusText || 'Failed to delete'); // Deleting a history row only affects history/settings-dependent panels. await loadData({ skipPatients: true }); // refresh UI with new history } catch (err) { alert(`Failed to delete: ${err.message}`); } } /** * Get display name for crew member in "Last, First" format. * Used in lists and headers where formal display is preferred. * * @param {Object} crew - Crew member object * @returns {string} Display name in "Last, First" format or fallback * * @example * getCrewDisplayName({firstName: "John", lastName: "Smith"}) // "Smith, John" * getCrewDisplayName({name: "John Smith"}) // "John Smith" * getCrewDisplayName({}) // "Unknown" */ function getCrewDisplayName(crew) { if (crew.firstName && crew.lastName) { return `${crew.lastName}, ${crew.firstName}`; } return crew.name || 'Unknown'; } /** * Get full name for crew member in "First Last" format. * Used in conversational contexts and chat system. * * @param {Object} crew - Crew member object * @returns {string} Full name in "First Last" format or fallback * * @example * getCrewFullName({firstName: "John", lastName: "Smith"}) // "John Smith" * getCrewFullName({name: "John Smith"}) // "John Smith" * getCrewFullName({}) // "Unknown" */ function getCrewFullName(crew) { if (crew.firstName && crew.lastName) { return `${crew.firstName} ${crew.lastName}`; } return crew.name || 'Unknown'; } /** * normalizePatientName: function-level behavior note for maintainers. * Keep this block synchronized with implementation changes. */ function normalizePatientName(value) { return (value || '') .toString() .trim() .toLowerCase() .replace(/[^a-z0-9,\s]/g, ' ') .replace(/\s+/g, ' '); } /** * appendHistoryKey: function-level behavior note for maintainers. * Keep this block synchronized with implementation changes. */ function appendHistoryKey(target, key) { const val = (key || '').toString().trim(); if (!val) return; if (!target.includes(val)) target.push(val); } /** * Group chat history entries by patient/crew member. * * Creates multiple lookup keys per entry to handle various matching scenarios: * - By patient name (text) * - By patient ID (id:xxx) * - "Unnamed Crew" for entries without patient info * - Inquiry-placeholder records are folded into "Unnamed Crew" * * Also populates historyStoreById map for quick ID-based lookups. * * @param {Array} history - Array of chat history entries * @returns {Object} Map of patient keys to arrays of history entries * * @example * groupHistoryByPatient([ * {id: "h1", patient: "John Smith", patient_id: "123", ...}, * {id: "h2", patient: "Inquiry", ...} * ]) * // Returns: * { * "John Smith": [{id: "h1", ...}], * "id:123": [{id: "h1", ...}], * "Unnamed Crew": [{id: "h2", ...}] * } */ function groupHistoryByPatient(history) { const map = {}; history.forEach((item) => { if (item && item.id) { historyStoreById[item.id] = item; } const keys = []; const patientName = (item.patient || '').trim(); const isInquiryPlaceholder = patientName.toLowerCase() === 'inquiry'; if (patientName && !isInquiryPlaceholder) { appendHistoryKey(keys, patientName); const normalizedName = normalizePatientName(patientName); if (normalizedName) { appendHistoryKey(keys, `name:${normalizedName}`); const parts = normalizedName.split(' ').filter(Boolean); if (parts.length >= 2) { appendHistoryKey(keys, `name:${parts[0]} ${parts[parts.length - 1]}`); } } } if (item.patient_id != null && String(item.patient_id).trim()) { appendHistoryKey(keys, `id:${String(item.patient_id).trim()}`); } if ((!patientName && !item.patient_id) || (isInquiryPlaceholder && !item.patient_id)) { appendHistoryKey(keys, 'Unnamed Crew'); } keys.forEach((k) => { if (!map[k]) map[k] = []; map[k].push(item); }); }); return map; } /** * Render HTML for a list of chat history entries. * * Each entry is displayed as a collapsible card with: * - Date and preview of first line of query * - Expandable query/response details * - Action buttons: Restore, Demo Restore, Export, Delete * - Developer Mode action: Edit (visible when expanded) * * @param {Array} entries - Array of history entry objects * @returns {string} HTML markup for all entries */ function renderHistoryEntries(entries) { if (!entries || entries.length === 0) { return '
No chat history recorded.
'; } const sorted = [...entries].sort((a, b) => (a.date || '').localeCompare(b.date || '')).reverse(); return sorted .map((item, idx) => { const parsed = parseHistoryTranscriptEntry(item); const transcriptMessages = parsed.messages || []; const messagesForRender = transcriptMessages.length ? transcriptMessages : [ ...(item.query ? [{ role: 'user', message: item.query, ts: item.date || '' }] : []), ...(item.response ? [{ role: 'assistant', message: item.response, ts: item.date || '', model: item.model || '', duration_ms: item.duration_ms, }] : []), ]; // Backfill assistant duration from entry-level latency when transcript // messages are missing duration_ms (older/mixed history payloads). const normalizedMessages = messagesForRender.map((msg) => (msg && typeof msg === 'object') ? { ...msg } : msg ); if (item.duration_ms != null) { normalizedMessages.forEach((msg) => { const role = (msg?.role || msg?.type || '').toString().toLowerCase(); if ((role === 'assistant' || role === 'user') && (msg.duration_ms == null || msg.duration_ms === '')) { msg.duration_ms = item.duration_ms; } }); } const date = escapeHtml(item.date || ''); const firstUser = transcriptMessages.find((m) => (m.role || '').toString().toLowerCase() === 'user'); const previewRaw = (firstUser?.message || item.query || '').split('\n')[0] || ''; let preview = escapeHtml(previewRaw); if (preview.length > 80) preview = preview.slice(0, 80) + '...'; const transcriptHtml = renderTranscriptHtml(normalizedMessages); const devEditDisplay = isDeveloperModeActive() ? '' : 'display:none;'; const editable = getHistoryEditableFields(item); const editableDate = escapeHtml(item.date || ''); const editableQuery = escapeHtml(editable.query || ''); const editableResponse = escapeHtml(editable.response || ''); return `
dev:crew-history-entry ${date || 'Entry'}${preview ? ' — ' + preview : ''}
`; }) .join(''); } /** * Render a collapsible section containing grouped history entries. * * @param {string} label - Section header label (e.g., "Smith, John Log") * @param {Array} entries - History entries for this section * @param {boolean} defaultOpen - Whether section starts expanded (default: true) * @returns {string} HTML markup for the entire section */ function renderHistorySection(label, entries, defaultOpen = true) { const bodyStyle = defaultOpen ? 'display:block;' : ''; const arrow = defaultOpen ? '▾' : '▸'; const count = Array.isArray(entries) ? entries.length : 0; const countLabel = count ? ` (${count} ${count === 1 ? 'entry' : 'entries'})` : ''; return `
dev:crew-history-section ${arrow} ${escapeHtml(label)}${countLabel}
${renderHistoryEntries(entries)}
`; } /** * Auto-open Ethan Buchan's consultation log section and first entry. * * This is used for the initial Consultation Log tab experience in demo mode. * Returns true when the target section was found (opened or already open). */ function autoOpenEthanConsultationLog() { const medicalContainer = document.getElementById('crew-medical-list'); if (!medicalContainer) return false; const sectionHeaders = Array.from(medicalContainer.querySelectorAll('.history-item > .col-header')); const ethanHeader = sectionHeaders.find((header) => { const text = (header.textContent || '').toLowerCase(); return text.includes('ethan') && text.includes('buchan'); }); if (!ethanHeader) return false; const ethanBody = ethanHeader.nextElementSibling; if (!ethanBody) return false; if (ethanBody.style.display !== 'block') { toggleCrewSection(ethanHeader); } const firstEntryHeader = ethanBody.querySelector('.collapsible .col-header'); if (firstEntryHeader) { const firstEntryBody = firstEntryHeader.nextElementSibling; if (firstEntryBody && firstEntryBody.style.display !== 'block') { toggleLogEntry(firstEntryHeader); } } return true; } /** * Retrieve chat history entries for a specific crew member. * * Tries multiple lookup strategies to find matching history: * 1. Full name from getCrewFullName() * 2. Legacy name field * 3. Constructed name from firstName/lastName * 4. Patient ID lookup (id:xxx) * * @param {Object} p - Crew member object * @param {Object} historyMap - Map from groupHistoryByPatient() * @returns {Array} Array of history entries or empty array */ function getHistoryForCrew(p, historyMap) { const keys = []; const fullName = getCrewFullName(p); if (fullName) appendHistoryKey(keys, fullName); if (p.name) appendHistoryKey(keys, p.name); if (p.firstName || p.middleName || p.lastName) { appendHistoryKey(keys, `${p.firstName || ''} ${p.middleName || ''} ${p.lastName || ''}`.trim()); } if (p.firstName || p.lastName) appendHistoryKey(keys, `${p.firstName || ''} ${p.lastName || ''}`.trim()); if (p.id != null && String(p.id).trim()) appendHistoryKey(keys, `id:${String(p.id).trim()}`); for (const k of keys) { if (historyMap[k]) return historyMap[k]; const normalized = normalizePatientName(k); if (normalized && historyMap[`name:${normalized}`]) return historyMap[`name:${normalized}`]; } if (p.firstName && p.lastName) { const firstNorm = normalizePatientName(p.firstName); const lastNorm = normalizePatientName(p.lastName); if (firstNorm && lastNorm) { const merged = []; Object.entries(historyMap).forEach(([key, entries]) => { if (!key.startsWith('name:')) return; const label = key.slice(5); if (label.includes(firstNorm) && label.includes(lastNorm)) { merged.push(...(Array.isArray(entries) ? entries : [])); } }); if (merged.length) { const seen = new Set(); return merged.filter((entry) => { const id = String(entry?.id || ''); if (!id) return true; if (seen.has(id)) return false; seen.add(id); return true; }); } } } return []; } /** * Get vaccine type options from settings or defaults. * * Normalizes and deduplicates vaccine types, preferring settings values * when available. Case-insensitive duplicate detection ensures clean lists. * * @param {Object} settings - Settings object with optional vaccine_types array * @returns {Array} Normalized, deduplicated vaccine type names */ function getVaccineOptions(settings = {}) { const raw = Array.isArray(settings.vaccine_types) ? settings.vaccine_types : DEFAULT_VACCINE_TYPES; const seen = new Set(); return raw .map((v) => (typeof v === 'string' ? v.trim() : '')) .filter((v) => !!v) .filter((v) => { const key = v.toLowerCase(); if (seen.has(key)) return false; seen.add(key); return true; }); } /** * Render detailed information for a single vaccine record. * * Displays all available fields for a vaccine entry including dates, * manufacturer info, batch numbers, provider details, and reactions. * Only shows fields that have values. * * @param {Object} v - Vaccine record object * @returns {string} HTML markup with labeled field details */ function renderVaccineDetails(v) { const fields = [ ['Vaccine Type/Disease', v.vaccineType], ['Date Administered', v.dateAdministered], ['Dose Number', v.doseNumber], ['Next Dose Due Date', v.nextDoseDue], ['Trade Name & Manufacturer', v.tradeNameManufacturer], ['Lot/Batch Number', v.lotNumber], ['Administering Clinic/Provider', v.provider], ['Clinic/Provider Country', v.providerCountry], ['Expiration Date (dose)', v.expirationDate], ['Site & Route', v.siteRoute], ['Allergic Reactions', v.reactions], ['Remarks', v.remarks], ]; const rows = fields .filter(([, val]) => val) .map(([label, val]) => `
${escapeHtml(label)}: ${escapeHtml(val)}
`) .join(''); return rows || '
No details recorded for this dose.
'; } /** * Render a list of vaccine records for a crew member. * * Each vaccine is shown as a collapsible card with: * - Header: vaccine type and date administered * - Body: Full details when expanded * - Delete button (visible when expanded) * * @param {Array} vaccines - Array of vaccine records * @param {string} crewId - Crew member ID for scoping delete operations * @returns {string} HTML markup for vaccine list or "no vaccines" message */ function renderVaccineList(vaccines = [], crewId) { if (!Array.isArray(vaccines) || vaccines.length === 0) { return '
No vaccines recorded.
'; } return vaccines .map((v) => { const vid = escapeHtml(v.id || ''); const label = escapeHtml(v.vaccineType || 'Vaccine'); const date = escapeHtml(v.dateAdministered || ''); return `
dev:crew-vax-entry ${label}${date ? ' — ' + date : ''}
`; }) .join(''); } /** * Clear all vaccine input fields after successful add operation. * Also hides the "other" vaccine type input field. * * @param {string} crewId - Crew member ID for field identification */ function clearVaccineInputs(crewId) { const ids = [ `vx-type-${crewId}`, `vx-type-other-${crewId}`, `vx-date-${crewId}`, `vx-dose-${crewId}`, `vx-trade-${crewId}`, `vx-lot-${crewId}`, `vx-provider-${crewId}`, `vx-provider-country-${crewId}`, `vx-next-${crewId}`, `vx-exp-${crewId}`, `vx-site-${crewId}`, `vx-remarks-${crewId}`, ]; ids.forEach((id) => { const el = document.getElementById(id); if (el) { el.value = ''; if (id.includes('type-other')) { el.style.display = 'none'; } } }); const rx = document.getElementById(`vx-reactions-${crewId}`); if (rx) rx.value = ''; const typeSelect = document.getElementById(`vx-type-${crewId}`); if (typeSelect) typeSelect.value = ''; } /** * Handle vaccine type dropdown change to show/hide "Other" input field. * * When "__other__" option is selected, displays a text input for custom * vaccine types. Otherwise hides it and clears any custom value. * * @param {string} crewId - Crew member ID for field identification */ function handleVaccineTypeChange(crewId) { const select = document.getElementById(`vx-type-${crewId}`); const other = document.getElementById(`vx-type-other-${crewId}`); if (!select || !other) return; const showOther = select.value === '__other__'; other.style.display = showOther ? 'block' : 'none'; if (!showOther) { other.value = ''; } else { other.focus(); } } /** * Primary data loading and rendering function for crew management. * * This is the main controller function that orchestrates rendering of: * - Patient selection dropdown in chat interface * - Crew medical history list with chat logs * - Crew information list with editable profiles * - Vaccine tracking sections * * Data Flow: * 1. Receives crew data, history, and settings from main loadData() * 2. Groups history by patient for efficient lookup * 3. Sorts crew based on user preference (last name, first name, age) * 4. Updates chat system's patient selector dropdown * 5. Renders medical histories with grouped chat logs * 6. Renders editable crew profile cards with all fields * 7. Renders vaccine tracking UI for each crew member * * Called By: * - main.js loadData() on initial page load * - main.js loadData() after any crew data changes * - Internally after crew additions/deletions * * @param {Array} data - Array of crew member objects * @param {Array} history - Array of chat history entries * @param {Object} settings - Settings object with vaccine_types, etc. */ function loadCrewData(data, history = [], settings = {}) { if (!Array.isArray(data)) { console.warn('loadCrewData expected array, got', data); return; } historyStore = Array.isArray(history) ? history : []; historyStoreById = {}; const historyMap = groupHistoryByPatient(historyStore); // Sort crew data const sortBy = document.getElementById('crew-sort')?.value || 'last'; data.sort((a, b) => { if (sortBy === 'last') { const aLast = a.lastName || a.name || ''; const bLast = b.lastName || b.name || ''; return aLast.localeCompare(bLast); } else if (sortBy === 'first') { const aFirst = a.firstName || a.name || ''; const bFirst = b.firstName || b.name || ''; return aFirst.localeCompare(bFirst); } else if (sortBy === 'birthdate-asc') { // Youngest first = most recent birthdates first const aDate = a.birthdate || '0000-01-01'; const bDate = b.birthdate || '0000-01-01'; return bDate.localeCompare(aDate); } else if (sortBy === 'birthdate-desc') { // Oldest first = earliest birthdates first const aDate = a.birthdate || '9999-12-31'; const bDate = b.birthdate || '9999-12-31'; return aDate.localeCompare(bDate); } return 0; }); // Update patient select dropdown const pSelect = document.getElementById('p-select'); if (pSelect) { let storedValue = null; try { storedValue = localStorage.getItem(CREW_LAST_PATIENT_KEY); } catch (err) { // Ignore storage read issues; fallback selection is handled below. } const options = data .map((p) => { const fullName = escapeHtml(getCrewFullName(p) || 'Unnamed Crew'); const value = escapeHtml(p.id || ''); return ``; }) .join(''); pSelect.innerHTML = `` + options; const hasPrevId = storedValue && Array.from(pSelect.options).some(opt => opt.value === storedValue); if (hasPrevId) { pSelect.value = storedValue; } else if (storedValue) { const matchingByText = Array.from(pSelect.options).find(opt => opt.textContent === storedValue); if (matchingByText) { pSelect.value = matchingByText.value || ''; } else { pSelect.value = ''; } } else { pSelect.value = ''; } pSelect.onchange = (e) => { try { localStorage.setItem(CREW_LAST_PATIENT_KEY, e.target.value); } catch (err) { /* ignore */ } if (typeof refreshPromptPreview === 'function') { refreshPromptPreview(); } }; if (typeof refreshPromptPreview === 'function') { refreshPromptPreview(); } } // Medical histories list const medicalContainer = document.getElementById('crew-medical-list'); if (medicalContainer) { const matchedHistoryIds = new Set(); const medicalBlocks = data.map(p => { const displayName = getCrewDisplayName(p); const crewHistory = getHistoryForCrew(p, historyMap); crewHistory.forEach((entry) => { const entryId = String(entry?.id || '').trim(); if (entryId) matchedHistoryIds.add(entryId); }); const historyCount = Array.isArray(crewHistory) ? crewHistory.length : 0; const countLabel = historyCount ? ` (${historyCount} ${historyCount === 1 ? 'entry' : 'entries'})` : ''; return `
dev:crew-entry ${displayName}${countLabel}
${renderHistoryEntries(crewHistory)}
`; }); // Add pseudo entry for unnamed/unassigned history if (historyMap['Unnamed Crew']) { historyMap['Unnamed Crew'].forEach((entry) => { const entryId = String(entry?.id || '').trim(); if (entryId) matchedHistoryIds.add(entryId); }); medicalBlocks.push(renderHistorySection('Unnamed Crew Log', historyMap['Unnamed Crew'], true)); } const unmatchedHistory = historyStore.filter((entry) => { const entryId = String(entry?.id || '').trim(); return !entryId || !matchedHistoryIds.has(entryId); }); if (unmatchedHistory.length) { medicalBlocks.push(renderHistorySection('All Consultation Entries', unmatchedHistory, true)); } medicalContainer.innerHTML = `
dev:crew-medical-wrapper
${medicalBlocks.join('')} `; // Apply one-time Consultation Log startup preference after each render pass. // This keeps Ethan's section open even if a late rerender occurs. if (window.__SMA_PENDING_ETHAN_AUTO_OPEN) { try { if (autoOpenEthanConsultationLog()) { window.__SMA_PENDING_ETHAN_AUTO_OPEN = false; if (typeof window.__SMA_MARK_ETHAN_AUTO_OPENED === 'function') { window.__SMA_MARK_ETHAN_AUTO_OPENED(); } } } catch (err) { console.warn('Unable to apply pending Ethan auto-open after render:', err); } } } // Vessel & crew info list const infoContainer = document.getElementById('crew-info-list'); if (infoContainer) { infoContainer.innerHTML = `
dev:crew-info-list
` + data.map(p => { const displayName = getCrewDisplayName(p); const ageStr = calculateAge(p.birthdate); const posInfo = p.position ? ` • ${p.position}` : ''; const info = `${displayName}${ageStr}${posInfo}`; const vaccines = Array.isArray(p.vaccines) ? p.vaccines : []; const vaccineOptions = getVaccineOptions(settings); const vaccineOptionMarkup = vaccineOptions.map((opt) => ``).join(''); const vaccineList = renderVaccineList(vaccines, p.id); // Check if crew has data to determine default collapse state const hasData = p.firstName && p.lastName && p.citizenship; return `
dev:crew-profile ${info}
`}).join(''); } } // Expose for cross-file calls (main.js/chat.js) that reference this loader. window.loadCrewData = loadCrewData; window.toggleLogEntry = toggleLogEntry; window.exportHistoryItemById = exportHistoryItemById; window.toggleHistoryEntryEditor = toggleHistoryEntryEditor; window.saveHistoryItemById = saveHistoryItemById; window.deleteHistoryItemById = deleteHistoryItemById; window.autoOpenEthanConsultationLog = autoOpenEthanConsultationLog; /** * Add a new crew member from the "Add New Crew Member" form. * * Validation: * - First name and last name are required * - All other fields are optional * * After successful addition: * - Clears all form fields * - Calls loadData() to refresh UI * - New crew appears in all crew lists and patient selector * * Data Structure Created: * ```javascript * { * id: string, // Timestamp-based unique ID * firstName: string, // Required * middleName: string, * lastName: string, // Required * sex: string, * birthdate: string, // ISO date * position: string, // Captain | Crew | Passenger * citizenship: string, * birthplace: string, * passportNumber: string, * passportIssue: string, * passportExpiry: string, * emergencyContactName: string, * emergencyContactRelation: string, * emergencyContactPhone: string, * emergencyContactEmail: string, * emergencyContactNotes: string, * phoneNumber: string, * vaccines: [], // Empty array, vaccines added separately * passportHeadshot: '', // Base64 or URL, uploaded separately * passportPage: '', // Base64 or URL, uploaded separately * history: '' // Medical notes, edited separately * } * ``` */ async function addCrew() { const firstName = document.getElementById('cn-first').value.trim(); const middleName = document.getElementById('cn-middle').value.trim(); const lastName = document.getElementById('cn-last').value.trim(); const sex = document.getElementById('cn-sex').value; const birthdate = document.getElementById('cn-birthdate').value; const position = document.getElementById('cn-position').value; const citizenship = document.getElementById('cn-citizenship').value.trim(); const birthplace = document.getElementById('cn-birthplace').value.trim(); const passportNumber = document.getElementById('cn-passport').value.trim(); const passportIssue = document.getElementById('cn-pass-issue').value; const passportExpiry = document.getElementById('cn-pass-expiry').value; const emergencyContactName = document.getElementById('cn-emerg-name').value.trim(); const emergencyContactRelation = document.getElementById('cn-emerg-rel').value.trim(); const emergencyContactPhone = document.getElementById('cn-emerg-phone').value.trim(); const emergencyContactEmail = document.getElementById('cn-emerg-email').value.trim(); const emergencyContactNotes = document.getElementById('cn-emerg-notes').value.trim(); const phoneNumber = document.getElementById('cn-phone').value.trim(); // Validate required fields if (!firstName || !lastName) { alert('Please enter first name and last name'); return; } // All other fields are optional const data = await (await fetch('/api/data/patients', {credentials:'same-origin'})).json(); data.push({ id: Date.now().toString(), firstName: firstName, middleName: middleName, lastName: lastName, sex: sex, birthdate: birthdate, position: position, citizenship: citizenship, birthplace: birthplace, passportNumber: passportNumber, passportIssue: passportIssue, passportExpiry: passportExpiry, emergencyContactName: emergencyContactName, emergencyContactRelation: emergencyContactRelation, emergencyContactPhone: emergencyContactPhone, emergencyContactEmail: emergencyContactEmail, emergencyContactNotes: emergencyContactNotes, phoneNumber: phoneNumber, vaccines: [], passportHeadshot: '', passportPage: '', history: '' }); await fetch('/api/data/patients', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(data), credentials:'same-origin'}); // Clear form document.getElementById('cn-first').value = ''; document.getElementById('cn-middle').value = ''; document.getElementById('cn-last').value = ''; document.getElementById('cn-sex').value = ''; document.getElementById('cn-birthdate').value = ''; document.getElementById('cn-position').value = ''; document.getElementById('cn-citizenship').value = ''; document.getElementById('cn-birthplace').value = ''; document.getElementById('cn-passport').value = ''; document.getElementById('cn-pass-issue').value = ''; document.getElementById('cn-pass-expiry').value = ''; document.getElementById('cn-emerg-name').value = ''; document.getElementById('cn-emerg-rel').value = ''; document.getElementById('cn-emerg-phone').value = ''; document.getElementById('cn-emerg-email').value = ''; document.getElementById('cn-emerg-notes').value = ''; document.getElementById('cn-phone').value = ''; document.getElementById('cn-passport-photo').value = ''; document.getElementById('cn-passport-page').value = ''; loadData(); } /** * Add a new vaccine record to a crew member's profile. * * Validation: * - Vaccine type/disease is required (either from dropdown or custom input) * - All other fields are optional * * The vaccine record is sent to the backend API which appends it to the * crew member's vaccines array. After successful save, the form is cleared * and the UI is refreshed to show the new vaccine entry. * * Vaccine Record Structure: * ```javascript * { * id: string, // "vax-[timestamp]" * vaccineType: string, // Required * dateAdministered: string, * doseNumber: string, // e.g., "Dose 1 of 3" * tradeNameManufacturer: string, * lotNumber: string, * provider: string, // Clinic/provider name * providerCountry: string, * nextDoseDue: string, * expirationDate: string, // Expiry of the dose * siteRoute: string, // e.g., "Left Arm - IM" * reactions: string, // Allergic reactions or side effects * remarks: string // Additional notes * } * ``` * * @param {string} crewId - The crew member's unique ID */ async function addVaccine(crewId) { const getVal = (suffix) => document.getElementById(`vx-${suffix}-${crewId}`)?.value.trim() || ''; const typeSelect = document.getElementById(`vx-type-${crewId}`); const selectedType = typeSelect ? typeSelect.value : ''; const otherVal = getVal('type-other'); const vaccineType = selectedType === '__other__' ? otherVal : selectedType; if (!vaccineType) { alert('Please enter Vaccine Type/Disease'); if (selectedType === '__other__') { const otherField = document.getElementById(`vx-type-other-${crewId}`); if (otherField) otherField.focus(); } else if (typeSelect) { typeSelect.focus(); } return; } const entry = { id: `vax-${Date.now()}`, vaccineType, dateAdministered: getVal('date'), doseNumber: getVal('dose'), tradeNameManufacturer: getVal('trade'), lotNumber: getVal('lot'), provider: getVal('provider'), providerCountry: getVal('provider-country'), nextDoseDue: getVal('next'), expirationDate: getVal('exp'), siteRoute: getVal('site'), reactions: document.getElementById(`vx-reactions-${crewId}`)?.value.trim() || '', remarks: document.getElementById(`vx-remarks-${crewId}`)?.value.trim() || '' }; try { const res = await fetch('/api/crew/vaccine', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ crew_id: crewId, vaccine: entry }) }); const text = await res.text(); if (!res.ok) throw new Error(text || `Status ${res.status}`); } catch (err) { alert(`Failed to save vaccine: ${err.message}`); return; } clearVaccineInputs(crewId); loadData(); } /** * Delete a vaccine record from a crew member's profile. * * Single confirmation dialog before deletion. After successful delete, * refreshes the UI to remove the vaccine entry from display. * * @param {string} crewId - The crew member's unique ID * @param {string} vaccineId - The vaccine record's unique ID */ async function deleteVaccine(crewId, vaccineId) { if (!vaccineId) return; if (!confirm('Delete this vaccine record?')) return; try { const res = await fetch(`/api/crew/vaccine/${crewId}/${vaccineId}`, { method: 'DELETE', credentials: 'same-origin' }); const text = await res.text(); if (!res.ok) throw new Error(text || `Status ${res.status}`); } catch (err) { alert(`Failed to delete vaccine: ${err.message}`); return; } loadData(); } /** * Auto-save crew profile changes with debouncing. * * Debouncing Strategy: * - Waits 1 second after last change before saving * - Each crew member has independent save timer * - Prevents excessive API calls during rapid typing * * After Save: * - Updates chat system patient dropdown if name changed * - Refreshes prompt preview in chat if needed * - Logs confirmation to console * * Fields Saved: * All profile fields including names, dates, passport info, emergency contacts, * and medical history notes. Document uploads are handled separately. * * @param {string} id - Crew member's unique ID */ let saveTimers = {}; async function autoSaveProfile(id) { // Clear any existing timer for this crew member if (saveTimers[id]) { clearTimeout(saveTimers[id]); } // Set new timer to save after 1 second of no changes saveTimers[id] = setTimeout(async () => { const data = await (await fetch('/api/data/patients', {credentials:'same-origin'})).json(); const patient = data.find(p => p.id === id); const val = (prefix) => { const el = document.getElementById(prefix + id); return el ? el.value : undefined; }; if (patient) { const setters = { firstName: val('fn-'), middleName: val('mn-'), lastName: val('ln-'), sex: val('sx-'), birthdate: val('bd-'), position: val('pos-'), citizenship: val('cit-'), birthplace: val('bp-'), passportNumber: val('pass-'), passportIssue: val('piss-'), passportExpiry: val('pexp-'), emergencyContactName: val('ename-'), emergencyContactRelation: val('erel-'), emergencyContactPhone: val('ephone-'), emergencyContactEmail: val('eemail-'), emergencyContactNotes: val('enotes-'), phoneNumber: val('phone-'), history: val('h-') }; Object.entries(setters).forEach(([k,v]) => { if (v !== undefined) patient[k] = k === 'history' ? v : (typeof v === 'string' ? v.trim() : v); }); await fetch('/api/data/patients', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(data), credentials:'same-origin'}); const pSelect = document.getElementById('p-select'); if (pSelect) { const option = pSelect.querySelector(`option[value="${id}"]`); if (option) { const displayName = getCrewFullName(patient) || 'Unnamed Crew'; option.textContent = displayName; if (pSelect.value === id) { try { localStorage.setItem(CREW_LAST_PATIENT_KEY, id); } catch (err) { /* ignore */ } } } } if (typeof refreshPromptPreview === 'function') { refreshPromptPreview(); } } }, 1000); } /** * Copy emergency contact information from one crew member to another. * * Useful Feature: * Family members traveling together often share the same emergency contact. * This function copies all emergency contact fields and auto-saves. * * Important: * Shows alert reminding user to check the RELATIONSHIP field, as it likely * needs adjustment (e.g., "spouse" for one person might be "parent" for another). * * Fields Copied: * - Emergency contact name * - Relationship * - Phone number * - Email address * - Notes * * @param {string} targetId - Crew member receiving the copied contact info * @param {string} sourceId - Crew member whose contact info to copy from */ async function copyEmergencyContact(targetId, sourceId) { if (!sourceId) return; const data = await (await fetch('/api/data/patients', {credentials:'same-origin'})).json(); const source = data.find(p => p.id === sourceId); if (source) { document.getElementById('ename-' + targetId).value = source.emergencyContactName || ''; document.getElementById('erel-' + targetId).value = source.emergencyContactRelation || ''; document.getElementById('ephone-' + targetId).value = source.emergencyContactPhone || ''; document.getElementById('eemail-' + targetId).value = source.emergencyContactEmail || ''; document.getElementById('enotes-' + targetId).value = source.emergencyContactNotes || ''; // Auto-save after copy await autoSaveProfile(targetId); alert(`Emergency contact copied from ${getCrewFullName(source)}.\n\n⚠️ Please check the RELATIONSHIP field - it may need to be updated for this crew member!`); } } /** * Upload and store a document (passport photo or passport page) for a crew member. * * Process: * 1. Validates file size (max 5MB) * 2. Converts file to base64 data URL using FileReader * 3. Sends to backend API for storage in crew record * 4. Refreshes UI to display uploaded document * * Storage: * Documents are stored as base64-encoded strings directly in the crew member's * JSON record. Images display inline; PDFs show as links. * * Limitations: * - 5MB max file size * - Base64 storage increases data size by ~33% * - Large documents may cause performance issues with JSON parsing * * @param {string} id - Crew member's unique ID * @param {string} fieldName - Field name ("passportHeadshot" or "passportPage") * @param {HTMLInputElement} inputElement - The file input element */ async function uploadDocument(id, fieldName, inputElement) { const file = inputElement.files[0]; if (!file) return; // Validate file size (max 5MB) if (file.size > 5 * 1024 * 1024) { alert('File size must be less than 5MB'); inputElement.value = ''; return; } // Convert to base64 const reader = new FileReader(); reader.onload = async function(e) { const base64Data = e.target.result; try { localStorage.setItem('sailingmed:lastOpenCrew', id); } catch (err) { /* ignore */ } try { const resp = await fetch('/api/crew/photo', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({id, field: fieldName, data: base64Data}), credentials:'same-origin' }); const txt = await resp.text(); if (!resp.ok) { console.warn('[crew] uploadDocument failed', resp.status, txt.slice(0,200)); alert('Upload failed. Please try again.'); return; } if (typeof loadData === 'function') { await loadData(); // Refresh to show the new document } expandCrewInfoCard(id); } catch (err) { console.warn('[crew] uploadDocument error', err); alert('Upload failed. Please try again.'); } }; reader.readAsDataURL(file); } /** * Delete a document (passport photo or passport page) from crew record. * * Single confirmation dialog before deletion. Sets the document field to * empty string and refreshes UI to remove display. * * @param {string} id - Crew member's unique ID * @param {string} fieldName - Field name ("passportHeadshot" or "passportPage") */ async function deleteDocument(id, fieldName) { if (!confirm('Are you sure you want to delete this document?')) return; try { const resp = await fetch('/api/crew/photo', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({id, field: fieldName, data: ''}), credentials:'same-origin' }); const txt = await resp.text(); if (!resp.ok) { console.warn('[crew] delete document failed', resp.status, txt.slice(0,200)); alert('Delete failed. Please try again.'); return; } try { localStorage.setItem('sailingmed:lastOpenCrew', id); } catch (err) { /* ignore */ } if (typeof loadData === 'function') { await loadData(); } expandCrewInfoCard(id); } catch (err) { console.warn('[crew] delete document error', err); alert('Delete failed. Please try again.'); } } /** * Ensure a specific crew info card is expanded after a UI refresh. * Used after document upload/delete so users stay in-context. * * @param {string} crewId - Crew member unique ID * @returns {boolean} True when card was found and expanded */ function expandCrewInfoCard(crewId) { if (!crewId) return false; const infoContainer = document.getElementById('crew-info-list'); if (!infoContainer) return false; const cards = infoContainer.querySelectorAll('.collapsible[data-crew-id]'); const card = Array.from(cards).find((node) => node.getAttribute('data-crew-id') === crewId); if (!card) return false; const header = card.querySelector('.col-header'); const body = header ? header.nextElementSibling : null; if (!header || !body) return false; body.style.display = 'block'; const icon = header.querySelector('.toggle-label'); if (icon) icon.textContent = '▾'; header.querySelectorAll('.history-action-btn').forEach((btn) => { btn.style.visibility = 'visible'; }); return true; } /** * Import medical history data from a text file into crew member's notes. * * Use Case: * Allows importing pre-existing medical records, doctor's notes, or * previous health history from external text files. * * Behavior: * - Appends imported content to existing medical history notes * - Separates with double newlines for readability * - User must manually save after reviewing import * * @param {string} id - Crew member's unique ID */ async function importCrewData(id) { const input = document.createElement('input'); input.type = 'file'; input.accept = '.txt'; input.onchange = async (e) => { const file = e.target.files[0]; if (!file) return; const text = await file.text(); const textarea = document.getElementById('h-' + id); // Append with newline separator const currentContent = textarea.value.trim(); textarea.value = currentContent ? currentContent + '\n\n' + text : text; alert(`Imported ${file.name} successfully! Don't forget to Save.`); }; input.click(); } /** * Export individual crew member's medical history to text file. * * Exports only the medical history/notes field. For complete crew profiles * including passport info and emergency contacts, use exportCrewList(). * * Filename Format: crew_[Name]_[YYYY-MM-DD].txt * * @param {string} id - Crew member's unique ID * @param {string} name - Crew member's name for filename */ function exportCrew(id, name) { const textarea = document.getElementById('h-' + id); const content = textarea.value; if (!content.trim()) { alert('No data to export for ' + name); return; } const date = new Date().toISOString().split('T')[0]; const filename = `crew_${name.replace(/[^a-z0-9]/gi, '_')}_${date}.txt`; const blob = new Blob([content], { type: 'text/plain' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); alert(`Exported ${name}'s data to ${filename}`); } /** * Export all crew members' intake information to a single text file. * * Creates a formatted document with: * - Header with export date * - Each crew member's complete history notes * - Separator lines between entries * * Filename Format: all_crew_[YYYY-MM-DD].txt * * Use Case: * Creating backup of all crew medical information for offline access * or transfer to shore-based medical facility. */ async function exportAllCrew() { const data = await (await fetch('/api/data/patients', {credentials:'same-origin'})).json(); if (data.length === 0) { alert('No crew data to export'); return; } const date = new Date().toISOString().replace('T', ' ').substring(0, 19); const fileDate = new Date().toISOString().split('T')[0]; let content = '==========================================\n'; content += 'CREW INTAKE INFORMATION EXPORT\n'; content += `Date: ${date}\n`; content += '==========================================\n\n'; data.forEach((crew, index) => { content += `--- ${crew.name} ---\n`; content += (crew.history || '(No data recorded)') + '\n'; if (index < data.length - 1) content += '\n'; }); const filename = `all_crew_${fileDate}.txt`; const blob = new Blob([content], { type: 'text/plain' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); alert(`Exported all crew data (${data.length} members) to ${filename}`); } /** * Export all crew medical histories to a single text file. * * Similar to exportAllCrew() but specifically labeled as "MEDICAL HISTORY EXPORT". * Contains the same data (history notes for each crew member). * * Filename Format: all_crew_medical_[YYYY-MM-DD].txt */ async function exportAllMedical() { const data = await (await fetch('/api/data/patients', {credentials:'same-origin'})).json(); if (data.length === 0) { alert('No crew data to export'); return; } const date = new Date().toISOString().replace('T', ' ').substring(0, 19); const fileDate = new Date().toISOString().split('T')[0]; let content = '==========================================\n'; content += 'CREW MEDICAL HISTORY EXPORT\n'; content += `Date: ${date}\n`; content += '==========================================\n\n'; data.forEach((crew, index) => { const name = getCrewFullName(crew); content += `--- ${name} ---\n`; content += (crew.history || '(No history recorded)') + '\n'; if (index < data.length - 1) content += '\n'; }); const filename = `all_crew_medical_${fileDate}.txt`; const blob = new Blob([content], { type: 'text/plain' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); alert(`Exported all crew medical histories (${data.length} members) to ${filename}`); } /** * Delete a crew member with double confirmation process. * * Two-Step Confirmation: * 1. Standard confirm dialog with warning message * 2. Type "DELETE" prompt for final confirmation * * Safety: * - Requires exact text "DELETE" (all caps) * - Shows multiple warnings about permanent data loss * - Cancels if confirmation text doesn't match * * After Deletion: * - Removes from patients.json * - Refreshes all crew displays * - Shows confirmation alert * * Note: This does NOT delete associated chat history, which remains in * history.json but becomes orphaned. * * @param {string} category - Data category ("patients") * @param {string} id - Crew member's unique ID * @param {string} name - Crew member's name for confirmation messages */ async function deleteCrewMember(category, id, name) { // First warning if (!confirm(`Are you sure you want to delete ${name}'s intake information?`)) return; // Second warning with typed confirmation const confirmText = prompt( `⚠️ FINAL WARNING: This action CANNOT be undone.\n\n` + `All data for ${name} will be permanently deleted.\n\n` + `Type "DELETE" (all caps) to confirm:` ); if (confirmText !== 'DELETE') { alert('Deletion cancelled. Confirmation text did not match.'); return; } // Proceed with deletion const data = await (await fetch(`/api/data/${category}`, {credentials:'same-origin'})).json(); const filtered = data.filter(item => item.id !== id); await fetch(`/api/data/${category}`, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(filtered), credentials:'same-origin'}); loadData(); alert(`${name}'s data has been permanently deleted.`); } /** * Export crew list in CSV format for official use (border crossings, port authorities). * * CSV Structure: * - Header with vessel name and date * - Table with columns: No., Name, Birth Date, Position, Citizenship, Birthplace, * Passport No., Issue Date, Expiry Date * - Footer with captain name and signature line * * Use Case: * Required documentation for: * - International border crossings * - Port authority check-ins * - Maritime customs declarations * - Crew change documentation * * Filename Format: crew_list_[YYYY-MM-DD].csv */ async function exportCrewList() { const data = await (await fetch('/api/data/patients', {credentials:'same-origin'})).json(); const vessel = await (await fetch('/api/data/vessel', {credentials:'same-origin'})).json(); if (data.length === 0) { alert('No crew data to export'); return; } const date = new Date().toISOString().split('T')[0]; const vesselName = vessel.vesselName || '[Vessel Name]'; // Find captain const captain = data.find(c => c.position === 'Captain'); const captainName = captain ? getCrewFullName(captain) : '[Captain Name]'; // Build CSV content let csv = `CREW LIST\n`; csv += `Vessel: ${vesselName}\n`; csv += `Date: ${date}\n\n`; csv += `No.,Name,Birth Date,Position,Citizenship,Birthplace,Passport No.,Issue Date,Expiry Date\n`; data.forEach((crew, index) => { const name = getCrewFullName(crew); const bd = crew.birthdate || ''; const pos = crew.position || ''; const cit = crew.citizenship || ''; const birth = crew.birthplace || ''; const pass = crew.passportNumber || ''; const issue = crew.passportIssue || ''; const expiry = crew.passportExpiry || ''; csv += `${index + 1},${name},${bd},${pos},${cit},${birth},${pass},${issue},${expiry}\n`; }); csv += `\n\nCaptain: ${captainName}\n`; csv += `Signature: _________________________\n`; // Download const blob = new Blob([csv], { type: 'text/csv' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `crew_list_${date}.csv`; a.click(); URL.revokeObjectURL(url); alert(`Crew list exported to crew_list_${date}.csv`); } /** * Export immigration package ZIP (crew list, passport pages, vessel info). */ async function exportImmigrationZip() { try { const resp = await fetch('/api/export/immigration-zip', { credentials: 'same-origin' }); if (!resp.ok) { const txt = await resp.text(); alert(`Export failed (${resp.status}): ${txt || 'Unknown error'}`); return; } const blob = await resp.blob(); const cd = resp.headers.get('content-disposition') || ''; let filename = `immigration_export_${new Date().toISOString().split('T')[0]}.zip`; const utf8 = cd.match(/filename\*=UTF-8''([^;]+)/i); const plain = cd.match(/filename=\"?([^\";]+)\"?/i); try { if (utf8 && utf8[1]) filename = decodeURIComponent(utf8[1]); else if (plain && plain[1]) filename = plain[1]; } catch (err) { // Keep fallback filename if decode fails. } const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); alert(`Immigration zip exported to ${filename}`); } catch (err) { console.warn('[IMMIGRATION] export failed', err); alert('Export failed. Please try again.'); } } const VESSEL_INPUT_MAP = { 'vessel-name': 'vesselName', 'vessel-registration': 'registrationNumber', 'vessel-flag': 'flagCountry', 'vessel-homeport': 'homePort', 'vessel-callsign': 'callSign', 'vessel-tonnage': 'tonnage', 'vessel-net-tonnage': 'netTonnage', 'vessel-mmsi': 'mmsi', 'vessel-hull-number': 'hullNumber', 'vessel-starboard-engine': 'starboardEngine', 'vessel-starboard-sn': 'starboardEngineSn', 'vessel-port-engine': 'portEngine', 'vessel-port-sn': 'portEngineSn', 'vessel-rib-sn': 'ribSn', }; const VESSEL_PHOTO_FIELDS = { boatPhoto: { previewId: 'vessel-photo-preview-boatPhoto', label: 'Boat photo' }, registrationFrontPhoto: { previewId: 'vessel-photo-preview-registrationFrontPhoto', label: 'Registration front image' }, registrationBackPhoto: { previewId: 'vessel-photo-preview-registrationBackPhoto', label: 'Registration back image' }, }; /** * setVesselFieldValues: function-level behavior note for maintainers. * Keep this block synchronized with implementation changes. */ function setVesselFieldValues(payload, sourceLabel) { const setVal = (id, val) => { const el = document.getElementById(id); if (el) el.value = val || ''; }; Object.entries(VESSEL_INPUT_MAP).forEach(([id, key]) => { const val = payload?.[key] || ''; setVal(id, val); }); } /** * renderVesselPhotoPreview: function-level behavior note for maintainers. * Keep this block synchronized with implementation changes. */ function renderVesselPhotoPreview(field, dataUrl) { const meta = VESSEL_PHOTO_FIELDS[field]; if (!meta) return; const preview = document.getElementById(meta.previewId); if (!preview) return; const value = typeof dataUrl === 'string' ? dataUrl.trim() : ''; preview.innerHTML = ''; if (!value) { preview.textContent = 'No file uploaded.'; return; } if (value.startsWith('data:image/')) { const img = document.createElement('img'); img.src = value; img.alt = meta.label; img.onclick = () => window.open(value, '_blank', 'noopener'); preview.appendChild(img); return; } const link = document.createElement('a'); link.href = value; link.target = '_blank'; link.rel = 'noopener'; link.style.color = 'var(--inquiry)'; link.style.fontWeight = '700'; link.textContent = value.startsWith('data:application/pdf') ? 'View uploaded PDF' : 'View uploaded file'; preview.appendChild(link); } /** * renderAllVesselPhotoPreviews: function-level behavior note for maintainers. * Keep this block synchronized with implementation changes. */ function renderAllVesselPhotoPreviews(payload) { Object.keys(VESSEL_PHOTO_FIELDS).forEach((field) => { renderVesselPhotoPreview(field, payload?.[field] || ''); }); } async function uploadVesselPhoto(fieldName, inputElement) { if (!VESSEL_PHOTO_FIELDS[fieldName]) return; const file = inputElement?.files?.[0]; if (!file) return; if (file.size > 8 * 1024 * 1024) { alert('File size must be less than 8MB'); inputElement.value = ''; return; } const reader = new FileReader(); reader.onload = async (event) => { try { const resp = await fetch('/api/vessel/photo', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ field: fieldName, data: event.target.result || '' }), credentials: 'same-origin', }); if (!resp.ok) { console.warn('Vessel photo upload failed', resp.status); alert('Upload failed. Please try again.'); return; } const statusEl = document.getElementById('vessel-save-status'); if (statusEl) { statusEl.textContent = 'Saved image at ' + new Date().toLocaleTimeString(); statusEl.style.color = '#2e7d32'; setTimeout(() => { statusEl.textContent = ''; }, 4000); } await loadVesselInfo(); } catch (err) { console.warn('Vessel photo upload error', err); alert('Upload failed. Please try again.'); } finally { if (inputElement) inputElement.value = ''; } }; reader.readAsDataURL(file); } async function deleteVesselPhoto(fieldName) { const meta = VESSEL_PHOTO_FIELDS[fieldName]; if (!meta) return; if (!confirm(`Delete ${meta.label}?`)) return; try { const resp = await fetch('/api/vessel/photo', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ field: fieldName, data: '' }), credentials: 'same-origin', }); if (!resp.ok) { console.warn('Vessel photo delete failed', resp.status); alert('Delete failed. Please try again.'); return; } const statusEl = document.getElementById('vessel-save-status'); if (statusEl) { statusEl.textContent = 'Image removed'; statusEl.style.color = '#2e7d32'; setTimeout(() => { statusEl.textContent = ''; }, 3000); } await loadVesselInfo(); } catch (err) { console.warn('Vessel photo delete error', err); alert('Delete failed. Please try again.'); } } /** * Load vessel information from server and populate form fields. * * Two-Stage Loading: * 1. Uses server-rendered prefill data (window.VESSEL_PREFILL) if available * 2. Fetches latest data from API to ensure current values * * This dual approach provides instant UI population (from prefill) while * ensuring accuracy (from API fetch). * * Vessel Fields: * - vesselName, registrationNumber, flagCountry, homePort * - callSign, mmsi, hullNumber * - tonnage, netTonnage * - Engine info: starboardEngine, starboardEngineSn, portEngine, portEngineSn * - ribSn (tender/dinghy serial number) * * Integration: * Called by ensureVesselLoaded() on tab navigation to Vessel & Crew section. */ async function loadVesselInfo() { // 1) use preloaded data if present (server-rendered) if (window.VESSEL_PREFILL && typeof window.VESSEL_PREFILL === 'object') { const p = window.VESSEL_PREFILL; setVesselFieldValues(p, 'prefill'); renderAllVesselPhotoPreviews(p); } // 2) fetch latest from API (overwrites prefill) try { const resp = await fetch('/api/data/vessel', {credentials:'same-origin'}); const v = resp.ok ? await resp.json() : {}; setVesselFieldValues(v, 'API load'); renderAllVesselPhotoPreviews(v); window.VESSEL_PREFILL = v; } catch (err) { console.warn('Vessel info load failed', err); } } /** * Save vessel information to server. * * Process: * 1. Collects all vessel field values from form * 2. POSTs to /api/data/vessel endpoint * 3. Fetches back latest data to ensure UI matches database * 4. Shows save confirmation with timestamp * 5. Clears user-edited flags to allow future refreshes * * User Feedback: * Displays "Saved at [time]" message in green for 4 seconds. * * Called By: * - mouseleave events on vessel text fields with pending edits */ let vesselSaveInFlight = false; let vesselSaveQueued = false; async function saveVesselInfo() { // Serialize vessel saves: mouseleave can fire in quick succession as the // cursor moves across fields. Queue one follow-up save if needed. if (vesselSaveInFlight) { vesselSaveQueued = true; return; } vesselSaveInFlight = true; try { const v = {}; Object.entries(VESSEL_INPUT_MAP).forEach(([id, key]) => { v[key] = document.getElementById(id)?.value || ''; }); const resp = await fetch('/api/data/vessel', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(v), credentials:'same-origin'}); if (!resp.ok) { throw new Error(`Save failed (${resp.status})`); } const statusEl = document.getElementById('vessel-save-status'); if (statusEl) { statusEl.textContent = 'Saved at ' + new Date().toLocaleTimeString(); statusEl.style.color = '#2e7d32'; setTimeout(() => { statusEl.textContent = ''; }, 4000); } // After save, pull latest to ensure UI matches persisted state try { const latestResp = await fetch('/api/data/vessel', {credentials:'same-origin'}); const latest = latestResp.ok ? await latestResp.json() : {}; setVesselFieldValues(latest, 'post-save'); renderAllVesselPhotoPreviews(latest); window.VESSEL_PREFILL = latest; } catch (err) { console.warn('Vessel post-save refresh failed', err); } // Clear user-edited flags after successful save so subsequent loads can refresh Object.keys(VESSEL_INPUT_MAP).forEach(id => { const el = document.getElementById(id); if (el) el.dataset.userEdited = ''; }); } finally { vesselSaveInFlight = false; if (vesselSaveQueued) { vesselSaveQueued = false; saveVesselInfo().catch(err => console.warn('Vessel queued save failed', err)); } } } /** * Bind auto-save event listeners to all vessel input fields. * * Event Strategy: * - input: Marks field as edited * - mouseleave: Immediate save if field has pending edits * * This intentionally does NOT save on every keystroke/change event. Vessel * text fields persist only when the user leaves the field area with the mouse. * * Idempotent: * Uses data-vesselAutosave flag to prevent duplicate binding on repeated calls. */ function bindVesselAutosave() { const ids = Object.keys(VESSEL_INPUT_MAP); ids.forEach(id => { const el = document.getElementById(id); if (el && !el.dataset.vesselAutosave) { el.dataset.vesselAutosave = '1'; el.addEventListener('input', () => { el.dataset.userEdited = '1'; }); el.addEventListener('change', () => { el.dataset.userEdited = '1'; }); el.addEventListener('mouseleave', () => { if (el.dataset.userEdited) { saveVesselInfo().catch(err => console.warn('Vessel mouseleave save failed', err)); } }); } }); } /** * Ensure vessel information is loaded and auto-save is enabled. * * One-Time Initialization: * Uses vesselLoaded flag to prevent redundant setup on repeated tab navigation. * * Setup Process: * 1. Binds auto-save event listeners to all vessel fields * 2. Loads initial vessel data from server * 3. Exposes manual save helper on window object * * Manual Helper: * - window.forceSaveVessel() (manual save trigger) * * Called By: * Tab navigation handlers when user switches to Vessel & Crew tab. */ let vesselLoaded = false; async function ensureVesselLoaded() { if (vesselLoaded) return; vesselLoaded = true; bindVesselAutosave(); if (typeof loadVesselInfo === 'function') { try { await loadVesselInfo(); } catch (err) { console.warn('Initial vessel load failed', err); } } window.forceSaveVessel = saveVesselInfo; } // Expose for other scripts window.exportImmigrationZip = exportImmigrationZip; window.uploadVesselPhoto = uploadVesselPhoto; window.deleteVesselPhoto = deleteVesselPhoto; window.loadVesselInfo = loadVesselInfo; window.saveVesselInfo = saveVesselInfo; window.ensureVesselLoaded = ensureVesselLoaded; // // 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.