795 lines
32 KiB
Cheetah
795 lines
32 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>
|
||
|
||
{{/* 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">▼</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}}
|