inou/portal/templates/dossier.tmpl

795 lines
32 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>
{{/* 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}}";
const labRefData = {{if .LabRefJSON}}{{.LabRefJSON}}{{else}}{}{{end}};
const labSearchIndex = {{if .LabSearchJSON}}{{.LabSearchJSON}}{{else}}{}{{end}};
const loincNames = {{if .LoincNameJSON}}{{.LoincNameJSON}}{{else}}{}{{end}};
// Section expand/collapse
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')) {
children.classList.toggle('show');
}
}
// 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();
}
});
// Helper: Look up LOINC codes for a search term
function getMatchingLoincs(query) {
const matchLoincs = new Set();
if (!labSearchIndex || !query) return matchLoincs;
const q = query.toLowerCase();
for (const [term, loincs] of Object.entries(labSearchIndex)) {
if (term.includes(q)) {
loincs.forEach(loinc => matchLoincs.add(loinc));
}
}
return matchLoincs;
}
// Filter/search within a section
function filterSection(input) {
const table = input.closest('.data-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) {
// Reset: restore hidden-row, collapse all
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;
}
// Look up LOINC codes for this search term
const matchLoincs = getMatchingLoincs(q);
// Hide show-more when filtering
if (showMore) showMore.style.display = 'none';
// Check each expandable/single row
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 textMatch = cl.includes(q);
// Also check LOINC code match for lab results
const childLoinc = c.dataset.loinc;
const loincMatch = childLoinc && matchLoincs.has(childLoinc);
const matches = textMatch || loincMatch;
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 parent label matches, show all children
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');
}
}
});
// Auto-chart: collect numeric values from visible matching children
renderFilterChart(input.closest('.data-card'), table, q);
}
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: {name, value, date} from visible children
const series = {};
let debugInfo = { rowsFound: 0, rowsWithDate: 0, childrenProcessed: 0, standaloneProcessed: 0, regexFails: 0, pointsAdded: 0, loincMatches: 0 };
// Look up LOINC codes for this search term
const matchLoincs = getMatchingLoincs(q);
console.log('[Chart Debug] Query:', q, 'Matched LOINCs:', Array.from(matchLoincs));
// Process expandable rows (lab orders with children)
table.querySelectorAll('.data-row.expandable').forEach(row => {
debugInfo.rowsFound++;
if (row.style.display === 'none') return;
const dateStr = row.querySelector('[data-date]')?.dataset.date;
if (!dateStr || dateStr.length !== 8) {
console.log('[Chart Debug] Row missing date:', row.querySelector('.data-label')?.textContent, 'dateStr:', dateStr);
return;
}
debugInfo.rowsWithDate++;
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 => {
debugInfo.childrenProcessed++;
if (c.style.display === 'none') return;
// Match by LOINC code
const childLoinc = c.dataset.loinc;
if (childLoinc && matchLoincs.has(childLoinc)) {
debugInfo.loincMatches++;
const text = c.querySelector('.data-label')?.textContent || '';
const m = text.match(/^([^:]+):\s*([\d.]+)\s*(.*)/);
if (!m) {
console.log('[Chart Debug] Regex failed for text:', text);
debugInfo.regexFails++;
return;
}
const abbr = m[1].trim();
const val = parseFloat(m[2]);
const unit = m[3].trim();
if (isNaN(val)) return;
// Use LOINC code as key to group all results for same test
if (!series[childLoinc]) {
series[childLoinc] = { abbr, unit, points: [], loinc: childLoinc };
}
series[childLoinc].points.push({ date, val });
debugInfo.pointsAdded++;
}
});
});
// Also process standalone rows (non-expandable lab results)
table.querySelectorAll('.data-row.single').forEach(row => {
debugInfo.standaloneProcessed++;
if (row.style.display === 'none') return;
const rowLoinc = row.dataset.loinc;
if (rowLoinc && matchLoincs.has(rowLoinc)) {
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 text = row.querySelector('.data-label')?.textContent || '';
const m = text.match(/^([^:]+):\s*([\d.]+)\s*(.*)/);
if (!m) {
debugInfo.regexFails++;
return;
}
const abbr = m[1].trim();
const val = parseFloat(m[2]);
const unit = m[3].trim();
if (isNaN(val)) return;
// Use LOINC code as key to group all results for same test
if (!series[rowLoinc]) {
series[rowLoinc] = { abbr, unit, points: [], loinc: rowLoinc };
}
series[rowLoinc].points.push({ date, val });
debugInfo.pointsAdded++;
debugInfo.loincMatches++;
}
});
console.log('[Chart Debug] Debug info:', debugInfo, 'Series:', Object.keys(series).map(k => `${k}: ${series[k].points.length} points`));
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 = {};
async function loadGeneticsCategories() {
const container = document.getElementById('genetics-content');
if (!container) return;
try {
const resp = await fetch(`/api/categories?dossier=${dossierGUID}&type=genome`);
allCategories = await resp.json();
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 includeHidden = showAllGenetics ? '&include_hidden=true' : '';
const resp = await fetch(`/api/genome?dossier=${dossierGUID}&category=${encodeURIComponent(category)}${includeHidden}`);
const data = await resp.json();
if (!data.matches || data.matches.length === 0) {
children.innerHTML = '<div class="data-row child"><span class="text-muted">No variants found</span></div>';
return;
}
let html = '';
data.matches.slice(0, 5).forEach(m => {
const mag = m.magnitude ? m.magnitude.toFixed(1) : '0';
const allele = m.genotype.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">${m.gene || ''}</span>
<span class="sg-gene-rsid">${m.rsid}</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">${m.summary || ''}</div>
</div>
</div>`;
});
if (data.total > 5) {
const remaining = data.total - 5;
html += `<div class="sg-show-more" data-offset="5" data-total="${data.total}" data-category="${category}" 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>';
}
}
async function loadMoreGenetics(el) {
const offset = parseInt(el.dataset.offset, 10);
const total = parseInt(el.dataset.total, 10);
const category = el.dataset.category;
el.textContent = 'Loading...';
try {
const includeHidden = showAllGenetics ? '&include_hidden=true' : '';
const resp = await fetch(`/api/genome?dossier=${dossierGUID}&category=${encodeURIComponent(category)}&offset=${offset}&limit=20${includeHidden}`);
const data = await resp.json();
let html = '';
data.matches.forEach(m => {
const mag = m.magnitude ? m.magnitude.toFixed(1) : '0';
const allele = m.genotype.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">${m.gene || ''}</span>
<span class="sg-gene-rsid">${m.rsid}</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">${m.summary || ''}</div>
</div>
</div>`;
});
el.insertAdjacentHTML('beforebegin', html);
const newOffset = offset + data.matches.length;
const remaining = total - 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();
}
} catch (e) {
el.textContent = 'Error';
}
}
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}}
<input type="text" class="search-input" placeholder="Filter (3+ chars for chart)" oninput="filterSection(this)">
{{end}}
</div>
{{if .ShowBuildPrompt}}
<div class="build-profile-prompt">
<div class="build-profile-buttons">
{{range .PromptButtons}}
<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}}" onclick="toggleSection(this)">
<div class="data-row-main">
<span class="expand-icon">+</span>
<span class="data-label">{{$item.Label}}</span>
</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 $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">{{.Label}}</span>
{{if .Value}}<span class="data-value mono">{{.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 $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}}