Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 136 additions & 0 deletions js/dna-runtime.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// @ts-check
// dna-runtime.js - Browser runtime adapters for DNA import and shell refresh flows.

import { installDNAWindowBindings } from './dna-window-bindings.js';

function getRuntimeWindow() {
return typeof window !== 'undefined'
? /** @type {any} */ (window)
: /** @type {any} */ (globalThis);
}

/**
* @param {string} name
* @returns {Function | null}
*/
function getRuntimeFunction(name) {
const runtime = getRuntimeWindow();
return typeof runtime[name] === 'function' ? runtime[name].bind(runtime) : null;
}

/** @returns {boolean} */
function isDnaDebugMode() {
try {
return getRuntimeFunction('isDebugMode')?.() === true;
} catch {
return false;
}
}

/** @param {any} data */
export function cacheDnaSnpTable(data) {
getRuntimeWindow()._snpTableCache = data;
}

/** @param {...any} args */
export function logDnaDebugError(...args) {
if (isDnaDebugMode()) console.error(...args);
}

/** @param {...any} args */
export function logDnaDebugWarn(...args) {
if (isDnaDebugMode()) console.warn(...args);
}

/** @param {string} route */
export function navigateDnaRoute(route) {
getRuntimeFunction('navigate')?.(route);
}

export function refreshDnaSidebar() {
const buildSidebar = getRuntimeFunction('buildSidebar');
if (!buildSidebar) return;
try {
buildSidebar();
} catch {}
}

/** @param {string} route */
export function refreshDnaShell(route) {
refreshDnaSidebar();
navigateDnaRoute(route);
}

/** @returns {boolean} */
export function isDnaLabImportRunning() {
const isImportRunning = getRuntimeFunction('isImportRunning');
if (!isImportRunning) return false;
try {
return isImportRunning() === true;
} catch {
return true;
}
}

/** @param {File} file */
export function callDnaFileHandler(file) {
getRuntimeFunction('handleDNAFile')?.(file);
}

export function triggerDnaFilePicker() {
getRuntimeFunction('triggerDNAFilePicker')?.();
}

export function updateDnaChatNudge() {
getRuntimeFunction('updateChatNudge')?.();
}

/** @returns {Promise<boolean>} */
export async function confirmDnaDeleteDialog() {
const confirmDialog = getRuntimeFunction('showConfirmDialog');
if (!confirmDialog) return false;
try {
return await confirmDialog('Delete genetic data? This cannot be undone.') === true;
} catch {
return false;
}
}

/** @returns {{ importedData?: any } | null} */
export function getDnaRuntimeState() {
try {
return getRuntimeFunction('_getState')?.() || null;
} catch {
return null;
}
}

/** @returns {Promise<boolean>} */
export async function saveDnaRuntimeAndRefresh() {
const saveAndRefresh = getRuntimeFunction('_saveAndRefresh');
if (!saveAndRefresh) return false;
try {
return await saveAndRefresh() !== false;
} catch {
return false;
}
}

/** @param {any} result */
export function setPendingDnaImport(result) {
getRuntimeWindow()._pendingDNAImport = result;
}

/** @returns {any} */
export function getPendingDnaImport() {
return getRuntimeWindow()._pendingDNAImport || null;
}

export function clearPendingDnaImport() {
getRuntimeWindow()._pendingDNAImport = null;
}

