inou/portal/templates/dossier.tmpl

1057 lines
44 KiB
Cheetah
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{{define "dossier"}}
<div class="sg-container">
<div class="dossier-header" style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 32px;">
<div>
<h1 style="font-size: 2.5rem; font-weight: 700; margin-bottom: 8px;">{{.TargetDossier.Name}}</h1>
{{if .ShowDetails}}
<p style="font-size: 1.15rem; font-weight: 300; color: var(--text-muted);">
{{if .TargetDossier.DateOfBirth}}{{.T.born}}: {{printf "%.10s" .TargetDossier.DateOfBirth}}{{end}}
{{if .TargetDossier.Sex}} · {{sexT .TargetDossier.Sex .Lang}}{{end}}
</p>
{{end}}
</div>
<a href="/dashboard" class="btn btn-secondary btn-small">← {{.T.back_to_dossiers}}</a>
</div>
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
{{if .Success}}<div class="success">{{.Success}}</div>{{end}}
{{/* Render all sections using unified template */}}
{{range .Sections}}
{{template "section_block" .}}
{{end}}
{{/* Privacy section - special structure */}}
<div class="data-card">
<div class="data-card-header">
<div class="data-card-indicator privacy"></div>
<div class="data-card-title">
<span class="section-heading">{{$.T.section_privacy}}</span>
<span class="data-card-summary">{{len $.AccessList}} {{$.T.people_with_access_count}}</span>
</div>
</div>
<div class="data-table">
{{range $.AccessList}}
<div class="data-row">
<div class="data-row-main">
<span class="data-label">{{.Name}}{{if .IsSelf}} ({{$.T.you}}){{else if .IsPending}} ({{$.T.pending}}){{end}}</span>
<span class="data-meta">{{.Relation}}{{if .CanEdit}} · {{$.T.can_edit}}{{end}}</span>
</div>
{{if and $.CanManageAccess (not .IsSelf)}}
<div style="display: flex; gap: 8px;">
<a href="/dossier/{{$.TargetDossier.DossierID}}/rbac/{{.DossierID}}" class="btn btn-secondary btn-small">Edit</a>
<form action="/dossier/{{$.TargetDossier.DossierID}}/revoke" method="POST" style="display:inline;">
<input type="hidden" name="accessor_id" value="{{.DossierID}}">
<button type="submit" class="btn btn-danger btn-small" onclick="return confirm('Remove access for {{.Name}} to {{$.TargetDossier.Name}}?')">{{$.T.remove}}</button>
</form>
</div>
{{end}}
</div>
{{end}}
{{if not $.AccessList}}
<div class="data-row">
<span class="text-muted">{{$.T.no_access_yet}}</span>
</div>
{{end}}
<div class="data-row privacy-actions">
<a href="/dossier/{{$.TargetDossier.DossierID}}/share" class="privacy-action">{{$.T.share_access}}</a>
{{if $.CanManageAccess}}<a href="/dossier/{{$.TargetDossier.DossierID}}/permissions" class="privacy-action">{{$.T.manage_permissions}}</a>{{end}}
<a href="/dossier/{{$.TargetDossier.DossierID}}/audit" class="privacy-action">{{$.T.view_audit_log}}</a>
{{if or (eq $.Dossier.DossierID $.TargetDossier.DossierID) $.CanManageAccess}}<a href="/dossier/{{$.TargetDossier.DossierID}}/export" class="privacy-action">{{$.T.export_data}}</a>{{end}}
</div>
</div>
</div>
{{template "footer"}}
</div>
{{/* Document Side Pane */}}
<div id="doc-pane-overlay" class="doc-pane-overlay" style="display:none;" onclick="closeDocPane()"></div>
<div id="doc-pane" class="doc-pane" style="display:none;">
<div class="doc-pane-header">
<span class="doc-pane-title">Source Document</span>
<div id="doc-pane-tabs" class="doc-pane-tabs" style="display:none;">
<button class="doc-pane-tab active" onclick="switchDocTab('original')">Original</button>
<button class="doc-pane-tab" onclick="switchDocTab('translated')">Translated</button>
</div>
<a id="doc-pane-pdf" class="btn btn-small btn-secondary" href="#" target="_blank" style="display:none; margin-left:auto; margin-right:8px;">PDF</a>
<button class="btn-icon" onclick="closeDocPane()">&times;</button>
</div>
<div class="doc-pane-body" id="doc-pane-body"></div>
</div>
{{/* Genetics Warning Modal */}}
<div id="genetics-warning-modal" class="modal" style="display:none;">
<div class="modal-content" style="max-width: 520px;">
<h3 style="margin-bottom: 16px;">⚠️ Before you continue</h3>
<p style="margin-bottom: 16px;">Here you can browse all your raw genetic variants. However, the real value comes from using <a href="/connect">Claude and other LLMs with your health dossier</a> — they can interpret these variants and correlate them with your labs, imaging, and medical history.</p>
<p style="margin-bottom: 12px;"><strong>Keep in mind:</strong></p>
<ul style="margin-bottom: 16px; padding-left: 20px; line-height: 1.6;">
<li>Many associations are based on early or limited research</li>
<li>A "risk variant" means slightly higher odds — not a diagnosis</li>
<li>Consumer tests (23andMe, AncestryDNA) can have false positives</li>
</ul>
<p style="margin-bottom: 20px;">These findings can be a starting point for conversations with your doctor — especially if certain conditions run in your family.</p>
<div style="display: flex; gap: 12px; justify-content: flex-end;">
<button class="btn btn-secondary" onclick="closeGeneticsWarning()">Close</button>
<button class="btn btn-primary" onclick="confirmShowAllGenetics()">I understand, show all</button>
</div>
</div>
</div>
<script>
const dossierGUID = "{{.TargetDossier.DossierID}}";
const userLang = "{{.Lang}}";
let labRefData = {};
let loincNames = {};
// Document side pane
let docCache = {}; // docID → {html, htmlTranslated, markdown, hasPDF, hasTranslation}
let currentDocID = null;
let currentDocTab = 'original';
async function openDocPane(docID, spansJSON, fallbackText) {
const pane = document.getElementById('doc-pane');
const overlay = document.getElementById('doc-pane-overlay');
const body = document.getElementById('doc-pane-body');
pane.style.display = '';
overlay.style.display = '';
document.body.style.overflow = 'hidden';
currentDocID = docID;
currentDocTab = 'original';
// Reset PDF button and tabs
const pdfBtn = document.getElementById('doc-pane-pdf');
if (pdfBtn) pdfBtn.style.display = 'none';
const tabs = document.getElementById('doc-pane-tabs');
if (tabs) tabs.style.display = 'none';
let doc;
if (docCache[docID]) {
doc = docCache[docID];
} else {
body.innerHTML = '<p class="text-muted">Loading...</p>';
try {
const resp = await fetch(`/dossier/${dossierGUID}/document/${docID}`);
if (!resp.ok) throw new Error('Not found');
const data = await resp.json();
doc = {
html: markdownToHTML(data.markdown || ''),
markdown: data.markdown || '',
hasPDF: data.has_pdf,
hasTranslation: !!data.markdown_translated,
htmlTranslated: data.markdown_translated ? markdownToHTML(data.markdown_translated) : '',
};
docCache[docID] = doc;
} catch (e) {
body.innerHTML = '<p class="text-muted">Could not load document</p>';
return;
}
}
body.innerHTML = doc.html;
// Show PDF button if original is available
if (doc.hasPDF && pdfBtn) {
pdfBtn.href = `/dossier/${dossierGUID}/document/${docID}?pdf=1`;
pdfBtn.style.display = '';
}
// Show translation tabs if translated version exists
if (doc.hasTranslation && tabs) {
tabs.style.display = '';
tabs.querySelectorAll('.doc-pane-tab').forEach(t => t.classList.toggle('active', t.textContent === 'Original'));
}
// Try source_spans first, fall back to keyword matching
let spans = [];
try { if (spansJSON) spans = JSON.parse(spansJSON); } catch(e) {}
if (spans.length > 0) {
highlightBySpans(body, doc.markdown, spans);
} else if (fallbackText) {
highlightByKeywords(body, fallbackText);
}
}
function switchDocTab(tab) {
if (!currentDocID || !docCache[currentDocID]) return;
currentDocTab = tab;
const doc = docCache[currentDocID];
const body = document.getElementById('doc-pane-body');
body.innerHTML = tab === 'translated' ? doc.htmlTranslated : doc.html;
document.querySelectorAll('#doc-pane-tabs .doc-pane-tab').forEach(t => {
t.classList.toggle('active', (tab === 'original' && t.textContent === 'Original') || (tab === 'translated' && t.textContent === 'Translated'));
});
}
function closeDocPane() {
document.getElementById('doc-pane').style.display = 'none';
document.getElementById('doc-pane-overlay').style.display = 'none';
document.body.style.overflow = '';
}
// Highlight using LLM-provided source spans (start/end verbatim text)
function highlightBySpans(body, markdown, spans) {
body.querySelectorAll('.doc-highlight').forEach(el => el.classList.remove('doc-highlight'));
const paras = body.querySelectorAll('p, li, h2, h3, h4');
let firstHighlight = null;
for (const span of spans) {
if (!span.start) continue;
// Extract significant words (LLM may paraphrase, so match by keywords)
const startWords = extractWords(span.start);
const endWords = extractWords(span.end || '');
// Score each paragraph for start/end match
let inSpan = false;
for (const el of paras) {
const t = el.textContent.toLowerCase();
if (!inSpan && fuzzyMatch(t, startWords) >= 0.6) {
inSpan = true;
el.classList.add('doc-highlight');
if (!firstHighlight) firstHighlight = el;
if (endWords.length === 0 || fuzzyMatch(t, endWords) >= 0.6) { inSpan = false; continue; }
} else if (inSpan) {
el.classList.add('doc-highlight');
if (endWords.length > 0 && fuzzyMatch(t, endWords) >= 0.6) inSpan = false;
}
}
}
if (firstHighlight) firstHighlight.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
// Extract significant words (>= 4 chars) from text, lowercased
function extractWords(text) {
if (!text) return [];
return text.toLowerCase().split(/[\s,.:;()\-\/]+/).filter(w => w.length >= 4);
}
// Fuzzy match: fraction of keywords found in text (0.0 - 1.0)
function fuzzyMatch(text, keywords) {
if (keywords.length === 0) return 0;
let hits = 0;
for (const kw of keywords) { if (text.includes(kw)) hits++; }
return hits / keywords.length;
}
// Fallback: highlight by keyword matching
function highlightByKeywords(body, text) {
body.querySelectorAll('.doc-highlight').forEach(el => el.classList.remove('doc-highlight'));
const skip = new Set(['the','and','for','with','from','that','this','was','age','bei','und','der','die','des','den','dem','mit','von','aus','nach','zur','zum','ein','eine','eines','einer']);
const keywords = text.split(/[\s,.:;()\-\/]+/).map(w => w.trim()).filter(w => w.length >= 3 && !skip.has(w.toLowerCase()));
if (keywords.length === 0) return;
const paras = body.querySelectorAll('p, li, h2, h3, h4');
let bestEl = null, bestScore = 0;
paras.forEach(el => {
const t = el.textContent.toLowerCase();
let score = 0;
for (const kw of keywords) { if (t.includes(kw.toLowerCase())) score++; }
if (score > bestScore) { bestScore = score; bestEl = el; }
});
if (bestEl && bestScore > 0) {
bestEl.classList.add('doc-highlight');
bestEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
// Markdown→HTML with inline formatting
function markdownToHTML(md) {
// Inline formatting: escape then apply bold/italic
function inline(text) {
let s = esc(text);
s = s.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
s = s.replace(/\*(.+?)\*/g, '<em>$1</em>');
return s;
}
let inTable = false;
let tableRows = [];
function flushTable() {
if (tableRows.length === 0) return '';
// First row = header, skip separator row (---|---)
let html = '<table class="doc-table"><thead><tr>';
const headers = tableRows[0].split('|').map(c => c.trim()).filter(c => c);
for (const h of headers) html += `<th>${inline(h)}</th>`;
html += '</tr></thead><tbody>';
for (let i = 1; i < tableRows.length; i++) {
if (tableRows[i].match(/^[\s|:-]+$/)) continue; // skip separator
const cells = tableRows[i].split('|').map(c => c.trim()).filter(c => c);
html += '<tr>';
for (const c of cells) html += `<td>${inline(c)}</td>`;
html += '</tr>';
}
html += '</tbody></table>';
tableRows = [];
return html;
}
const lines = md.split('\n');
let out = [];
for (const line of lines) {
// Table rows
if (line.includes('|') && line.trim().startsWith('|')) {
tableRows.push(line);
continue;
}
if (tableRows.length > 0) out.push(flushTable());
if (line.match(/^---+$/)) { out.push('<hr>'); continue; }
if (line.match(/^#### /)) { out.push(`<h5>${inline(line.slice(5))}</h5>`); continue; }
if (line.match(/^### /)) { out.push(`<h4>${inline(line.slice(4))}</h4>`); continue; }
if (line.match(/^## /)) { out.push(`<h3>${inline(line.slice(3))}</h3>`); continue; }
if (line.match(/^# /)) { out.push(`<h2>${inline(line.slice(2))}</h2>`); continue; }
if (line.match(/^[-*] /)) { out.push(`<li>${inline(line.slice(2))}</li>`); continue; }
if (line.match(/^\d+\. /)) { out.push(`<li>${inline(line.replace(/^\d+\.\s*/, ''))}</li>`); continue; }
if (line.trim() === '') { out.push('<br>'); continue; }
out.push(`<p>${inline(line)}</p>`);
}
if (tableRows.length > 0) out.push(flushTable());
return out.join('\n');
}
// Section expand/collapse (with lazy loading for labs)
function toggleSection(el) {
el.classList.toggle('expanded');
const icon = el.querySelector('.expand-icon');
if (icon) icon.textContent = el.classList.contains('expanded') ? '' : '+';
const children = el.nextElementSibling;
if (!children || !children.classList.contains('section-children')) return;
children.classList.toggle('show');
// Lazy-load lab children on first expand
const card = el.closest('.data-card');
const entryID = el.dataset.entryId;
if (card && card.id === 'section-labs' && entryID && !children.dataset.loaded && el.classList.contains('expanded')) {
children.dataset.loaded = 'true';
children.innerHTML = '<div class="data-row child"><span class="text-muted">Loading...</span></div>';
fetch(`/dossier/${dossierGUID}/labs?order=${entryID}`)
.then(r => r.json())
.then(data => {
// Merge ref data
Object.assign(labRefData, data.refs || {});
let html = '';
for (const c of (data.children || [])) {
html += `<div class="data-row child"${c.loinc ? ` data-loinc="${c.loinc}"` : ''}><span class="data-label">${esc(c.label)}</span></div>`;
}
children.innerHTML = html || '<div class="data-row child"><span class="text-muted">No results</span></div>';
})
.catch(() => { children.innerHTML = '<div class="data-row child"><span class="text-muted">Error loading</span></div>'; });
}
}
// Format dates from YYYYMMDD
document.querySelectorAll('[data-date]').forEach(el => {
const d = el.dataset.date;
if (d && d.length === 8) {
const date = new Date(+d.slice(0,4), +d.slice(4,6)-1, +d.slice(6,8));
el.textContent = date.toLocaleDateString();
}
});
// Lab search: debounced fetch from server
let labSearchTimeout;
let labOriginalHTML = null; // saved order list for restore on clear
function clearSearch(btn) {
const input = btn.parentElement.querySelector('.search-input');
input.value = '';
filterSection(input);
input.focus();
}
function filterSection(input) {
const card = input.closest('.data-card');
const sectionId = card.id.replace('section-', '');
if (sectionId === 'labs') {
clearTimeout(labSearchTimeout);
const q = input.value.trim();
const table = card.querySelector('.data-table');
if (q.length < 2) {
// Restore original order list
if (table && labOriginalHTML !== null) {
table.innerHTML = labOriginalHTML;
labOriginalHTML = null;
}
const chart = card.querySelector('.filter-chart');
if (chart) chart.style.display = 'none';
return;
}
// Save original HTML before first search
if (table && labOriginalHTML === null) {
labOriginalHTML = table.innerHTML;
}
labSearchTimeout = setTimeout(() => loadLabResults(card, q), 300);
return;
}
// Non-lab sections: client-side filtering
const table = card.querySelector('.data-table');
if (!table) return;
const q = input.value.toLowerCase().trim();
const rows = table.querySelectorAll('.data-row, .section-children');
const showMore = table.querySelector('.show-more');
if (!q) {
rows.forEach(row => {
if (row.classList.contains('section-children')) {
row.classList.remove('show');
row.style.display = '';
row.querySelectorAll('.data-row.child').forEach(c => c.style.display = '');
} else if (row.classList.contains('show-more')) {
return;
} else {
row.style.display = '';
row.classList.remove('expanded');
const icon = row.querySelector('.expand-icon');
if (icon) icon.textContent = '+';
const idx = parseInt(row.dataset.index);
if (idx > 4) row.classList.add('hidden-row');
}
});
if (showMore) showMore.style.display = '';
return;
}
if (showMore) showMore.style.display = 'none';
table.querySelectorAll('.data-row.expandable, .data-row.single').forEach(row => {
const label = (row.querySelector('.data-label')?.textContent || '').toLowerCase();
const children = row.nextElementSibling;
let childMatch = false;
if (children && children.classList.contains('section-children')) {
children.querySelectorAll('.data-row.child').forEach(c => {
const cl = (c.querySelector('.data-label')?.textContent || '').toLowerCase();
const matches = cl.includes(q);
if (matches) childMatch = true;
c.style.display = matches ? '' : 'none';
});
}
const labelMatch = label.includes(q);
const match = labelMatch || childMatch;
row.classList.remove('hidden-row');
row.style.display = match ? '' : 'none';
if (children && children.classList.contains('section-children')) {
children.classList.remove('hidden-row');
if (match) {
if (labelMatch && !childMatch) {
children.querySelectorAll('.data-row.child').forEach(c => c.style.display = '');
}
children.style.display = '';
children.classList.add('show');
row.classList.add('expanded');
const icon = row.querySelector('.expand-icon');
if (icon) icon.textContent = '';
} else {
children.style.display = 'none';
children.classList.remove('show');
}
}
});
}
async function loadLabResults(card, query) {
const table = card.querySelector('.data-table');
if (!table) return;
try {
const resp = await fetch(`/dossier/${dossierGUID}/labs?q=${encodeURIComponent(query)}`);
const data = await resp.json();
// Update ref data and loinc names from server
labRefData = data.refs || {};
loincNames = data.loincNames || {};
if (!data.orders || data.orders.length === 0) {
table.innerHTML = '<div class="data-row"><span class="text-muted">No results</span></div>';
const chart = card.querySelector('.filter-chart');
if (chart) chart.style.display = 'none';
return;
}
// Render orders + children into DOM (same structure as server-rendered buildLabItems)
let html = '';
data.orders.forEach((order, i) => {
// Expandable order row
html += `<div class="data-row expandable expanded" data-index="${i}" onclick="toggleSection(this)">
<div class="data-row-main">
<span class="expand-icon"></span>
<span class="data-label">${esc(order.name)}</span>
</div>
<div class="data-values">
<span class="data-value mono">${order.count} results</span>
${order.date ? `<span class="data-date" data-date="${order.date}"></span>` : ''}
${order.time ? `<span class="data-time">${esc(order.time)}</span>` : ''}
</div>
</div>`;
// Children (visible)
html += `<div class="section-children show" data-index="${i}">`;
for (const child of order.children) {
html += `<div class="data-row child"${child.loinc ? ` data-loinc="${child.loinc}"` : ''}>
<span class="data-label">${esc(child.label)}</span>
</div>`;
}
html += '</div>';
});
table.innerHTML = html;
// Format dates
table.querySelectorAll('[data-date]').forEach(el => {
const d = el.dataset.date;
if (d && d.length === 8) {
const date = new Date(+d.slice(0,4), +d.slice(4,6)-1, +d.slice(6,8));
el.textContent = date.toLocaleDateString();
}
});
// Build chart from the rendered DOM (scraping approach)
renderFilterChart(card, table, query);
} catch (e) {
table.innerHTML = '<div class="data-row"><span class="text-muted">Error loading results</span></div>';
}
}
function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
function renderFilterChart(card, table, q) {
let wrapper = card.querySelector('.filter-chart');
if (!wrapper) {
wrapper = document.createElement('div');
wrapper.className = 'filter-chart';
wrapper.innerHTML = '<div class="filter-chart-header" onclick="this.parentNode.classList.toggle(\'collapsed\')"><span class="filter-chart-toggle">&#9660;</span> Trends</div><div class="filter-chart-body"></div>';
table.parentNode.insertBefore(wrapper, table);
}
const body = wrapper.querySelector('.filter-chart-body');
if (!q || q.length < 3) { wrapper.style.display = 'none'; return; }
// Collect data points from visible children with data-loinc attributes
const series = {};
table.querySelectorAll('.data-row.expandable').forEach(row => {
if (row.style.display === 'none') return;
const dateStr = row.querySelector('[data-date]')?.dataset.date;
if (!dateStr || dateStr.length !== 8) return;
const date = new Date(+dateStr.slice(0,4), +dateStr.slice(4,6)-1, +dateStr.slice(6,8));
const children = row.nextElementSibling;
if (!children || !children.classList.contains('section-children')) return;
children.querySelectorAll('.data-row.child').forEach(c => {
if (c.style.display === 'none') return;
const childLoinc = c.dataset.loinc;
if (!childLoinc) return;
const text = c.querySelector('.data-label')?.textContent || '';
const m = text.match(/^([^:]+):\s*([\d.]+)\s*(.*)/);
if (!m) return;
const abbr = m[1].trim();
const val = parseFloat(m[2]);
const unit = m[3].trim();
if (isNaN(val)) return;
if (!series[childLoinc]) {
series[childLoinc] = { abbr, unit, points: [], loinc: childLoinc };
}
series[childLoinc].points.push({ date, val });
});
});
const chartable = Object.entries(series).filter(([,s]) => s.points.length >= 2);
if (chartable.length === 0) {
console.log('[Chart Debug] No chartable series (need >= 2 points)');
wrapper.style.display = 'none';
return;
}
// Calculate global time range across all charts for alignment
let globalTMin = Infinity, globalTMax = -Infinity;
for (const [, s] of chartable) {
s.points.sort((a, b) => a.date - b.date);
if (s.points.length > 0) {
globalTMin = Math.min(globalTMin, s.points[0].date.getTime());
globalTMax = Math.max(globalTMax, s.points[s.points.length - 1].date.getTime());
}
}
// Extend to today if last point is in the past
globalTMax = Math.max(globalTMax, new Date().getTime());
wrapper.style.display = '';
wrapper.classList.remove('collapsed');
let html = '';
for (const [loinc, s] of chartable) {
// Build display name: "Full Name (Abbr)" or fallback to abbreviation
const fullName = loincNames[loinc] || s.abbr;
const displayName = fullName !== s.abbr ? `${fullName} (${s.abbr})` : s.abbr;
html += buildSVGChart(displayName, s.unit, s.points, s.abbr, globalTMin, globalTMax);
}
body.innerHTML = html;
}
function buildSVGChart(name, unit, points, abbr, globalTMin, globalTMax) {
const W = 1200, H = 200, PAD = { top: 30, right: 30, bottom: 35, left: 55 };
const pw = W - PAD.left - PAD.right;
const ph = H - PAD.top - PAD.bottom;
// Look up reference data for this test (use abbreviation for lookup)
const ref = labRefData[abbr || name] || null;
const vals = points.map(p => p.val);
let yMin = Math.min(...vals), yMax = Math.max(...vals);
// Include reference range in Y axis bounds if available
if (ref) {
yMin = Math.min(yMin, ref.refLow);
yMax = Math.max(yMax, ref.refHigh);
}
const yPad = (yMax - yMin) * 0.15 || 1;
yMin -= yPad; yMax += yPad;
// Never show negative Y when all values are >= 0
if (Math.min(...vals) >= 0 && (!ref || ref.refLow >= 0)) yMin = Math.max(0, yMin);
// Use global time range if provided, otherwise fall back to local range
const tMin = globalTMin !== undefined ? globalTMin : points[0].date.getTime();
const tMax = globalTMax !== undefined ? globalTMax : Math.max(new Date().getTime(), points[points.length-1].date.getTime());
const tRange = tMax - tMin || 1;
const x = p => PAD.left + ((p.date.getTime() - tMin) / tRange) * pw;
const yScale = v => PAD.top + ph - ((v - yMin) / (yMax - yMin)) * ph;
const y = p => yScale(p.val);
const polyline = points.map(p => `${x(p)},${y(p)}`).join(' ');
// Reference band (drawn first, behind everything)
let refBand = '';
if (ref) {
const bandTop = yScale(ref.refHigh);
const bandBot = yScale(ref.refLow);
const chartTop = PAD.top;
const chartBot = PAD.top + ph;
// Red zones above and below normal range
if (bandTop > chartTop) {
refBand += `<rect x="${PAD.left}" y="${chartTop}" width="${pw}" height="${bandTop - chartTop}" fill="#fee2e2" opacity="0.5"/>`;
}
if (bandBot < chartBot) {
refBand += `<rect x="${PAD.left}" y="${bandBot}" width="${pw}" height="${chartBot - bandBot}" fill="#fee2e2" opacity="0.5"/>`;
}
// Green normal range
refBand += `<rect x="${PAD.left}" y="${bandTop}" width="${pw}" height="${bandBot - bandTop}" fill="#dcfce7" opacity="0.6"/>`;
// Boundary lines
refBand += `<line x1="${PAD.left}" y1="${bandTop}" x2="${W - PAD.right}" y2="${bandTop}" stroke="#86efac" stroke-width="1" stroke-dasharray="4,3"/>`;
refBand += `<line x1="${PAD.left}" y1="${bandBot}" x2="${W - PAD.right}" y2="${bandBot}" stroke="#86efac" stroke-width="1" stroke-dasharray="4,3"/>`;
}
// Y-axis: 4 ticks
let yTicks = '';
for (let i = 0; i <= 3; i++) {
const v = yMin + (yMax - yMin) * (i / 3);
const yy = PAD.top + ph - (i / 3) * ph;
yTicks += `<line x1="${PAD.left}" y1="${yy}" x2="${W - PAD.right}" y2="${yy}" stroke="#e5e7eb" stroke-width="1"/>`;
yTicks += `<text x="${PAD.left - 8}" y="${yy + 4}" text-anchor="end" fill="#6b7280" font-size="11">${v.toFixed(1)}</text>`;
}
// X-axis: date labels on data points (skip if too close)
let xLabels = '';
let lastLabelX = -100;
const fmt = new Intl.DateTimeFormat(undefined, { month: 'short', year: '2-digit' });
points.forEach(p => {
const px = x(p);
if (px - lastLabelX > 70) {
xLabels += `<text x="${px}" y="${H - 5}" text-anchor="middle" fill="#6b7280" font-size="11">${fmt.format(p.date)}</text>`;
lastLabelX = px;
}
});
// "today" marker at right edge
const todayX = W - PAD.right;
if (todayX - lastLabelX > 50) {
xLabels += `<text x="${todayX}" y="${H - 5}" text-anchor="end" fill="#9ca3af" font-size="10">today</text>`;
}
// Dots + value labels — color by reference range
let dots = '';
let lastLabelPx = -100;
const dateFmt = new Intl.DateTimeFormat(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
points.forEach(p => {
const px = x(p), py = y(p);
// Determine dot color: green if in range, red if out of range, amber if no ref
let dotColor = '#B45309'; // amber default
let textColor = '#1f2937';
if (ref) {
const inRange = p.val >= ref.refLow && p.val <= ref.refHigh;
if (inRange) {
dotColor = '#16a34a'; // green
} else {
dotColor = '#dc2626'; // red
textColor = '#dc2626';
}
}
const tooltip = `${p.val}${unit ? ' ' + unit : ''}${dateFmt.format(p.date)}`;
dots += `<g style="cursor:pointer">`;
dots += `<title>${tooltip}</title>`;
dots += `<circle cx="${px}" cy="${py}" r="12" fill="transparent"></circle>`;
dots += `<circle cx="${px}" cy="${py}" r="3.5" fill="${dotColor}"></circle>`;
if (px - lastLabelPx > 28) {
dots += `<text x="${px}" y="${py - 9}" text-anchor="middle" fill="${textColor}" font-size="11" font-weight="500">${p.val}</text>`;
lastLabelPx = px;
}
dots += `</g>`;
});
// Line color: amber (default) or contextual
const lineColor = ref ? '#6b7280' : '#B45309';
// Title — add direction indicator
let dirLabel = '';
if (ref && ref.direction === 'lower_better') dirLabel = ' ↓';
else if (ref && ref.direction === 'higher_better') dirLabel = ' ↑';
const title = `<text x="${PAD.left}" y="18" fill="#374151" font-size="13" font-weight="600">${name}${unit ? ' (' + unit + ')' : ''}${dirLabel}</text>`;
return `<svg class="lab-chart" viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg">
${refBand}${title}${yTicks}${xLabels}
<polyline points="${polyline}" fill="none" stroke="${lineColor}" stroke-width="2" stroke-linejoin="round"/>
${dots}
</svg>`;
}
// Genetics dynamic loading (if genetics section exists)
{{if .HasGenome}}
const i18n = {
genomeEnglishOnly: "{{.T.genome_english_only}}",
genomeVariants: "{{.T.genome_variants}}",
genomeHidden: "{{.T.genome_hidden}}",
genomeShowAllCategories: "{{.T.genome_show_all_categories}}"
};
let showAllGenetics = false;
let showAllCategories = false;
let allCategories = {};
let variantCache = {}; // category -> filtered variant entries
async function loadGeneticsCategories() {
const container = document.getElementById('genetics-content');
if (!container) return;
try {
const resp = await fetch(`/api/entries?dossier=${dossierGUID}&category=genome&type=tier`);
const tiers = await resp.json();
allCategories = {};
for (const t of tiers) {
const d = typeof t.data === 'string' ? JSON.parse(t.data || '{}') : (t.data || {});
allCategories[t.value] = {id: t.id, shown: d.shown || 0, hidden: d.hidden || 0};
}
const totalHidden = Object.values(allCategories).reduce((sum, c) => sum + (c.hidden || 0), 0);
const showAllBtn = document.getElementById('show-all-genetics');
if (showAllBtn && totalHidden > 0) {
showAllBtn.style.display = '';
}
renderGeneticsCategories();
} catch (e) {
container.innerHTML = '<div class="data-row"><span class="text-muted">Error loading genetics</span></div>';
}
}
function renderGeneticsCategories() {
const container = document.getElementById('genetics-content');
const summaryEl = document.getElementById('genetics-summary');
if (!container) return;
let totalShown = 0, totalHidden = 0;
for (const counts of Object.values(allCategories)) {
totalShown += counts.shown || 0;
totalHidden += counts.hidden || 0;
}
if (totalShown === 0 && totalHidden === 0) {
document.getElementById('section-genetics')?.remove();
return;
}
let summary = showAllGenetics ? `${totalShown + totalHidden} ${i18n.genomeVariants}` : `${totalShown} ${i18n.genomeVariants}`;
if (totalHidden > 0 && !showAllGenetics) {
summary += ` <span class="text-muted">(${totalHidden} ${i18n.genomeHidden})</span>`;
}
if (summaryEl) summaryEl.innerHTML = summary;
const btn = document.getElementById('show-all-genetics');
if (btn) {
if (showAllGenetics) {
btn.textContent = 'Hide negative';
btn.onclick = () => { showAllGenetics = false; renderGeneticsCategories(); return false; };
} else {
btn.textContent = 'Show all';
btn.onclick = () => { showGeneticsWarning(); return false; };
}
}
const categoryPriority = {
'traits': 1, 'metabolism': 2, 'longevity': 3, 'blood': 4, 'cardiovascular': 5,
'neurological': 6, 'mental_health': 7, 'autoimmune': 8, 'medication': 9,
'fertility': 10, 'disease': 11, 'cancer': 12, 'other': 13
};
const sorted = Object.entries(allCategories).sort((a, b) => {
const aPriority = categoryPriority[a[0]] || 99;
const bPriority = categoryPriority[b[0]] || 99;
return aPriority !== bPriority ? aPriority - bPriority :
(showAllGenetics ? (b[1].shown + b[1].hidden) : b[1].shown) -
(showAllGenetics ? (a[1].shown + a[1].hidden) : a[1].shown);
});
const maxCategories = 5;
const displayCategories = showAllCategories ? sorted : sorted.slice(0, maxCategories);
let html = '';
if (userLang !== 'en' && i18n.genomeEnglishOnly) {
html += `<div class="data-row" style="background: var(--bg-muted); color: var(--text-muted); font-size: 0.9rem; padding: 12px 16px;">
<span>🌐 ${i18n.genomeEnglishOnly}</span>
</div>`;
}
for (const [cat, counts] of displayCategories) {
const shown = counts.shown || 0, hidden = counts.hidden || 0;
const displayCount = showAllGenetics ? (shown + hidden) : shown;
if (displayCount === 0) continue;
const label = cat.charAt(0).toUpperCase() + cat.slice(1).replace(/_/g, ' ');
let countText = `${displayCount} ${i18n.genomeVariants}`;
if (hidden > 0 && !showAllGenetics) {
countText = `${shown} ${i18n.genomeVariants} <span class="text-muted">(${hidden} ${i18n.genomeHidden})</span>`;
}
html += `
<div class="data-row expandable" data-category="${cat}" onclick="toggleGeneticsCategory(this, '${cat}')">
<div class="data-row-main">
<span class="expand-icon">+</span>
<span class="data-label">${label}</span>
</div>
<div class="data-values">
<span class="data-meta">${countText}</span>
</div>
</div>
<div class="section-children" data-category="${cat}"></div>`;
}
if (!showAllCategories && sorted.length > maxCategories) {
html += `<div class="data-row show-more" style="cursor: pointer;" onclick="showAllCategories = true; renderGeneticsCategories();">${i18n.genomeShowAllCategories.replace('%d', sorted.length)} →</div>`;
}
container.innerHTML = html;
}
function showGeneticsWarning() {
document.getElementById('genetics-warning-modal').style.display = 'flex';
}
function closeGeneticsWarning() {
document.getElementById('genetics-warning-modal').style.display = 'none';
}
function confirmShowAllGenetics() {
closeGeneticsWarning();
showAllGenetics = true;
renderGeneticsCategories();
}
async function toggleGeneticsCategory(el, category) {
el.classList.toggle('expanded');
const icon = el.querySelector('.expand-icon');
if (icon) icon.textContent = el.classList.contains('expanded') ? '' : '+';
const children = el.nextElementSibling;
if (!children) return;
children.classList.toggle('show');
if (children.dataset.loaded) return;
children.dataset.loaded = 'true';
children.innerHTML = '<div class="data-row child"><span class="text-muted">Loading...</span></div>';
try {
const tierID = allCategories[category]?.id;
if (!tierID) return;
const resp = await fetch(`/api/entries?dossier=${dossierGUID}&category=genome&parent=${tierID}`);
const entries = await resp.json();
// Filter hidden unless showAllGenetics (already sorted by ordinal = 100-mag*10)
const visible = showAllGenetics ? entries : entries.filter(e => {
const d = typeof e.data === 'string' ? JSON.parse(e.data || '{}') : (e.data || {});
return !((d.mag || 0) > 4.0 || (d.rep || '').toLowerCase() === 'bad');
});
if (visible.length === 0) {
children.innerHTML = '<div class="data-row child"><span class="text-muted">No variants found</span></div>';
return;
}
let html = '';
visible.slice(0, 5).forEach(e => {
const d = typeof e.data === 'string' ? JSON.parse(e.data || '{}') : (e.data || {});
const mag = d.mag ? d.mag.toFixed(1) : '0';
const allele = (e.value || '').replace(/;/g, '').split('').join(';');
html += `
<div class="data-row child" style="flex-direction: column; align-items: flex-start; gap: 8px; padding: 12px 16px;">
<div class="sg-gene-row" style="width: 100%;">
<div style="display: flex; justify-content: space-between; align-items: flex-start; width: 100%;">
<div class="sg-gene-main">
<span class="sg-gene-name">${(e.search_key || e.tags || '').toUpperCase()}</span>
<span class="sg-gene-rsid">${e.search_key2 || e.type}</span>
</div>
<div style="display: flex; align-items: center; gap: 8px;">
<span class="sg-gene-allele">${allele}</span>
<span class="badge" style="font-size: 0.7rem;">mag ${mag}</span>
</div>
</div>
<div class="sg-gene-summary">${d.sum || ''}</div>
</div>
</div>`;
});
variantCache[category] = visible;
if (visible.length > 5) {
const remaining = visible.length - 5;
html += `<div class="sg-show-more" data-category="${category}" data-offset="5" onclick="loadMoreGenetics(this)">${remaining > 50 ? `${remaining} more — <a href="/connect">use Claude</a>` : `Show more (${remaining}) →`}</div>`;
}
children.innerHTML = html;
} catch (e) {
children.innerHTML = '<div class="data-row child"><span class="text-muted">Error loading</span></div>';
}
}
function loadMoreGenetics(el) {
const category = el.dataset.category;
const offset = parseInt(el.dataset.offset, 10);
const entries = variantCache[category] || [];
const batch = entries.slice(offset, offset + 20);
let html = '';
batch.forEach(e => {
const d = typeof e.data === 'string' ? JSON.parse(e.data || '{}') : (e.data || {});
const mag = d.mag ? d.mag.toFixed(1) : '0';
const allele = (e.value || '').replace(/;/g, '').split('').join(';');
html += `
<div class="data-row child" style="flex-direction: column; align-items: flex-start; gap: 8px; padding: 12px 16px;">
<div class="sg-gene-row" style="width: 100%;">
<div style="display: flex; justify-content: space-between; align-items: flex-start; width: 100%;">
<div class="sg-gene-main">
<span class="sg-gene-name">${(e.search_key || e.tags || '').toUpperCase()}</span>
<span class="sg-gene-rsid">${e.search_key2 || e.type}</span>
</div>
<div style="display: flex; align-items: center; gap: 8px;">
<span class="sg-gene-allele">${allele}</span>
<span class="badge" style="font-size: 0.7rem;">mag ${mag}</span>
</div>
</div>
<div class="sg-gene-summary">${d.sum || ''}</div>
</div>
</div>`;
});
el.insertAdjacentHTML('beforebegin', html);
const newOffset = offset + batch.length;
const remaining = entries.length - newOffset;
if (remaining > 0) {
el.dataset.offset = newOffset;
el.innerHTML = remaining > 50 ? `${remaining} more — <a href="/connect">use Claude</a>` : `Show more (${remaining}) →`;
} else {
el.remove();
}
}
loadGeneticsCategories();
{{end}}
</script>
{{end}}
{{/* Unified section block template */}}
{{define "section_block"}}
<div class="data-card{{if .ComingSoon}} coming-soon{{end}}" id="section-{{.ID}}"{{if and .HideEmpty (eq (len .Items) 0) (not .Dynamic) (not .ComingSoon)}} style="display:none;"{{end}}>
<div class="data-card-header">
<div class="data-card-indicator" style="background: #{{.Color}};"></div>
<div class="data-card-title">
<span class="section-heading">{{.Heading}}</span>
<span class="data-card-summary"{{if .Dynamic}} id="{{.ID}}-summary"{{end}}>{{.Summary}}</span>
</div>
{{if .ComingSoon}}
<span class="badge-soon">Coming soon</span>
{{else if eq .ID "genetics"}}
<a href="#" class="btn btn-small" id="show-all-genetics" onclick="showGeneticsWarning(); return false;">Show all</a>
{{else if .ActionURL}}
<a href="{{.ActionURL}}" {{if eq .ID "imaging"}}target="_blank"{{end}} class="btn btn-small{{if eq .ID "checkin"}} btn-primary{{end}}">{{.ActionLabel}}</a>
{{end}}
{{if .Searchable}}
<div class="search-wrap">
<input type="text" class="search-input" placeholder="Filter (3+ chars for chart)" oninput="filterSection(this)">
<span class="search-clear" onclick="clearSearch(this)">&times;</span>
</div>
{{end}}
</div>
{{if .ShowBuildTracker}}
<div class="build-profile-tracker">
<div class="build-profile-buttons">
{{range .TrackerButtons}}
<a href="{{.URL}}" class="build-profile-btn">
<span>{{.Label}}</span>
</a>
{{end}}
</div>
</div>
{{end}}
{{if .Dynamic}}
<div class="data-table" id="{{.ID}}-content"></div>
{{else if .Items}}
<div class="data-table">
{{range $i, $item := .Items}}
{{if $item.Expandable}}
<div class="data-row expandable{{if gt $i 4}} hidden-row{{end}}" data-index="{{$i}}"{{if $item.ID}} data-entry-id="{{$item.ID}}"{{end}} onclick="toggleSection(this)">
<div class="data-row-main">
<span class="expand-icon">+</span>
<span class="data-label">{{$item.Label}}</span>
{{if $item.Meta}}<span class="data-meta">{{$item.Meta}}</span>{{end}}
</div>
<div class="data-values">
{{if $item.Value}}<span class="data-value mono">{{$item.Value}}</span>{{end}}
{{if $item.Date}}<span class="data-date" data-date="{{$item.Date}}"></span>{{end}}
{{if $item.Time}}<span class="data-time">{{$item.Time}}</span>{{end}}
{{if and $item.LinkURL (eq $item.LinkTitle "source")}}<a href="#" class="btn-icon" data-doc-id="{{$item.LinkURL}}" data-spans="{{$item.SourceSpansJSON}}" data-highlight="{{$item.Label}}" onclick="event.stopPropagation(); openDocPane(this.dataset.docId, this.dataset.spans, this.dataset.highlight); return false;" title="View source document">📄</a>{{else if $item.LinkURL}}<a href="{{$item.LinkURL}}" target="_blank" class="btn-icon" onclick="event.stopPropagation()" title="{{$item.LinkTitle}}">→</a>{{end}}
</div>
</div>
<div class="section-children{{if gt $i 4}} hidden-row{{end}}" data-index="{{$i}}">
{{range $item.Children}}
<div class="data-row child"{{if .Type}} data-loinc="{{.Type}}"{{end}}>
<span class="data-label detail-key">{{.Label}}</span>
<div class="data-value-group">
{{if .Value}}<span class="data-value">{{.Value}}</span>{{end}}
{{if .Meta}}<span class="data-value translated">{{.Meta}}</span>{{end}}
</div>
{{if .LinkURL}}<a href="{{.LinkURL}}" target="_blank" class="btn-icon" title="{{.LinkTitle}}">→</a>{{end}}
</div>
{{end}}
</div>
{{else}}
<div class="data-row single{{if gt $i 4}} hidden-row{{end}}" data-index="{{$i}}">
<div class="data-row-main">
<span class="data-label">{{$item.Label}}</span>
{{if $item.Meta}}<span class="data-meta">{{$item.Meta}}</span>{{end}}
</div>
<div class="data-values">
{{if $item.Value}}<span class="data-value mono">{{$item.Value}}</span>{{end}}
{{if $item.Type}}<span class="data-value">{{$item.Type}}</span>{{end}}
{{if $item.Date}}<span class="data-date" data-date="{{$item.Date}}"></span>{{end}}
{{if $item.Time}}<span class="data-time">{{$item.Time}}</span>{{end}}
{{if and $item.LinkURL (eq $item.LinkTitle "source")}}<a href="#" class="btn-icon" data-doc-id="{{$item.LinkURL}}" data-spans="{{$item.SourceSpansJSON}}" data-highlight="{{$item.Label}}" onclick="event.stopPropagation(); openDocPane(this.dataset.docId, this.dataset.spans, this.dataset.highlight); return false;" title="View source document">📄</a>{{else if $item.LinkURL}}<a href="{{$item.LinkURL}}" target="_blank" class="btn-icon" title="{{$item.LinkTitle}}">→</a>{{end}}
</div>
</div>
{{end}}
{{end}}
{{if gt (len .Items) 5}}
<div class="data-row show-more" onclick="this.parentElement.querySelectorAll('.hidden-row').forEach(el => el.classList.remove('hidden-row')); this.remove();">
<span class="text-muted">Show all {{len .Items}}...</span>
</div>
{{end}}
</div>
{{end}}
</div>
{{end}}