diff --git a/backend/src/app.rs b/backend/src/app.rs index de21413..2e29c2c 100644 --- a/backend/src/app.rs +++ b/backend/src/app.rs @@ -53,9 +53,6 @@ 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 e1410ae..861b34e 100644 --- a/backend/src/db/mongodb_impl.rs +++ b/backend/src/db/mongodb_impl.rs @@ -338,4 +338,15 @@ 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 66e8a25..69e3b55 100644 --- a/backend/src/handlers/auth.rs +++ b/backend/src/handlers/auth.rs @@ -108,20 +108,6 @@ 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 54bbbcb..2b6fd35 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, MedicationDose, MedicationRepository, + CreateMedicationRequest, LogDoseRequest, Medication, MedicationRepository, UpdateMedicationRequest, }, }; @@ -158,12 +158,12 @@ pub async fn log_dose( Extension(claims): Extension, Path(id): Path, Json(req): Json, -) -> Result<(StatusCode, Json), StatusCode> { +) -> Result { let database = state.db.get_database(); let now = SystemTime::now(); - let mut dose = crate::models::medication::MedicationDose { + let dose = crate::models::medication::MedicationDose { id: None, medication_id: id.clone(), user_id: claims.sub.clone(), @@ -173,16 +173,14 @@ pub async fn log_dose( notes: req.notes, }; - let result = database - .collection::("medication_doses") - .insert_one(&dose, None) + match database + .collection("medication_doses") + .insert_one(dose.clone(), None) .await - .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))) + { + Ok(_) => Ok(StatusCode::CREATED), + Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + } } pub async fn get_adherence( @@ -190,50 +188,11 @@ pub async fn get_adherence( Extension(_claims): Extension, Path(id): Path, ) -> Result, StatusCode> { - use mongodb::bson::{doc, DateTime}; - - const PERIOD_DAYS: i64 = 30; - let database = state.db.get_database(); - let doses: mongodb::Collection = database.collection("medication_doses"); + let 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( - 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, - })) + match repo.calculate_adherence(&id, 30).await { + Ok(stats) => Ok(Json(stats)), + Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + } } diff --git a/backend/src/handlers/mod.rs b/backend/src/handlers/mod.rs index a6930c2..21e266f 100644 --- a/backend/src/handlers/mod.rs +++ b/backend/src/handlers/mod.rs @@ -4,7 +4,6 @@ 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; @@ -22,7 +21,6 @@ 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 deleted file mode 100644 index c0cb77e..0000000 --- a/backend/src/handlers/profile.rs +++ /dev/null @@ -1,137 +0,0 @@ -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 977d66c..ff70bd4 100644 --- a/backend/src/models/medication.rs +++ b/backend/src/models/medication.rs @@ -335,7 +335,21 @@ impl MedicationRepository { Ok(result.deleted_count > 0) } - // 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). + 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, + }) + } } diff --git a/backend/src/models/profile.rs b/backend/src/models/profile.rs index f336ce3..9f828ce 100644 --- a/backend/src/models/profile.rs +++ b/backend/src/models/profile.rs @@ -52,33 +52,4 @@ 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 69819b3..c189c7c 100644 --- a/web/normogen-web/package-lock.json +++ b/web/normogen-web/package-lock.json @@ -24,7 +24,6 @@ "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", @@ -2013,20 +2012,6 @@ } } }, - "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 2ab78a4..fd4c3cc 100644 --- a/web/normogen-web/package.json +++ b/web/normogen-web/package.json @@ -27,7 +27,6 @@ "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 deleted file mode 100644 index c098cd9..0000000 --- a/web/normogen-web/src/components/common/SeverityChip.test.tsx +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index e13bbfb..0000000 --- a/web/normogen-web/src/components/health/HealthStats.test.tsx +++ /dev/null @@ -1,59 +0,0 @@ -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 deleted file mode 100644 index 6b741d3..0000000 --- a/web/normogen-web/src/components/interactions/InteractionsChecker.test.tsx +++ /dev/null @@ -1,113 +0,0 @@ -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 deleted file mode 100644 index 7c51f05..0000000 --- a/web/normogen-web/src/components/medication/DoseLogger.tsx +++ /dev/null @@ -1,84 +0,0 @@ -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 deleted file mode 100644 index b70c813..0000000 --- a/web/normogen-web/src/components/medication/MedicationManager.test.tsx +++ /dev/null @@ -1,83 +0,0 @@ -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 90f411d..8c9dc62 100644 --- a/web/normogen-web/src/components/medication/MedicationManager.tsx +++ b/web/normogen-web/src/components/medication/MedicationManager.tsx @@ -30,7 +30,6 @@ import type { CreateMedicationRequest, UpdateMedicationRequest, } from '../../types/api'; -import { DoseLogger } from './DoseLogger'; const ROUTES = ['oral', 'topical', 'injection', 'inhalation', 'other'] as const; @@ -67,12 +66,11 @@ export const MedicationManager: FC = () => { }, [loadMedications]); const openCreate = () => { - // 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'}`; + // TODO: real profile management — for now, source profile_id from the user, + // falling back to 'default' (the backend accepts any string). setCreateForm({ ...emptyCreate, - profile_id: profileId, + profile_id: user?.profile_id ?? 'default', }); setCreateOpen(true); }; @@ -180,7 +178,6 @@ 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 deleted file mode 100644 index 7f258f5..0000000 --- a/web/normogen-web/src/components/profile/ProfileEditor.tsx +++ /dev/null @@ -1,128 +0,0 @@ -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 94921f3..fb139f7 100644 --- a/web/normogen-web/src/pages/Dashboard.tsx +++ b/web/normogen-web/src/pages/Dashboard.tsx @@ -5,9 +5,8 @@ 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 | 3; +type TabIndex = 0 | 1 | 2; export const Dashboard: FC = () => { const navigate = useNavigate(); @@ -15,7 +14,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 derives profile_id from user_id). + // still valid, and medication-create needs user.profile_id). useEffect(() => { if (isAuthenticated && !user) { loadUser(); @@ -50,14 +49,12 @@ 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 79b31bd..a5a76ae 100644 --- a/web/normogen-web/src/services/api.ts +++ b/web/normogen-web/src/services/api.ts @@ -14,10 +14,6 @@ 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 @@ -210,18 +206,6 @@ 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 { @@ -250,18 +234,6 @@ 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 8208e4d..8f2609b 100644 --- a/web/normogen-web/src/setupTests.ts +++ b/web/normogen-web/src/setupTests.ts @@ -1,16 +1,5 @@ +// 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 deleted file mode 100644 index 2af1974..0000000 --- a/web/normogen-web/src/store/useStore.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -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 36fcda8..01bee8f 100644 --- a/web/normogen-web/src/store/useStore.ts +++ b/web/normogen-web/src/store/useStore.ts @@ -1,13 +1,6 @@ import { create } from 'zustand'; import { devtools, persist } from 'zustand/middleware'; -import { - User, - Medication, - HealthStat, - DrugInteraction, - AdherenceStats, - Profile, -} from '../types/api'; +import { User, Medication, HealthStat, DrugInteraction } from '../types/api'; import apiService from '../services/api'; interface AuthState { @@ -30,9 +23,7 @@ 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; @@ -40,8 +31,6 @@ 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 { @@ -63,7 +52,7 @@ interface InteractionState { interactions: DrugInteraction[]; isChecking: boolean; error: string | null; - + // Actions checkInteractions: (medications: string[]) => Promise; checkNewMedication: (name: string, dosage: string) => Promise; @@ -71,15 +60,6 @@ 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( @@ -198,7 +178,6 @@ export const useMedicationStore = create()( selectedMedication: null, isLoading: false, error: null, - adherence: {}, loadMedications: async () => { set({ isLoading: true, error: null }); @@ -273,34 +252,10 @@ 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: [], @@ -428,41 +383,3 @@ 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 deleted file mode 100644 index 2127484..0000000 --- a/web/normogen-web/src/test/mockStore.ts +++ /dev/null @@ -1,49 +0,0 @@ -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 25debf2..6f4eeb8 100644 --- a/web/normogen-web/src/types/api.ts +++ b/web/normogen-web/src/types/api.ts @@ -235,44 +235,14 @@ export interface LabResult { created_at?: string; } -// Dose Log Types — match backend MedicationDose (camelCase serialization). +// Dose Log Types export interface DoseLog { - 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 { + log_id?: string; 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; + scheduled_time: string; + taken_time?: string; + status: 'scheduled' | 'taken' | 'skipped' | 'missed'; + notes?: string; } // Error Types