feat(backend): dose scheduling + health stats zero-knowledge
Backend changes (frontend + tests follow in next commit): Dose scheduling: - New DoseSchedule struct (times_per_day + days_of_week) as a top-level plaintext field on Medication. Create/update requests accept it. - Revised get_adherence: computes scheduled_doses from the schedule over the period (days_of_week filtering), so missed doses are now reflected. Falls back to taken/total_logged when no schedule. Health stats zero-knowledge: - HealthStatistic model now uses opaque encrypted_data blob (like medications). Only recorded_at stays plaintext (filterable/sortable). - HealthStatResponse wire type. Handlers echo opaque blobs. - Removed the trends endpoint (server can't compute trends on ciphertext; frontend computes them client-side after decrypting). - Deleted the dead HealthData model (kept EncryptedField which it defined). Verified: backend 24 tests, 0 clippy warnings.
This commit is contained in:
parent
69faa43a72
commit
09589b3bb2
8 changed files with 270 additions and 200 deletions
|
|
@ -50,6 +50,7 @@ pub async fn create_medication(
|
|||
created_at: now.into(),
|
||||
updated_at: now.into(),
|
||||
active: req.active,
|
||||
dose_schedule: req.dose_schedule,
|
||||
pill_identification: None,
|
||||
};
|
||||
|
||||
|
|
@ -173,6 +174,7 @@ pub async fn get_adherence(
|
|||
|
||||
let database = state.db.get_database();
|
||||
let doses: mongodb::Collection<MedicationDose> = 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(
|
||||
|
|
@ -183,7 +185,7 @@ pub async fn get_adherence(
|
|||
"loggedAt": { "$gte": since }
|
||||
};
|
||||
|
||||
let total = doses
|
||||
let total_logged = doses
|
||||
.count_documents(filter.clone(), None)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
|
@ -194,23 +196,81 @@ pub async fn get_adherence(
|
|||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
// 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
|
||||
};
|
||||
// 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)
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Json(crate::models::medication::AdherenceStats {
|
||||
medication_id: id,
|
||||
total_doses: total as i64,
|
||||
scheduled_doses: total as i64,
|
||||
total_doses,
|
||||
scheduled_doses,
|
||||
taken_doses: taken as i64,
|
||||
missed_doses: missed as i64,
|
||||
missed_doses,
|
||||
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<usize> = 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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue