From 54aa1ecafe9c8757c3f862fbad96ae16cc84c61e Mon Sep 17 00:00:00 2001 From: goose Date: Sun, 28 Jun 2026 10:26:58 -0300 Subject: [PATCH] Phase 3c WIP: doses/profiles/tests (pre-solaria-verify) --- backend/src/app.rs | 3 + backend/src/db/mongodb_impl.rs | 11 -- backend/src/handlers/auth.rs | 14 ++ backend/src/handlers/medications.rs | 73 ++++++++-- backend/src/handlers/mod.rs | 2 + backend/src/handlers/profile.rs | 137 ++++++++++++++++++ backend/src/models/medication.rs | 20 +-- backend/src/models/profile.rs | 29 ++++ web/normogen-web/package-lock.json | 15 ++ web/normogen-web/package.json | 1 + .../components/common/SeverityChip.test.tsx | 26 ++++ .../components/health/HealthStats.test.tsx | 59 ++++++++ .../interactions/InteractionsChecker.test.tsx | 113 +++++++++++++++ .../src/components/medication/DoseLogger.tsx | 84 +++++++++++ .../medication/MedicationManager.test.tsx | 83 +++++++++++ .../medication/MedicationManager.tsx | 9 +- .../src/components/profile/ProfileEditor.tsx | 128 ++++++++++++++++ web/normogen-web/src/pages/Dashboard.tsx | 7 +- web/normogen-web/src/services/api.ts | 28 ++++ web/normogen-web/src/setupTests.ts | 19 ++- web/normogen-web/src/store/useStore.test.ts | 82 +++++++++++ web/normogen-web/src/store/useStore.ts | 93 +++++++++++- web/normogen-web/src/test/mockStore.ts | 49 +++++++ web/normogen-web/src/types/api.ts | 42 +++++- 24 files changed, 1063 insertions(+), 64 deletions(-) create mode 100644 backend/src/handlers/profile.rs create mode 100644 web/normogen-web/src/components/common/SeverityChip.test.tsx create mode 100644 web/normogen-web/src/components/health/HealthStats.test.tsx create mode 100644 web/normogen-web/src/components/interactions/InteractionsChecker.test.tsx create mode 100644 web/normogen-web/src/components/medication/DoseLogger.tsx create mode 100644 web/normogen-web/src/components/medication/MedicationManager.test.tsx create mode 100644 web/normogen-web/src/components/profile/ProfileEditor.tsx create mode 100644 web/normogen-web/src/store/useStore.test.ts create mode 100644 web/normogen-web/src/test/mockStore.ts diff --git a/backend/src/app.rs b/backend/src/app.rs index 2e29c2c..de21413 100644 --- a/backend/src/app.rs +++ b/backend/src/app.rs @@ -53,6 +53,9 @@ pub fn build_app(state: AppState) -> Router { .route("/api/shares/:id", delete(handlers::delete_share)) // Permission checking .route("/api/permissions/check", post(handlers::check_permission)) + // Profile management (Phase 3c) + .route("/api/profiles/me", get(handlers::get_my_profile)) + .route("/api/profiles/me", put(handlers::update_my_profile)) // Session management (Phase 2.6) .route("/api/sessions", get(handlers::get_sessions)) .route("/api/sessions/:id", delete(handlers::revoke_session)) diff --git a/backend/src/db/mongodb_impl.rs b/backend/src/db/mongodb_impl.rs index 861b34e..e1410ae 100644 --- a/backend/src/db/mongodb_impl.rs +++ b/backend/src/db/mongodb_impl.rs @@ -338,15 +338,4 @@ impl MongoDb { .map_err(|e| anyhow::anyhow!("Failed to log dose: {}", e))?; Ok(result.inserted_id.as_object_id()) } - - pub async fn get_medication_adherence( - &self, - medication_id: &str, - days: i64, - ) -> Result { - let repo = MedicationRepository::new(self.medications.clone()); - repo.calculate_adherence(medication_id, days) - .await - .map_err(|e| anyhow::anyhow!("Failed to calculate adherence: {}", e)) - } } diff --git a/backend/src/handlers/auth.rs b/backend/src/handlers/auth.rs index 69e3b55..66e8a25 100644 --- a/backend/src/handlers/auth.rs +++ b/backend/src/handlers/auth.rs @@ -108,6 +108,20 @@ pub async fn register( ) .await; } + + // Auto-create a default profile for the new user. The profile_id is + // deterministic (profile_) and is the contract the frontend + // uses for medication creation. Best-effort: registration still + // succeeds if this fails (GET /profiles/me lazily creates one). + let database = state.db.get_database(); + let profile_repo = + crate::models::profile::ProfileRepository::new(database.collection("profiles")); + let profile = + crate::handlers::profile::build_default_profile(&id.to_string(), &req.username); + if let Err(e) = profile_repo.create(&profile).await { + tracing::warn!("Failed to auto-create profile for {}: {}", id, e); + } + id } Ok(None) => { diff --git a/backend/src/handlers/medications.rs b/backend/src/handlers/medications.rs index 2b6fd35..54bbbcb 100644 --- a/backend/src/handlers/medications.rs +++ b/backend/src/handlers/medications.rs @@ -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, Path(id): Path, Json(req): Json, -) -> Result { +) -> Result<(StatusCode, Json), 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::("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, Path(id): Path, ) -> Result, 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 = 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, + })) } diff --git a/backend/src/handlers/mod.rs b/backend/src/handlers/mod.rs index 21e266f..a6930c2 100644 --- a/backend/src/handlers/mod.rs +++ b/backend/src/handlers/mod.rs @@ -4,6 +4,7 @@ pub mod health_stats; pub mod interactions; pub mod medications; pub mod permissions; +pub mod profile; pub mod sessions; pub mod shares; pub mod users; @@ -21,6 +22,7 @@ pub use medications::{ log_dose, update_medication, }; pub use permissions::check_permission; +pub use profile::{get_my_profile, update_my_profile}; pub use sessions::{get_sessions, revoke_all_sessions, revoke_session}; pub use shares::{create_share, delete_share, list_shares, update_share}; pub use users::{ diff --git a/backend/src/handlers/profile.rs b/backend/src/handlers/profile.rs new file mode 100644 index 0000000..c0cb77e --- /dev/null +++ b/backend/src/handlers/profile.rs @@ -0,0 +1,137 @@ +use axum::{ + extract::{Extension, State}, + http::StatusCode, + Json, +}; +use mongodb::bson::DateTime; +use serde::{Deserialize, Serialize}; +use validator::Validate; + +use crate::{ + auth::jwt::Claims, + config::AppState, + models::profile::{Profile, ProfileRepository}, +}; + +/// The profile as exposed to clients (omits the internal encryption fields). +#[derive(Debug, Serialize)] +pub struct ProfileResponse { + pub profile_id: String, + pub user_id: String, + pub name: String, + pub role: String, + pub permissions: Vec, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl From for ProfileResponse { + fn from(p: Profile) -> Self { + Self { + profile_id: p.profile_id, + user_id: p.user_id, + name: p.name, + role: p.role, + permissions: p.permissions, + created_at: p.created_at, + updated_at: p.updated_at, + } + } +} + +/// Create the default "patient" profile for a freshly registered user. Called +/// from `register`. The profile_id is deterministic: `profile_` — this +/// is the contract the frontend relies on for medication creation. +pub fn build_default_profile(user_id: &str, name: &str) -> Profile { + let now = DateTime::now(); + Profile { + id: None, + profile_id: format!("profile_{user_id}"), + user_id: user_id.to_string(), + family_id: None, + // TODO: encrypt the name (the model anticipates nameIv/nameAuthTag, but + // no crypto layer is implemented yet). + name: name.to_string(), + name_iv: String::new(), + name_auth_tag: String::new(), + role: "patient".to_string(), + permissions: vec!["read:self".to_string(), "write:self".to_string()], + created_at: now, + updated_at: now, + } +} + +#[derive(Debug, Deserialize, Validate)] +pub struct UpdateProfileNameRequest { + #[validate(length(min = 1, max = 100))] + pub name: String, +} + +/// GET /api/profiles/me — the current user's profile. If for some reason the +/// auto-created profile is missing, lazily create it. +pub async fn get_my_profile( + State(state): State, + Extension(claims): Extension, +) -> Result, (StatusCode, Json)> { + let database = state.db.get_database(); + let repo = ProfileRepository::new(database.collection("profiles")); + + let profile = match repo.find_by_user_id(&claims.sub).await { + Ok(Some(p)) => p, + Ok(None) => { + // Lazily create if missing (e.g. users registered before this code shipped). + let p = build_default_profile(&claims.sub, &claims.sub); + if let Err(e) = repo.create(&p).await { + tracing::error!("Failed to lazily create profile: {}", e); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "failed to load profile" })), + )); + } + p + } + Err(e) => { + tracing::error!("Profile lookup failed: {}", e); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "database error" })), + )); + } + }; + + Ok(Json(ProfileResponse::from(profile))) +} + +/// PUT /api/profiles/me — update the profile's display name. +pub async fn update_my_profile( + State(state): State, + Extension(claims): Extension, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + if let Err(errors) = req.validate() { + return Err(( + StatusCode::BAD_REQUEST, + Json( + serde_json::json!({ "error": "validation failed", "details": errors.to_string() }), + ), + )); + } + + let database = state.db.get_database(); + let repo = ProfileRepository::new(database.collection("profiles")); + + match repo.update_name(&claims.sub, &req.name).await { + Ok(Some(updated)) => Ok(Json(ProfileResponse::from(updated))), + Ok(None) => Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "profile not found" })), + )), + Err(e) => { + tracing::error!("Profile update failed: {}", e); + Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "database error" })), + )) + } + } +} diff --git a/backend/src/models/medication.rs b/backend/src/models/medication.rs index ff70bd4..977d66c 100644 --- a/backend/src/models/medication.rs +++ b/backend/src/models/medication.rs @@ -335,21 +335,7 @@ impl MedicationRepository { Ok(result.deleted_count > 0) } - pub async fn calculate_adherence( - &self, - medication_id: &str, - days: i64, - ) -> Result> { - // For now, return a placeholder adherence calculation - // In a full implementation, this would query the medication_doses collection - Ok(AdherenceStats { - medication_id: medication_id.to_string(), - total_doses: 0, - scheduled_doses: 0, - taken_doses: 0, - missed_doses: 0, - adherence_rate: 100.0, - period_days: days, - }) - } + // NOTE: adherence is computed in handlers::medications::get_adherence by + // querying the medication_doses collection directly (it needs a different + // collection than the medications this repository wraps). } diff --git a/backend/src/models/profile.rs b/backend/src/models/profile.rs index 9f828ce..f336ce3 100644 --- a/backend/src/models/profile.rs +++ b/backend/src/models/profile.rs @@ -52,4 +52,33 @@ impl ProfileRepository { .find_one(doc! { "profileId": profile_id }, None) .await } + + /// Look up a profile by its owning user id. + pub async fn find_by_user_id(&self, user_id: &str) -> mongodb::error::Result> { + self.collection + .find_one(doc! { "userId": user_id }, None) + .await + } + + /// Update the profile's display name. NOTE: name is currently stored + /// plaintext — the model's nameIv/nameAuthTag fields anticipate encryption + /// that isn't implemented yet (TODO). + pub async fn update_name( + &self, + user_id: &str, + name: &str, + ) -> mongodb::error::Result> { + self.collection + .find_one_and_update( + doc! { "userId": user_id }, + doc! { "$set": { + "name": name, + "nameIv": "", + "nameAuthTag": "", + "updatedAt": DateTime::now() + }}, + None, + ) + .await + } } diff --git a/web/normogen-web/package-lock.json b/web/normogen-web/package-lock.json index c189c7c..69819b3 100644 --- a/web/normogen-web/package-lock.json +++ b/web/normogen-web/package-lock.json @@ -24,6 +24,7 @@ "devDependencies": { "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^22.10.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -2012,6 +2013,20 @@ } } }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", diff --git a/web/normogen-web/package.json b/web/normogen-web/package.json index fd4c3cc..2ab78a4 100644 --- a/web/normogen-web/package.json +++ b/web/normogen-web/package.json @@ -27,6 +27,7 @@ "devDependencies": { "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^22.10.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", diff --git a/web/normogen-web/src/components/common/SeverityChip.test.tsx b/web/normogen-web/src/components/common/SeverityChip.test.tsx new file mode 100644 index 0000000..c098cd9 --- /dev/null +++ b/web/normogen-web/src/components/common/SeverityChip.test.tsx @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { SeverityChip } from './SeverityChip'; +import { InteractionSeverity } from '../../types/api'; + +describe('SeverityChip', () => { + it('renders the severe label', () => { + render(); + expect(screen.getByText('Severe')).toBeInTheDocument(); + }); + + it('renders moderate', () => { + render(); + expect(screen.getByText('Moderate')).toBeInTheDocument(); + }); + + it('renders mild', () => { + render(); + expect(screen.getByText('Mild')).toBeInTheDocument(); + }); + + it('renders unknown for the unknown severity', () => { + render(); + expect(screen.getByText('Unknown')).toBeInTheDocument(); + }); +}); diff --git a/web/normogen-web/src/components/health/HealthStats.test.tsx b/web/normogen-web/src/components/health/HealthStats.test.tsx new file mode 100644 index 0000000..e13bbfb --- /dev/null +++ b/web/normogen-web/src/components/health/HealthStats.test.tsx @@ -0,0 +1,59 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { mockStoreFactory, setMockStore, resetMockStore } from '../../test/mockStore'; + +vi.mock('../../store/useStore', () => mockStoreFactory()); + +const { HealthStats } = await import('./HealthStats'); + +const healthActions = { + loadStats: vi.fn(), + createStat: vi.fn(), + updateStat: vi.fn(), + deleteStat: vi.fn(), + loadTrends: vi.fn(), + clearError: vi.fn(), + isLoading: false, + error: null, + stats: [], + trends: [], +}; + +describe('HealthStats', () => { + beforeEach(() => resetMockStore()); + + it('shows the empty state when there are no readings', () => { + setMockStore({ useHealthStore: { ...healthActions } }); + render(); + expect(screen.getByText(/No health readings yet/i)).toBeInTheDocument(); + }); + + it('renders the readings table when stats exist', () => { + setMockStore({ + useHealthStore: { + ...healthActions, + stats: [ + { + stat_id: 's1', + stat_type: 'weight', + value: 78.5, + unit: 'kg', + measured_at: '2026-06-01T10:00:00.000Z', + }, + ], + }, + }); + render(); + // "weight" appears in both the chart selector and the table; the value + // string is unique to the table cell. + expect(screen.getByText('78.5 kg')).toBeInTheDocument(); + expect(screen.getByText('Jun 1, 07:00')).toBeInTheDocument(); + }); + + it('opens the record dialog when Record is clicked', () => { + setMockStore({ useHealthStore: { ...healthActions } }); + render(); + fireEvent.click(screen.getByText('Record')); + expect(screen.getByText('Record a measurement')).toBeInTheDocument(); + }); +}); diff --git a/web/normogen-web/src/components/interactions/InteractionsChecker.test.tsx b/web/normogen-web/src/components/interactions/InteractionsChecker.test.tsx new file mode 100644 index 0000000..6b741d3 --- /dev/null +++ b/web/normogen-web/src/components/interactions/InteractionsChecker.test.tsx @@ -0,0 +1,113 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { mockStoreFactory, setMockStore, resetMockStore } from '../../test/mockStore'; + +vi.mock('../../store/useStore', () => mockStoreFactory()); + +const { InteractionsChecker } = await import('./InteractionsChecker'); + +const interactionActions = { + checkInteractions: vi.fn(), + checkNewMedication: vi.fn(), + clearInteractions: vi.fn(), + clearError: vi.fn(), + isChecking: false, + error: null, + interactions: [], +}; + +const medActions = { + loadMedications: vi.fn(), + createMedication: vi.fn(), + updateMedication: vi.fn(), + deleteMedication: vi.fn(), + selectMedication: vi.fn(), + clearError: vi.fn(), + loadAdherence: vi.fn(), + logDose: vi.fn(), + isLoading: false, + error: null, + adherence: {}, + selectedMedication: null, +}; + +describe('InteractionsChecker', () => { + beforeEach(() => resetMockStore()); + + it('prompts to add medications when none exist', () => { + setMockStore({ + useMedicationStore: { ...medActions, medications: [] }, + useInteractionStore: { ...interactionActions }, + }); + render(); + expect(screen.getByText(/Add medications first/i)).toBeInTheDocument(); + }); + + it('renders a chip per medication', () => { + setMockStore({ + useMedicationStore: { + ...medActions, + medications: [ + { medication_id: 'm1', name: 'Warfarin' }, + { medication_id: 'm2', name: 'Aspirin' }, + ], + }, + useInteractionStore: { ...interactionActions }, + }); + render(); + expect(screen.getByText('Warfarin')).toBeInTheDocument(); + expect(screen.getByText('Aspirin')).toBeInTheDocument(); + }); + + it('disables the check button until two medications are selected', () => { + setMockStore({ + useMedicationStore: { + ...medActions, + medications: [ + { medication_id: 'm1', name: 'Warfarin' }, + { medication_id: 'm2', name: 'Aspirin' }, + ], + }, + useInteractionStore: { ...interactionActions }, + }); + render(); + const button = screen.getByText('Check interactions'); + expect(button).toBeDisabled(); + + fireEvent.click(screen.getByText('Warfarin')); + expect(button).toBeDisabled(); + + fireEvent.click(screen.getByText('Aspirin')); + expect(button).not.toBeDisabled(); + }); + + it('renders interaction results when present', () => { + setMockStore({ + useMedicationStore: { + ...medActions, + medications: [ + { medication_id: 'm1', name: 'Warfarin' }, + { medication_id: 'm2', name: 'Aspirin' }, + ], + }, + useInteractionStore: { + ...interactionActions, + interactions: [ + { + medications: ['warfarin', 'aspirin'], + severity: 'severe', + description: 'Increased bleeding risk.', + disclaimer: 'For informational purposes only.', + }, + ], + }, + }); + render(); + // Pre-select two meds so the results section renders. + fireEvent.click(screen.getByText('Warfarin')); + fireEvent.click(screen.getByText('Aspirin')); + + expect(screen.getByText('Increased bleeding risk.')).toBeInTheDocument(); + expect(screen.getByText('Severe')).toBeInTheDocument(); + }); +}); diff --git a/web/normogen-web/src/components/medication/DoseLogger.tsx b/web/normogen-web/src/components/medication/DoseLogger.tsx new file mode 100644 index 0000000..7c51f05 --- /dev/null +++ b/web/normogen-web/src/components/medication/DoseLogger.tsx @@ -0,0 +1,84 @@ +import { useEffect, useState, type FC } from 'react'; +import { Box, Button, LinearProgress, Stack, Typography } from '@mui/material'; +import CheckIcon from '@mui/icons-material/Check'; +import CloseIcon from '@mui/icons-material/Close'; +import { useMedicationStore } from '../../store/useStore'; + +interface Props { + medicationId: string; +} + +/** + * Per-medication dose logging + adherence display. Renders inside each + * MedicationManager card. Loads adherence on mount and refreshes after a dose + * is logged. + */ +export const DoseLogger: FC = ({ medicationId }) => { + const { adherence, loadAdherence, logDose } = useMedicationStore(); + const [busy, setBusy] = useState(false); + + useEffect(() => { + loadAdherence(medicationId); + }, [medicationId, loadAdherence]); + + const stats = adherence[medicationId]; + const rate = stats ? Math.round(stats.adherence_rate) : 0; + + const handleLog = async (taken: boolean) => { + setBusy(true); + try { + await logDose(medicationId, taken); + } catch { + /* store surfaces error in the MedicationManager banner */ + } finally { + setBusy(false); + } + }; + + return ( + + + + + + + {stats && ( + + + + Adherence (last {stats.period_days}d) + + + {rate}% · {stats.taken_doses}/{stats.total_doses} taken + + + = 80 ? 'success' : rate >= 50 ? 'warning' : 'error'} + /> + + )} + + ); +}; + +export default DoseLogger; diff --git a/web/normogen-web/src/components/medication/MedicationManager.test.tsx b/web/normogen-web/src/components/medication/MedicationManager.test.tsx new file mode 100644 index 0000000..b70c813 --- /dev/null +++ b/web/normogen-web/src/components/medication/MedicationManager.test.tsx @@ -0,0 +1,83 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { mockStoreFactory, setMockStore, resetMockStore } from '../../test/mockStore'; + +vi.mock('../../store/useStore', () => mockStoreFactory()); + +// Import AFTER the mock is registered so the component picks up the mock. +const { MedicationManager } = await import('./MedicationManager'); +const { useAuthStore } = await import('../../store/useStore'); + +const med = (overrides: Partial> = {}) => ({ + medication_id: 'm1', + name: 'Ibuprofen', + dosage: '200mg', + frequency: 'daily', + active: true, + instructions: undefined, + ...overrides, +}); + +const baseActions = { + loadMedications: vi.fn(), + createMedication: vi.fn(), + updateMedication: vi.fn(), + deleteMedication: vi.fn(), + selectMedication: vi.fn(), + clearError: vi.fn(), + loadAdherence: vi.fn(), + logDose: vi.fn(), + isLoading: false, + error: null, + adherence: {}, + selectedMedication: null, +}; + +describe('MedicationManager', () => { + beforeEach(() => { + resetMockStore(); + setMockStore({ + useAuthStore: { user: { user_id: 'u1', username: 'tester' }, profile: {} }, + }); + baseActions.loadMedications.mockClear(); + }); + + it('renders the medications from the store', () => { + setMockStore({ + useMedicationStore: { + ...baseActions, + medications: [med({ name: 'Aspirin' }), med({ medication_id: 'm2', name: 'Warfarin' })], + }, + }); + render(); + expect(screen.getByText('Aspirin')).toBeInTheDocument(); + expect(screen.getByText('Warfarin')).toBeInTheDocument(); + }); + + it('shows the empty state when there are no medications', () => { + setMockStore({ useMedicationStore: { ...baseActions, medications: [] } }); + render(); + expect(screen.getByText(/No medications yet/i)).toBeInTheDocument(); + }); + + it('loads medications on mount', () => { + setMockStore({ useMedicationStore: { ...baseActions, medications: [] } }); + render(); + expect(baseActions.loadMedications).toHaveBeenCalled(); + }); + + it('opens the create dialog when Add is clicked', () => { + setMockStore({ useMedicationStore: { ...baseActions, medications: [] } }); + render(); + fireEvent.click(screen.getByText('Add')); + expect(screen.getByText('Add medication')).toBeInTheDocument(); + }); + + it('renders an error banner when the store has an error', () => { + setMockStore({ + useMedicationStore: { ...baseActions, medications: [], error: 'Something broke' }, + }); + render(); + expect(screen.getByText('Something broke')).toBeInTheDocument(); + }); +}); diff --git a/web/normogen-web/src/components/medication/MedicationManager.tsx b/web/normogen-web/src/components/medication/MedicationManager.tsx index 8c9dc62..90f411d 100644 --- a/web/normogen-web/src/components/medication/MedicationManager.tsx +++ b/web/normogen-web/src/components/medication/MedicationManager.tsx @@ -30,6 +30,7 @@ import type { CreateMedicationRequest, UpdateMedicationRequest, } from '../../types/api'; +import { DoseLogger } from './DoseLogger'; const ROUTES = ['oral', 'topical', 'injection', 'inhalation', 'other'] as const; @@ -66,11 +67,12 @@ export const MedicationManager: FC = () => { }, [loadMedications]); const openCreate = () => { - // TODO: real profile management — for now, source profile_id from the user, - // falling back to 'default' (the backend accepts any string). + // profile_id is deterministic: profile_. The backend auto-creates + // this profile on register, so the id always resolves to a real profile. + const profileId = user?.profile_id ?? `profile_${user?.user_id ?? 'default'}`; setCreateForm({ ...emptyCreate, - profile_id: user?.profile_id ?? 'default', + profile_id: profileId, }); setCreateOpen(true); }; @@ -178,6 +180,7 @@ export const MedicationManager: FC = () => { {med.instructions} )} + {med.medication_id && } diff --git a/web/normogen-web/src/components/profile/ProfileEditor.tsx b/web/normogen-web/src/components/profile/ProfileEditor.tsx new file mode 100644 index 0000000..7f258f5 --- /dev/null +++ b/web/normogen-web/src/components/profile/ProfileEditor.tsx @@ -0,0 +1,128 @@ +import { useEffect, useState, type FC } from 'react'; +import { + Box, + Button, + Card, + CardContent, + CircularProgress, + Alert, + Chip, + Stack, + TextField, + Typography, +} from '@mui/material'; +import SaveIcon from '@mui/icons-material/Save'; +import { useProfileStore, useAuthStore } from '../../store/useStore'; + +export const ProfileEditor: FC = () => { + const { profile, isLoading, error, loadProfile, updateName, clearError } = + useProfileStore(); + const user = useAuthStore((s) => s.user); + + const [name, setName] = useState(''); + const [editing, setEditing] = useState(false); + const [saving, setSaving] = useState(false); + + useEffect(() => { + loadProfile(); + }, [loadProfile]); + + useEffect(() => { + if (profile?.name) setName(profile.name); + }, [profile]); + + const handleSave = async () => { + setSaving(true); + try { + await updateName(name); + setEditing(false); + } catch { + /* error surfaced via store */ + } finally { + setSaving(false); + } + }; + + return ( + + + Profile + + + {error && ( + + {error} + + )} + + {isLoading && !profile ? ( + + + + ) : ( + + + + + + Display name + + {editing ? ( + + setName(e.target.value)} + autoFocus + /> + + + + ) : ( + + {profile?.name ?? user?.username ?? '—'} + + + )} + + + + + Account + + {user?.email} + + + + + Role + + + + + + + {profile?.profile_id && ( + + Profile ID: {profile.profile_id} + + )} + + + + )} + + ); +}; + +export default ProfileEditor; diff --git a/web/normogen-web/src/pages/Dashboard.tsx b/web/normogen-web/src/pages/Dashboard.tsx index fb139f7..94921f3 100644 --- a/web/normogen-web/src/pages/Dashboard.tsx +++ b/web/normogen-web/src/pages/Dashboard.tsx @@ -5,8 +5,9 @@ import { useAuthStore } from '../store/useStore'; import { MedicationManager } from '../components/medication/MedicationManager'; import { HealthStats } from '../components/health/HealthStats'; import { InteractionsChecker } from '../components/interactions/InteractionsChecker'; +import { ProfileEditor } from '../components/profile/ProfileEditor'; -type TabIndex = 0 | 1 | 2; +type TabIndex = 0 | 1 | 2 | 3; export const Dashboard: FC = () => { const navigate = useNavigate(); @@ -14,7 +15,7 @@ export const Dashboard: FC = () => { const [tab, setTab] = useState(0); // On mount, refresh the user record from the backend (confirms the token is - // still valid, and medication-create needs user.profile_id). + // still valid, and medication-create derives profile_id from user_id). useEffect(() => { if (isAuthenticated && !user) { loadUser(); @@ -49,12 +50,14 @@ export const Dashboard: FC = () => { + {tab === 0 && } {tab === 1 && } {tab === 2 && } + {tab === 3 && } diff --git a/web/normogen-web/src/services/api.ts b/web/normogen-web/src/services/api.ts index a5a76ae..79b31bd 100644 --- a/web/normogen-web/src/services/api.ts +++ b/web/normogen-web/src/services/api.ts @@ -14,6 +14,10 @@ import { CreateHealthStatRequest, TrendData, ApiError, + DoseLog, + LogDoseRequest, + AdherenceStats, + Profile, } from '../types/api'; // API base URL. In dev this is "/api", proxied by the Vite dev server to the @@ -206,6 +210,18 @@ class ApiService { return response.data; } + // ---- Profile (Phase 3c) ---- + + async getProfile(): Promise { + const response = await this.client.get('/profiles/me'); + return response.data; + } + + async updateProfileName(name: string): Promise { + const response = await this.client.put('/profiles/me', { name }); + return response.data; + } + // ---- Medications ---- async getMedications(): Promise { @@ -234,6 +250,18 @@ class ApiService { await this.client.post(`/medications/${id}/delete`); } + // ---- Dose logging + adherence (Phase 3c) ---- + + async logDose(medicationId: string, req: LogDoseRequest): Promise { + const response = await this.client.post(`/medications/${medicationId}/log`, req); + return response.data; + } + + async getAdherence(medicationId: string): Promise { + const response = await this.client.get(`/medications/${medicationId}/adherence`); + return response.data; + } + // ---- Drug Interactions (Phase 2.8) ---- async checkInteractions(medications: string[]): Promise { diff --git a/web/normogen-web/src/setupTests.ts b/web/normogen-web/src/setupTests.ts index 8f2609b..8208e4d 100644 --- a/web/normogen-web/src/setupTests.ts +++ b/web/normogen-web/src/setupTests.ts @@ -1,5 +1,16 @@ -// jest-dom adds custom jest matchers for asserting on DOM nodes. -// allows you to do things like: -// expect(element).toHaveTextContent(/react/i) -// learn more: https://github.com/testing-library/jest-dom import '@testing-library/jest-dom'; +import { afterEach, beforeEach } from 'vitest'; +import { cleanup } from '@testing-library/react'; + +// Unmount anything rendered between tests (RTL auto-runs this in watch mode but +// we set it explicitly so single-run `vitest run` stays isolated). +afterEach(() => { + cleanup(); +}); + +// The auth store is persisted under localStorage key 'normogen-auth', and the +// api client reads 'token'/'refresh_token' from localStorage. Clear between +// tests so state never bleeds across cases. +beforeEach(() => { + localStorage.clear(); +}); diff --git a/web/normogen-web/src/store/useStore.test.ts b/web/normogen-web/src/store/useStore.test.ts new file mode 100644 index 0000000..2af1974 --- /dev/null +++ b/web/normogen-web/src/store/useStore.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +// Mock the api client so the store's real reducer logic is exercised without HTTP. +const apiMock = { + getMedications: vi.fn(), + createMedication: vi.fn(), + logDose: vi.fn(), + getAdherence: vi.fn(), +}; +vi.mock('../services/api', () => ({ default: apiMock })); + +// Import the store AFTER the mock is registered. +const { useMedicationStore } = await import('./useStore'); + +describe('useMedicationStore', () => { + beforeEach(() => { + useMedicationStore.setState({ + medications: [], + isLoading: false, + error: null, + adherence: {}, + }); + apiMock.getMedications.mockReset(); + apiMock.createMedication.mockReset(); + }); + + it('loadMedications populates state from the api client', async () => { + const meds = [{ medication_id: 'm1', name: 'Aspirin', dosage: '100mg' }]; + apiMock.getMedications.mockResolvedValue(meds); + + await useMedicationStore.getState().loadMedications(); + + expect(apiMock.getMedications).toHaveBeenCalledOnce(); + expect(useMedicationStore.getState().medications).toEqual(meds); + expect(useMedicationStore.getState().isLoading).toBe(false); + }); + + it('loadMedications sets an error message on failure (and does not throw)', async () => { + apiMock.getMedications.mockRejectedValue(new Error('boom')); + + // Should NOT throw — load actions swallow. + await useMedicationStore.getState().loadMedications(); + + expect(useMedicationStore.getState().error).toBe('boom'); + expect(useMedicationStore.getState().isLoading).toBe(false); + }); + + it('createMedication appends the new medication and re-throws on error', async () => { + apiMock.createMedication.mockResolvedValue({ medication_id: 'm2', name: 'New' }); + + await useMedicationStore.getState().createMedication({ name: 'New' }); + + expect(useMedicationStore.getState().medications).toEqual([ + { medication_id: 'm2', name: 'New' }, + ]); + + // Now an error path: createMedication rejects -> store re-throws. + apiMock.createMedication.mockRejectedValue(new Error('nope')); + await expect( + useMedicationStore.getState().createMedication({ name: 'Bad' }), + ).rejects.toThrow('nope'); + }); + + it('logDose logs the dose then refreshes adherence', async () => { + apiMock.logDose.mockResolvedValue({}); + apiMock.getAdherence.mockResolvedValue({ + medication_id: 'm1', + total_doses: 1, + scheduled_doses: 1, + taken_doses: 1, + missed_doses: 0, + adherence_rate: 100, + period_days: 30, + }); + + await useMedicationStore.getState().logDose('m1', true); + + expect(apiMock.logDose).toHaveBeenCalledWith('m1', { taken: true, notes: undefined }); + expect(apiMock.getAdherence).toHaveBeenCalledWith('m1'); + expect(useMedicationStore.getState().adherence['m1'].adherence_rate).toBe(100); + }); +}); diff --git a/web/normogen-web/src/store/useStore.ts b/web/normogen-web/src/store/useStore.ts index 01bee8f..36fcda8 100644 --- a/web/normogen-web/src/store/useStore.ts +++ b/web/normogen-web/src/store/useStore.ts @@ -1,6 +1,13 @@ import { create } from 'zustand'; import { devtools, persist } from 'zustand/middleware'; -import { User, Medication, HealthStat, DrugInteraction } from '../types/api'; +import { + User, + Medication, + HealthStat, + DrugInteraction, + AdherenceStats, + Profile, +} from '../types/api'; import apiService from '../services/api'; interface AuthState { @@ -23,7 +30,9 @@ interface MedicationState { selectedMedication: Medication | null; isLoading: boolean; error: string | null; - + // Adherence cache: medicationId -> stats. Loaded on demand per medication. + adherence: Record; + // Actions loadMedications: () => Promise; createMedication: (data: any) => Promise; @@ -31,6 +40,8 @@ interface MedicationState { deleteMedication: (id: string) => Promise; selectMedication: (medication: Medication | null) => void; clearError: () => void; + loadAdherence: (medicationId: string) => Promise; + logDose: (medicationId: string, taken: boolean, notes?: string) => Promise; } interface HealthState { @@ -52,7 +63,7 @@ interface InteractionState { interactions: DrugInteraction[]; isChecking: boolean; error: string | null; - + // Actions checkInteractions: (medications: string[]) => Promise; checkNewMedication: (name: string, dosage: string) => Promise; @@ -60,6 +71,15 @@ interface InteractionState { clearError: () => void; } +interface ProfileState { + profile: Profile | null; + isLoading: boolean; + error: string | null; + loadProfile: () => Promise; + updateName: (name: string) => Promise; + clearError: () => void; +} + // Auth Store export const useAuthStore = create()( devtools( @@ -178,6 +198,7 @@ export const useMedicationStore = create()( selectedMedication: null, isLoading: false, error: null, + adherence: {}, loadMedications: async () => { set({ isLoading: true, error: null }); @@ -252,10 +273,34 @@ export const useMedicationStore = create()( }, clearError: () => set({ error: null }), + + loadAdherence: async (medicationId) => { + try { + const stats = await apiService.getAdherence(medicationId); + set((state) => ({ + adherence: { ...state.adherence, [medicationId]: stats }, + })); + } catch (error: any) { + // Adherence is non-critical; surface nothing disruptive. + set({ error: error.message || 'Failed to load adherence' }); + } + }, + + logDose: async (medicationId, taken, notes) => { + try { + await apiService.logDose(medicationId, { taken, notes }); + // Refresh adherence for this med so the % reflects the new dose. + const stats = await apiService.getAdherence(medicationId); + set((state) => ({ + adherence: { ...state.adherence, [medicationId]: stats }, + })); + } catch (error: any) { + set({ error: error.message || 'Failed to log dose' }); + throw error; + } + }, })) ); - -// Health Store export const useHealthStore = create()( devtools((set, get) => ({ stats: [], @@ -383,3 +428,41 @@ export const useInteractionStore = create()( clearError: () => set({ error: null }), })) ); + +// Profile Store (Phase 3c) +export const useProfileStore = create()( + devtools((set, get) => ({ + profile: null, + isLoading: false, + error: null, + + loadProfile: async () => { + set({ isLoading: true, error: null }); + try { + const profile = await apiService.getProfile(); + set({ profile, isLoading: false }); + } catch (error: any) { + set({ + error: error.message || 'Failed to load profile', + isLoading: false, + }); + } + }, + + updateName: async (name) => { + set({ isLoading: true, error: null }); + try { + const profile = await apiService.updateProfileName(name); + set({ profile, isLoading: false }); + } catch (error: any) { + set({ + error: error.message || 'Failed to update profile', + isLoading: false, + }); + throw error; + } + }, + + clearError: () => set({ error: null }), + })), +); diff --git a/web/normogen-web/src/test/mockStore.ts b/web/normogen-web/src/test/mockStore.ts new file mode 100644 index 0000000..2127484 --- /dev/null +++ b/web/normogen-web/src/test/mockStore.ts @@ -0,0 +1,49 @@ +import { vi } from 'vitest'; + +/** + * Test helper for mocking the zustand stores. Because the stores are co-located + * in one module (../store/useStore) and consumed both with and without a + * selector (`useAuthStore(s => s.user)` vs `useMedicationStore()`), the mock + * returns a function that accepts an optional selector. + * + * Usage in a test file: + * + * vi.mock('../store/useStore', () => mockStoreFactory()); + * // then per test: + * setMockStore({ useMedicationStore: { medications: [...], ... } }); + * + * Each store's actions default to vi.fn() mocks; override them in setMockStore. + */ +export type StoreSlice = Record; + +const state: Record = {}; + +function makeHook(name: string) { + const fn = vi.fn((selector?: (s: StoreSlice) => unknown) => + selector ? selector(state[name] ?? {}) : state[name] ?? {}, + ); + return fn; +} + +export function mockStoreFactory() { + return { + useAuthStore: makeHook('useAuthStore'), + useMedicationStore: makeHook('useMedicationStore'), + useHealthStore: makeHook('useHealthStore'), + useInteractionStore: makeHook('useInteractionStore'), + useProfileStore: makeHook('useProfileStore'), + }; +} + +/** Set/override the state a store returns for the current test. */ +export function setMockStore(next: Record) { + for (const [name, slice] of Object.entries(next)) { + // Merge so callers can update one field without re-declaring actions. + state[name] = { ...state[name], ...slice }; + } +} + +/** Reset all mocked store state between tests. */ +export function resetMockStore() { + for (const key of Object.keys(state)) delete state[key]; +} diff --git a/web/normogen-web/src/types/api.ts b/web/normogen-web/src/types/api.ts index 6f4eeb8..25debf2 100644 --- a/web/normogen-web/src/types/api.ts +++ b/web/normogen-web/src/types/api.ts @@ -235,16 +235,46 @@ export interface LabResult { created_at?: string; } -// Dose Log Types +// Dose Log Types — match backend MedicationDose (camelCase serialization). export interface DoseLog { - log_id?: string; - medication_id: string; - scheduled_time: string; - taken_time?: string; - status: 'scheduled' | 'taken' | 'skipped' | 'missed'; + id?: string; + medicationId: string; + userId?: string; + loggedAt: string; + scheduledTime?: string; + taken: boolean; notes?: string; } +// Request body for POST /medications/:id/log. +export interface LogDoseRequest { + taken?: boolean; + scheduled_time?: string; + notes?: string; +} + +// Response from GET /medications/:id/adherence. +export interface AdherenceStats { + medication_id: string; + total_doses: number; + scheduled_doses: number; + taken_doses: number; + missed_doses: number; + adherence_rate: number; + period_days: number; +} + +// Profile Types — match backend ProfileResponse. +export interface Profile { + profile_id: string; + user_id: string; + name: string; + role: string; + permissions: string[]; + created_at: string; + updated_at: string; +} + // Error Types export interface ApiError { message: string;