diff --git a/backend/src/app.rs b/backend/src/app.rs index fc19592..0644947 100644 --- a/backend/src/app.rs +++ b/backend/src/app.rs @@ -72,6 +72,7 @@ pub fn build_app(state: AppState) -> Router { // Health statistics management (Phase 2.7) .route("/api/health-stats", post(handlers::create_health_stat)) .route("/api/health-stats", get(handlers::list_health_stats)) + .route("/api/health-stats/trends", get(handlers::get_health_trends)) .route("/api/health-stats/:id", get(handlers::get_health_stat)) .route("/api/health-stats/:id", put(handlers::update_health_stat)) .route("/api/health-stats/:id", delete(handlers::delete_health_stat)) diff --git a/backend/src/handlers/health_stats.rs b/backend/src/handlers/health_stats.rs index ca902cd..3db6a67 100644 --- a/backend/src/handlers/health_stats.rs +++ b/backend/src/handlers/health_stats.rs @@ -1,9 +1,8 @@ use crate::auth::jwt::Claims; use crate::config::AppState; -use crate::models::health_stats::{HealthStatResponse, HealthStatistic}; -use crate::models::medication::EncryptedFieldWire; +use crate::models::health_stats::HealthStatistic; use axum::{ - extract::{Path, State}, + extract::{Path, Query, State}, http::StatusCode, response::IntoResponse, Extension, Json, @@ -13,13 +12,24 @@ use serde::Deserialize; #[derive(Debug, Deserialize)] pub struct CreateHealthStatRequest { - pub encrypted_data: EncryptedFieldWire, + pub stat_type: String, + pub value: serde_json::Value, // Support complex values like blood pressure + pub unit: String, + pub notes: Option, pub recorded_at: Option, } #[derive(Debug, Deserialize)] pub struct UpdateHealthStatRequest { - pub encrypted_data: EncryptedFieldWire, + pub value: Option, + pub unit: Option, + pub notes: Option, +} + +#[derive(Debug, Deserialize)] +pub struct HealthTrendsQuery { + pub stat_type: String, + pub period: Option, // "7d", "30d", etc. } pub async fn create_health_stat( @@ -38,29 +48,36 @@ pub async fn create_health_stat( } }; + // Convert complex value to f64 or store as string + let value_num = match req.value { + serde_json::Value::Number(n) => n.as_f64().unwrap_or(0.0), + serde_json::Value::Object(_) => { + // For complex objects like blood pressure, use a default + 0.0 + } + _ => 0.0, + }; + let stat = HealthStatistic { id: None, user_id: claims.sub.clone(), - encrypted_data: req.encrypted_data, - recorded_at: req - .recorded_at - .unwrap_or_else(|| chrono::Utc::now().to_rfc3339()), + stat_type: req.stat_type, + value: value_num, + unit: req.unit, + notes: req.notes, + recorded_at: req.recorded_at.unwrap_or_else(|| { + use chrono::Utc; + Utc::now().to_rfc3339() + }), }; match repo.create(&stat).await { - Ok(Some(created)) => { - (StatusCode::CREATED, Json(HealthStatResponse::from(created))).into_response() - } - Ok(None) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ "error": "failed to create" })), - ) - .into_response(), + Ok(created) => (StatusCode::CREATED, Json(created)).into_response(), Err(e) => { eprintln!("Error creating health stat: {:?}", e); ( StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ "error": "database error" })), + "Failed to create health stat", ) .into_response() } @@ -82,15 +99,15 @@ pub async fn list_health_stats( } }; match repo.find_by_user(&claims.sub).await { - Ok(stats) => { - let resp: Vec = stats.into_iter().map(Into::into).collect(); - (StatusCode::OK, Json(resp)).into_response() + Ok(stats) => (StatusCode::OK, Json(stats)).into_response(), + Err(e) => { + eprintln!("Error fetching health stats: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to fetch health stats", + ) + .into_response() } - Err(_) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ "error": "database error" })), - ) - .into_response(), } } @@ -111,13 +128,7 @@ pub async fn get_health_stat( }; let object_id = match ObjectId::parse_str(&id) { Ok(oid) => oid, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ "error": "invalid id" })), - ) - .into_response() - } + Err(_) => return (StatusCode::BAD_REQUEST, "Invalid ID").into_response(), }; match repo.find_by_id(&object_id).await { @@ -125,18 +136,17 @@ pub async fn get_health_stat( if stat.user_id != claims.sub { return (StatusCode::FORBIDDEN, "Access denied").into_response(); } - (StatusCode::OK, Json(HealthStatResponse::from(stat))).into_response() + (StatusCode::OK, Json(stat)).into_response() + } + Ok(None) => (StatusCode::NOT_FOUND, "Health stat not found").into_response(), + Err(e) => { + eprintln!("Error fetching health stat: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to fetch health stat", + ) + .into_response() } - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ "error": "not found" })), - ) - .into_response(), - Err(_) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ "error": "database error" })), - ) - .into_response(), } } @@ -158,52 +168,45 @@ pub async fn update_health_stat( }; let object_id = match ObjectId::parse_str(&id) { Ok(oid) => oid, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ "error": "invalid id" })), - ) - .into_response() - } + Err(_) => return (StatusCode::BAD_REQUEST, "Invalid ID").into_response(), }; - let existing = match repo.find_by_id(&object_id).await { + let mut stat = match repo.find_by_id(&object_id).await { Ok(Some(s)) => s, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ "error": "not found" })), - ) - .into_response() - } + Ok(None) => return (StatusCode::NOT_FOUND, "Health stat not found").into_response(), Err(_) => { return ( StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ "error": "database error" })), + "Failed to fetch health stat", ) .into_response() } }; - if existing.user_id != claims.sub { + if stat.user_id != claims.sub { return (StatusCode::FORBIDDEN, "Access denied").into_response(); } - match repo - .update_encrypted_data(&object_id, req.encrypted_data) - .await - { - Ok(Some(updated)) => { - (StatusCode::OK, Json(HealthStatResponse::from(updated))).into_response() - } - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ "error": "not found" })), - ) - .into_response(), + if let Some(value) = req.value { + let value_num = match value { + serde_json::Value::Number(n) => n.as_f64().unwrap_or(0.0), + _ => 0.0, + }; + stat.value = value_num; + } + if let Some(unit) = req.unit { + stat.unit = unit; + } + if let Some(notes) = req.notes { + stat.notes = Some(notes); + } + + match repo.update(&object_id, &stat).await { + Ok(Some(updated)) => (StatusCode::OK, Json(updated)).into_response(), + Ok(None) => (StatusCode::NOT_FOUND, "Failed to update").into_response(), Err(_) => ( StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ "error": "database error" })), + "Failed to update health stat", ) .into_response(), } @@ -226,28 +229,16 @@ pub async fn delete_health_stat( }; let object_id = match ObjectId::parse_str(&id) { Ok(oid) => oid, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ "error": "invalid id" })), - ) - .into_response() - } + Err(_) => return (StatusCode::BAD_REQUEST, "Invalid ID").into_response(), }; let stat = match repo.find_by_id(&object_id).await { Ok(Some(s)) => s, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ "error": "not found" })), - ) - .into_response() - } + Ok(None) => return (StatusCode::NOT_FOUND, "Health stat not found").into_response(), Err(_) => { return ( StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ "error": "database error" })), + "Failed to fetch health stat", ) .into_response() } @@ -261,8 +252,71 @@ pub async fn delete_health_stat( Ok(true) => StatusCode::NO_CONTENT.into_response(), _ => ( StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ "error": "database error" })), + "Failed to delete health stat", ) .into_response(), } } + +pub async fn get_health_trends( + State(state): State, + Extension(claims): Extension, + Query(query): Query, +) -> impl IntoResponse { + let repo = match state.health_stats_repo.as_ref() { + Some(r) => r, + None => { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(serde_json::json!({ "error": "health stats unavailable" })), + ) + .into_response() + } + }; + match repo.find_by_user(&claims.sub).await { + Ok(stats) => { + // Filter by stat_type + let filtered: Vec = stats + .into_iter() + .filter(|s| s.stat_type == query.stat_type) + .collect(); + + // Calculate basic trend statistics + if filtered.is_empty() { + return ( + StatusCode::OK, + Json(serde_json::json!({ + "stat_type": query.stat_type, + "count": 0, + "data": [] + })), + ) + .into_response(); + } + + let values: Vec = filtered.iter().map(|s| s.value).collect(); + let avg = values.iter().sum::() / values.len() as f64; + let min = values.iter().fold(f64::INFINITY, |a, &b| a.min(b)); + let max = values.iter().fold(f64::NEG_INFINITY, |a, &b| a.max(b)); + + let response = serde_json::json!({ + "stat_type": query.stat_type, + "count": filtered.len(), + "average": avg, + "min": min, + "max": max, + "data": filtered + }); + + (StatusCode::OK, Json(response)).into_response() + } + Err(e) => { + eprintln!("Error fetching health trends: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to fetch health trends", + ) + .into_response() + } + } +} diff --git a/backend/src/handlers/medications.rs b/backend/src/handlers/medications.rs index 0d70c0e..9a9beb8 100644 --- a/backend/src/handlers/medications.rs +++ b/backend/src/handlers/medications.rs @@ -50,7 +50,6 @@ pub async fn create_medication( created_at: now.into(), updated_at: now.into(), active: req.active, - dose_schedule: req.dose_schedule, pill_identification: None, }; @@ -174,7 +173,6 @@ pub async fn get_adherence( let database = state.db.get_database(); let doses: mongodb::Collection = database.collection("medication_doses"); - let med_repo = MedicationRepository::new(database.collection("medications")); // Look at doses logged in the last PERIOD_DAYS days for this medication. let since = DateTime::from_system_time( @@ -185,7 +183,7 @@ pub async fn get_adherence( "loggedAt": { "$gte": since } }; - let total_logged = doses + let total = doses .count_documents(filter.clone(), None) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; @@ -196,81 +194,23 @@ pub async fn get_adherence( .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - // Fetch the medication to check for a dose schedule. - let medication = med_repo - .find_by_medication_id(&id) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - let (scheduled_doses, total_doses, missed_doses, rate) = - match medication.and_then(|m| m.dose_schedule) { - Some(schedule) => { - // Compute the expected number of doses in the period from the schedule. - let scheduled = compute_scheduled_doses(&schedule, PERIOD_DAYS); - let missed = scheduled.saturating_sub(taken as i64); - let r = if scheduled == 0 { - 0.0 - } else { - (taken as f64 / scheduled as f64) * 100.0 - }; - (scheduled, scheduled, missed, r) - } - None => { - // No schedule: fall back to taken / total_logged. - let missed = (total_logged as i64).saturating_sub(taken as i64); - let r = if total_logged == 0 { - 0.0 - } else { - (taken as f64 / total_logged as f64) * 100.0 - }; - (total_logged as i64, total_logged as i64, missed, r) - } - }; + // Without a dose-schedule model, every logged dose counts as one scheduled + // dose that was resolved (taken or intentionally skipped). Adherence is the + // share that were marked taken. Real scheduling is future work. + let missed = total.saturating_sub(taken); + let rate = if total == 0 { + 0.0 + } else { + (taken as f64 / total as f64) * 100.0 + }; Ok(Json(crate::models::medication::AdherenceStats { medication_id: id, - total_doses, - scheduled_doses, + total_doses: total as i64, + scheduled_doses: total as i64, taken_doses: taken as i64, - missed_doses, + missed_doses: missed as i64, adherence_rate: rate, period_days: PERIOD_DAYS, })) } - -/// Compute the expected number of doses over the last `days` days from a schedule. -fn compute_scheduled_doses(schedule: &crate::models::medication::DoseSchedule, days: i64) -> i64 { - use chrono::{Datelike, Utc}; - let now = Utc::now(); - let weekdays: &[&str] = &["mon", "tue", "wed", "thu", "fri", "sat", "sun"]; - // chrono weekday(): 0=Sunday, 1=Monday, ... 6=Saturday - let active_days: Vec = if schedule.days_of_week.is_empty() { - // Every day. - (0..7).collect() - } else { - schedule - .days_of_week - .iter() - .filter_map(|d| weekdays.iter().position(|&w| w == d)) - .map(|pos| { - // weekdays is Mon-first (0=Mon); chrono is Sun-first (0=Sun). - // Map: Mon=0 -> 1, Tue=1 -> 2, ..., Sun=6 -> 0 - if pos == 6 { - 0 - } else { - pos + 1 - } - }) - .collect() - }; - - let mut count: i64 = 0; - for i in 0..days { - let date = now - chrono::Duration::days(i); - let chrono_dow = date.weekday().num_days_from_sunday() as usize; // 0=Sun - if active_days.contains(&chrono_dow) { - count += schedule.times_per_day as i64; - } - } - count -} diff --git a/backend/src/handlers/mod.rs b/backend/src/handlers/mod.rs index 521d0b1..632369f 100644 --- a/backend/src/handlers/mod.rs +++ b/backend/src/handlers/mod.rs @@ -17,7 +17,8 @@ pub use appointments::{ pub use auth::{login, logout, recover_password, recovery_info, refresh, register}; pub use health::{health_check, ready_check}; pub use health_stats::{ - create_health_stat, delete_health_stat, get_health_stat, list_health_stats, update_health_stat, + create_health_stat, delete_health_stat, get_health_stat, get_health_trends, list_health_stats, + update_health_stat, }; pub use interactions::{check_interactions, check_new_medication}; pub use medications::{ diff --git a/backend/src/models/health_data.rs b/backend/src/models/health_data.rs index 7041400..f55efe4 100644 --- a/backend/src/models/health_data.rs +++ b/backend/src/models/health_data.rs @@ -1,8 +1,28 @@ +use mongodb::bson::{oid::ObjectId, DateTime}; use serde::{Deserialize, Serialize}; -/// The storage-layer encrypted-field wrapper used by Medication and Appointment -/// documents. NOTE: the `HealthData` model that previously used `Vec` -/// was dead code (no handler/route/repository) and has been removed. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HealthData { + #[serde(rename = "_id", skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(rename = "healthDataId")] + pub health_data_id: String, + #[serde(rename = "userId")] + pub user_id: String, + #[serde(rename = "profileId")] + pub profile_id: String, + #[serde(rename = "familyId")] + pub family_id: Option, + #[serde(rename = "healthData")] + pub health_data: Vec, + #[serde(rename = "createdAt")] + pub created_at: DateTime, + #[serde(rename = "updatedAt")] + pub updated_at: DateTime, + #[serde(rename = "dataSource")] + pub data_source: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct EncryptedField { pub encrypted: bool, diff --git a/backend/src/models/health_stats.rs b/backend/src/models/health_stats.rs index 5f28485..a03d78d 100644 --- a/backend/src/models/health_stats.rs +++ b/backend/src/models/health_stats.rs @@ -6,42 +6,19 @@ use mongodb::{ }; use serde::{Deserialize, Serialize}; -use crate::models::medication::EncryptedFieldWire; - -/// Zero-knowledge health stat: the value/unit/type/notes are encrypted inside -/// the opaque blob; only `recorded_at` stays plaintext (filterable/sortable). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HealthStatistic { #[serde(rename = "_id", skip_serializing_if = "Option::is_none")] pub id: Option, pub user_id: String, - /// Opaque client-encrypted blob (value, unit, type, notes inside). - #[serde(rename = "encryptedData")] - pub encrypted_data: EncryptedFieldWire, - /// When the reading was taken (plaintext, filterable/sortable). + #[serde(rename = "type")] + pub stat_type: String, + pub value: f64, + pub unit: String, + pub notes: Option, pub recorded_at: String, } -/// Wire response (what the API returns). -#[derive(Debug, Serialize)] -pub struct HealthStatResponse { - pub id: String, - pub user_id: String, - pub encrypted_data: EncryptedFieldWire, - pub recorded_at: String, -} - -impl From for HealthStatResponse { - fn from(s: HealthStatistic) -> Self { - HealthStatResponse { - id: s.id.map(|o| o.to_hex()).unwrap_or_default(), - user_id: s.user_id, - encrypted_data: s.encrypted_data, - recorded_at: s.recorded_at, - } - } -} - #[derive(Clone)] pub struct HealthStatisticsRepository { collection: Collection, @@ -54,14 +31,11 @@ impl HealthStatisticsRepository { } } - pub async fn create( - &self, - stat: &HealthStatistic, - ) -> Result, MongoError> { + pub async fn create(&self, stat: &HealthStatistic) -> Result { let result = self.collection.insert_one(stat, None).await?; let mut created = stat.clone(); - created.id = result.inserted_id.as_object_id(); - Ok(Some(created)) + created.id = Some(result.inserted_id.as_object_id().unwrap()); + Ok(created) } pub async fn find_by_user(&self, user_id: &str) -> Result, MongoError> { @@ -75,22 +49,14 @@ impl HealthStatisticsRepository { self.collection.find_one(filter, None).await } - pub async fn update_encrypted_data( + pub async fn update( &self, id: &ObjectId, - encrypted_data: EncryptedFieldWire, + stat: &HealthStatistic, ) -> Result, MongoError> { - self.collection - .find_one_and_update( - doc! { "_id": id }, - doc! { "$set": { - "encryptedData.data": encrypted_data.data, - "encryptedData.iv": encrypted_data.iv, - "encryptedData.authTag": encrypted_data.auth_tag, - }}, - None, - ) - .await + let filter = doc! { "_id": id }; + self.collection.replace_one(filter, stat, None).await?; + Ok(Some(stat.clone())) } pub async fn delete(&self, id: &ObjectId) -> Result { diff --git a/backend/src/models/medication.rs b/backend/src/models/medication.rs index 45e708d..9498a10 100644 --- a/backend/src/models/medication.rs +++ b/backend/src/models/medication.rs @@ -106,8 +106,6 @@ pub struct MedicationResponse { pub user_id: String, pub profile_id: String, pub active: bool, - /// Dose schedule (plaintext, for adherence calc). - pub dose_schedule: Option, /// Opaque client-encrypted blob (base64 ciphertext + iv). The server stores /// and returns this verbatim; only the client can decrypt it. pub encrypted_data: EncryptedFieldWire, @@ -134,7 +132,6 @@ impl std::convert::From for MedicationResponse { user_id: m.user_id, profile_id: m.profile_id, active: m.active, - dose_schedule: m.dose_schedule, encrypted_data: EncryptedFieldWire { data: m.medication_data.data, iv: m.medication_data.iv, @@ -157,17 +154,6 @@ fn system_time_to_rfc3339(t: std::time::SystemTime) -> String { .unwrap_or_default() } -/// Plaintext dose schedule (top-level on the Medication document so the server -/// can compute adherence without decrypting the opaque medication_data blob). -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DoseSchedule { - pub times_per_day: u32, - /// Which days of the week the medication is taken. Empty = every day. - /// Values: "mon","tue","wed","thu","fri","sat","sun" (lowercase). - #[serde(default)] - pub days_of_week: Vec, -} - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Medication { #[serde(rename = "_id", skip_serializing_if = "Option::is_none")] @@ -190,9 +176,6 @@ pub struct Medication { /// documents that predate the field (see `default_true`). #[serde(rename = "active", default = "default_true")] pub active: bool, - /// Dose schedule for adherence calculation (plaintext, top-level). - #[serde(rename = "doseSchedule", skip_serializing_if = "Option::is_none")] - pub dose_schedule: Option, /// Physical pill identification (Phase 2.8 - optional) #[serde(skip_serializing_if = "Option::is_none")] @@ -243,8 +226,6 @@ pub struct CreateMedicationRequest { /// Whether the medication is active on creation (defaults to true). #[serde(default = "default_true")] pub active: bool, - /// Dose schedule for adherence calculation (plaintext). - pub dose_schedule: Option, } #[derive(Debug, Deserialize)] @@ -254,8 +235,6 @@ pub struct UpdateMedicationRequest { pub encrypted_data: Option, /// Set the medication active/inactive. pub active: Option, - /// Update the dose schedule. - pub dose_schedule: Option>, } #[derive(Debug, Deserialize)] @@ -381,19 +360,6 @@ impl MedicationRepository { if let Some(active) = updates.active { update_doc.insert("active", active); } - if let Some(schedule) = updates.dose_schedule { - match schedule { - Some(s) => { - if let Ok(sched_doc) = mongodb::bson::to_document(&s) { - update_doc.insert("doseSchedule", sched_doc); - } - } - None => { - // Explicitly clear the schedule. - update_doc.insert("doseSchedule", mongodb::bson::Bson::Null); - } - } - } let medication = self .collection @@ -429,19 +395,6 @@ impl MedicationRepository { if let Some(active) = updates.active { update_doc.insert("active", active); } - if let Some(schedule) = updates.dose_schedule { - match schedule { - Some(s) => { - if let Ok(sched_doc) = mongodb::bson::to_document(&s) { - update_doc.insert("doseSchedule", sched_doc); - } - } - None => { - // Explicitly clear the schedule. - update_doc.insert("doseSchedule", mongodb::bson::Bson::Null); - } - } - } let medication = self .collection @@ -485,7 +438,6 @@ mod tests { created_at: DateTime::now(), updated_at: DateTime::now(), active: true, - dose_schedule: None, pill_identification: None, }; diff --git a/backend/tests/common/mod.rs b/backend/tests/common/mod.rs index 4088ef4..d66556b 100644 --- a/backend/tests/common/mod.rs +++ b/backend/tests/common/mod.rs @@ -79,10 +79,6 @@ pub fn test_config(db_name: &str) -> Config { allowed_origins: vec!["http://localhost:3000".to_string()], }, environment: Environment::Development, - rate_limit: normogen_backend::config::RateLimitConfig { - max_requests: 1000, - window_secs: 60, - }, } } diff --git a/web/normogen-web/src/components/health/HealthStats.tsx b/web/normogen-web/src/components/health/HealthStats.tsx index f014765..a76284d 100644 --- a/web/normogen-web/src/components/health/HealthStats.tsx +++ b/web/normogen-web/src/components/health/HealthStats.tsx @@ -37,16 +37,7 @@ import { } from 'recharts'; import { format } from 'date-fns'; import { useHealthStore } from '../../store/useStore'; -import { HealthStatType, type TrendData } from '../../types/api'; - -// Domain form type (decrypted fields the UI collects; the store encrypts them). -interface HealthFormData { - stat_type: HealthStatType; - value: number; - unit: string; - measured_at: string; - notes?: string; -} +import { HealthStatType, type CreateHealthStatRequest, type TrendData } from '../../types/api'; const STAT_TYPES = Object.values(HealthStatType); @@ -82,7 +73,7 @@ export const HealthStats: FC = () => { const [saving, setSaving] = useState(false); const [chartType, setChartType] = useState(HealthStatType.Weight); - const [form, setForm] = useState({ + const [form, setForm] = useState({ stat_type: HealthStatType.Weight, value: 0, unit: UNIT_DEFAULTS[HealthStatType.Weight], diff --git a/web/normogen-web/src/services/api.ts b/web/normogen-web/src/services/api.ts index 344586c..eb8f6be 100644 --- a/web/normogen-web/src/services/api.ts +++ b/web/normogen-web/src/services/api.ts @@ -10,7 +10,9 @@ import { DrugInteraction, CheckInteractionRequest, CheckNewMedicationRequest, + HealthStat, CreateHealthStatRequest, + TrendData, ApiError, DoseLog, LogDoseRequest, @@ -18,8 +20,6 @@ import { MedicationWireResponse, AppointmentWireResponse, ProfileWireResponse, - HealthStatWireResponse, - UpdateHealthStatRequest, EncryptedFieldWire, CreateAppointmentRequest, UpdateAppointmentRequest, @@ -330,25 +330,25 @@ class ApiService { return response.data; } - // ---- Health Statistics (zero-knowledge: opaque encrypted blobs) ---- + // ---- Health Statistics ---- - async getHealthStats(): Promise { - const response = await this.client.get('/health-stats'); + async getHealthStats(): Promise { + const response = await this.client.get('/health-stats'); return response.data; } - async getHealthStat(id: string): Promise { - const response = await this.client.get(`/health-stats/${id}`); + async getHealthStat(id: string): Promise { + const response = await this.client.get(`/health-stats/${id}`); return response.data; } - async createHealthStat(data: CreateHealthStatRequest): Promise { - const response = await this.client.post('/health-stats', data); + async createHealthStat(data: CreateHealthStatRequest): Promise { + const response = await this.client.post('/health-stats', data); return response.data; } - async updateHealthStat(id: string, data: UpdateHealthStatRequest): Promise { - const response = await this.client.put(`/health-stats/${id}`, data); + async updateHealthStat(id: string, data: Partial): Promise { + const response = await this.client.put(`/health-stats/${id}`, data); return response.data; } @@ -356,6 +356,11 @@ class ApiService { await this.client.delete(`/health-stats/${id}`); } + async getHealthTrends(): Promise { + const response = await this.client.get('/health-stats/trends'); + return response.data; + } + // NOTE: lab-results endpoints are intentionally absent — the backend has no // /lab-results routes yet. The LabResult type stays in types/api.ts for when // those routes land. diff --git a/web/normogen-web/src/store/useStore.ts b/web/normogen-web/src/store/useStore.ts index 69211ee..1bcfda5 100644 --- a/web/normogen-web/src/store/useStore.ts +++ b/web/normogen-web/src/store/useStore.ts @@ -17,7 +17,6 @@ import { User, Medication, HealthStat, - HealthStatType, DrugInteraction, AdherenceStats, Profile, @@ -343,7 +342,6 @@ export const useMedicationStore = create()( notes: data.notes as string | undefined, tags: data.tags as string[] | undefined, active: w.active, - dose_schedule: (w as any).dose_schedule ?? undefined, created_at: w.created_at, updated_at: w.updated_at, } as Medication; @@ -508,31 +506,10 @@ export const useHealthStore = create()( error: null, loadStats: async () => { - const key = getEncKey(); - if (!key) { - set({ error: 'No encryption key — log in to decrypt data', isLoading: false }); - return; - } set({ isLoading: true, error: null }); try { - const wireStats = await apiService.getHealthStats(); - const stats = await Promise.all( - wireStats.map(async (w) => { - const data = await decryptJson>(w.encrypted_data, key); - return { - stat_id: w.id, - user_id: w.user_id, - stat_type: (data.stat_type as HealthStatType) ?? HealthStatType.Other, - value: (data.value as number) ?? 0, - unit: (data.unit as string) ?? '', - measured_at: w.recorded_at, - notes: data.notes as string | undefined, - } as HealthStat; - }), - ); - // Compute trends client-side (server can't compute on ciphertext). - const computedTrends = computeTrends(stats); - set({ stats, trends: computedTrends, isLoading: false }); + const stats = await apiService.getHealthStats(); + set({ stats, isLoading: false }); } catch (error: any) { set({ error: error.message || 'Failed to load health stats', @@ -542,33 +519,9 @@ export const useHealthStore = create()( }, createStat: async (data: any) => { - const key = getEncKey(); - if (!key) { - set({ error: 'No encryption key — cannot encrypt data' }); - throw new Error('No encryption key'); - } set({ isLoading: true, error: null }); try { - const d = data as any; - const encrypted_data = await encryptJson({ - stat_type: d.stat_type, - value: d.value, - unit: d.unit, - notes: d.notes, - }, key); - const wire = await apiService.createHealthStat({ - encrypted_data, - recorded_at: d.measured_at, - }); - const stat: HealthStat = { - stat_id: wire.id, - user_id: wire.user_id, - stat_type: d.stat_type, - value: d.value, - unit: d.unit, - measured_at: wire.recorded_at, - notes: d.notes, - }; + const stat = await apiService.createHealthStat(data); set((state) => ({ stats: [...state.stats, stat], isLoading: false, @@ -583,80 +536,58 @@ export const useHealthStore = create()( }, updateStat: async (id: string, data: any) => { - const key = getEncKey(); - if (!key) { - set({ error: 'No encryption key — cannot encrypt data' }); - throw new Error('No encryption key'); - } + set({ isLoading: true, error: null }); try { - const d = data as any; - const encrypted_data = await encryptJson({ - stat_type: d.stat_type, - value: d.value, - unit: d.unit, - notes: d.notes, - }, key); - await apiService.updateHealthStat(id, { encrypted_data }); + const updated = await apiService.updateHealthStat(id, data); set((state) => ({ - stats: state.stats.map((s) => - s.stat_id === id ? { ...s, ...d } : s, + stats: state.stats.map((stat) => + stat.stat_id === id ? updated : stat ), + isLoading: false, })); } catch (error: any) { - set({ error: error.message || 'Failed to update stat' }); + set({ + error: error.message || 'Failed to update stat', + isLoading: false, + }); throw error; } }, deleteStat: async (id: string) => { + set({ isLoading: true, error: null }); try { await apiService.deleteHealthStat(id); set((state) => ({ - stats: state.stats.filter((s) => s.stat_id !== id), + stats: state.stats.filter((stat) => stat.stat_id !== id), + isLoading: false, })); } catch (error: any) { - set({ error: error.message || 'Failed to delete stat' }); + set({ + error: error.message || 'Failed to delete stat', + isLoading: false, + }); throw error; } }, loadTrends: async () => { - // Trends are now computed client-side in loadStats. - const computedTrends = computeTrends(get().stats); - set({ trends: computedTrends }); + set({ isLoading: true, error: null }); + try { + const trends = await apiService.getHealthTrends(); + set({ trends, isLoading: false }); + } catch (error: any) { + set({ + error: error.message || 'Failed to load trends', + isLoading: false, + }); + } }, clearError: () => set({ error: null }), })) ); -/** Compute trend data from decrypted stats (client-side, since the server - * can't compute trends on ciphertext). */ -function computeTrends(stats: HealthStat[]): any[] { - const byType: Record = {}; - for (const s of stats) { - const key = String(s.stat_type); - (byType[key] ??= []).push(s); - } - const trends: any[] = []; - for (const [type, items] of Object.entries(byType)) { - const values = items.map((s) => s.value); - if (values.length === 0) continue; - const avg = values.reduce((a, b) => a + b, 0) / values.length; - const min = Math.min(...values); - const max = Math.max(...values); - trends.push({ - stat_type: type, - average: avg, - min, - max, - trend: 'stable' as const, - data_points: items, - }); - } - return trends; -} - // Interaction Store (Phase 2.8) export const useInteractionStore = create()( devtools((set, get) => ({ diff --git a/web/normogen-web/src/types/api.ts b/web/normogen-web/src/types/api.ts index 67ccf49..cb52fc7 100644 --- a/web/normogen-web/src/types/api.ts +++ b/web/normogen-web/src/types/api.ts @@ -139,7 +139,6 @@ export interface Medication { notes?: string; tags?: string[]; active: boolean; - dose_schedule?: DoseSchedule; created_at?: string; updated_at?: string; } @@ -151,7 +150,6 @@ export interface MedicationWireResponse { user_id: string; profile_id: string; active: boolean; - dose_schedule?: DoseSchedule; encrypted_data: EncryptedFieldWire; created_at: string; updated_at: string; @@ -162,13 +160,11 @@ export interface CreateMedicationRequest { profile_id: string; encrypted_data: EncryptedFieldWire; active?: boolean; - dose_schedule?: DoseSchedule; } export interface UpdateMedicationRequest { encrypted_data?: EncryptedFieldWire; active?: boolean; - dose_schedule?: DoseSchedule; } // Drug Interaction Types (Phase 2.8) @@ -208,32 +204,24 @@ export enum HealthStatType { Other = 'other' } -// Domain type (decrypted, used by UI) export interface HealthStat { stat_id?: string; - user_id?: string; + user_id: string; stat_type: HealthStatType; value: number; unit: string; measured_at: string; notes?: string; -} - -// Wire response (opaque blob from the server) -export interface HealthStatWireResponse { - id: string; - user_id: string; - encrypted_data: EncryptedFieldWire; - recorded_at: string; + created_at?: string; + updated_at?: string; } export interface CreateHealthStatRequest { - encrypted_data: EncryptedFieldWire; - recorded_at?: string; -} - -export interface UpdateHealthStatRequest { - encrypted_data: EncryptedFieldWire; + stat_type: HealthStatType; + value: number; + unit: string; + measured_at: string; + notes?: string; } export interface TrendData { @@ -245,12 +233,6 @@ export interface TrendData { data_points: HealthStat[]; } -// Dose schedule (plaintext metadata for adherence calc) -export interface DoseSchedule { - times_per_day: number; - days_of_week?: string[]; -} - // Lab Results Types export interface LabResult { lab_id?: string;