/* =============================================================================
* 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