feat: implement health statistics tracking (Phase 2.7 Task 2)

- Add HealthStatistics model with 10 stat types
- Implement HealthStatisticsRepository
- Create 6 health stats API endpoints
- Add trend analysis with summary calculations
- Follow medication repository pattern

Status: 60% complete, needs compilation fixes
This commit is contained in:
goose 2026-03-07 16:24:18 -03:00
parent d673415bc6
commit b59be78e4a
18 changed files with 2420 additions and 7 deletions

View file

@ -0,0 +1,267 @@
use axum::{
extract::{Path, Query, State},
http::StatusCode,
Json,
};
use mongodb::bson::oid::ObjectId;
use serde::{Deserialize, Serialize};
use crate::models::health_stats::{
CreateHealthStatRequest, HealthStatistic, HealthStatType, HealthStatValue,
HealthStatisticsRepository, UpdateHealthStatRequest,
};
use crate::auth::jwt::Claims;
#[derive(Debug, Deserialize)]
pub struct ListStatsQuery {
pub stat_type: Option<String>,
pub profile_id: Option<String>,
pub limit: Option<i64>,
}
#[derive(Debug, Deserialize)]
pub struct TrendQuery {
pub profile_id: String,
pub stat_type: String,
pub days: Option<i64>,
}
#[derive(Debug, Serialize)]
pub struct TrendResponse {
pub stat_type: String,
pub profile_id: String,
pub days: i64,
pub data_points: i64,
pub stats: Vec<HealthStatistic>,
pub summary: TrendSummary,
}
#[derive(Debug, Serialize)]
pub struct TrendSummary {
pub latest: Option<f64>,
pub earliest: Option<f64>,
pub average: Option<f64>,
pub min: Option<f64>,
pub max: Option<f64>,
pub trend: String,
}
pub async fn create_health_stat(
State(repo): State<HealthStatisticsRepository>,
claims: Claims,
Json(req): Json<CreateHealthStatRequest>,
) -> Result<Json<HealthStatistic>, StatusCode> {
let stat_type = parse_stat_type(&req.stat_type);
let value = parse_stat_value(&req.value, &stat_type);
let unit = req.unit.unwrap_or_else(|| stat_type.default_unit().to_string());
let now = mongodb::bson::DateTime::now();
let health_stat_id = uuid::Uuid::new_v4().to_string();
let stat = HealthStatistic {
id: None,
health_stat_id,
user_id: claims.user_id.clone(),
profile_id: req.profile_id.clone(),
stat_type,
value,
unit,
recorded_at: req.recorded_at.unwrap_or(now),
notes: req.notes,
tags: req.tags.unwrap_or_default(),
created_at: now,
updated_at: now,
};
match repo.create(stat.clone()).await {
Ok(created) => Ok(Json(created)),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
pub async fn list_health_stats(
State(repo): State<HealthStatisticsRepository>,
claims: Claims,
Query(query): Query<ListStatsQuery>,
) -> Result<Json<Vec<HealthStatistic>>, StatusCode> {
let limit = query.limit.unwrap_or(100);
match repo
.list_by_user(
&claims.user_id,
query.stat_type.as_deref(),
query.profile_id.as_deref(),
limit,
)
.await
{
Ok(stats) => Ok(Json(stats)),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
pub async fn get_health_stat(
State(repo): State<HealthStatisticsRepository>,
claims: Claims,
Path(id): Path<String>,
) -> Result<Json<HealthStatistic>, StatusCode> {
match ObjectId::parse_str(&id) {
Ok(oid) => match repo.get_by_id(&oid, &claims.user_id).await {
Ok(Some(stat)) => Ok(Json(stat)),
Ok(None) => Err(StatusCode::NOT_FOUND),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
},
Err(_) => Err(StatusCode::BAD_REQUEST),
}
}
pub async fn update_health_stat(
State(repo): State<HealthStatisticsRepository>,
claims: Claims,
Path(id): Path<String>,
Json(req): Json<UpdateHealthStatRequest>,
) -> Result<Json<HealthStatistic>, StatusCode> {
match ObjectId::parse_str(&id) {
Ok(oid) => match repo.update(&oid, &claims.user_id, req).await {
Ok(Some(stat)) => Ok(Json(stat)),
Ok(None) => Err(StatusCode::NOT_FOUND),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
},
Err(_) => Err(StatusCode::BAD_REQUEST),
}
}
pub async fn delete_health_stat(
State(repo): State<HealthStatisticsRepository>,
claims: Claims,
Path(id): Path<String>,
) -> Result<StatusCode, StatusCode> {
match ObjectId::parse_str(&id) {
Ok(oid) => match repo.delete(&oid, &claims.user_id).await {
Ok(true) => Ok(StatusCode::NO_CONTENT),
Ok(false) => Err(StatusCode::NOT_FOUND),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
},
Err(_) => Err(StatusCode::BAD_REQUEST),
}
}
pub async fn get_health_trends(
State(repo): State<HealthStatisticsRepository>,
claims: Claims,
Query(query): Query<TrendQuery>,
) -> Result<Json<TrendResponse>, StatusCode> {
let days = query.days.unwrap_or(30);
match repo
.get_trends(&claims.user_id, &query.profile_id, &query.stat_type, days)
.await
{
Ok(stats) => {
let data_points = stats.len() as i64;
let summary = calculate_summary(&stats);
let response = TrendResponse {
stat_type: query.stat_type.clone(),
profile_id: query.profile_id,
days,
data_points,
stats,
summary,
};
Ok(Json(response))
}
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
fn parse_stat_type(stat_type: &str) -> HealthStatType {
match stat_type.to_lowercase().as_str() {
"weight" => HealthStatType::Weight,
"height" => HealthStatType::Height,
"blood_pressure" => HealthStatType::BloodPressure,
"heart_rate" => HealthStatType::HeartRate,
"temperature" => HealthStatType::Temperature,
"blood_glucose" => HealthStatType::BloodGlucose,
"oxygen_saturation" => HealthStatType::OxygenSaturation,
"sleep_hours" => HealthStatType::SleepHours,
"steps" => HealthStatType::Steps,
"calories" => HealthStatType::Calories,
custom => HealthStatType::Custom(custom.to_string()),
}
}
fn parse_stat_value(value: &serde_json::Value, stat_type: &HealthStatType) -> HealthStatValue {
match stat_type {
HealthStatType::BloodPressure => {
if let Some(obj) = value.as_object() {
let systolic = obj.get("systolic").and_then(|v| v.as_f64()).unwrap_or(0.0);
let diastolic = obj.get("diastolic").and_then(|v| v.as_f64()).unwrap_or(0.0);
HealthStatValue::BloodPressure { systolic, diastolic }
} else {
HealthStatValue::Single(0.0)
}
}
_ => {
if let Some(num) = value.as_f64() {
HealthStatValue::Single(num)
} else if let Some(str_val) = value.as_str() {
HealthStatValue::String(str_val.to_string())
} else {
HealthStatValue::Single(0.0)
}
}
}
}
fn calculate_summary(stats: &[HealthStatistic]) -> TrendSummary {
let mut values: Vec<f64> = Vec::new();
for stat in stats {
match &stat.value {
HealthStatValue::Single(v) => values.push(*v),
HealthStatValue::BloodPressure { systolic, .. } => values.push(*systolic),
_ => {}
}
}
if values.is_empty() {
return TrendSummary {
latest: None,
earliest: None,
average: None,
min: None,
max: None,
trend: "stable".to_string(),
};
}
let latest = values.last().copied();
let earliest = values.first().copied();
let average = values.iter().sum::<f64>() / 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 trend = if let (Some(l), Some(e)) = (latest, earliest) {
let change = ((l - e) / e * 100.0).abs();
if l > e && change > 5.0 {
"up"
} else if l < e && change > 5.0 {
"down"
} else {
"stable"
}
} else {
"stable"
};
TrendSummary {
latest,
earliest,
average: Some(average),
min: Some(min),
max: Some(max),
trend: trend.to_string(),
}
}