/** @param {Record<string, any>} bindings */
export function publishDnaWindowBindings(bindings) {
installDNAWindowBindings(getRuntimeWindow(), bindings);
}
3 changes: 2 additions & 1 deletion js/dna-window-bindings.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ export function installDNAWindowBindings(win, deps) {
_getRelevantSNPs: getRelevantSNPs,
_getState: () => state,
_saveAndRefresh: async () => {
if (!await saveImportedData()) return;
if (!await saveImportedData()) return false;
if (win.buildSidebar) try { win.buildSidebar(); } catch (e) {}
if (win.navigate) win.navigate('dashboard');
return true;
},
});
}
76 changes: 41 additions & 35 deletions js/dna.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,14 @@ import { saveImportedData } from './data.js';
import { closeModalOverlay, openModalOverlay } from './modal-lifecycle.js';
import { dnaActionAttrs, initDnaActionDelegates } from './dna-actions.js';
import { findGenotypeInfo as findGenotypeInfoImpl, findSnpHint as findSnpHintImpl, sortAlleles } from './dna-genotype.js';
import { installDNAWindowBindings } from './dna-window-bindings.js';
import {
cacheDnaSnpTable, callDnaFileHandler, clearPendingDnaImport,
confirmDnaDeleteDialog, getDnaRuntimeState, getPendingDnaImport,
isDnaLabImportRunning, logDnaDebugError, logDnaDebugWarn,
navigateDnaRoute, publishDnaWindowBindings, refreshDnaShell,
refreshDnaSidebar, saveDnaRuntimeAndRefresh, setPendingDnaImport,
triggerDnaFilePicker, updateDnaChatNudge,
} from './dna-runtime.js';
import {
HAPLOGROUP_LIST,
closeMtDNAPreview,
Expand All @@ -22,12 +29,6 @@ import {
resolveHaplogroup,
setManualHaplogroup,
} from './dna-mtdna.js';
/** @typedef {Window & typeof globalThis & {
* _pendingDNAImport?: any,
* _getState: () => { importedData: any },
* _saveAndRefresh: () => Promise<void> | void
* }} DnaWindow */
const dnaWindow = /** @type {DnaWindow} */ (window);
// ═══════════════════════════════════════════════
// FORMAT DETECTION
// ═══════════════════════════════════════════════
Expand Down Expand Up @@ -238,8 +239,8 @@ function loadSNPTable({ forceFresh = false } = {}) {
if (!_snpTablePromise) {
_snpTablePromise = fetch('data/snp-health.json', forceFresh ? { cache: 'no-store' } : undefined)
.then(r => r.json())
.then(data => { _snpTable = data; window._snpTableCache = data; return data; })
.catch(err => { _snpTablePromise = null; if (window.isDebugMode?.()) console.error('Failed to load SNP table:', err); throw err; });
.then(data => { _snpTable = data; cacheDnaSnpTable(data); return data; })
.catch(err => { _snpTablePromise = null; logDnaDebugError('Failed to load SNP table:', err); throw err; });
}
return _snpTablePromise;
}
Expand Down Expand Up @@ -840,7 +841,7 @@ export function renderGeneticsSection() {
</div>`;
}
if (hasSnps && !_snpTable) {
loadSNPTable().then(() => { if (window.navigate) window.navigate('dashboard'); });
loadSNPTable().then(() => navigateDnaRoute('dashboard'));
return '';
}
const snpTable = _snpTable;
Expand Down Expand Up @@ -1095,7 +1096,7 @@ function reimportDNA() {
input.accept = '.txt,.csv';
input.onchange = () => {
const file = input.files?.[0];
if (file && window.handleDNAFile) window.handleDNAFile(file);
if (file) callDnaFileHandler(file);
};
input.click();
}
Expand Down Expand Up @@ -1142,7 +1143,7 @@ function openDnaModalOverlay(overlay, initialFocus) {

async function openManualSnpModal() {
await loadSNPTable();
dnaWindow._pendingDNAImport = null;
clearPendingDnaImport();
const html = `<div class="dna-preview-header dna-manual-header">
<div>
<div class="dna-preview-title">Add SNPs manually</div>
Expand Down Expand Up @@ -1216,9 +1217,9 @@ async function saveManualSnpFromModal() {
closeModalOverlay('dna-modal-overlay');
const errorSuffix = errors.length ? ` (${errors.length} skipped)` : '';
showNotification(`Saved ${accepted.length} SNP${accepted.length === 1 ? '' : 's'}${errorSuffix}`, errors.length ? 'info' : 'success', 5000);
if (errors.length && window.isDebugMode?.()) console.warn('Manual SNP import skipped rows:', errors);
if (window.buildSidebar) try { window.buildSidebar(); } catch (e) {}
if (window.navigate) window.navigate('genome');
if (errors.length) logDnaDebugWarn('Manual SNP import skipped rows:', errors);
refreshDnaSidebar();
navigateDnaRoute('genome');
}

async function importSnpReport() {
Expand Down Expand Up @@ -1255,7 +1256,7 @@ async function handleSnpReportFile(file) {
}
showDNAImportPreview(result, file.name);
} catch (e) {
if (window.isDebugMode?.()) console.error('SNP report import error:', e);
logDnaDebugError('SNP report import error:', e);
showNotification(e.message || 'Failed to read SNP report', 'error');
_dnaImportRunning = false;
}
Expand All @@ -1269,7 +1270,7 @@ let _dnaImportRunning = false;

export async function handleDNAFile(file) {
if (_dnaImportRunning) { showNotification('DNA import already in progress', 'info'); return; }
if (window.isImportRunning && window.isImportRunning()) { showNotification('Lab import in progress — wait for it to finish', 'info'); return; }
if (isDnaLabImportRunning()) { showNotification('Lab import in progress — wait for it to finish', 'info'); return; }
_dnaImportRunning = true;
try {
showNotification('Parsing DNA file...', 'info');
Expand All @@ -1281,14 +1282,14 @@ export async function handleDNAFile(file) {
}
showDNAImportPreview(result, file.name);
} catch (e) {
if (window.isDebugMode?.()) console.error('DNA import error:', e);
logDnaDebugError('DNA import error:', e);
showNotification(e.message || 'Failed to parse DNA file', 'error');
_dnaImportRunning = false;
}
}

function showDNAImportPreview(result, fileName) {
dnaWindow._pendingDNAImport = result;
setPendingDnaImport(result);

// Categorize matches by effect — skip raw APOE components when haplotype resolved
const apoe = resolveAPOE(result.matches);
Expand Down Expand Up @@ -1376,14 +1377,14 @@ function showDNAImportPreview(result, fileName) {
}

function closeDNAImportPreview() {
dnaWindow._pendingDNAImport = null;
clearPendingDnaImport();
_dnaImportRunning = false;
closeModalOverlay('dna-modal-overlay');
if (_dnaModalEscapeBound) { document.removeEventListener('keydown', handleDnaModalEscape); _dnaModalEscapeBound = false; }
}

async function confirmDNAImport() {
const result = dnaWindow._pendingDNAImport;
const result = getPendingDnaImport();
if (!result) return;
const originalData = state.importedData;
const draftData = JSON.parse(JSON.stringify(originalData || {}));
Expand All @@ -1398,7 +1399,7 @@ async function confirmDNAImport() {
state.importedData = originalData; _dnaImportRunning = false;
return;
}
dnaWindow._pendingDNAImport = null;
clearPendingDnaImport();
_dnaImportRunning = false;
closeModalOverlay('dna-modal-overlay');
showNotification(`Imported ${result.coverage.found} SNPs from ${result.source}`, 'success');
Expand Down Expand Up @@ -1430,8 +1431,8 @@ async function confirmDNAImport() {
dnaEl.innerHTML = `<p style="margin:0 0 6px">\uD83E\uDDEC <strong>${result.coverage.found} SNPs imported</strong> from ${escapeHTML(result.source)}</p>
<div style="font-size:13px;line-height:1.8">${parts.join(' &nbsp;\u00B7&nbsp; ')}</div>
<div style="font-size:12px;color:var(--text-muted);margin-top:6px">I'll factor these into all your lab interpretations.</div>`;
} else if (window.updateChatNudge) {
window.updateChatNudge();
} else {
updateDnaChatNudge();
}

// Refresh sidebar (genetics nav count) AND dashboard. Without the
Expand All @@ -1440,8 +1441,7 @@ async function confirmDNAImport() {
// Symptom that surfaced this: after re-importing on the same device
// the dashboard correctly showed "43 SNPs" while the sidebar still
// said "🧬 Genetics 40".
if (window.buildSidebar) try { window.buildSidebar(); } catch (e) {}
if (window.navigate) window.navigate('dashboard');
refreshDnaShell('dashboard');
}

// ═══════════════════════════════════════════════
Expand Down Expand Up @@ -1482,20 +1482,26 @@ export {
setManualHaplogroup,
};

/// Custom confirm dialog so the destructive Delete on the genetics
/// card matches the rest of the app's modal styling and respects the
/// PWA / file:// contexts where the native window.confirm prompt
/// would feel out of place.
/// Custom confirm dialog so the destructive Delete on the genetics card
/// matches the rest of the app's modal styling and respects PWA/file
/// contexts where the native confirm prompt would feel out of place.
async function confirmDeleteDNA() {
if (await window.showConfirmDialog('Delete genetic data? This cannot be undone.')) {
deleteGeneticsData(dnaWindow._getState().importedData);
await dnaWindow._saveAndRefresh();
if (await confirmDnaDeleteDialog()) {
const runtimeState = getDnaRuntimeState();
const targetState = runtimeState || state;
const originalData = JSON.parse(JSON.stringify(targetState.importedData || {}));
deleteGeneticsData(targetState.importedData);
if (!await saveDnaRuntimeAndRefresh()) {
targetState.importedData = originalData;
state.importedData = originalData;
showNotification('Could not save genetic data deletion. Try again after the app finishes loading.', 'error');
}
}
}

initDnaActionDelegates({ triggerDNAFilePicker: () => window.triggerDNAFilePicker?.(), closeDNAImportPreview, closeMtDNAPreview, confirmDeleteDNA, confirmDNAImport, confirmMtDNAImport, deleteMtDNAData, importSnpReport, openManualSnpModal, reimportDNA, saveManualSnpFromModal, toggleGeneticsCollapse, toggleGeneticsExpand });
initDnaActionDelegates({ triggerDNAFilePicker: triggerDnaFilePicker, closeDNAImportPreview, closeMtDNAPreview, confirmDeleteDNA, confirmDNAImport, confirmMtDNAImport, deleteMtDNAData, importSnpReport, openManualSnpModal, reimportDNA, saveManualSnpFromModal, toggleGeneticsCollapse, toggleGeneticsExpand });

installDNAWindowBindings(window, {
publishDnaWindowBindings({
state, saveImportedData, buildGeneticsContext, getRelevantSNPs,
isDNAFile, isDNAFileByContent, detectDNAFile, parseClinicalSnpReportText, parseManualSnpRows, upsertGeneticsSnp, handleDNAFile, handleSnpReportFile,
importSnpReport, openManualSnpModal, saveManualSnpFromModal, closeDNAImportPreview, confirmDNAImport, confirmDeleteDNA, deleteGeneticsData,
Expand Down
2 changes: 1 addition & 1 deletion scripts/quality-baseline.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"inlineEventAttributes": 0,
"windowReferences": 613,
"windowReferences": 589,
"largeJsFilesOver800Lines": 29,
"maxJsFileLines": 1513
}
1 change: 1 addition & 0 deletions service-worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ const APP_SHELL = [
'/js/dna-actions.js',
'/js/dna-genotype.js',
'/js/dna.js',
'/js/dna-runtime.js',
'/js/dna-window-bindings.js',
'/js/dna-mtdna.js',
'/js/hardware.js',
Expand Down
1 change: 1 addition & 0 deletions tests/_vitest-legacy.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ const LEGACY_TESTS = [
// Blob-backed Worker; the synchronous Worker shim added to _node-shim.js
// (+ _vitest-setup.js) runs self-contained pure-JS workers in-process.
// renderGeneticsSection returns an HTML string with no DOM dependency.
'./test-dna-runtime.js',
'./test-dna.js',
'./test-dna-illumina-and-valence.js',
// Batch 36 — family-history (source-inspection + getConditionsSummary;
Expand Down
Loading