feat: Phase 3c — dose logging + adherence, profile management, tests
Three workstreams, all backend+frontend (per scope decisions): Dose logging + real adherence (backend + frontend): * log_dose now returns the created dose (201 + body) instead of an empty 201. * get_adherence implemented for real: queries the medication_doses collection over the last 30 days, counts taken vs total, computes the rate. The previous implementation hardcoded zeros. Removed the dead calculate_adherence stub. * Frontend: fixed DoseLog type to match backend MedicationDose (taken:bool, loggedAt, camelCase); added AdherenceStats + LogDoseRequest types; logDose() + getAdherence() in api.ts; loadAdherence/logDose actions in the medication store (adherence cache keyed by med id); new DoseLogger component (Taken/ Skipped buttons + LinearProgress adherence bar) embedded in each MedicationManager card. Profile management (backend + frontend): * New GET/PUT /api/profiles/me endpoints (ProfileResponse excludes encryption fields; find_by_user_id + update_name on ProfileRepository). * Register auto-creates a default 'patient' profile (deterministic profile_id = profile_<user_id>) — this is the contract the frontend relies on. * Frontend: Profile type; getProfile()/updateProfileName() in api.ts; useProfileStore; new ProfileEditor component (view/edit name, shows role) as a 4th Dashboard tab. * Resolved the MedicationManager profile_id TODO: now derives profile_<user_id> instead of the 'default' fallback. * NOTE: profile name is stored plaintext (the model anticipates encryption via nameIv/nameAuthTag but no crypto layer is implemented yet — TODO). Vitest tests: * Added @testing-library/user-event; setupTests clears localStorage + cleanup between tests; new test/mockStore.ts helper (mocks the co-located stores, handles both selector and no-selector call patterns). * 5 test files, 20 tests: SeverityChip (4), useMedicationStore actions incl. loadMedications/createMedication/logDose (4), MedicationManager render+dialog (5), InteractionsChecker selection+results (4), HealthStats table+dialog (3). Verified: backend cargo fmt/build/clippy 0 warnings, 19 unit tests pass; frontend npm build clean, 20 vitest tests pass. Solaria round-trip confirmed: profile auto-created on register (GET /profiles/me), PUT updates name, dose log returns the dose body, adherence computes 66.7% for 2-taken/1-skipped. KNOWN FOLLOW-UP (separate task): the backend Medication list response is deeply nested + camelCase + stores fields inside medicationData.data; the frontend Medication type assumes flat top-level snake_case fields. This pre-dates Phase 3c and affects the whole MedicationManager — needs a backend serialization fix or a frontend adapter.
This commit is contained in:
parent
71add3fe92
commit
b6be945855
24 changed files with 1063 additions and 64 deletions
|
|
@ -9,7 +9,7 @@ use crate::{
|
|||
auth::jwt::Claims, // Fixed: import from auth::jwt instead of handlers::auth
|
||||
config::AppState,
|
||||
models::medication::{
|
||||
CreateMedicationRequest, LogDoseRequest, Medication, MedicationRepository,
|
||||
CreateMedicationRequest, LogDoseRequest, Medication, MedicationDose, MedicationRepository,
|
||||
UpdateMedicationRequest,
|
||||
},
|
||||
};
|
||||
|
|
@ -158,12 +158,12 @@ pub async fn log_dose(
|
|||
Extension(claims): Extension<Claims>,
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<LogDoseRequest>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
) -> Result<(StatusCode, Json<MedicationDose>), StatusCode> {
|
||||
let database = state.db.get_database();
|
||||
|
||||
let now = SystemTime::now();
|
||||
|
||||
let dose = crate::models::medication::MedicationDose {
|
||||
let mut dose = crate::models::medication::MedicationDose {
|
||||
id: None,
|
||||
medication_id: id.clone(),
|
||||
user_id: claims.sub.clone(),
|
||||
|
|
@ -173,14 +173,16 @@ pub async fn log_dose(
|
|||
notes: req.notes,
|
||||
};
|
||||
|
||||
match database
|
||||
.collection("medication_doses")
|
||||
.insert_one(dose.clone(), None)
|
||||
let result = database
|
||||
.collection::<crate::models::medication::MedicationDose>("medication_doses")
|
||||
.insert_one(&dose, None)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Ok(StatusCode::CREATED),
|
||||
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
}
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
// Populate the generated _id so the caller gets the persisted dose back.
|
||||
dose.id = result.inserted_id.as_object_id();
|
||||
|
||||
Ok((StatusCode::CREATED, Json(dose)))
|
||||
}
|
||||
|
||||
pub async fn get_adherence(
|
||||
|
|
@ -188,11 +190,50 @@ pub async fn get_adherence(
|
|||
Extension(_claims): Extension<Claims>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<crate::models::medication::AdherenceStats>, StatusCode> {
|
||||
let database = state.db.get_database();
|
||||
let repo = MedicationRepository::new(database.collection("medications"));
|
||||
use mongodb::bson::{doc, DateTime};
|
||||
|
||||
match repo.calculate_adherence(&id, 30).await {
|
||||
Ok(stats) => Ok(Json(stats)),
|
||||
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
}
|
||||
const PERIOD_DAYS: i64 = 30;
|
||||
|
||||
let database = state.db.get_database();
|
||||
let doses: mongodb::Collection<MedicationDose> = database.collection("medication_doses");
|
||||
|
||||
// Look at doses logged in the last PERIOD_DAYS days for this medication.
|
||||
let since = DateTime::from_system_time(
|
||||
SystemTime::now() - std::time::Duration::from_secs(PERIOD_DAYS as u64 * 86400),
|
||||
);
|
||||
let filter = doc! {
|
||||
"medicationId": &id,
|
||||
"loggedAt": { "$gte": since }
|
||||
};
|
||||
|
||||
let total = doses
|
||||
.count_documents(filter.clone(), None)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let taken_filter = doc! { "$and": [filter, doc! { "taken": true }] };
|
||||
let taken = doses
|
||||
.count_documents(taken_filter, None)
|
||||
.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
|
||||
};
|
||||
|
||||
Ok(Json(crate::models::medication::AdherenceStats {
|
||||
medication_id: id,
|
||||
total_doses: total as i64,
|
||||
scheduled_doses: total as i64,
|
||||
taken_doses: taken as i64,
|
||||
missed_doses: missed as i64,
|
||||
adherence_rate: rate,
|
||||
period_days: PERIOD_DAYS,
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue