1054 lines
44 KiB
Cheetah
1054 lines
44 KiB
Cheetah
{{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()">×</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">▼</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)">×</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>
|
||
{{if .Value}}<span class="data-value">{{.Value}}</span>{{end}}
|
||
{{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}}
|