diff --git a/Makefile b/Makefile index 96cf6d2..09e21a9 100644 --- a/Makefile +++ b/Makefile @@ -54,7 +54,7 @@ frontend-install: cd frontend && npm install frontend-dev: - cd frontend && npm run dev + cd frontend && npm run dev -- --host 0.0.0.0 frontend-build: cd frontend && npm run build @@ -82,7 +82,7 @@ test: backend-test frontend-test serve: @echo "Starting backend (port 3000) and frontend (port 5173)..." - @cd backend && cargo run -- serve & cd frontend && npm run dev + @cd backend && cargo run -- serve & cd frontend && npm run dev -- --host 0.0.0.0 clean: cd backend && cargo clean diff --git a/backend/src/handlers/entries.rs b/backend/src/handlers/entries.rs index e5a76c9..5c592c7 100644 --- a/backend/src/handlers/entries.rs +++ b/backend/src/handlers/entries.rs @@ -6,8 +6,10 @@ use axum::{ use chrono::Utc; use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, Set}; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; -use crate::models::bio::{biomarker, biomarker_entry}; +use crate::models::bio::{biomarker, biomarker_entry, biomarker_reference_rule}; +use crate::models::user::user; /// Request to create a new biomarker entry. #[derive(Deserialize)] @@ -30,6 +32,23 @@ pub struct EntryResponse { pub notes: Option, } +/// Response for biomarker result with reference info. +#[derive(Serialize)] +pub struct BiomarkerResult { + pub biomarker_id: i32, + pub name: String, + pub category_id: i32, + pub unit: String, + // Latest entry + pub value: Option, + pub measured_at: Option, + // Reference info + pub ref_min: Option, + pub ref_max: Option, + pub label: String, + pub severity: i32, +} + /// POST /api/entries - Create a new biomarker entry. pub async fn create_entry( State(db): State, @@ -103,7 +122,7 @@ pub async fn list_user_entries( .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - let bm_map: std::collections::HashMap = biomarkers + let bm_map: HashMap = biomarkers .into_iter() .map(|b| (b.id, b.name)) .collect(); @@ -122,3 +141,143 @@ pub async fn list_user_entries( Ok(Json(items)) } + +/// GET /api/users/:user_id/results - Get latest biomarker results with reference rules. +pub async fn get_user_results( + State(db): State, + Path(user_id): Path, +) -> Result>, StatusCode> { + // Get user profile for sex/age matching + let user_profile = user::Entity::find_by_id(user_id) + .one(&db) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + + // Calculate age from birthdate + let user_age = user_profile.birthdate.map(|bd| { + let today = chrono::Utc::now().date_naive(); + let years = today.years_since(bd).unwrap_or(0) as i32; + years + }); + + // Fetch all biomarkers + let biomarkers = biomarker::Entity::find() + .all(&db) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + // Fetch all entries for this user, ordered by measured_at desc + let entries = biomarker_entry::Entity::find() + .filter(biomarker_entry::Column::UserId.eq(user_id)) + .order_by_desc(biomarker_entry::Column::MeasuredAt) + .all(&db) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + // Build map of biomarker_id -> latest entry + let mut latest_entries: HashMap = HashMap::new(); + for entry in &entries { + latest_entries.entry(entry.biomarker_id).or_insert(entry); + } + + // Fetch all reference rules + let rules = biomarker_reference_rule::Entity::find() + .order_by_asc(biomarker_reference_rule::Column::SortOrder) + .all(&db) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + // Group rules by biomarker_id + let mut rules_map: HashMap> = HashMap::new(); + for rule in &rules { + rules_map.entry(rule.biomarker_id).or_default().push(rule); + } + + // Build results + let mut results: Vec = Vec::new(); + + for bm in &biomarkers { + let entry = latest_entries.get(&bm.id); + let value = entry.map(|e| e.value); + let measured_at = entry.map(|e| e.measured_at.to_string()); + + // Find matching reference rule + let bm_rules = rules_map.get(&bm.id).map(|v| v.as_slice()).unwrap_or(&[]); + let (ref_min, ref_max, label, severity) = find_matching_rule(bm_rules, value, user_age, None); + + results.push(BiomarkerResult { + biomarker_id: bm.id, + name: bm.name.clone(), + category_id: bm.category_id, + unit: bm.unit.clone(), + value, + measured_at, + ref_min, + ref_max, + label, + severity, + }); + } + + Ok(Json(results)) +} + +/// Find the best matching reference rule for a value. +fn find_matching_rule( + rules: &[&biomarker_reference_rule::Model], + value: Option, + user_age: Option, + _user_sex: Option<&str>, +) -> (Option, Option, String, i32) { + // Default: no data + if rules.is_empty() { + return (None, None, "No reference".to_string(), 0); + } + + // Find the "range" type rule first (defines normal range) + let range_rule = rules.iter().find(|r| r.rule_type == "range"); + let (ref_min, ref_max) = range_rule + .map(|r| (r.value_min, r.value_max)) + .unwrap_or((None, None)); + + // If no value, return range with "No data" label + let Some(val) = value else { + return (ref_min, ref_max, "No data".to_string(), 0); + }; + + // Find matching scale rule based on value + for rule in rules { + // Check age bounds + if let Some(min_age) = rule.age_min { + if user_age.map(|a| a < min_age).unwrap_or(false) { + continue; + } + } + if let Some(max_age) = rule.age_max { + if user_age.map(|a| a > max_age).unwrap_or(false) { + continue; + } + } + + // Check value bounds + let min_ok = rule.value_min.map(|min| val >= min).unwrap_or(true); + let max_ok = rule.value_max.map(|max| val <= max).unwrap_or(true); + + if min_ok && max_ok { + return (ref_min, ref_max, rule.label.clone(), rule.severity); + } + } + + // No matching rule found, determine based on range + if let (Some(min), Some(max)) = (ref_min, ref_max) { + if val < min { + return (ref_min, ref_max, "Low".to_string(), 1); + } else if val > max { + return (ref_min, ref_max, "High".to_string(), 1); + } + } + + (ref_min, ref_max, "Normal".to_string(), 0) +} + diff --git a/backend/src/handlers/ocr/matching.rs b/backend/src/handlers/ocr/matching.rs index c0a2602..c1515aa 100644 --- a/backend/src/handlers/ocr/matching.rs +++ b/backend/src/handlers/ocr/matching.rs @@ -1,6 +1,6 @@ //! Biomarker matching and merging logic. -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use strsim::jaro_winkler; use super::types::{Biomarker, DocumentAnnotation, OcrResult}; @@ -10,49 +10,51 @@ use super::types::{Biomarker, DocumentAnnotation, OcrResult}; const FUZZY_THRESHOLD: f64 = 0.90; /// Find a matching biomarker name from the valid set. -/// Returns the canonical name if found (exact, alias, or fuzzy match). +/// Returns the canonical name (original case) if found (exact, alias, or fuzzy match). /// /// Matching order: -/// 1. Exact match on full name +/// 1. Exact match on full name (case-insensitive) /// 2. Extract parenthetical alias from INPUT (e.g., `(HS-CRP)` from `HIGH SENSITIVITY C-REACTIVE PROTEIN (HS-CRP)`) /// 3. Extract parenthetical alias from SCHEMA (e.g., `HS-CRP` matches `HIGH SENSITIVITY C-REACTIVE PROTEIN (HS-CRP)`) /// 4. Fuzzy match with Jaro-Winkler (threshold 0.90) -fn find_matching_biomarker(name: &str, valid_biomarkers: &HashSet) -> Option { +/// +/// valid_biomarkers: HashMap +fn find_matching_biomarker(name: &str, valid_biomarkers: &HashMap) -> Option { let name_upper = name.to_uppercase(); - // 1. Exact match first (fast path) - if valid_biomarkers.contains(&name_upper) { - return Some(name_upper); + // 1. Exact match first (fast path) - lookup by uppercase key, return original case value + if let Some(canonical) = valid_biomarkers.get(&name_upper) { + return Some(canonical.clone()); } // 2. Try extracting parenthetical alias from INPUT if let Some(alias) = extract_parenthetical_alias(&name_upper) { - if valid_biomarkers.contains(&alias) { + if let Some(canonical) = valid_biomarkers.get(&alias) { tracing::debug!( "Alias matched '{}' -> '{}' (extracted from parentheses in input)", - name, alias + name, canonical ); - return Some(alias); + return Some(canonical.clone()); } } // 3. Try matching input against aliases in SCHEMA // This handles input "HS-CRP" matching schema "HIGH SENSITIVITY C-REACTIVE PROTEIN (HS-CRP)" - for valid in valid_biomarkers { - if let Some(alias) = extract_parenthetical_alias(valid) { + for (upper_key, canonical) in valid_biomarkers { + if let Some(alias) = extract_parenthetical_alias(upper_key) { if alias == name_upper { tracing::debug!( "Reverse alias matched '{}' -> '{}' (input is alias in schema)", - name, valid + name, canonical ); - return Some(valid.clone()); + return Some(canonical.clone()); } } } - // 4. Fuzzy match with threshold + // 4. Fuzzy match with threshold - compare against uppercase keys valid_biomarkers.iter() - .map(|valid| (valid, jaro_winkler(&name_upper, valid))) + .map(|(upper_key, canonical)| (canonical, jaro_winkler(&name_upper, upper_key))) .filter(|(_, score)| *score >= FUZZY_THRESHOLD) .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap()) .map(|(matched_name, score)| { @@ -88,7 +90,8 @@ fn extract_parenthetical_alias(name: &str) -> Option { /// Merge multiple OCR results into one, filtering to only known biomarkers. /// Uses fuzzy matching to handle name variations. -pub fn merge_results(results: Vec, valid_biomarkers: &HashSet) -> OcrResult { +/// valid_biomarkers: HashMap +pub fn merge_results(results: Vec, valid_biomarkers: &HashMap) -> OcrResult { let mut merged = OcrResult { patient_name: None, patient_age: None, diff --git a/backend/src/handlers/ocr/mod.rs b/backend/src/handlers/ocr/mod.rs index f7d8e78..812e160 100644 --- a/backend/src/handlers/ocr/mod.rs +++ b/backend/src/handlers/ocr/mod.rs @@ -12,9 +12,9 @@ use axum::{ http::StatusCode, Json, }; -use sea_orm::{ActiveModelTrait, EntityTrait, Set}; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; -use crate::models::bio::source; +use crate::models::bio::{biomarker, biomarker_entry, source}; // Re-export public types pub use types::{ErrorResponse, OcrState, ParseResponse}; @@ -166,6 +166,18 @@ async fn process_ocr_background( let mut failed_chunk: Option = None; for start_page in (0..max_pages).step_by(chunk_size) { + // Check if source still exists before processing next chunk + let source_exists = source::Entity::find_by_id(source_id) + .one(&db) + .await + .map(|opt| opt.is_some()) + .unwrap_or(false); + + if !source_exists { + tracing::warn!("Source {} was deleted mid-parse, aborting OCR", source_id); + return Err("Source was deleted during parsing".to_string()); + } + let pages: Vec = (start_page..std::cmp::min(start_page + chunk_size, max_pages)).collect(); tracing::info!("Processing OCR for pages {:?}", pages); @@ -246,6 +258,8 @@ async fn process_ocr_background( .map_err(|e| format!("Database error: {}", e))? .ok_or_else(|| "Source not found".to_string())?; + let user_id = source_entity.user_id; + let mut active_model: source::ActiveModel = source_entity.into(); active_model.ocr_data = Set(Some(ocr_json)); active_model.status = Set("parsed".to_string()); @@ -254,9 +268,53 @@ async fn process_ocr_background( active_model.update(&db).await .map_err(|e| format!("Database update failed: {}", e))?; + // Create biomarker entries from parsed data + let mut entries_created = 0; + let now = chrono::Utc::now().naive_utc(); + + // Parse test_date or use current time + let measured_at = merged.test_date + .as_ref() + .and_then(|d| chrono::NaiveDate::parse_from_str(d, "%d %b %Y").ok() + .or_else(|| chrono::NaiveDate::parse_from_str(d, "%d %b, %Y").ok()) + .or_else(|| chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d").ok())) + .map(|date| date.and_hms_opt(0, 0, 0).unwrap()) + .unwrap_or(now); + + for bio in &merged.biomarkers { + // Skip if no numeric value + let Some(value) = bio.value else { continue }; + + // Look up biomarker ID by name + let biomarker_entity = biomarker::Entity::find() + .filter(biomarker::Column::Name.eq(&bio.name)) + .one(&db) + .await + .map_err(|e| format!("Biomarker lookup error: {}", e))?; + + let Some(biomarker_entity) = biomarker_entity else { continue }; + + // Create entry + let entry = biomarker_entry::ActiveModel { + biomarker_id: Set(biomarker_entity.id), + user_id: Set(user_id), + measured_at: Set(measured_at), + value: Set(value), + notes: Set(bio.unit.clone()), + source_id: Set(Some(source_id)), + created_at: Set(now), + }; + + // Insert (ignore if duplicate composite key) + if entry.insert(&db).await.is_ok() { + entries_created += 1; + } + } + tracing::info!( - "Successfully parsed {} biomarkers for source {}", + "Successfully parsed {} biomarkers, created {} entries for source {}", merged.biomarkers.len(), + entries_created, source_id ); diff --git a/backend/src/handlers/ocr/schema.rs b/backend/src/handlers/ocr/schema.rs index 1fe7f73..790989f 100644 --- a/backend/src/handlers/ocr/schema.rs +++ b/backend/src/handlers/ocr/schema.rs @@ -1,10 +1,11 @@ //! Schema handling utilities. use serde_json::Value; -use std::collections::HashSet; +use std::collections::HashMap; /// Extract valid biomarker names from the ocr_schema.json enum. -pub fn extract_valid_biomarker_names() -> Result, String> { +/// Returns a HashMap where keys are UPPERCASE names (for matching) and values are original case names. +pub fn extract_valid_biomarker_names() -> Result, String> { let schema_content = std::fs::read_to_string("ocr_schema.json") .map_err(|e| format!("Failed to read ocr_schema.json: {}", e))?; let schema: Value = serde_json::from_str(&schema_content) @@ -21,10 +22,11 @@ pub fn extract_valid_biomarker_names() -> Result, String> { .and_then(|e| e.as_array()) .ok_or_else(|| "Could not find biomarker name enum in schema".to_string())?; - let valid_names: HashSet = names + // Key = uppercase (for matching), Value = original case (for DB lookup) + let valid_names: HashMap = names .iter() .filter_map(|v| v.as_str()) - .map(|s| s.to_uppercase()) + .map(|s| (s.to_uppercase(), s.to_string())) .collect(); Ok(valid_names) diff --git a/backend/src/handlers/sources.rs b/backend/src/handlers/sources.rs index 1f99405..3e19af5 100644 --- a/backend/src/handlers/sources.rs +++ b/backend/src/handlers/sources.rs @@ -7,13 +7,13 @@ use axum::{ }; use axum_extra::extract::Multipart; use chrono::Utc; -use sea_orm::{ActiveModelTrait, DatabaseConnection, EntityTrait, Set}; +use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; use tokio::fs; use tokio::io::AsyncWriteExt; -use crate::models::bio::source; +use crate::models::bio::{biomarker_entry, source}; /// Response for a source. #[derive(Serialize)] @@ -215,6 +215,13 @@ pub async fn delete_source( .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .ok_or(StatusCode::NOT_FOUND)?; + // Delete related biomarker entries first (cascade delete) + biomarker_entry::Entity::delete_many() + .filter(biomarker_entry::Column::SourceId.eq(id)) + .exec(&state.db) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + // Delete file from disk if let Err(e) = fs::remove_file(&s.file_path).await { tracing::warn!("Failed to delete file {}: {:?}", s.file_path, e); diff --git a/backend/src/main.rs b/backend/src/main.rs index 62b121e..7cfddbf 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -143,6 +143,7 @@ fn create_router(db: DatabaseConnection, config: &config::Config) -> Router { // Entries API .route("/api/entries", post(handlers::entries::create_entry)) .route("/api/users/{user_id}/entries", get(handlers::entries::list_user_entries)) + .route("/api/users/{user_id}/results", get(handlers::entries::get_user_results)) .route_layer(middleware::from_fn(require_auth)); // Sources routes (need separate state for uploads path) diff --git a/frontend/src/index.css b/frontend/src/index.css index 75b6c4f..7271ea5 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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 */ diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index b46e35c..a9f0675 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -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 = { + 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([]) - const [biomarkers, setBiomarkers] = useState([]) + const [results, setResults] = useState([]) const [expandedCategories, setExpandedCategories] = useState>(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() {

Dashboard

-

View all biomarker categories and their reference markers

+

Your latest biomarker results

Biomarker Categories

{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 (
@@ -75,7 +120,7 @@ export function DashboardPage() {
{category.name} - ({categoryBiomarkers.length} biomarkers) + ({withData}/{categoryResults.length} biomarkers)
- {categoryBiomarkers.length === 0 ? ( + {categoryResults.length === 0 ? (

No biomarkers in this category

) : (
- {categoryBiomarkers.map(biomarker => ( -
-
-
- {biomarker.name} - {biomarker.unit} + {categoryResults.map(result => { + const scalePos = getScalePosition(result) + const dotColor = result.value !== null + ? severityColors[result.severity] || severityColors[0] + : 'var(--text-secondary)' + + return ( +
+
+
+ {result.name} + {result.value !== null ? ( + + {result.value.toFixed(2)} {result.unit} + + ) : ( + + No data + + )} +
+
+
+ {scalePos !== null && ( +
+ )} +
+ {result.ref_min !== null && result.ref_max !== null && ( +
+ {result.ref_min} + {result.ref_max} +
+ )} +
-
-
-
-
- ))} + ) + })}
)}