feat: display user biomarker results with severity indicators and visual scale bars on dashboard
This commit is contained in:
@@ -650,8 +650,8 @@ select.input {
|
||||
.biomarker-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
@@ -661,11 +661,12 @@ select.input {
|
||||
}
|
||||
|
||||
.biomarker-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 0 3px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.biomarker-dot.status-low {
|
||||
@@ -681,36 +682,49 @@ select.input {
|
||||
}
|
||||
|
||||
.biomarker-info {
|
||||
flex: 0 0 320px;
|
||||
flex: 0 0 280px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
gap: var(--space-xs);
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.biomarker-info .biomarker-name {
|
||||
font-size: 14px;
|
||||
.biomarker-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.biomarker-info .biomarker-unit {
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
.biomarker-unit,
|
||||
.biomarker-value {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.biomarker-value {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Biomarker Scale Bar */
|
||||
.biomarker-scale {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
height: 16px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.scale-bar {
|
||||
width: 120px;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
width: 220px;
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--border);
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.scale-bar.placeholder {
|
||||
@@ -723,13 +737,26 @@ select.input {
|
||||
|
||||
.scale-marker {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background: var(--text-primary);
|
||||
border-radius: 2px;
|
||||
transform: translateX(-50%);
|
||||
box-shadow: 0 0 2px rgba(0, 0, 0, 0.3);
|
||||
top: 50%;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
box-shadow: 0 0 4px rgba(0, 0, 0, 0.4);
|
||||
border: 2px solid var(--bg-secondary);
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.scale-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 140px;
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* App Layout with Sidebar */
|
||||
|
||||
@@ -6,30 +6,61 @@ interface Category {
|
||||
description: string | null
|
||||
}
|
||||
|
||||
interface Biomarker {
|
||||
id: number
|
||||
category_id: number
|
||||
interface BiomarkerResult {
|
||||
biomarker_id: number
|
||||
name: string
|
||||
test_category: string
|
||||
category_id: number
|
||||
unit: string
|
||||
methodology: string | null
|
||||
value: number | null
|
||||
measured_at: string | null
|
||||
ref_min: number | null
|
||||
ref_max: number | null
|
||||
label: string
|
||||
severity: number
|
||||
}
|
||||
|
||||
// Severity to color mapping
|
||||
const severityColors: Record<number, string> = {
|
||||
0: 'var(--indicator-normal)', // Normal - green
|
||||
1: 'var(--indicator-warning)', // Mild - yellow/orange
|
||||
2: '#ff8c00', // Moderate - dark orange
|
||||
3: 'var(--indicator-critical)', // Severe - red
|
||||
4: '#8b0000', // Critical - dark red
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [biomarkers, setBiomarkers] = useState<Biomarker[]>([])
|
||||
const [results, setResults] = useState<BiomarkerResult[]>([])
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<number>>(new Set())
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
fetch('/api/categories', { credentials: 'include' }).then(r => r.json()),
|
||||
fetch('/api/biomarkers', { credentials: 'include' }).then(r => r.json()),
|
||||
]).then(([cats, bms]) => {
|
||||
setCategories(cats)
|
||||
setBiomarkers(bms)
|
||||
setLoading(false)
|
||||
})
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
// Get current user
|
||||
const authRes = await fetch('/api/auth/me', { credentials: 'include' })
|
||||
if (!authRes.ok) return
|
||||
const authData = await authRes.json()
|
||||
const user = authData.user
|
||||
if (!user) return // Not authenticated
|
||||
|
||||
// Fetch categories and results in parallel
|
||||
const [catsRes, resultsRes] = await Promise.all([
|
||||
fetch('/api/categories', { credentials: 'include' }),
|
||||
fetch(`/api/users/${user.id}/results`, { credentials: 'include' }),
|
||||
])
|
||||
|
||||
if (catsRes.ok && resultsRes.ok) {
|
||||
setCategories(await catsRes.json())
|
||||
setResults(await resultsRes.json())
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load dashboard data:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
const toggleCategory = (categoryId: number) => {
|
||||
@@ -44,8 +75,20 @@ export function DashboardPage() {
|
||||
})
|
||||
}
|
||||
|
||||
const getBiomarkersForCategory = (categoryId: number) => {
|
||||
return biomarkers.filter(b => b.category_id === categoryId)
|
||||
const getResultsForCategory = (categoryId: number) => {
|
||||
return results.filter(r => r.category_id === categoryId)
|
||||
}
|
||||
|
||||
// Calculate scale bar position (0-100%)
|
||||
const getScalePosition = (result: BiomarkerResult): number | null => {
|
||||
if (result.value === null || result.ref_min === null || result.ref_max === null) {
|
||||
return null
|
||||
}
|
||||
const range = result.ref_max - result.ref_min
|
||||
if (range <= 0) return 50
|
||||
// Clamp to 5-95% for visual bounds
|
||||
const pos = ((result.value - result.ref_min) / range) * 100
|
||||
return Math.max(5, Math.min(95, pos))
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
@@ -56,15 +99,17 @@ export function DashboardPage() {
|
||||
<div className="page">
|
||||
<header className="page-header">
|
||||
<h1>Dashboard</h1>
|
||||
<p className="text-secondary">View all biomarker categories and their reference markers</p>
|
||||
<p className="text-secondary">Your latest biomarker results</p>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
<h2 className="mb-md">Biomarker Categories</h2>
|
||||
<div className="flex-col gap-sm">
|
||||
{categories.map(category => {
|
||||
const categoryBiomarkers = getBiomarkersForCategory(category.id)
|
||||
const categoryResults = getResultsForCategory(category.id)
|
||||
const isExpanded = expandedCategories.has(category.id)
|
||||
// Count how many have data
|
||||
const withData = categoryResults.filter(r => r.value !== null).length
|
||||
|
||||
return (
|
||||
<div key={category.id} className="card category-card">
|
||||
@@ -75,7 +120,7 @@ export function DashboardPage() {
|
||||
<div>
|
||||
<span className="category-name">{category.name}</span>
|
||||
<span className="text-secondary text-sm ml-sm">
|
||||
({categoryBiomarkers.length} biomarkers)
|
||||
({withData}/{categoryResults.length} biomarkers)
|
||||
</span>
|
||||
</div>
|
||||
<img
|
||||
@@ -90,24 +135,59 @@ export function DashboardPage() {
|
||||
|
||||
{isExpanded && (
|
||||
<div className="category-content border-t p-sm">
|
||||
{categoryBiomarkers.length === 0 ? (
|
||||
{categoryResults.length === 0 ? (
|
||||
<p className="text-secondary text-sm p-sm">
|
||||
No biomarkers in this category
|
||||
</p>
|
||||
) : (
|
||||
<div className="biomarker-list">
|
||||
{categoryBiomarkers.map(biomarker => (
|
||||
<div key={biomarker.id} className="biomarker-row">
|
||||
<div className="biomarker-dot" title="No data"></div>
|
||||
<div className="biomarker-info">
|
||||
<span className="biomarker-name">{biomarker.name}</span>
|
||||
<span className="biomarker-unit">{biomarker.unit}</span>
|
||||
{categoryResults.map(result => {
|
||||
const scalePos = getScalePosition(result)
|
||||
const dotColor = result.value !== null
|
||||
? severityColors[result.severity] || severityColors[0]
|
||||
: 'var(--text-secondary)'
|
||||
|
||||
return (
|
||||
<div key={result.biomarker_id} className="biomarker-row">
|
||||
<div
|
||||
className="biomarker-dot"
|
||||
title={result.label}
|
||||
style={{ backgroundColor: dotColor }}
|
||||
/>
|
||||
<div className="biomarker-info">
|
||||
<span className="biomarker-name">{result.name}</span>
|
||||
{result.value !== null ? (
|
||||
<span className="biomarker-value">
|
||||
{result.value.toFixed(2)} {result.unit}
|
||||
</span>
|
||||
) : (
|
||||
<span className="biomarker-unit text-muted">
|
||||
No data
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="biomarker-scale">
|
||||
<div className="scale-bar">
|
||||
{scalePos !== null && (
|
||||
<div
|
||||
className="scale-marker"
|
||||
style={{
|
||||
left: `${scalePos}%`,
|
||||
backgroundColor: dotColor
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{result.ref_min !== null && result.ref_max !== null && (
|
||||
<div className="scale-labels">
|
||||
<span>{result.ref_min}</span>
|
||||
<span>{result.ref_max}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="biomarker-scale">
|
||||
<div className="scale-bar placeholder"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user