feat: display user biomarker results with severity indicators and visual scale bars on dashboard
This commit is contained in:
4
Makefile
4
Makefile
@@ -54,7 +54,7 @@ frontend-install:
|
|||||||
cd frontend && npm install
|
cd frontend && npm install
|
||||||
|
|
||||||
frontend-dev:
|
frontend-dev:
|
||||||
cd frontend && npm run dev
|
cd frontend && npm run dev -- --host 0.0.0.0
|
||||||
|
|
||||||
frontend-build:
|
frontend-build:
|
||||||
cd frontend && npm run build
|
cd frontend && npm run build
|
||||||
@@ -82,7 +82,7 @@ test: backend-test frontend-test
|
|||||||
|
|
||||||
serve:
|
serve:
|
||||||
@echo "Starting backend (port 3000) and frontend (port 5173)..."
|
@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:
|
clean:
|
||||||
cd backend && cargo clean
|
cd backend && cargo clean
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ use axum::{
|
|||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, Set};
|
use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, Set};
|
||||||
use serde::{Deserialize, Serialize};
|
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.
|
/// Request to create a new biomarker entry.
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -30,6 +32,23 @@ pub struct EntryResponse {
|
|||||||
pub notes: Option<String>,
|
pub notes: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<f64>,
|
||||||
|
pub measured_at: Option<String>,
|
||||||
|
// Reference info
|
||||||
|
pub ref_min: Option<f64>,
|
||||||
|
pub ref_max: Option<f64>,
|
||||||
|
pub label: String,
|
||||||
|
pub severity: i32,
|
||||||
|
}
|
||||||
|
|
||||||
/// POST /api/entries - Create a new biomarker entry.
|
/// POST /api/entries - Create a new biomarker entry.
|
||||||
pub async fn create_entry(
|
pub async fn create_entry(
|
||||||
State(db): State<DatabaseConnection>,
|
State(db): State<DatabaseConnection>,
|
||||||
@@ -103,7 +122,7 @@ pub async fn list_user_entries(
|
|||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
let bm_map: std::collections::HashMap<i32, String> = biomarkers
|
let bm_map: HashMap<i32, String> = biomarkers
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|b| (b.id, b.name))
|
.map(|b| (b.id, b.name))
|
||||||
.collect();
|
.collect();
|
||||||
@@ -122,3 +141,143 @@ pub async fn list_user_entries(
|
|||||||
|
|
||||||
Ok(Json(items))
|
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<DatabaseConnection>,
|
||||||
|
Path(user_id): Path<i32>,
|
||||||
|
) -> Result<Json<Vec<BiomarkerResult>>, 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<i32, &biomarker_entry::Model> = 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<i32, Vec<&biomarker_reference_rule::Model>> = HashMap::new();
|
||||||
|
for rule in &rules {
|
||||||
|
rules_map.entry(rule.biomarker_id).or_default().push(rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build results
|
||||||
|
let mut results: Vec<BiomarkerResult> = 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<f64>,
|
||||||
|
user_age: Option<i32>,
|
||||||
|
_user_sex: Option<&str>,
|
||||||
|
) -> (Option<f64>, Option<f64>, 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//! Biomarker matching and merging logic.
|
//! Biomarker matching and merging logic.
|
||||||
|
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::HashMap;
|
||||||
use strsim::jaro_winkler;
|
use strsim::jaro_winkler;
|
||||||
|
|
||||||
use super::types::{Biomarker, DocumentAnnotation, OcrResult};
|
use super::types::{Biomarker, DocumentAnnotation, OcrResult};
|
||||||
@@ -10,49 +10,51 @@ use super::types::{Biomarker, DocumentAnnotation, OcrResult};
|
|||||||
const FUZZY_THRESHOLD: f64 = 0.90;
|
const FUZZY_THRESHOLD: f64 = 0.90;
|
||||||
|
|
||||||
/// Find a matching biomarker name from the valid set.
|
/// 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:
|
/// 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)`)
|
/// 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)`)
|
/// 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)
|
/// 4. Fuzzy match with Jaro-Winkler (threshold 0.90)
|
||||||
fn find_matching_biomarker(name: &str, valid_biomarkers: &HashSet<String>) -> Option<String> {
|
///
|
||||||
|
/// valid_biomarkers: HashMap<uppercase_name, original_case_name>
|
||||||
|
fn find_matching_biomarker(name: &str, valid_biomarkers: &HashMap<String, String>) -> Option<String> {
|
||||||
let name_upper = name.to_uppercase();
|
let name_upper = name.to_uppercase();
|
||||||
|
|
||||||
// 1. Exact match first (fast path)
|
// 1. Exact match first (fast path) - lookup by uppercase key, return original case value
|
||||||
if valid_biomarkers.contains(&name_upper) {
|
if let Some(canonical) = valid_biomarkers.get(&name_upper) {
|
||||||
return Some(name_upper);
|
return Some(canonical.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Try extracting parenthetical alias from INPUT
|
// 2. Try extracting parenthetical alias from INPUT
|
||||||
if let Some(alias) = extract_parenthetical_alias(&name_upper) {
|
if let Some(alias) = extract_parenthetical_alias(&name_upper) {
|
||||||
if valid_biomarkers.contains(&alias) {
|
if let Some(canonical) = valid_biomarkers.get(&alias) {
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
"Alias matched '{}' -> '{}' (extracted from parentheses in input)",
|
"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
|
// 3. Try matching input against aliases in SCHEMA
|
||||||
// This handles input "HS-CRP" matching schema "HIGH SENSITIVITY C-REACTIVE PROTEIN (HS-CRP)"
|
// This handles input "HS-CRP" matching schema "HIGH SENSITIVITY C-REACTIVE PROTEIN (HS-CRP)"
|
||||||
for valid in valid_biomarkers {
|
for (upper_key, canonical) in valid_biomarkers {
|
||||||
if let Some(alias) = extract_parenthetical_alias(valid) {
|
if let Some(alias) = extract_parenthetical_alias(upper_key) {
|
||||||
if alias == name_upper {
|
if alias == name_upper {
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
"Reverse alias matched '{}' -> '{}' (input is alias in schema)",
|
"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()
|
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)
|
.filter(|(_, score)| *score >= FUZZY_THRESHOLD)
|
||||||
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap())
|
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap())
|
||||||
.map(|(matched_name, score)| {
|
.map(|(matched_name, score)| {
|
||||||
@@ -88,7 +90,8 @@ fn extract_parenthetical_alias(name: &str) -> Option<String> {
|
|||||||
|
|
||||||
/// Merge multiple OCR results into one, filtering to only known biomarkers.
|
/// Merge multiple OCR results into one, filtering to only known biomarkers.
|
||||||
/// Uses fuzzy matching to handle name variations.
|
/// Uses fuzzy matching to handle name variations.
|
||||||
pub fn merge_results(results: Vec<DocumentAnnotation>, valid_biomarkers: &HashSet<String>) -> OcrResult {
|
/// valid_biomarkers: HashMap<uppercase_name, original_case_name>
|
||||||
|
pub fn merge_results(results: Vec<DocumentAnnotation>, valid_biomarkers: &HashMap<String, String>) -> OcrResult {
|
||||||
let mut merged = OcrResult {
|
let mut merged = OcrResult {
|
||||||
patient_name: None,
|
patient_name: None,
|
||||||
patient_age: None,
|
patient_age: None,
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ use axum::{
|
|||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
Json,
|
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
|
// Re-export public types
|
||||||
pub use types::{ErrorResponse, OcrState, ParseResponse};
|
pub use types::{ErrorResponse, OcrState, ParseResponse};
|
||||||
@@ -166,6 +166,18 @@ async fn process_ocr_background(
|
|||||||
let mut failed_chunk: Option<String> = None;
|
let mut failed_chunk: Option<String> = None;
|
||||||
|
|
||||||
for start_page in (0..max_pages).step_by(chunk_size) {
|
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<usize> = (start_page..std::cmp::min(start_page + chunk_size, max_pages)).collect();
|
let pages: Vec<usize> = (start_page..std::cmp::min(start_page + chunk_size, max_pages)).collect();
|
||||||
|
|
||||||
tracing::info!("Processing OCR for pages {:?}", pages);
|
tracing::info!("Processing OCR for pages {:?}", pages);
|
||||||
@@ -246,6 +258,8 @@ async fn process_ocr_background(
|
|||||||
.map_err(|e| format!("Database error: {}", e))?
|
.map_err(|e| format!("Database error: {}", e))?
|
||||||
.ok_or_else(|| "Source not found".to_string())?;
|
.ok_or_else(|| "Source not found".to_string())?;
|
||||||
|
|
||||||
|
let user_id = source_entity.user_id;
|
||||||
|
|
||||||
let mut active_model: source::ActiveModel = source_entity.into();
|
let mut active_model: source::ActiveModel = source_entity.into();
|
||||||
active_model.ocr_data = Set(Some(ocr_json));
|
active_model.ocr_data = Set(Some(ocr_json));
|
||||||
active_model.status = Set("parsed".to_string());
|
active_model.status = Set("parsed".to_string());
|
||||||
@@ -254,9 +268,53 @@ async fn process_ocr_background(
|
|||||||
active_model.update(&db).await
|
active_model.update(&db).await
|
||||||
.map_err(|e| format!("Database update failed: {}", e))?;
|
.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!(
|
tracing::info!(
|
||||||
"Successfully parsed {} biomarkers for source {}",
|
"Successfully parsed {} biomarkers, created {} entries for source {}",
|
||||||
merged.biomarkers.len(),
|
merged.biomarkers.len(),
|
||||||
|
entries_created,
|
||||||
source_id
|
source_id
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
//! Schema handling utilities.
|
//! Schema handling utilities.
|
||||||
|
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
/// Extract valid biomarker names from the ocr_schema.json enum.
|
/// Extract valid biomarker names from the ocr_schema.json enum.
|
||||||
pub fn extract_valid_biomarker_names() -> Result<HashSet<String>, String> {
|
/// Returns a HashMap where keys are UPPERCASE names (for matching) and values are original case names.
|
||||||
|
pub fn extract_valid_biomarker_names() -> Result<HashMap<String, String>, String> {
|
||||||
let schema_content = std::fs::read_to_string("ocr_schema.json")
|
let schema_content = std::fs::read_to_string("ocr_schema.json")
|
||||||
.map_err(|e| format!("Failed to read ocr_schema.json: {}", e))?;
|
.map_err(|e| format!("Failed to read ocr_schema.json: {}", e))?;
|
||||||
let schema: Value = serde_json::from_str(&schema_content)
|
let schema: Value = serde_json::from_str(&schema_content)
|
||||||
@@ -21,10 +22,11 @@ pub fn extract_valid_biomarker_names() -> Result<HashSet<String>, String> {
|
|||||||
.and_then(|e| e.as_array())
|
.and_then(|e| e.as_array())
|
||||||
.ok_or_else(|| "Could not find biomarker name enum in schema".to_string())?;
|
.ok_or_else(|| "Could not find biomarker name enum in schema".to_string())?;
|
||||||
|
|
||||||
let valid_names: HashSet<String> = names
|
// Key = uppercase (for matching), Value = original case (for DB lookup)
|
||||||
|
let valid_names: HashMap<String, String> = names
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|v| v.as_str())
|
.filter_map(|v| v.as_str())
|
||||||
.map(|s| s.to_uppercase())
|
.map(|s| (s.to_uppercase(), s.to_string()))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Ok(valid_names)
|
Ok(valid_names)
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use axum_extra::extract::Multipart;
|
use axum_extra::extract::Multipart;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use sea_orm::{ActiveModelTrait, DatabaseConnection, EntityTrait, Set};
|
use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
use tokio::io::AsyncWriteExt;
|
use tokio::io::AsyncWriteExt;
|
||||||
|
|
||||||
use crate::models::bio::source;
|
use crate::models::bio::{biomarker_entry, source};
|
||||||
|
|
||||||
/// Response for a source.
|
/// Response for a source.
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -215,6 +215,13 @@ pub async fn delete_source(
|
|||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||||
.ok_or(StatusCode::NOT_FOUND)?;
|
.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
|
// Delete file from disk
|
||||||
if let Err(e) = fs::remove_file(&s.file_path).await {
|
if let Err(e) = fs::remove_file(&s.file_path).await {
|
||||||
tracing::warn!("Failed to delete file {}: {:?}", s.file_path, e);
|
tracing::warn!("Failed to delete file {}: {:?}", s.file_path, e);
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ fn create_router(db: DatabaseConnection, config: &config::Config) -> Router {
|
|||||||
// Entries API
|
// Entries API
|
||||||
.route("/api/entries", post(handlers::entries::create_entry))
|
.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}/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));
|
.route_layer(middleware::from_fn(require_auth));
|
||||||
|
|
||||||
// Sources routes (need separate state for uploads path)
|
// Sources routes (need separate state for uploads path)
|
||||||
|
|||||||
@@ -650,8 +650,8 @@ select.input {
|
|||||||
.biomarker-row {
|
.biomarker-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-md);
|
gap: var(--space-sm);
|
||||||
padding: var(--space-xs) var(--space-sm);
|
padding: var(--space-sm);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
transition: background-color 0.15s;
|
transition: background-color 0.15s;
|
||||||
}
|
}
|
||||||
@@ -661,11 +661,12 @@ select.input {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.biomarker-dot {
|
.biomarker-dot {
|
||||||
width: 10px;
|
width: 12px;
|
||||||
height: 10px;
|
height: 12px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--text-secondary);
|
background: var(--text-secondary);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 0 3px rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.biomarker-dot.status-low {
|
.biomarker-dot.status-low {
|
||||||
@@ -681,36 +682,49 @@ select.input {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.biomarker-info {
|
.biomarker-info {
|
||||||
flex: 0 0 320px;
|
flex: 0 0 280px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: column;
|
||||||
align-items: baseline;
|
gap: 2px;
|
||||||
gap: var(--space-xs);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.biomarker-info .biomarker-name {
|
.biomarker-name {
|
||||||
font-size: 14px;
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.biomarker-info .biomarker-unit {
|
.biomarker-unit,
|
||||||
font-size: 11px;
|
.biomarker-value {
|
||||||
flex-shrink: 0;
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.biomarker-value {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Biomarker Scale Bar */
|
/* Biomarker Scale Bar */
|
||||||
.biomarker-scale {
|
.biomarker-scale {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
position: relative;
|
gap: 4px;
|
||||||
height: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.scale-bar {
|
.scale-bar {
|
||||||
width: 120px;
|
width: 220px;
|
||||||
height: 6px;
|
height: 8px;
|
||||||
border-radius: 3px;
|
border-radius: 4px;
|
||||||
|
background: var(--border);
|
||||||
|
position: relative;
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scale-bar.placeholder {
|
.scale-bar.placeholder {
|
||||||
@@ -723,13 +737,26 @@ select.input {
|
|||||||
|
|
||||||
.scale-marker {
|
.scale-marker {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 50%;
|
||||||
bottom: 0;
|
width: 12px;
|
||||||
width: 3px;
|
height: 12px;
|
||||||
background: var(--text-primary);
|
border-radius: 50%;
|
||||||
border-radius: 2px;
|
transform: translate(-50%, -50%);
|
||||||
transform: translateX(-50%);
|
box-shadow: 0 0 4px rgba(0, 0, 0, 0.4);
|
||||||
box-shadow: 0 0 2px rgba(0, 0, 0, 0.3);
|
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 */
|
/* App Layout with Sidebar */
|
||||||
|
|||||||
@@ -6,30 +6,61 @@ interface Category {
|
|||||||
description: string | null
|
description: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Biomarker {
|
interface BiomarkerResult {
|
||||||
id: number
|
biomarker_id: number
|
||||||
category_id: number
|
|
||||||
name: string
|
name: string
|
||||||
test_category: string
|
category_id: number
|
||||||
unit: string
|
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() {
|
export function DashboardPage() {
|
||||||
const [categories, setCategories] = useState<Category[]>([])
|
const [categories, setCategories] = useState<Category[]>([])
|
||||||
const [biomarkers, setBiomarkers] = useState<Biomarker[]>([])
|
const [results, setResults] = useState<BiomarkerResult[]>([])
|
||||||
const [expandedCategories, setExpandedCategories] = useState<Set<number>>(new Set())
|
const [expandedCategories, setExpandedCategories] = useState<Set<number>>(new Set())
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
const fetchData = async () => {
|
||||||
fetch('/api/categories', { credentials: 'include' }).then(r => r.json()),
|
try {
|
||||||
fetch('/api/biomarkers', { credentials: 'include' }).then(r => r.json()),
|
// Get current user
|
||||||
]).then(([cats, bms]) => {
|
const authRes = await fetch('/api/auth/me', { credentials: 'include' })
|
||||||
setCategories(cats)
|
if (!authRes.ok) return
|
||||||
setBiomarkers(bms)
|
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)
|
setLoading(false)
|
||||||
})
|
}
|
||||||
|
}
|
||||||
|
fetchData()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const toggleCategory = (categoryId: number) => {
|
const toggleCategory = (categoryId: number) => {
|
||||||
@@ -44,8 +75,20 @@ export function DashboardPage() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const getBiomarkersForCategory = (categoryId: number) => {
|
const getResultsForCategory = (categoryId: number) => {
|
||||||
return biomarkers.filter(b => b.category_id === categoryId)
|
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) {
|
if (loading) {
|
||||||
@@ -56,15 +99,17 @@ export function DashboardPage() {
|
|||||||
<div className="page">
|
<div className="page">
|
||||||
<header className="page-header">
|
<header className="page-header">
|
||||||
<h1>Dashboard</h1>
|
<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>
|
</header>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 className="mb-md">Biomarker Categories</h2>
|
<h2 className="mb-md">Biomarker Categories</h2>
|
||||||
<div className="flex-col gap-sm">
|
<div className="flex-col gap-sm">
|
||||||
{categories.map(category => {
|
{categories.map(category => {
|
||||||
const categoryBiomarkers = getBiomarkersForCategory(category.id)
|
const categoryResults = getResultsForCategory(category.id)
|
||||||
const isExpanded = expandedCategories.has(category.id)
|
const isExpanded = expandedCategories.has(category.id)
|
||||||
|
// Count how many have data
|
||||||
|
const withData = categoryResults.filter(r => r.value !== null).length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={category.id} className="card category-card">
|
<div key={category.id} className="card category-card">
|
||||||
@@ -75,7 +120,7 @@ export function DashboardPage() {
|
|||||||
<div>
|
<div>
|
||||||
<span className="category-name">{category.name}</span>
|
<span className="category-name">{category.name}</span>
|
||||||
<span className="text-secondary text-sm ml-sm">
|
<span className="text-secondary text-sm ml-sm">
|
||||||
({categoryBiomarkers.length} biomarkers)
|
({withData}/{categoryResults.length} biomarkers)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<img
|
<img
|
||||||
@@ -90,24 +135,59 @@ export function DashboardPage() {
|
|||||||
|
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="category-content border-t p-sm">
|
<div className="category-content border-t p-sm">
|
||||||
{categoryBiomarkers.length === 0 ? (
|
{categoryResults.length === 0 ? (
|
||||||
<p className="text-secondary text-sm p-sm">
|
<p className="text-secondary text-sm p-sm">
|
||||||
No biomarkers in this category
|
No biomarkers in this category
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="biomarker-list">
|
<div className="biomarker-list">
|
||||||
{categoryBiomarkers.map(biomarker => (
|
{categoryResults.map(result => {
|
||||||
<div key={biomarker.id} className="biomarker-row">
|
const scalePos = getScalePosition(result)
|
||||||
<div className="biomarker-dot" title="No data"></div>
|
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">
|
<div className="biomarker-info">
|
||||||
<span className="biomarker-name">{biomarker.name}</span>
|
<span className="biomarker-name">{result.name}</span>
|
||||||
<span className="biomarker-unit">{biomarker.unit}</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>
|
||||||
<div className="biomarker-scale">
|
<div className="biomarker-scale">
|
||||||
<div className="scale-bar placeholder"></div>
|
<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>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user