From 876c95d6971fac13121791df9f4f50c3495da8bc Mon Sep 17 00:00:00 2001 From: elkimek <36666630+elkimek@users.noreply.github.com> Date: Fri, 15 May 2026 14:52:40 +0200 Subject: [PATCH 01/24] Implement redesign follow-up fixes --- index.html | 26 +- js/changelog.js | 12 +- js/dna.js | 8 +- js/feedback.js | 12 +- js/lens.js | 16 +- js/main.js | 4 +- js/nav.js | 135 ++- js/pdf-import.js | 16 +- js/settings.js | 182 +++- js/theme.js | 30 +- js/views.js | 1626 ++++++++++++++++++++++++++--- service-worker.js | 1 + styles.css | 2547 ++++++++++++++++++++++++++++++++++++++++++--- themes-extra.css | 570 ++++++++++ version.js | 2 +- 15 files changed, 4795 insertions(+), 392 deletions(-) create mode 100644 themes-extra.css diff --git a/index.html b/index.html index 1061b543..a808c5eb 100755 --- a/index.html +++ b/index.html @@ -17,30 +17,34 @@ + + + +
-
+ -
+
-
-
Dates:
+
Dates:
- -
- +
+
+ + + -
@@ -141,8 +145,8 @@ Lens - - + + diff --git a/js/changelog.js b/js/changelog.js index f884e0b8..9dc95fab 100644 --- a/js/changelog.js +++ b/js/changelog.js @@ -236,8 +236,15 @@ export function openChangelog(showAll) { const entries = showAll ? CHANGELOG : CHANGELOG.slice(0, 3); - let html = ``; - html += `

What's New

`; + modal.className = 'modal changelog-modal gb-history-modal'; + let html = `
+
+
Release notes
+
What's New
+
+ +
+
`; for (const entry of entries) { html += `
`; @@ -249,6 +256,7 @@ export function openChangelog(showAll) { html += '
'; } + html += `
`; modal.innerHTML = html; overlay.classList.add('show'); } diff --git a/js/dna.js b/js/dna.js index ba304a72..e414db6b 100644 --- a/js/dna.js +++ b/js/dna.js @@ -208,7 +208,9 @@ function loadSNPTable({ forceFresh = false } = {}) { } // Eagerly load SNP table when genetics data exists (e.g. after JSON import) -export function ensureSNPTable() { if (state.importedData?.genetics) loadSNPTable(); } +export function ensureSNPTable() { + return state.importedData?.genetics ? loadSNPTable() : Promise.resolve(null); +} // Catalog signature: { size, hash } over the sorted rsID list. Stamped on // genetics at import time and re-computed at render time so the genetics @@ -263,6 +265,7 @@ export async function parseDNAFile(file) { category: entry.category, markers: entry.markers || [], effect: genotypeInfo.effect, + valence: genotypeInfo.valence, note: genotypeInfo.note, }; } @@ -409,6 +412,9 @@ export function saveGeneticsData(profileData, parseResult) { genotype: data.genotype, gene: data.gene, variant: data.variant, + effect: data.effect, + valence: data.valence, + note: data.note, }; } if (apoe) { diff --git a/js/feedback.js b/js/feedback.js index b85a147a..71373035 100644 --- a/js/feedback.js +++ b/js/feedback.js @@ -15,9 +15,16 @@ export function openFeedbackModal() { const modal = document.getElementById('feedback-modal'); const overlay = document.getElementById('feedback-modal-overlay'); const typeOptions = FEEDBACK_TYPES.map(t => ``).join(''); + modal.className = 'modal gb-form-modal feedback-redesign-modal'; modal.innerHTML = ` - -

Send Feedback

+
+
+
GitHub issue
+
Send Feedback
+
+ +
+
@@ -38,6 +45,7 @@ export function openFeedbackModal() {

Opens a GitHub issue in a new tab. Requires a GitHub account.

+
`; overlay.classList.add('show'); // Focus the title input diff --git a/js/lens.js b/js/lens.js index e5e48f21..a20f19f5 100644 --- a/js/lens.js +++ b/js/lens.js @@ -746,18 +746,22 @@ export function openKnowledgeBaseModal() { if (!modal) { modal = document.createElement('div'); modal.id = 'kb-modal'; - modal.className = 'modal kb-modal'; overlay.appendChild(modal); } + modal.className = 'modal kb-modal settings-modal'; modal.innerHTML = ` - -

- - Knowledge Base -

+
+
+
Local context
+
Knowledge Base
+
+ +
+
${renderCustomLensSection()}
+
`; overlay.classList.add('show'); document.addEventListener('keydown', _kbModalKeydown); diff --git a/js/main.js b/js/main.js index 741e82ab..7ad85986 100644 --- a/js/main.js +++ b/js/main.js @@ -404,6 +404,8 @@ document.addEventListener("keydown", e => { if (feedbackOverlay && feedbackOverlay.classList.contains("show")) { window.closeFeedbackModal(); return; } const settingsOverlay = document.getElementById("settings-modal-overlay"); if (settingsOverlay && settingsOverlay.classList.contains("show")) { window.closeSettingsModal(); return; } + const tweaksOverlay = document.getElementById("tweaks-panel-overlay"); + if (tweaksOverlay && tweaksOverlay.classList.contains("show")) { window.closeTweaksPanel(); return; } const modalOverlay = document.getElementById("modal-overlay"); if (modalOverlay && modalOverlay.classList.contains("show")) { window.closeModal(); return; } // Generic fallback: any dynamically-injected `.modal-overlay.show` (sun @@ -421,7 +423,7 @@ document.addEventListener("keydown", e => { // `.modal-overlay` — include them so Tab doesn't escape back to the page // while the modal is visible. if (e.key === "Tab") { - const overlayIds = ["client-list-overlay","changelog-modal-overlay","settings-modal-overlay","import-modal-overlay","feedback-modal-overlay","sync-restore-overlay","sync-setup-overlay","modal-overlay","kb-modal-overlay","ai-personalize-picker-overlay","data-protection-picker-overlay"]; + const overlayIds = ["client-list-overlay","changelog-modal-overlay","settings-modal-overlay","tweaks-panel-overlay","import-modal-overlay","feedback-modal-overlay","sync-restore-overlay","sync-setup-overlay","modal-overlay","kb-modal-overlay","ai-personalize-picker-overlay","data-protection-picker-overlay"]; for (const oid of overlayIds) { const ov = document.getElementById(oid); if (ov && ov.classList.contains("show")) { diff --git a/js/nav.js b/js/nav.js index f255a110..183f6a0c 100644 --- a/js/nav.js +++ b/js/nav.js @@ -5,6 +5,27 @@ import { escapeHTML, escapeAttr, hashString } from './utils.js'; import { getActiveData, countFlagged, filterDatesByRange } from './data.js'; import { getProfiles } from './profile.js'; +function _iconSvg(name) { + const attrs = 'viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"'; + const icons = { + search: ``, + labs: ``, + genome: ``, + body: ``, + light: ``, + insight: ``, + compare: ``, + correlations: ``, + recommendations: ``, + knowledge: ``, + plus: ``, + supplements: ``, + cycle: ``, + emf: ``, + }; + return icons[name] || escapeHTML(String(name || '')); +} + // Render a conditional sidebar entry (e.g. Light & Sun, Wearables, Cycle, EMF, Genetics). // Appears only when the predicate yields data. Soft-promote via scroll-selector + // optional expand callback; hard-promote via dedicated `navigate` target. @@ -23,9 +44,11 @@ function _renderConditionalNavItem({ key, icon, label, navigate = 'dashboard', b } else { onclick = `window.navigate('${navigate}')`; } - const badgeHtml = badge ? `${escapeHTML(String(badge))}` : ''; + const badgeHtml = badge ? `${escapeHTML(String(badge))}` : ''; return ``; + + ${escapeHTML(label)} + ${badgeHtml}`; } function _buildNavItem(key, cat) { @@ -34,8 +57,8 @@ function _buildNavItem(key, cat) { if (withData === 0) return null; const flagged = countFlagged(markers); const flagHtml = flagged > 0 - ? `${flagged}` - : `${withData}`; + ? `${flagged}` + : `${withData}`; const markerNames = markers.map(m => m.name).join('|'); // Strip redundant group prefix from label when shown under a group header let label = cat.label; @@ -43,26 +66,58 @@ function _buildNavItem(key, cat) { label = label.slice(cat.group.length + 2); } return { withData, flagged, html: `` }; + + ${escapeHTML(label)} + ${flagHtml}` }; } export function buildSidebar(data) { if (!data) data = getActiveData(); data = filterDatesByRange(data); const nav = document.getElementById("sidebar-nav"); - let html = ``; - html += ``; - html += ``; - html += ``; + const counts = (() => { + let markerCount = 0; + for (const cat of Object.values(data.categories || {})) { + for (const marker of Object.values(cat.markers || {})) { + if (!marker.hidden && marker.values?.some(v => v !== null)) markerCount++; + } + } + return markerCount; + })(); + let html = ``; + html += ``; + html += ``; // \u2500\u2500\u2500 Conditional module entries \u2014 only render when the module has data. // Order: most-used first; new modules (Light & Sun) at the top to highlight discovery. // All except Light & Sun soft-promote (scroll + expand on dashboard); Light & Sun // hard-promotes to its dedicated view. + const genetics = state.importedData?.genetics; + const hasGeneticsData = genetics && ((genetics.snps && Object.keys(genetics.snps).length > 0) || genetics.mtdna); + const gParts = []; + if (hasGeneticsData) { + if (genetics.snps && Object.keys(genetics.snps).length > 0) gParts.push(Object.keys(genetics.snps).length); + if (genetics.mtdna) gParts.push(genetics.mtdna.haplogroup); + } + html += ``; + + const wearableConn = state.importedData?.wearableConnections || {}; + const wearableCount = Object.keys(wearableConn).length; + html += ``; + // \u2600 Light & Sun \u2014 always visible (flagship module). New users // need a discoverable entry point even before logging anything; once // sessions exist, the badge shows this week's count. @@ -71,29 +126,35 @@ export function buildSidebar(data) { const weekCount = sunSessions.filter(s => (s.endedAt || s.startedAt || 0) >= weekStart).length; html += _renderConditionalNavItem({ key: 'light', - icon: '\u2600', // monochrome glyph (no FE0F selector) \u2014 matches the rest of the sidebar (\uD83D\uDCCB \uD83C\uDF38 \uD83D\uDCE1 \uD83E\uDDEC) - label: 'Light & Sun', + icon: 'light', + label: 'Light', navigate: 'light', badge: weekCount > 0 ? (weekCount > 9 ? '9+' : weekCount) : null, }); - // \u231A Wearables \u2014 soft-promote (scroll to wearable strip) - const wearableConn = state.importedData?.wearableConnections || {}; - if (Object.keys(wearableConn).length > 0) { - html += _renderConditionalNavItem({ - key: 'wearables', - icon: '\u231A', - label: 'Wearables', - scrollSelector: '#wearable-strip', - }); - } + html += ``; + + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; // \uD83D\uDC8A Supplements \u2014 soft-promote const supps = state.importedData?.supplements; if (Array.isArray(supps) && supps.length > 0) { html += _renderConditionalNavItem({ key: 'supplements', - icon: '\uD83D\uDC8A', + icon: 'supplements', label: 'Supplements', scrollSelector: '.supp-timeline-section', badge: supps.length, @@ -106,7 +167,7 @@ export function buildSidebar(data) { if (sex === 'female' && mc) { html += _renderConditionalNavItem({ key: 'cycle', - icon: '\uD83C\uDF38', + icon: 'cycle', label: 'Cycle', scrollSelector: '.cycle-section', }); @@ -117,25 +178,13 @@ export function buildSidebar(data) { if (Array.isArray(emfAssessments) && emfAssessments.length > 0) { html += _renderConditionalNavItem({ key: 'emf', - icon: '\uD83D\uDCE1', + icon: 'emf', label: 'EMF', navigate: 'fn:window.openEMFAssessmentEditor&&window.openEMFAssessmentEditor()', badge: emfAssessments.length, }); } - // \uD83E\uDDEC Genetics \u2014 original conditional entry, refactored to use helper - const genetics = state.importedData?.genetics; - const hasGeneticsData = genetics && ((genetics.snps && Object.keys(genetics.snps).length > 0) || genetics.mtdna); - if (hasGeneticsData) { - const gParts = []; - if (genetics.snps && Object.keys(genetics.snps).length > 0) gParts.push(Object.keys(genetics.snps).length); - if (genetics.mtdna) gParts.push(genetics.mtdna.haplogroup); - // Genetics has special expand-on-scroll behavior \u2014 keep its inline form - html += ``; - } - // Separate categories into blood work (no group) and specialty groups const bloodWork = []; const specialtyGroups = {}; @@ -152,7 +201,11 @@ export function buildSidebar(data) { } // Render blood work categories - html += ``; + html += ``; + html += ``; for (const item of bloodWork) html += item.html; // Render specialty groups @@ -237,7 +290,7 @@ export function filterSidebar() { // When searching: show matching items, expand groups with matches, hide empty groups items.forEach(el => { const cat = el.dataset.category; - if (cat === 'dashboard' || cat === 'correlations' || cat === 'compare' || cat === 'light' || cat === 'wearables' || cat === 'supplements' || cat === 'cycle' || cat === 'emf' || cat === 'genetics') { el.style.display = ''; return; } + if (cat === 'dashboard' || cat === 'all' || cat === 'correlations' || cat === 'compare' || cat === 'recommendations' || cat === 'knowledge' || cat === 'custom-markers' || cat === 'light' || cat === 'body' || cat === 'wearables' || cat === 'supplements' || cat === 'cycle' || cat === 'emf' || cat === 'genome' || cat === 'genetics' || cat === 'insight') { el.style.display = ''; return; } const label = el.textContent.toLowerCase(); const markers = (el.dataset.markers || '').toLowerCase(); el.style.display = (label.includes(query) || markers.includes(query)) ? '' : 'none'; diff --git a/js/pdf-import.js b/js/pdf-import.js index 0c62e22b..3ca6959e 100644 --- a/js/pdf-import.js +++ b/js/pdf-import.js @@ -691,9 +691,16 @@ export function showImportPreview(parseResult) { const unmatched = markers.filter(m => !m.matched && !m.suggestedKey); const importCount = matched.length + newMarkers.length; const batchCtx = window._batchImportContext; - const batchLabel = batchCtx ? `
File ${batchCtx.current} of ${batchCtx.total}
` : ''; - let html = ` - ${batchLabel}

Import Preview

+ const batchLabel = batchCtx ? `File ${batchCtx.current} of ${batchCtx.total}` : 'Lab import'; + modal.className = 'modal import-preview-modal gb-history-modal'; + let html = `
+
+
${escapeHTML(batchLabel)}
+
Import Preview
+
+ +
+

File: ${escapeHTML(fileName)}
Collection Date: ${dateFormatted}
Matched: ${matched.length} \u00b7 @@ -797,7 +804,8 @@ export function showImportPreview(parseResult) { const importDisabled = !date ? ' disabled style="opacity:0.5;cursor:not-allowed"' : ''; html += `

-
`; +
+ `; if (!parseResult._importProfileId) parseResult._importProfileId = state.currentProfile; window._pendingImport = parseResult; modal.innerHTML = html; diff --git a/js/settings.js b/js/settings.js index 4413177e..e6442a26 100644 --- a/js/settings.js +++ b/js/settings.js @@ -2,7 +2,7 @@ import { state } from './state.js'; import { escapeHTML, escapeAttr, showNotification, showConfirmDialog, isDebugMode, setDebugMode, isPIIReviewEnabled, setPIIReviewEnabled, isAnalyticsEnabled, setAnalyticsEnabled } from './utils.js'; -import { getTheme, setTheme, getTimeFormat, setTimeFormat } from './theme.js'; +import { getTheme, setTheme, getTimeFormat, setTimeFormat, THEMES } from './theme.js'; import { formatCost, getProfileUsage, getGlobalUsage, resetProfileUsage } from './schema.js'; import { getAIProvider, isAIPaused, getOllamaPIIUrl, getOllamaPIIModel } from './api.js'; import { isOllamaPIIEnabled, setOllamaPIIEnabled, getOllamaConfig, checkOpenAICompatible } from './pii.js'; @@ -17,6 +17,151 @@ import './provider-panels.js'; // ═══════════════════════════════════════════════ let _activeSettingsTab = 'display'; +const ACCENT_STORAGE_KEY = 'labcharts-accent-override'; +const TWEAK_ACCENTS = [ + { id: '', label: 'Theme default', color: 'var(--accent)', gradient: 'var(--accent-gradient)', light: 'var(--accent-light)', fill: 'var(--accent-fill)' }, + { id: 'blue', label: 'Blue', color: '#4f8cff', light: '#6ba0ff', fill: 'rgba(79, 140, 255, 0.10)', gradient: 'linear-gradient(135deg, #4f8cff 0%, #6366f1 100%)' }, + { id: 'green', label: 'Green', color: '#34d399', light: '#6ee7b7', fill: 'rgba(52, 211, 153, 0.12)', gradient: 'linear-gradient(135deg, #34d399 0%, #14b8a6 100%)' }, + { id: 'amber', label: 'Amber', color: '#f59e0b', light: '#fbbf24', fill: 'rgba(245, 158, 11, 0.12)', gradient: 'linear-gradient(135deg, #f59e0b 0%, #f97316 100%)' }, + { id: 'rose', label: 'Rose', color: '#f43f5e', light: '#fb7185', fill: 'rgba(244, 63, 94, 0.12)', gradient: 'linear-gradient(135deg, #f43f5e 0%, #d946ef 100%)' }, + { id: 'cyan', label: 'Cyan', color: '#06b6d4', light: '#22d3ee', fill: 'rgba(6, 182, 212, 0.12)', gradient: 'linear-gradient(135deg, #06b6d4 0%, #2563eb 100%)' }, +]; + +function getAccentOverride() { + const value = localStorage.getItem(ACCENT_STORAGE_KEY) || ''; + return TWEAK_ACCENTS.some(a => a.id === value) ? value : ''; +} + +export function applyAccentOverride(id = getAccentOverride()) { + const accent = TWEAK_ACCENTS.find(a => a.id === id); + const root = document.documentElement; + const props = ['--accent', '--accent-light', '--accent-fill', '--accent-gradient', '--shadow-glow', '--ref-band', '--ref-border']; + const setProp = (prop, value) => { + if (root.style?.setProperty) root.style.setProperty(prop, value); + else if (root.style) root.style[prop] = value; + }; + const removeProp = (prop) => { + if (root.style?.removeProperty) root.style.removeProperty(prop); + else if (root.style) delete root.style[prop]; + }; + if (!accent || !accent.id) { + props.forEach(removeProp); + return; + } + setProp('--accent', accent.color); + setProp('--accent-light', accent.light); + setProp('--accent-fill', accent.fill); + setProp('--accent-gradient', accent.gradient); + setProp('--shadow-glow', `0 0 0 1px ${accent.color}, 0 4px 12px ${accent.fill}`); + setProp('--ref-band', accent.fill); + setProp('--ref-border', accent.color); +} + +function refreshVisualSurfaces() { + window.updateSettingsUI?.(); + window.updateTweaksUI?.(); + window.destroyAllCharts?.(); + const activeView = state.currentView || document.querySelector('.nav-item.active,.nav-item.is-active')?.dataset.category || 'dashboard'; + window.navigate?.(activeView === 'all' ? 'dashboard' : activeView); +} + +window.handleThemeChange = function(themeId) { + setTheme(themeId); + applyAccentOverride(); + window.updateSettingsUI?.(); + window.updateTweaksUI?.(); + window.destroyAllCharts?.(); + const cat = document.querySelector('.nav-item.active')?.dataset.category || 'dashboard'; + window.navigate?.(cat); +}; + +export function selectTweaksTheme(themeId) { + setTheme(themeId); + applyAccentOverride(); + refreshVisualSurfaces(); +} + +export function selectTweaksAccent(accentId) { + const next = TWEAK_ACCENTS.some(a => a.id === accentId) ? accentId : ''; + if (next) localStorage.setItem(ACCENT_STORAGE_KEY, next); + else localStorage.removeItem(ACCENT_STORAGE_KEY); + applyAccentOverride(next); + refreshVisualSurfaces(); +} + +export function updateTweaksUI() { + const panel = document.getElementById('tweaks-panel'); + if (!panel) return; + const theme = getTheme(); + const accentId = getAccentOverride(); + panel.querySelectorAll('.tweaks-theme-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.themeId === theme)); + panel.querySelectorAll('.tweaks-accent-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.accentId === accentId)); +} + +export function closeTweaksPanel() { + document.getElementById('tweaks-panel-overlay')?.remove(); +} + +export function openTweaksPanel() { + closeTweaksPanel(); + const currentTheme = getTheme(); + const currentAccent = getAccentOverride(); + const themeButtons = THEMES.map(t => ` + + `).join(''); + const accentButtons = TWEAK_ACCENTS.map(a => ` + + `).join(''); + document.body.insertAdjacentHTML('beforeend', ` +
+ +
+ `); + document.querySelector('#tweaks-panel button')?.focus(); +} + +applyAccentOverride(); + export function openSettingsModal(tab) { window._settingsHadProvider = !!window.hasAIProvider?.(); const overlay = document.getElementById('settings-modal-overlay'); @@ -28,10 +173,17 @@ export function openSettingsModal(tab) { if (tab === 'integrations') tab = 'wearables'; if (tab) _activeSettingsTab = tab; + modal.className = 'modal settings-modal'; modal.innerHTML = ` - -

Settings

+
+
+
Controls
+
Settings
+
+ +
+
+
@@ -86,9 +239,16 @@ export function openSettingsModal(tab) {
-
- - +
+ ${THEMES.map(t => ` + + `).join('')}
@@ -238,6 +398,8 @@ export function openSettingsModal(tab) {
${renderMessengerSection()}
+
+
`; overlay.classList.add('show'); window.initSettingsOllamaCheck(); @@ -504,7 +666,7 @@ export function updateSettingsUI() { modal.querySelectorAll('.unit-toggle-btn[data-alt-units]').forEach(btn => btn.classList.toggle('active', (btn.dataset.altUnits === 'on') === !!state.showAltUnits)); const theme = getTheme(); modal.querySelectorAll('.settings-theme-btn').forEach(btn => { - btn.classList.toggle('active', btn.textContent.toLowerCase() === theme); + btn.classList.toggle('active', btn.dataset.themeId === theme); }); const timeFmt = getTimeFormat(); modal.querySelectorAll('.time-toggle-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.timefmt === timeFmt)); @@ -1234,4 +1396,10 @@ Object.assign(window, { renderDataEntriesSection, refreshDataEntriesSection, resetCurrentProfileUsage, + openTweaksPanel, + closeTweaksPanel, + selectTweaksTheme, + selectTweaksAccent, + applyAccentOverride, + updateTweaksUI, }); diff --git a/js/theme.js b/js/theme.js index 86bc690d..9d5c83bc 100644 --- a/js/theme.js +++ b/js/theme.js @@ -1,5 +1,16 @@ // theme.js — Theme management, chart colors, time format +const VALID_THEMES = ['dark', 'light', 'cyberterm', 'glass', 'synth-sunrise', 'neuromancer']; + +export const THEMES = [ + { id: 'dark', label: 'Modern Minimal' }, + { id: 'light', label: 'Soft Warm Light' }, + { id: 'cyberterm', label: 'Cypherpunk Terminal' }, + { id: 'glass', label: 'Glass / Liquid' }, + { id: 'synth-sunrise', label: 'Synth Sunrise' }, + { id: 'neuromancer', label: 'Neuromancer' }, +]; + export function getTimeFormat() { return localStorage.getItem('labcharts-time-format') || '24h'; } export function setTimeFormat(fmt) { localStorage.setItem('labcharts-time-format', fmt); } @@ -34,18 +45,23 @@ export function parseTimeInput(val) { return ''; } -export function getTheme() { return localStorage.getItem('labcharts-theme') || 'dark'; } +export function getTheme() { + const theme = localStorage.getItem('labcharts-theme') || 'dark'; + return VALID_THEMES.includes(theme) ? theme : 'dark'; +} export function setTheme(theme) { + if (!VALID_THEMES.includes(theme)) theme = 'dark'; localStorage.setItem('labcharts-theme', theme); - if (theme === 'light') document.documentElement.dataset.theme = 'light'; - else delete document.documentElement.dataset.theme; + if (theme === 'dark') delete document.documentElement.dataset.theme; + else document.documentElement.dataset.theme = theme; const meta = document.querySelector('meta[name="theme-color"]'); - if (meta) meta.content = theme === 'light' ? '#ffffff' : '#1a1d27'; + if (meta) meta.content = theme === 'light' ? '#ffffff' : '#0a0a12'; } export function toggleTheme() { - setTheme(getTheme() === 'dark' ? 'light' : 'dark'); + const current = getTheme(); + setTheme(current === 'light' ? 'dark' : 'light'); const activeNav = document.querySelector('.nav-item.active'); const activeCat = activeNav ? activeNav.dataset.category : 'dashboard'; window.destroyAllCharts(); @@ -66,11 +82,11 @@ export function getChartColors() { tooltipBody: g('--text-secondary'), tooltipBorder: g('--border'), tickColor: g('--text-muted'), gridColor: g('--chart-grid'), legendColor: g('--text-secondary'), lineColor: g('--accent'), - lineFill: getTheme() === 'light' ? 'rgba(59,124,245,0.1)' : 'rgba(79,140,255,0.1)', + lineFill: g('--accent-fill') || 'color-mix(in srgb, var(--accent) 10%, transparent)', canvasTooltipBg: g('--chart-tooltip-bg'), canvasTooltipText: g('--text-primary'), chronoLineColor: g('--text-muted'), green: g('--green'), red: g('--red'), yellow: g('--yellow'), }; } -Object.assign(window, { getTheme, setTheme, toggleTheme, getTimeFormat, setTimeFormat, formatTime, parseTimeInput, getChartColors }); +Object.assign(window, { getTheme, setTheme, toggleTheme, getTimeFormat, setTimeFormat, formatTime, parseTimeInput, getChartColors, THEMES }); diff --git a/js/views.js b/js/views.js index 84316ffd..5528fd4a 100644 --- a/js/views.js +++ b/js/views.js @@ -2,14 +2,15 @@ import { state } from './state.js'; import { CORRELATION_PRESETS, CHIP_COLORS, trackUsage, UNIT_CONVERSIONS, getAlternateUnit, convertUserInputToSI } from './schema.js'; -import { escapeHTML, getStatus, getRangePosition, formatValue, getTrend, showNotification, showConfirmDialog, showPromptDialog, hasCardContent, formatDate, safeMarkerId } from './utils.js'; +import { escapeHTML, escapeAttr, getStatus, getRangePosition, formatValue, getTrend, showNotification, showConfirmDialog, showPromptDialog, hasCardContent, formatDate, safeMarkerId } from './utils.js'; import { getChartColors } from './theme.js'; import { getActiveData, filterDatesByRange, destroyAllCharts, getEffectiveRange, getEffectiveRangeForDate, getLatestValueIndex, getAllFlaggedMarkers, statusIcon, detectTrendAlerts, getKeyTrendMarkers, getFocusCardFingerprint, saveImportedData, recalculateHOMAIR, updateHeaderDates, renderDateRangeFilter, renderChartLayersDropdown, convertDisplayToSI } from './data.js'; -import { profileStorageKey } from './profile.js'; +import { profileStorageKey, getProfiles } from './profile.js'; import { createLineChart, getMarkerDescription, getNotesForChart, getSupplementsForChart, refBandPlugin, noteAnnotationPlugin, supplementBarPlugin } from './charts.js'; import { renderSupplementsSection } from './supplements.js'; import { renderWearableStrip } from './wearables.js'; -import { renderGeneticsSection } from './dna.js'; +import { canonicalMetric, metricsForSources } from './wearable-adapters.js'; +import { ensureSNPTable, findGenotypeInfo, renderGeneticsSection } from './dna.js'; import { renderMenstrualCycleSection } from './cycle.js'; import { renderProfileContextCards, renderInterpretiveLensSection, loadContextHealthDots, closeSuggestionsOnClickOutside } from './context-cards.js'; import { callClaudeAPI, hasAIProvider, isAIPaused, getAIProvider, getActiveModelId } from './api.js'; @@ -19,12 +20,34 @@ import { hasLens, queryLens } from './lens.js'; import { applyInlineMarkdown } from './markdown.js'; function markerHasData(m) { return m.values?.some(v => v !== null) ?? false; } +function setDetailModalShell(...classes) { + const modal = document.getElementById('detail-modal'); + if (!modal) return null; + modal.className = ['modal', ...classes.filter(Boolean)].join(' '); + return modal; +} // ═══════════════════════════════════════════════ // NAVIGATE (router) // ═══════════════════════════════════════════════ export function navigate(category, data) { + category = category || 'dashboard'; + const dashboardLens = category === 'labs' || category === 'genome' || category === 'body'; + const routeCategory = dashboardLens ? 'dashboard' : category; + const activeCategory = category === 'labs' ? 'dashboard' : category; + if (category === 'insight') { + document.querySelectorAll(".nav-item").forEach(el => { + const isActive = el.dataset.category === 'insight'; + el.classList.toggle("active", isActive); + el.classList.toggle("is-active", isActive); + }); + if (window.closeMobileSidebar) window.closeMobileSidebar(); + if (window.syncImportStatusFab) window.syncImportStatusFab(); + if (window.openChatPanel) window.openChatPanel(); + return; + } + // Detect "re-render in place" (callsite is requesting a refresh of the // current view, not a real navigation). On in-place re-renders we use // ELEMENT-ANCHOR scroll preservation, not pixel-based: capture the @@ -34,7 +57,7 @@ export function navigate(category, data) { // when the new layout has different content heights above the user's // viewport — they'd see a jump even though scrollY was technically // preserved. - const sameView = category === state.currentView; + const sameView = category === state.currentView && !dashboardLens; let anchor = null; // Track whether the caller explicitly requested an anchor — even if // the element isn't found, an explicit request means "don't fall @@ -67,18 +90,29 @@ export function navigate(category, data) { } } document.querySelectorAll(".nav-item").forEach(el => { - el.classList.toggle("active", el.dataset.category === category); + const isActive = el.dataset.category === activeCategory; + el.classList.toggle("active", isActive); + el.classList.toggle("is-active", isActive); }); // Close mobile sidebar on navigation if (window.closeMobileSidebar) window.closeMobileSidebar(); + if (routeCategory !== "dashboard" && typeof document !== 'undefined') { + document.body.classList.remove('mobile-dashboard-active'); + } if (window.syncImportStatusFab) window.syncImportStatusFab(); destroyAllCharts(); - if (category === "dashboard") showDashboard(data); - else if (category === "correlations") showCorrelations(data); - else if (category === "compare") showCompare(data); - else if (category === "light") showLight(data); - else showCategory(category, data); - state.currentView = category; + if (category === 'genome' || category === 'body') { + _suppressEmptyDashboardChatUntil = Date.now() + 2500; + } + if (routeCategory === "dashboard") showDashboard(data); + else if (routeCategory === "correlations") showCorrelations(data); + else if (routeCategory === "compare") showCompare(data); + else if (routeCategory === "light") showLight(data); + else showCategory(routeCategory, data); + state.currentView = routeCategory; + if (category === 'genome' || category === 'body') { + _scrollDashboardLensIntoView(category); + } if (anchor) { // Force synchronous layout so getBoundingClientRect is accurate. @@ -126,6 +160,55 @@ export function navigate(category, data) { } } +function _scrollDashboardLensIntoView(lens) { + if (typeof window === 'undefined' || typeof document === 'undefined') return; + const configs = { + genome: { + selectors: [ + '#mobile-genome-section', + '#genetics-section', + '.dashboard-widget[data-widget-id="genome"]', + '.db-genome-empty', + '.genetics-empty-stub', + ], + afterScroll(target) { + const body = target.querySelector?.('.genetics-body') || document.querySelector('#genetics-section .genetics-body'); + if (body?.classList.contains('hidden') && window.toggleGeneticsCollapse) window.toggleGeneticsCollapse(); + }, + fallback() { + if (window.openSettingsModal) window.openSettingsModal('data'); + }, + }, + body: { + selectors: [ + '#mobile-body-section', + '.dashboard-widget[data-widget-id="wearables"]', + '#wearable-strip', + '.wearable-strip', + '.dashboard-widget[data-widget-id="wearable-strip"]', + '.m-wear-empty', + ], + fallback() { + if (window.openSettingsModal) window.openSettingsModal('wearables'); + }, + }, + }; + const config = configs[lens]; + if (!config) return; + setTimeout(() => { + const target = config.selectors + .map(selector => document.querySelector(selector)) + .find(Boolean); + if (!target) { + config.fallback(); + return; + } + const y = target.getBoundingClientRect().top + window.scrollY - 60; + window.scrollTo({ top: Math.max(0, y), behavior: 'smooth' }); + config.afterScroll?.(target); + }, 80); +} + // Monotonic counter for in-flight anchor-restore loops. Each navigate // captures a new token; older loops compare and bail when the user // has moved on. @@ -134,6 +217,7 @@ let _navAnchorToken = 0; // reuse the original captured viewportTop instead of re-capturing AFTER // the jump that the original was trying to prevent. let _activeAnchor = null; +let _suppressEmptyDashboardChatUntil = 0; // Capture identity + viewport position of the most reasonable scroll // anchor for the current interaction. Priority: @@ -2693,6 +2777,1268 @@ function getSunCoordsHint() { return ''; } +// ═══════════════════════════════════════════════ +// DASHBOARD WIDGETS +// ═══════════════════════════════════════════════ + +let _dashboardOrganizeMode = false; +let _draggingDashboardWidgetId = null; + +const DASHBOARD_WIDGETS_VERSION = 5; +const DASHBOARD_WIDGETS = [ + { id: 'bio-age', title: 'Biological Age', description: 'Age-derived biological readout', size: 'half', render: renderDashboardBioAgeWidget }, + { id: 'focus', title: 'Current Focus', description: 'One synthesized read on the latest data', size: 'third', render: () => renderFocusCard() }, + { id: 'spotlight', title: 'Marker Spotlight', description: 'Highest-priority current marker', size: 'half', render: renderDashboardSpotlightWidget }, + { id: 'wearables', title: 'Wearables Today', description: 'Compact body signal tiles', size: 'half', render: renderDashboardWearableTilesWidget }, + { id: 'stat-vitd', title: 'Vitamin D', description: 'Quick marker tile', size: 'quarter', render: (ctx) => renderDashboardStatWidget(ctx, 'vitamins', 'vitaminD') }, + { id: 'stat-hba1c', title: 'HbA1c', description: 'Quick marker tile', size: 'quarter', render: (ctx) => renderDashboardStatWidget(ctx, 'diabetes', 'hba1c') }, + { id: 'stat-testosterone', title: 'Testosterone', description: 'Quick marker tile', size: 'quarter', render: (ctx) => renderDashboardStatWidget(ctx, 'hormones', 'testosterone') }, + { id: 'stat-apob', title: 'ApoB', description: 'Quick marker tile', size: 'quarter', render: (ctx) => renderDashboardStatWidget(ctx, 'lipids', 'apoB') }, + { id: 'markers', title: 'All Biomarkers', description: 'Attention-ranked markers with sparklines', size: 'two-third', render: renderDashboardMarkerListWidget }, + { id: 'insights', title: 'AI Insights', description: 'Top trend and range reads', size: 'third', render: renderDashboardInsightsListWidget }, + { id: 'genome', title: 'Imported SNPs', description: 'All DNA calls and import status', size: 'half', render: renderDashboardGenomeWidget }, + { id: 'alerts', title: 'Trends & Alerts', description: 'Fast changes and critical out-of-range markers', size: 'half', render: renderDashboardAlertsWidget }, + { id: 'correlation', title: 'Correlations', description: 'Highest linked marker pairs', size: 'half', render: renderDashboardCorrelationWidget }, + { id: 'lens', title: 'AI Lens', description: 'Interpretive lens and knowledge base', size: 'half', render: () => renderInterpretiveLensSection() }, + { id: 'light-today', title: 'Light Today', description: 'Sun and light exposure channels', render: () => renderLightTodayStrip() }, + { id: 'wearable-strip', title: 'Wearable Connections', description: 'Recovery, sleep, HRV, body composition', render: () => renderWearableStrip() }, + { id: 'profile-context', title: 'Profile Context', description: 'Goals, history, lifestyle, and context cards', render: () => renderProfileContextCards() }, + { id: 'cycle', title: 'Cycle', description: 'Menstrual cycle context', render: (ctx) => state.profileSex === 'female' ? renderMenstrualCycleSection(ctx.data) : '' }, + { id: 'supplements', title: 'Supplements', description: 'Supplements and medication timeline', render: () => renderSupplementsSection() }, + { id: 'key-trends', title: 'Key Trends', description: 'Auto-selected markers from your current range', render: renderDashboardKeyTrendsWidget }, + { id: 'notes', title: 'Notes', description: 'Timeline notes linked to your data', render: renderDashboardNotesWidget }, +]; +const DASHBOARD_WIDGET_IDS = DASHBOARD_WIDGETS.map(w => w.id); +const DASHBOARD_WIDGET_DEFAULT_IDS = [ + 'bio-age', + 'focus', + 'spotlight', + 'wearables', + 'light-today', + 'stat-vitd', + 'stat-hba1c', + 'stat-testosterone', + 'stat-apob', + 'markers', + 'insights', + 'genome', + 'alerts', + 'correlation', + 'profile-context', + 'supplements', + 'key-trends', +]; + +function dashboardWidgetStorageKey() { + return profileStorageKey(state.currentProfile || 'default', `dashboardWidgetsV${DASHBOARD_WIDGETS_VERSION}`); +} + +function getDashboardDefaultWidgetPrefs() { + const order = [ + ...DASHBOARD_WIDGET_DEFAULT_IDS, + ...DASHBOARD_WIDGET_IDS.filter(id => !DASHBOARD_WIDGET_DEFAULT_IDS.includes(id)), + ].filter(id => DASHBOARD_WIDGET_IDS.includes(id)); + const hidden = DASHBOARD_WIDGET_IDS.filter(id => !DASHBOARD_WIDGET_DEFAULT_IDS.includes(id)); + return { order, hidden }; +} + +function getDashboardWidgetPrefs() { + const fallback = getDashboardDefaultWidgetPrefs(); + try { + const raw = JSON.parse(localStorage.getItem(dashboardWidgetStorageKey())); + if (!raw || !Array.isArray(raw.order) || !Array.isArray(raw.hidden)) return fallback; + const order = raw.order.filter(id => DASHBOARD_WIDGET_IDS.includes(id)); + for (const id of DASHBOARD_WIDGET_IDS) if (!order.includes(id)) order.push(id); + return { + order, + hidden: raw.hidden.filter(id => DASHBOARD_WIDGET_IDS.includes(id)), + }; + } catch (e) { + return fallback; + } +} + +function saveDashboardWidgetPrefs(prefs) { + const order = (prefs.order || []).filter(id => DASHBOARD_WIDGET_IDS.includes(id)); + for (const id of DASHBOARD_WIDGET_IDS) if (!order.includes(id)) order.push(id); + const hidden = [...new Set(prefs.hidden || [])].filter(id => DASHBOARD_WIDGET_IDS.includes(id)); + localStorage.setItem(dashboardWidgetStorageKey(), JSON.stringify({ order, hidden })); +} + +function buildDashboardWidgetContext(data) { + const filteredData = filterDatesByRange(data); + const keyMarkers = getKeyTrendMarkers(filteredData); + const trendAlerts = detectTrendAlerts(filteredData); + const trendMarkerIds = new Set(trendAlerts.map(a => a.id)); + const allFlags = getAllFlaggedMarkers(data); + const criticalFlags = allFlags.filter(f => { + if (trendMarkerIds.has(f.id)) return false; + const refRange = f.refMax - f.refMin; + if (refRange <= 0 || f.refMin == null || f.refMax == null) return false; + const distance = f.status === 'high' ? (f.rawValue - f.refMax) : (f.refMin - f.rawValue); + return distance > refRange * 0.5; + }); + return { data, filteredData, keyMarkers, trendAlerts, criticalFlags }; +} + +function getOrderedDashboardWidgets(prefs = getDashboardWidgetPrefs()) { + const byId = new Map(DASHBOARD_WIDGETS.map(w => [w.id, w])); + return prefs.order.map(id => byId.get(id)).filter(Boolean); +} + +function getDashboardProfileName() { + const profile = getMobileDashboardProfile(); + const name = getMobileGreetingName(profile); + return name === 'there' ? 'Dashboard' : name; +} + +function getDashboardPanelCount(data) { + return Object.values(data.categories || {}).filter(cat => { + if (cat.singlePoint && cat.singleDate) return true; + return Object.values(cat.markers || {}).some(markerHasData); + }).length; +} + +function getDashboardMonthSpan(data) { + const dates = (data.dates || []).filter(Boolean); + if (dates.length < 2) return ''; + const first = new Date(dates[0] + 'T00:00:00'); + const last = new Date(dates[dates.length - 1] + 'T00:00:00'); + if (Number.isNaN(first.getTime()) || Number.isNaN(last.getTime())) return ''; + const months = Math.max(1, Math.round((last - first) / (1000 * 60 * 60 * 24 * 30.4375))); + return `${months} month${months === 1 ? '' : 's'}`; +} + +function renderDashboardGreeting(ctx, title, visibleCount) { + const organizeLabel = _dashboardOrganizeMode ? 'Done' : 'Customize'; + const counts = getMobileDashboardCounts(ctx.data); + const panelCount = getDashboardPanelCount(ctx.data); + const span = getDashboardMonthSpan(ctx.data); + const parts = [ + `${counts.inRange} of ${counts.markerCount || 0} markers in range`, + counts.latestDate ? `last draw ${formatDate(counts.latestDate, 'short')}` : '', + `${panelCount} panel${panelCount === 1 ? '' : 's'}${span ? ` across ${span}` : ''}`, + `${visibleCount} widget${visibleCount === 1 ? '' : 's'} active`, + ].filter(Boolean); + return `
+
+
${escapeHTML(title)}
+

Hey ${escapeHTML(getDashboardProfileName())}.

+
${parts.map(escapeHTML).join(' · ')}
+
+
+ + +
+
`; +} + +function renderDashboardWidget(entry, prefs, index, visibleEntries) { + const { def, body } = entry; + const isHidden = prefs.hidden.includes(def.id); + if (isHidden || (!body && !_dashboardOrganizeMode)) return ''; + const canMoveUp = index > 0; + const canMoveDown = index < visibleEntries.length - 1; + const controls = _dashboardOrganizeMode ? `
+ + + +
` : ''; + return `
+
+ +
+
${escapeHTML(def.title)}
+
${escapeHTML(def.description || '')}
+
+ ${controls} +
+
${body || '
No data available for this widget.
'}
+
`; +} + +function getDashboardMarkerByPath(data, catKey, markerKey) { + const id = `${catKey}_${markerKey}`; + if (!safeMarkerId(id)) return null; + const category = data.categories?.[catKey]; + const marker = category?.markers?.[markerKey]; + if (!category || !marker || !markerHasData(marker)) return null; + const latestIdx = getLatestValueIndex(marker.values || []); + if (latestIdx < 0) return null; + const range = getEffectiveRangeForDate(marker, latestIdx); + const value = marker.values[latestIdx]; + const status = getStatus(value, range.min, range.max); + const trend = getTrend(marker.values || [], range.min, range.max); + state.markerRegistry[id] = marker; + return { id, category, marker, latestIdx, range, value, status, trend }; +} + +function getDashboardAge() { + if (!state.profileDob) return null; + const dob = new Date(state.profileDob); + if (Number.isNaN(dob.getTime())) return null; + return Math.floor((Date.now() - dob.getTime()) / (365.25 * 24 * 60 * 60 * 1000)); +} + +function getDashboardBioAgeMarker(ctx) { + const paths = [ + ['ratios', 'bioAge'], + ['specialty', 'glycanAge'], + ['ratios', 'phenoAge'], + ['ratios', 'bortzAge'], + ]; + for (const [cat, key] of paths) { + const hit = getDashboardMarkerByPath(ctx.data, cat, key); + if (hit) return hit; + } + return null; +} + +function renderDashboardBioAgeWidget(ctx) { + const hit = getDashboardBioAgeMarker(ctx); + const age = getDashboardAge(); + const value = hit ? Number(hit.value) : null; + const display = Number.isFinite(value) ? value.toFixed(1) : (age != null ? String(age) : '—'); + const delta = Number.isFinite(value) && age != null ? value - age : null; + const deltaText = delta == null ? 'Chronological comparison unavailable' + : `${delta >= 0 ? '+' : ''}${delta.toFixed(1)} yr vs chronological`; + const pheno = getDashboardMarkerByPath(ctx.data, 'ratios', 'phenoAge'); + const bortz = getDashboardMarkerByPath(ctx.data, 'ratios', 'bortzAge'); + const pct = Number.isFinite(value) ? Math.max(4, Math.min(100, (value / 70) * 100)) : 35; + const open = hit ? ` onclick="window.showDetailModal('${hit.id}')"` : ''; + return `
+
+
${escapeHTML(display)}
+
+ ${escapeHTML(hit?.marker?.name || 'Biological Age')} + Chronological: ${age != null ? `${age} yr` : 'not set'} + ${escapeHTML(deltaText)} +
+
+
+
PhenoAge${pheno ? formatValue(pheno.value) : '—'}
+
Bortz Age${bortz ? formatValue(bortz.value) : '—'}
+
+
03570 yr
+
+
`; +} + +function renderDashboardMiniSparkline(values, status, width = 120, height = 30) { + const points = (values || []).filter(v => v !== null && Number.isFinite(Number(v))).slice(-10).map(Number); + if (points.length < 2) return ``; + const min = Math.min(...points); + const max = Math.max(...points); + const span = max - min || 1; + const coords = points.map((value, index) => { + const x = points.length === 1 ? width / 2 : (index / (points.length - 1)) * width; + const y = height - 2 - ((value - min) / span) * (height - 5); + return `${x.toFixed(1)},${y.toFixed(1)}`; + }).join(' '); + return ``; +} + +function renderDashboardStatWidget(ctx, catKey, markerKey) { + const hit = getDashboardMarkerByPath(ctx.data, catKey, markerKey); + if (!hit) return ''; + return ``; +} + +function getDashboardSpotlight(ctx) { + const firstAlert = ctx.trendAlerts?.[0]; + if (firstAlert?.id) { + const idx = firstAlert.id.indexOf('_'); + if (idx > 0) { + const hit = getDashboardMarkerByPath(ctx.data, firstAlert.id.slice(0, idx), firstAlert.id.slice(idx + 1)); + if (hit) return hit; + } + } + for (const km of ctx.keyMarkers || []) { + const hit = getDashboardMarkerByPath(ctx.filteredData, km.cat, km.key) || getDashboardMarkerByPath(ctx.data, km.cat, km.key); + if (hit) return hit; + } + return null; +} + +function renderDashboardSpotlightWidget(ctx) { + const hit = getDashboardSpotlight(ctx); + if (!hit) return ''; + const range = getEffectiveRange(hit.marker); + const rangeText = range.min != null || range.max != null + ? `Range ${range.min != null ? formatValue(range.min) : '—'}–${range.max != null ? formatValue(range.max) : '—'} ${hit.marker.unit || ''}` + : 'No active range'; + return `
+
+
+
${escapeHTML(hit.marker.name)}
+
${escapeHTML(rangeText)}
+
+
${escapeHTML(formatValue(hit.value))}${escapeHTML(hit.marker.unit || '')}
+
+ ${renderDashboardMiniSparkline(hit.marker.values, hit.status, 420, 150)} + +
`; +} + +function renderDashboardMarkerRow(marker) { + return ``; +} + +function renderDashboardMarkerListWidget(ctx) { + const markers = getMobileDashboardMarkers(ctx).slice(0, 10); + if (!markers.length) return ''; + return `
+
MarkerTrendLatestDelta
+
${markers.map(renderDashboardMarkerRow).join('')}
+
`; +} + +function renderDashboardInsightsListWidget(ctx) { + const markers = getMobileDashboardMarkers(ctx); + const insights = getMobileDashboardInsights(ctx, markers); + if (!insights.length) return ''; + return `
${insights.map(insight => { + const open = insight.id && safeMarkerId(insight.id) ? ` onclick="window.showDetailModal('${insight.id}')"` : ''; + return ``; + }).join('')}
`; +} + +function renderDashboardWearableTilesWidget() { + const tiles = getMobileWearableTiles(); + if (!tiles.length) return ''; + return `
${tiles.map(tile => ``).join('')}
`; +} + +function getDashboardGenomeImpact(stored, entry) { + const info = entry ? findGenotypeInfo(entry, stored?.genotype) : null; + const effect = info?.effect || stored?.effect || 'info'; + const valence = info?.valence || stored?.valence || 'risk'; + const note = info?.note || stored?.note || ''; + if (valence === 'protective') return { label: 'beneficial', rank: 3, note }; + if (valence === 'neutral') return { label: 'informational', rank: 4, note }; + if (effect === 'significant') return { label: 'significant', rank: 0, note }; + if (effect === 'moderate') return { label: 'moderate', rank: 1, note }; + if (effect === 'mild') return { label: 'mild', rank: 2, note }; + if (effect === 'none') return { label: 'normal', rank: 5, note }; + return { label: effect || 'info', rank: 6, note }; +} + +function renderDashboardGenomeWidget() { + const genetics = state.importedData?.genetics; + const snps = genetics?.snps || {}; + const apoe = genetics?.apoe; + const snpTable = typeof window !== 'undefined' ? window._snpTableCache : null; + const snpCount = Object.keys(snps).length; + if (snpCount && !snpTable) { + ensureSNPTable()?.then(() => { + if (state.currentView === 'dashboard' && window.navigate) window.navigate('dashboard'); + }).catch(() => {}); + } + const findings = Object.entries(snps) + .map(([rsid, stored]) => { + const entry = snpTable?.[rsid]; + const impact = getDashboardGenomeImpact(stored, entry); + return { + rsid, + ...stored, + impactLabel: impact.label, + impactRank: impact.rank, + note: impact.note || stored.note || '', + }; + }) + .filter(f => f.gene || f.variant || f.genotype) + .sort((a, b) => (a.impactRank - b.impactRank) || String(a.gene || a.rsid).localeCompare(String(b.gene || b.rsid)) || String(a.variant || '').localeCompare(String(b.variant || ''))); + if (!findings.length && !apoe && !genetics?.mtdna) { + return ``; + } + const rows = findings.map(f => `
+ + ${escapeHTML(f.gene || f.rsid)} + ${escapeHTML(f.variant || f.rsid)} + + ${escapeHTML(f.impactLabel || 'info')} + ${escapeHTML(f.genotype || '—')} + ${f.note ? `${escapeHTML(f.note)}` : ''} +
`).join(''); + const meta = [ + snpCount ? `${snpCount} imported SNP${snpCount === 1 ? '' : 's'}` : '', + genetics?.source || '', + genetics?.importDate || '', + ].filter(Boolean).join(' · '); + return `
+ ${meta ? `
${escapeHTML(meta)}
` : ''} + ${apoe ? `
APOEHaplotypecontext${escapeHTML(apoe)}
` : ''} + ${genetics?.mtdna ? `
mtDNA${escapeHTML(genetics.mtdna.coupling?.shortLabel || 'Haplogroup')}lineage${escapeHTML(genetics.mtdna.haplogroup)}
` : ''} + ${rows} +
`; +} + +function dashboardPearson(aValues, bValues) { + const xs = []; + const ys = []; + const n = Math.min(aValues?.length || 0, bValues?.length || 0); + for (let i = 0; i < n; i++) { + const x = Number(aValues[i]); + const y = Number(bValues[i]); + if (Number.isFinite(x) && Number.isFinite(y)) { + xs.push(x); + ys.push(y); + } + } + if (xs.length < 3) return null; + const meanX = xs.reduce((sum, value) => sum + value, 0) / xs.length; + const meanY = ys.reduce((sum, value) => sum + value, 0) / ys.length; + let num = 0; + let denX = 0; + let denY = 0; + for (let i = 0; i < xs.length; i++) { + const dx = xs[i] - meanX; + const dy = ys[i] - meanY; + num += dx * dy; + denX += dx * dx; + denY += dy * dy; + } + const den = Math.sqrt(denX * denY); + return den ? num / den : null; +} + +function getDashboardCorrelationPairs(ctx) { + const target = getDashboardMarkerByPath(ctx.data, 'lipids', 'apoB') + || getDashboardMarkerByPath(ctx.data, 'lipids', 'ldl') + || getDashboardMarkerByPath(ctx.data, 'diabetes', 'hba1c') + || getDashboardSpotlight(ctx); + if (!target?.marker?.values) return null; + const pairs = []; + for (const [catKey, category] of Object.entries(ctx.data.categories || {})) { + for (const [markerKey, marker] of Object.entries(category.markers || {})) { + const id = `${catKey}_${markerKey}`; + if (id === target.id || !safeMarkerId(id) || !markerHasData(marker)) continue; + const r = dashboardPearson(target.marker.values || [], marker.values || []); + if (r == null || !Number.isFinite(r)) continue; + const latestIdx = getLatestValueIndex(marker.values || []); + state.markerRegistry[id] = marker; + pairs.push({ + id, + name: marker.name || markerKey, + category: category.label || catKey, + value: latestIdx >= 0 ? formatValue(marker.values[latestIdx]) : '—', + unit: marker.unit || '', + r, + }); + } + } + pairs.sort((a, b) => Math.abs(b.r) - Math.abs(a.r)); + return { target, pairs: pairs.slice(0, 12) }; +} + +function renderDashboardCorrelationWidget(ctx) { + const result = getDashboardCorrelationPairs(ctx); + if (!result?.pairs?.length) { + return ``; + } + return `
+
+ vs ${escapeHTML(result.target.marker.name || 'target marker')} + +
+
+ ${result.pairs.map(pair => { + const directionClass = pair.r >= 0 ? 'db-correlation-cell-pos' : 'db-correlation-cell-neg'; + return ``; + }).join('')} +
+
`; +} + +function renderDashboardWidgets(ctx, title) { + const prefs = getDashboardWidgetPrefs(); + const ordered = getOrderedDashboardWidgets(prefs); + const visibleEntries = ordered + .map(def => ({ def, body: def.render(ctx) || '' })) + .filter(entry => !prefs.hidden.includes(entry.def.id) && (entry.body || _dashboardOrganizeMode)); + let html = renderDashboardGreeting(ctx, title, visibleEntries.length); + html += `
`; + html += renderOnboardingBanner(); + html += renderAIConnectionReminder(); + html += `
`; + visibleEntries.forEach((entry, index) => { html += renderDashboardWidget(entry, prefs, index, visibleEntries); }); + if (visibleEntries.length === 0) { + html += `
+
No widgets are visible.
+
`; + } + html += `
`; + if (_dashboardOrganizeMode) { + html += ``; + } + return html; +} + +function renderDashboardKeyTrendsWidget(ctx) { + const { filteredData, keyMarkers } = ctx; + let html = `
+ ${renderDateRangeFilter()} + ${renderChartLayersDropdown()} +
`; + if (keyMarkers.length > 0) { + html += `
`; + for (const km of keyMarkers) { + const marker = filteredData.categories[km.cat].markers[km.key]; + html += renderChartCard(km.cat + "_" + km.key, marker, filteredData.dateLabels); + } + html += `
`; + } else { + html += `
No trend markers available in this date range.
`; + } + return html; +} + +function renderDashboardAlertsWidget(ctx) { + const { trendAlerts, criticalFlags } = ctx; + const totalAttention = trendAlerts.length + criticalFlags.length; + if (totalAttention === 0) return ''; + let html = `
Trends & Alerts (${totalAttention})
`; + for (const alert of trendAlerts) { + const isSudden = alert.concern.startsWith('sudden_'); + const isPast = alert.concern.startsWith('past_'); + const cls = isSudden ? 'trend-alert-sudden' : isPast ? 'trend-alert-danger' : 'trend-alert-warning'; + const arrow = isSudden ? '\u26A1' : alert.direction === 'rising' ? '\u2197' : '\u2198'; + const label = alert.concern === 'sudden_high' ? 'Sudden jump above range' + : alert.concern === 'sudden_low' ? 'Sudden drop below range' + : alert.concern === 'past_high' ? 'Above range & rising' + : alert.concern === 'past_low' ? 'Below range & falling' + : alert.concern === 'approaching_high' ? 'Approaching upper limit' + : 'Approaching lower limit'; + html += `
+ ${arrow} +
+
${escapeHTML(alert.name)} ${escapeHTML(alert.category)}
+
${label}
+
+
${alert.spark.join(' \u2192 ')}
+
`; + } + for (const f of criticalFlags) { + const cls = f.status === "high" ? "alert-high" : "alert-low"; + const label = f.status === "high" ? "\u25B2 CRITICAL HIGH" : "\u25BC CRITICAL LOW"; + html += `
+ ${label} + ${escapeHTML(f.name)} + ${escapeHTML(String(f.value))} ${escapeHTML(f.unit)} + ${formatValue(f.effectiveMin)} \u2013 ${formatValue(f.effectiveMax)}
`; + } + html += `
`; + return html; +} + +function renderDashboardNotesWidget() { + const hasNotes = state.importedData.notes && state.importedData.notes.length > 0; + let html = `
`; + html += ``; + if (hasNotes) { + const notes = state.importedData.notes + .map((note, i) => ({ note, idx: i })) + .sort((a, b) => a.note.date.localeCompare(b.note.date)); + for (const { note, idx } of notes) { + const d = new Date(note.date + 'T00:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); + const preview = escapeHTML(note.text.length > 200 ? note.text.slice(0, 200) + '...' : note.text); + html += `
+
${d}
+
${preview}
+
+ + +
+
`; + } + } else { + html += `
No notes yet. Add notes to track context around your lab results.
`; + } + html += `
`; + return html; +} + +function rerenderDashboardFromWidgetChange() { + window.navigate?.('dashboard'); +} + +export function toggleDashboardOrganizeMode(force) { + _dashboardOrganizeMode = typeof force === 'boolean' ? force : !_dashboardOrganizeMode; + rerenderDashboardFromWidgetChange(); +} + +export function moveDashboardWidget(id, direction) { + const prefs = getDashboardWidgetPrefs(); + const visible = prefs.order.filter(widgetId => !prefs.hidden.includes(widgetId)); + const visibleIndex = visible.indexOf(id); + const targetVisibleId = visible[visibleIndex + direction]; + if (visibleIndex < 0 || !targetVisibleId) return; + const from = prefs.order.indexOf(id); + const to = prefs.order.indexOf(targetVisibleId); + prefs.order.splice(from, 1); + prefs.order.splice(to, 0, id); + saveDashboardWidgetPrefs(prefs); + rerenderDashboardFromWidgetChange(); +} + +export function hideDashboardWidget(id) { + const prefs = getDashboardWidgetPrefs(); + if (!prefs.hidden.includes(id)) prefs.hidden.push(id); + saveDashboardWidgetPrefs(prefs); + rerenderDashboardFromWidgetChange(); +} + +export function showDashboardWidget(id) { + const prefs = getDashboardWidgetPrefs(); + prefs.hidden = prefs.hidden.filter(widgetId => widgetId !== id); + if (!prefs.order.includes(id)) prefs.order.push(id); + saveDashboardWidgetPrefs(prefs); + closeDashboardWidgetPicker(); + rerenderDashboardFromWidgetChange(); +} + +export function resetDashboardWidgets() { + localStorage.removeItem(dashboardWidgetStorageKey()); + _dashboardOrganizeMode = false; + rerenderDashboardFromWidgetChange(); +} + +export function clearDashboardWidgets() { + saveDashboardWidgetPrefs({ + order: [...DASHBOARD_WIDGET_IDS], + hidden: [...DASHBOARD_WIDGET_IDS], + }); + _dashboardOrganizeMode = false; + rerenderDashboardFromWidgetChange(); +} + +export function openDashboardWidgetPicker() { + closeDashboardWidgetPicker(); + const prefs = getDashboardWidgetPrefs(); + const hidden = getOrderedDashboardWidgets(prefs).filter(def => prefs.hidden.includes(def.id)); + const hiddenList = hidden.length ? hidden.map(def => ``).join('') : `
All widgets are visible.
`; + document.body.insertAdjacentHTML('beforeend', ``); +} + +export function closeDashboardWidgetPicker() { + document.getElementById('dashboard-widget-picker-overlay')?.remove(); +} + +export function startDashboardWidgetDrag(event, id) { + _draggingDashboardWidgetId = id; + event.dataTransfer?.setData('text/plain', id); + event.dataTransfer?.setDragImage?.(event.currentTarget, 20, 20); +} + +export function allowDashboardWidgetDrop(event) { + if (!_dashboardOrganizeMode) return; + event.preventDefault(); +} + +export function dropDashboardWidget(event, targetId) { + if (!_dashboardOrganizeMode) return; + event.preventDefault(); + const sourceId = event.dataTransfer?.getData('text/plain') || _draggingDashboardWidgetId; + _draggingDashboardWidgetId = null; + if (!sourceId || sourceId === targetId) return; + const prefs = getDashboardWidgetPrefs(); + const from = prefs.order.indexOf(sourceId); + const to = prefs.order.indexOf(targetId); + if (from < 0 || to < 0) return; + prefs.order.splice(from, 1); + prefs.order.splice(to, 0, sourceId); + saveDashboardWidgetPrefs(prefs); + rerenderDashboardFromWidgetChange(); +} + +Object.assign(window, { + toggleDashboardOrganizeMode, + moveDashboardWidget, + hideDashboardWidget, + showDashboardWidget, + resetDashboardWidgets, + clearDashboardWidgets, + openDashboardWidgetPicker, + closeDashboardWidgetPicker, + startDashboardWidgetDrag, + allowDashboardWidgetDrop, + dropDashboardWidget, +}); + +// ═══════════════════════════════════════════════ +// MOBILE DASHBOARD +// ═══════════════════════════════════════════════ + +const MOBILE_DASHBOARD_QUERY = '(max-width: 799px)'; +let _mobileDashboardScrollHandler = null; +let _mobileDashboardManualTabLockUntil = 0; + +function isMobileDashboardViewport() { + return typeof window !== 'undefined' + && typeof window.matchMedia === 'function' + && window.matchMedia(MOBILE_DASHBOARD_QUERY).matches; +} + +function teardownMobileDashboardScrollSpy() { + if (_mobileDashboardScrollHandler && typeof window !== 'undefined') { + window.removeEventListener('scroll', _mobileDashboardScrollHandler); + } + _mobileDashboardScrollHandler = null; +} + +function setupMobileDashboardScrollSpy() { + teardownMobileDashboardScrollSpy(); + if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') return; + let ticking = false; + const update = () => { + if (!document.body.classList.contains('mobile-dashboard-active')) return; + if (document.getElementById('chat-panel')?.classList.contains('open')) return; + if (document.getElementById('settings-modal-overlay')?.classList.contains('show')) return; + if (document.getElementById('client-list-overlay')?.classList.contains('show')) return; + const lockRemaining = _mobileDashboardManualTabLockUntil - Date.now(); + if (lockRemaining > 0) { + setTimeout(update, lockRemaining + 20); + return; + } + + const threshold = window.innerHeight * 0.35; + const sections = [ + { tab: 'labs', el: document.querySelector('.m-greeting') }, + { tab: 'body', el: document.getElementById('mobile-body-section') }, + { tab: 'genome', el: document.getElementById('mobile-genome-section') }, + ].filter(section => section.el); + + let active = 'labs'; + for (const section of sections) { + if (section.el.getBoundingClientRect().top <= threshold) active = section.tab; + } + mobileDashboardSetTab(active, { fromScroll: true }); + }; + _mobileDashboardScrollHandler = () => { + if (ticking) return; + ticking = true; + window.requestAnimationFrame(() => { + ticking = false; + update(); + }); + }; + window.addEventListener('scroll', _mobileDashboardScrollHandler, { passive: true }); + setTimeout(update, 0); +} + +if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') { + const mobileDashboardMedia = window.matchMedia(MOBILE_DASHBOARD_QUERY); + const refreshDashboardForBreakpoint = () => { + if (state.currentView === 'dashboard') window.navigate?.('dashboard'); + }; + if (typeof mobileDashboardMedia.addEventListener === 'function') { + mobileDashboardMedia.addEventListener('change', refreshDashboardForBreakpoint); + } else if (typeof mobileDashboardMedia.addListener === 'function') { + mobileDashboardMedia.addListener(refreshDashboardForBreakpoint); + } +} + +function getMobileDashboardProfile() { + const profiles = getProfiles() || []; + return profiles.find(p => p.id === state.currentProfile) || profiles[0] || { id: 'default', name: 'Default' }; +} + +function getMobileGreetingName(profile) { + const name = (profile?.name || 'there').trim(); + if (!name || name === 'Default') return 'there'; + return name.split(/\s+/)[0]; +} + +function getMobileAvatar(profile) { + const name = profile?.name || '?'; + if (profile?.avatar) { + return ``; + } + const color = window.getAvatarColor ? window.getAvatarColor(profile?.id || 'default') : 'var(--accent)'; + return `${escapeHTML(name[0]?.toUpperCase() || '?')}`; +} + +function getMobileDashboardCounts(data) { + let markerCount = 0; + let inRange = 0; + let flagged = 0; + for (const cat of Object.values(data.categories || {})) { + for (const marker of Object.values(cat.markers || {})) { + if (!markerHasData(marker)) continue; + markerCount++; + const idx = getLatestValueIndex(marker.values || []); + if (idx < 0) continue; + const value = marker.values[idx]; + const range = getEffectiveRangeForDate(marker, idx); + const status = getStatus(value, range.min, range.max); + if (status === 'normal') inRange++; + else if (status === 'high' || status === 'low') flagged++; + } + } + const latestDate = data.dates?.[data.dates.length - 1] || ''; + return { markerCount, inRange, flagged, latestDate }; +} + +function mobileStatusLabel(status) { + if (status === 'normal') return 'In range'; + if (status === 'high') return 'High'; + if (status === 'low') return 'Low'; + return 'No value'; +} + +function mobileStatusTone(status) { + if (status === 'normal') return 'good'; + if (status === 'high' || status === 'low') return 'alert'; + return 'muted'; +} + +function getMobileMarkerSummary(data, catKey, markerKey) { + const id = `${catKey}_${markerKey}`; + if (!safeMarkerId(id)) return null; + const category = data.categories?.[catKey]; + const marker = category?.markers?.[markerKey]; + if (!marker || !markerHasData(marker)) return null; + const latestIdx = getLatestValueIndex(marker.values || []); + if (latestIdx < 0) return null; + const value = marker.values[latestIdx]; + const range = getEffectiveRangeForDate(marker, latestIdx); + const status = getStatus(value, range.min, range.max); + const trend = getTrend(marker.values || [], range.min, range.max); + const labelSource = marker.singlePoint ? [marker.singleDateLabel || 'Latest'] : (data.dateLabels || data.dates || []); + state.markerRegistry[id] = marker; + return { + id, + name: marker.name || markerKey, + category: category.label || catKey, + value: formatValue(value), + unit: marker.unit || '', + date: labelSource[latestIdx] || 'Latest', + status, + statusLabel: mobileStatusLabel(status), + tone: mobileStatusTone(status), + trend, + values: marker.values || [], + }; +} + +function getMobileDashboardMarkers(ctx) { + const seen = new Set(); + const summaries = []; + const add = (catKey, markerKey, sourceData = ctx.filteredData) => { + const id = `${catKey}_${markerKey}`; + if (seen.has(id)) return; + const summary = getMobileMarkerSummary(sourceData, catKey, markerKey) + || getMobileMarkerSummary(ctx.data, catKey, markerKey); + if (!summary) return; + seen.add(id); + summaries.push(summary); + }; + + for (const km of ctx.keyMarkers || []) add(km.cat, km.key); + for (const alert of ctx.trendAlerts || []) { + const idx = alert.id.indexOf('_'); + if (idx > 0) add(alert.id.slice(0, idx), alert.id.slice(idx + 1)); + } + for (const flag of getAllFlaggedMarkers(ctx.data).slice(0, 12)) { + add(flag.categoryKey, flag.markerKey, ctx.data); + } + for (const [catKey, category] of Object.entries(ctx.filteredData.categories || {})) { + for (const markerKey of Object.keys(category.markers || {})) add(catKey, markerKey); + if (summaries.length >= 10) break; + } + return summaries.slice(0, 10); +} + +function renderMobileSparkline(values, status) { + const points = (values || []).filter(v => v !== null && Number.isFinite(Number(v))).slice(-7).map(Number); + if (points.length < 2) return ``; + const min = Math.min(...points); + const max = Math.max(...points); + const span = max - min || 1; + const coords = points.map((value, index) => { + const x = points.length === 1 ? 50 : (index / (points.length - 1)) * 100; + const y = 28 - ((value - min) / span) * 24; + return `${x.toFixed(1)},${y.toFixed(1)}`; + }).join(' '); + return ``; +} + +function renderMobileStatCard(item) { + if (item.type === 'summary') { + return `
+
${escapeHTML(item.label)}
+ ${escapeHTML(item.value)} + ${escapeHTML(item.meta)} +
`; + } + return ``; +} + +function getMobileDashboardStats(data, ctx, markers) { + const counts = getMobileDashboardCounts(data); + const cards = markers.slice(0, 4).map(marker => ({ ...marker, type: 'marker' })); + const summaryCards = [ + { type: 'summary', label: 'Attention', value: String((ctx.trendAlerts?.length || 0) + counts.flagged), meta: 'active flags' }, + { type: 'summary', label: 'In range', value: `${counts.inRange}/${counts.markerCount || 0}`, meta: 'latest values' }, + { type: 'summary', label: 'Last labs', value: counts.latestDate ? formatDate(counts.latestDate, 'short') : 'N/A', meta: `${data.dates?.length || 0} dates` }, + { type: 'summary', label: 'Markers', value: String(counts.markerCount), meta: 'tracked' }, + ]; + for (const card of summaryCards) { + if (cards.length >= 4) break; + cards.push(card); + } + return cards.slice(0, 4); +} + +function getMobileDashboardInsights(ctx, markers) { + const insights = []; + for (const flag of ctx.criticalFlags.slice(0, 2)) { + const summary = markers.find(m => m.id === flag.id); + insights.push({ + id: flag.id, + tone: 'danger', + eyebrow: flag.status === 'high' ? 'Critical high' : 'Critical low', + title: flag.name, + body: `${formatValue(flag.rawValue)} ${flag.unit || ''} is outside the active range.`, + meta: summary?.trend?.arrow || summary?.date || '', + }); + } + for (const alert of ctx.trendAlerts.slice(0, 3)) { + if (insights.length >= 3) break; + insights.push({ + id: alert.id, + tone: alert.concern.startsWith('past_') || alert.concern.startsWith('sudden_') ? 'warn' : 'info', + eyebrow: 'Trend', + title: alert.name, + body: alert.concern.replace(/_/g, ' '), + meta: (alert.spark || []).join(' -> '), + }); + } + if (insights.length === 0) { + insights.push({ + tone: 'good', + eyebrow: 'Snapshot', + title: 'No urgent trend alerts', + body: 'Latest high-priority markers are not showing sudden range breaks.', + meta: `${markers.length} markers in the mobile watch list`, + }); + } + return insights.slice(0, 3); +} + +function renderMobileInsightCard(insight) { + const body = `${escapeHTML(insight.eyebrow)} + ${escapeHTML(insight.title)} + ${escapeHTML(insight.body)} + ${insight.meta ? `${escapeHTML(insight.meta)}` : ''}`; + if (insight.id && safeMarkerId(insight.id)) { + return ``; + } + return `
${body}
`; +} + +function renderMobileMarkerRow(marker) { + return ``; +} + +const MOBILE_WEARABLE_PRIORITY = [ + 'hrv_rmssd', + 'sleep_score', + 'readiness_score', + 'steps', + 'rhr', + 'weight', + 'body_fat_pct', + 'bp_systolic', +]; + +function formatMobileWearableValue(metricId, metric, summary) { + if (metricId === 'bp_systolic' && summary?.metrics?.bp_diastolic?.latest != null) { + return `${formatValue(metric.latest)}/${formatValue(summary.metrics.bp_diastolic.latest)}`; + } + const value = Number(metric?.latest); + if (!Number.isFinite(value)) return '—'; + if (metricId === 'steps' && Math.abs(value) >= 1000) { + return `${(value / 1000).toFixed(value >= 10000 ? 0 : 1).replace(/\.0$/, '')}k`; + } + return formatValue(value); +} + +function formatMobileWearableDelta(metricId, metric, canon) { + const latest = Number(metric?.latest); + const baseline = Number(metric?.baseline); + if (!Number.isFinite(latest) || !Number.isFinite(baseline) || baseline === 0) return ''; + if (metricId === 'steps') return ''; + if (canon?.sub === 'Δ') { + const diff = latest - baseline; + const arrow = diff > 0.005 ? '↑' : diff < -0.005 ? '↓' : '→'; + return `${arrow} ${Math.abs(diff).toFixed(2)}${canon.unit || ''}`; + } + const pct = ((latest - baseline) / baseline) * 100; + if (Math.abs(pct) < 0.5) return '→ baseline'; + const arrow = pct > 0 ? '↑' : '↓'; + return `${arrow} ${Math.abs(pct).toFixed(0)}%`; +} + +function getMobileWearableTiles() { + const summary = state.importedData?.wearableSummary; + if (!summary?.metrics || Object.keys(summary.metrics).length === 0) return []; + const sourceIds = Object.keys(summary.sources || {}); + const registryOrder = metricsForSources(sourceIds.length ? sourceIds : Object.keys(summary.metrics || {})); + const ordered = [ + ...MOBILE_WEARABLE_PRIORITY, + ...registryOrder, + ...Object.keys(summary.metrics || {}), + ]; + const seen = new Set(); + const tiles = []; + for (const metricId of ordered) { + if (seen.has(metricId)) continue; + seen.add(metricId); + if (metricId === 'bp_diastolic' && summary.metrics.bp_systolic) continue; + const metric = summary.metrics[metricId]; + const canon = canonicalMetric(metricId); + if (!metric || !canon || metric.latest == null) continue; + tiles.push({ + id: metricId, + label: canon.label, + value: formatMobileWearableValue(metricId, metric, summary), + unit: metricId === 'bp_systolic' ? 'mmHg' : (canon.unit || canon.sub || ''), + change: formatMobileWearableDelta(metricId, metric, canon), + }); + if (tiles.length >= 4) break; + } + return tiles; +} + +function renderMobileWearableTiles(tiles) { + if (!tiles.length) { + return `
+ Connect body data + HRV, sleep, steps, recovery and body composition can sit next to your labs. + +
`; + } + return `
+ ${tiles.map(tile => ``).join('')} +
`; +} + +function renderMobileSectionHead(title, count, actionLabel = '', action = '') { + return `
+ + ${actionLabel && action ? `` : ''} +
`; +} + +export function mobileDashboardSetTab(tab, { fromScroll = false } = {}) { + if (!fromScroll) _mobileDashboardManualTabLockUntil = Date.now() + 600; + document.querySelectorAll('.m-tab').forEach(btn => { + btn.classList.toggle('active', btn.dataset.tab === tab); + }); +} + +function renderMobileBottomTabs() { + return ``; +} + +export function openMobileDashboardSearch() { + if (window.toggleMobileSidebar) window.toggleMobileSidebar(); + setTimeout(() => document.getElementById('sidebar-search')?.focus(), 80); +} + +export function mobileDashboardJump(section) { + if (section === 'genome') { + const target = document.getElementById('mobile-genome-section') || document.getElementById('genetics-section'); + if (target) target.scrollIntoView({ behavior: 'auto', block: 'start' }); + else window.triggerDNAFilePicker?.(); + return; + } + if (section === 'body') { + const target = document.getElementById('mobile-body-section') || document.querySelector('.wearable-strip'); + if (target) target.scrollIntoView({ behavior: 'auto', block: 'start' }); + else window.openSettingsModal?.('wearables'); + } +} + +function renderMobileDashboard(data, { resetScroll = false } = {}) { + const main = document.getElementById("main-content"); + if (!main) return; + const ctx = buildDashboardWidgetContext(data); + const profile = getMobileDashboardProfile(); + const markers = getMobileDashboardMarkers(ctx); + const stats = getMobileDashboardStats(data, ctx, markers); + const insights = getMobileDashboardInsights(ctx, markers); + const lightHtml = renderLightTodayStrip(); + const wearableTiles = getMobileWearableTiles(); + const geneticsHtml = renderGeneticsSection(); + const watchRows = markers.slice(0, 7).map(renderMobileMarkerRow).join(''); + const firstName = getMobileGreetingName(profile); + const counts = getMobileDashboardCounts(data); + const greetingSub = [ + `${counts.markerCount || 0} markers`, + counts.latestDate ? `last draw ${formatDate(counts.latestDate, 'short')}` : '', + `${data.dates?.length || 0} draw${data.dates?.length === 1 ? '' : 's'}`, + ].filter(Boolean).join(' · '); + + document.body.classList.add('mobile-dashboard-active'); + main.innerHTML = `
+
+ +
+
+
+ getbased + ${escapeHTML(profile?.name || 'Default')} +
+
+ + +
+
+ +
+

Hey ${escapeHTML(firstName)}.

+
${escapeHTML(greetingSub)}
+
+ +
+ ${stats.map(renderMobileStatCard).join('')} +
+ +
+ ${renderMobileSectionHead('Insights', String(insights.length), 'Ask AI', 'window.openChatPanel && window.openChatPanel()')} +
${insights.map(renderMobileInsightCard).join('')}
+
+ +
+ ${renderMobileSectionHead('Watch list', String(Math.min(markers.length, 7)), 'Explore', "window.navigate('correlations')")} +
${watchRows || '
No markers in the current range.
'}
+
+ +
+ ${renderMobileSectionHead('Today', wearableTiles.length ? `synced body data` : 'not connected', 'Connect', "window.openSettingsModal && window.openSettingsModal('wearables')")} + ${renderMobileWearableTiles(wearableTiles)} +
+ + ${lightHtml ? `
+ ${renderMobileSectionHead('Light', 'sun + devices', 'Open', "window.navigate('light')")} +
${lightHtml}
+
` : ''} + + ${geneticsHtml ? `
+ ${renderMobileSectionHead('Genome', 'DNA context', 'Import', 'window.triggerDNAFilePicker && window.triggerDNAFilePicker()')} +
${geneticsHtml}
+
` : ''} +
+ + ${renderMobileBottomTabs()} +
`; + + if (resetScroll && typeof window.scrollTo === 'function') { + window.scrollTo(0, 0); + } + setupDropZone(); + setupMobileDashboardScrollSpy(); + loadContextHealthDots(); + if (window.loadContextCardTips) window.loadContextCardTips(); + loadCommitHash(); + if (window.loadCatalog) window.loadCatalog().then(c => { window._cachedCatalog = c; }); +} + +Object.assign(window, { + openMobileDashboardSearch, + mobileDashboardJump, + mobileDashboardSetTab, +}); + // ═══════════════════════════════════════════════ // DASHBOARD // ═══════════════════════════════════════════════ @@ -2705,6 +4051,9 @@ export function showDashboard(data) { if (window.ensureActiveDeviceTicker) try { window.ensureActiveDeviceTicker(); } catch (e) {} if (!data) data = getActiveData(); const main = document.getElementById("main-content"); + const wasMobileDashboardActive = document.body.classList.contains('mobile-dashboard-active'); + teardownMobileDashboardScrollSpy(); + document.body.classList.remove('mobile-dashboard-active'); const hasData = data.dates.length > 0 || Object.values(data.categories).some(c => c.singlePoint && c.singleDate); // Show/hide import FAB based on whether dashboard has data @@ -2792,8 +4141,13 @@ export function showDashboard(data) { // firing — openChatPanel idempotently re-toggles chat-panel-fullscreen // from localStorage, which would stomp manual class state set by tests // (or any other in-flight UI gesture). - if (state.chatHistory.length === 0) { + if (state.chatHistory.length === 0 && Date.now() > _suppressEmptyDashboardChatUntil) { setTimeout(() => { + if (Date.now() <= _suppressEmptyDashboardChatUntil) return; + const latestData = getActiveData(); + const stillEmpty = latestData.dates.length === 0 + && !Object.values(latestData.categories).some(c => c.singlePoint && c.singleDate); + if (!stillEmpty || window._demoLoadingProfileId) return; if (!document.getElementById('chat-panel')?.classList.contains('open')) { window.openChatPanel?.(); } @@ -2802,145 +4156,23 @@ export function showDashboard(data) { return; } - // ── Has data: full dashboard ── - let html = `

Dashboard Overview

-

Summary of all results across ${data.dates.length} collection date${data.dates.length !== 1 ? 's' : ''}

`; - // Drop zone hidden element for drag-drop + file input (no visible space on dashboard) - html += `
`; - - // ── 2. Onboarding Banner (Step 2) ── - html += renderOnboardingBanner(); - html += renderAIConnectionReminder(); - - // Knowledge Base is now discoverable via the dashboard CTA pill - // ("Connect a knowledge base") and lives in its own dedicated modal — - // see openKnowledgeBaseModal() in lens.js. No banner needed here. - - // ── 3. Interpretive Lens ── - html += renderInterpretiveLensSection(); - - // ── 3b. Focus Card (always render if data exists — shows cached insight even when AI is paused) ── - html += renderFocusCard(); - - // ── 3b1. Light Today strip (Light & Sun lens — appears once sessions exist or in solar windows) ── - html += renderLightTodayStrip(); - - // ── 3c. Wearable strip (Oura · Withings · Ultrahuman · WHOOP · Fitbit · Apple Health) ── - html += renderWearableStrip(); - - // ── 4. Profile Context Cards ── - html += renderProfileContextCards(); - - // ── 5. Menstrual Cycle (female only) ── - if (state.profileSex === 'female') html += renderMenstrualCycleSection(data); - - // ── 6. Supplements & Medications ── - html += renderSupplementsSection(); - - // ── 7. Key Trends ── - const filteredData = filterDatesByRange(data); - html += `
-

Key Trends

-

Auto-selected from your data

- ${renderDateRangeFilter()} - ${renderChartLayersDropdown()} -
`; - - const keyMarkers = getKeyTrendMarkers(filteredData); - if (keyMarkers.length > 0) { - html += `
`; - for (const km of keyMarkers) { - const marker = filteredData.categories[km.cat].markers[km.key]; - html += renderChartCard(km.cat + "_" + km.key, marker, filteredData.dateLabels); - } - html += `
`; - } - - // ── 7b. Genetics (static data, after dynamic trends) ── - html += renderGeneticsSection(); - - // ── 8. Trends & Critical Flags ── - const trendAlerts = detectTrendAlerts(filteredData); - const trendMarkerIds = new Set(trendAlerts.map(a => a.id)); - const allFlags = getAllFlaggedMarkers(data); - // Critical flags always use reference range (not optimal) — critical is a medical concept - const criticalFlags = allFlags.filter(f => { - if (trendMarkerIds.has(f.id)) return false; - const refRange = f.refMax - f.refMin; - if (refRange <= 0 || f.refMin == null || f.refMax == null) return false; - const distance = f.status === 'high' ? (f.rawValue - f.refMax) : (f.refMin - f.rawValue); - return distance > refRange * 0.5; - }); - const totalAttention = trendAlerts.length + criticalFlags.length; - if (totalAttention > 0) { - html += `
Trends & Alerts (${totalAttention})
`; - for (const alert of trendAlerts) { - const isSudden = alert.concern.startsWith('sudden_'); - const isPast = alert.concern.startsWith('past_'); - const cls = isSudden ? 'trend-alert-sudden' : isPast ? 'trend-alert-danger' : 'trend-alert-warning'; - const arrow = isSudden ? '\u26A1' : alert.direction === 'rising' ? '\u2197' : '\u2198'; - const label = alert.concern === 'sudden_high' ? 'Sudden jump above range' - : alert.concern === 'sudden_low' ? 'Sudden drop below range' - : alert.concern === 'past_high' ? 'Above range & rising' - : alert.concern === 'past_low' ? 'Below range & falling' - : alert.concern === 'approaching_high' ? 'Approaching upper limit' - : 'Approaching lower limit'; - html += `
- ${arrow} -
-
${escapeHTML(alert.name)} ${escapeHTML(alert.category)}
-
${label}
-
-
${alert.spark.join(' \u2192 ')}
-
`; - } - for (const f of criticalFlags) { - const cls = f.status === "high" ? "alert-high" : "alert-low"; - const label = f.status === "high" ? "\u25B2 CRITICAL HIGH" : "\u25BC CRITICAL LOW"; - html += `
- ${label} - ${escapeHTML(f.name)} - ${escapeHTML(String(f.value))} ${escapeHTML(f.unit)} - ${formatValue(f.effectiveMin)} \u2013 ${formatValue(f.effectiveMax)}
`; - } - html += `
`; + if (isMobileDashboardViewport()) { + renderMobileDashboard(data, { resetScroll: !wasMobileDashboardActive }); + return; } - // ── 9. Notes (bottom) ── - const hasNotes = state.importedData.notes && state.importedData.notes.length > 0; - { - const noteCount = (state.importedData.notes || []).length; - const noteBadge = noteCount > 0 ? ` (${noteCount})` : ''; - html += `
Notes${noteBadge}
`; - html += `
`; - html += ``; - if (hasNotes) { - const notes = state.importedData.notes - .map((note, i) => ({ note, idx: i })) - .sort((a, b) => a.note.date.localeCompare(b.note.date)); - for (const { note, idx } of notes) { - const d = new Date(note.date + 'T00:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); - const preview = escapeHTML(note.text.length > 200 ? note.text.slice(0, 200) + '...' : note.text); - html += `
-
${d}
-
${preview}
-
- - -
-
`; - } - } else { - html += `
No notes yet — add notes to track context around your lab results
`; - } - html += `
`; - } + // ── Has data: full dashboard, rendered through modular widgets ── + const dashboardCtx = buildDashboardWidgetContext(data); + const dashboardTitle = 'Dashboard Overview'; + let html = renderDashboardWidgets(dashboardCtx, dashboardTitle); main.innerHTML = html; - for (const km of keyMarkers) { - const marker = filteredData.categories[km.cat].markers[km.key]; - createLineChart(km.cat + "_" + km.key, marker, filteredData.dateLabels, filteredData.dates, filteredData.phaseLabels); + for (const km of dashboardCtx.keyMarkers) { + const id = km.cat + "_" + km.key; + if (!document.getElementById("chart-" + id)) continue; + const marker = dashboardCtx.filteredData.categories[km.cat].markers[km.key]; + createLineChart(id, marker, dashboardCtx.filteredData.dateLabels, dashboardCtx.filteredData.dates, dashboardCtx.filteredData.phaseLabels); } setupDropZone(); @@ -3969,10 +5201,49 @@ export function showDetailModal(id, opts = {}) { // Remember which marker is open so toggleAltUnits can re-render in place. state._activeDetailMarkerId = id; rememberModalTrigger(); - const modal = document.getElementById("detail-modal"); + const modal = setDetailModalShell('marker-detail-modal'); const overlay = document.getElementById("modal-overlay"); + if (!modal) return; const dates = marker.singlePoint ? [marker.singleDateLabel || "N/A"] : data.dateLabels; const r = getEffectiveRange(marker); + const modalPoints = marker.values.map((v, i) => ({ v, i })).filter(x => x.v !== null && x.v !== undefined); + const latestPoint = modalPoints[modalPoints.length - 1] || null; + const prevPoint = modalPoints.length > 1 ? modalPoints[modalPoints.length - 2] : null; + const firstPoint = modalPoints[0] || null; + const latestRange = latestPoint ? getEffectiveRangeForDate(marker, latestPoint.i) : r; + const latestStatus = latestPoint ? getStatus(latestPoint.v, latestRange.min, latestRange.max) : 'missing'; + const statusText = latestStatus === 'normal' ? 'In range' + : latestStatus === 'high' ? 'Above range' + : latestStatus === 'low' ? 'Below range' + : 'No value'; + const deltaFromPrev = latestPoint && prevPoint && Number(prevPoint.v) !== 0 + ? (((Number(latestPoint.v) - Number(prevPoint.v)) / Number(prevPoint.v)) * 100) + : null; + const deltaFromFirst = latestPoint && firstPoint && Number(firstPoint.v) !== 0 + ? (((Number(latestPoint.v) - Number(firstPoint.v)) / Number(firstPoint.v)) * 100) + : null; + const latestUnit = marker.unit || ''; + const latestDisplay = latestPoint ? formatValue(latestPoint.v) : '—'; + const latestDateLabel = latestPoint ? (dates[latestPoint.i] || 'Latest') : 'No values'; + const rangeDisplay = `${latestRange.min != null ? formatValue(latestRange.min) : '—'}–${latestRange.max != null ? formatValue(latestRange.max) : '—'} ${latestUnit}`.trim(); + const optimalDisplay = `${marker.optimalMin != null ? formatValue(marker.optimalMin) : '—'}–${marker.optimalMax != null ? formatValue(marker.optimalMax) : '—'} ${latestUnit}`.trim(); + const clampPct = value => Math.max(0, Math.min(100, value)); + const rangeBandHtml = (() => { + const min = latestRange.min; + const max = latestRange.max; + if (min == null || max == null || Number(max) === Number(min) || !latestPoint) return ''; + const span = Number(max) - Number(min); + const dot = clampPct(((Number(latestPoint.v) - Number(min)) / span) * 100); + const optStart = marker.optimalMin != null ? clampPct(((Number(marker.optimalMin) - Number(min)) / span) * 100) : null; + const optEnd = marker.optimalMax != null ? clampPct(((Number(marker.optimalMax) - Number(min)) / span) * 100) : null; + const optWidth = optStart != null && optEnd != null ? Math.max(2, optEnd - optStart) : 0; + return `
+
+ ${optWidth ? `
` : ''} +
+
${escapeHTML(formatValue(min))}${escapeHTML(formatValue(max))}
+
`; + })(); const dotKey = id.replace('_', '.'); let rangeInfo = ''; const overrides = state.importedData?.refOverrides?.[dotKey] || {}; @@ -4040,11 +5311,32 @@ export function showDetailModal(id, opts = {}) { } } let html = ` -

${escapeHTML(marker.name)}${renameLink}

- - ${altUnitInfo} +
+
+
${escapeHTML(data.categories[catKey]?.label || catKey)}
+

${escapeHTML(marker.name)}${renameLink}

+ + ${altUnitInfo} +
+ ${escapeHTML(statusText)} +
+
+
+
Latest
+
${escapeHTML(latestDisplay)}${latestUnit ? ` ${escapeHTML(latestUnit)}` : ''}
+
${escapeHTML(latestDateLabel)}${deltaFromPrev != null ? ` · ${deltaFromPrev >= 0 ? '+' : ''}${deltaFromPrev.toFixed(1)}% vs prev` : ''}
+
+
+
Ranges
+
${escapeHTML(optimalDisplay)} optimal
+
Ref ${escapeHTML(rangeDisplay)}${deltaFromFirst != null ? ` · ${deltaFromFirst >= 0 ? '+' : ''}${deltaFromFirst.toFixed(1)}% vs first` : ''}
+
+
+ ${rangeBandHtml} +
Trend
+
History