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.
468 lines
12 KiB
TypeScript
468 lines
12 KiB
TypeScript
import { create } from 'zustand';
|
|
import { devtools, persist } from 'zustand/middleware';
|
|
import {
|
|
User,
|
|
Medication,
|
|
HealthStat,
|
|
DrugInteraction,
|
|
AdherenceStats,
|
|
Profile,
|
|
} from '../types/api';
|
|
import apiService from '../services/api';
|
|
|
|
interface AuthState {
|
|
user: User | null;
|
|
token: string | null;
|
|
isAuthenticated: boolean;
|
|
isLoading: boolean;
|
|
error: string | null;
|
|
|
|
// Actions
|
|
login: (email: string, password: string) => Promise<void>;
|
|
register: (username: string, email: string, password: string) => Promise<void>;
|
|
logout: () => Promise<void>;
|
|
clearError: () => void;
|
|
loadUser: () => Promise<void>;
|
|
}
|
|
|
|
interface MedicationState {
|
|
medications: Medication[];
|
|
selectedMedication: Medication | null;
|
|
isLoading: boolean;
|
|
error: string | null;
|
|
// Adherence cache: medicationId -> stats. Loaded on demand per medication.
|
|
adherence: Record<string, AdherenceStats>;
|
|
|
|
// Actions
|
|
loadMedications: () => Promise<void>;
|
|
createMedication: (data: any) => Promise<void>;
|
|
updateMedication: (id: string, data: any) => Promise<void>;
|
|
deleteMedication: (id: string) => Promise<void>;
|
|
selectMedication: (medication: Medication | null) => void;
|
|
clearError: () => void;
|
|
loadAdherence: (medicationId: string) => Promise<void>;
|
|
logDose: (medicationId: string, taken: boolean, notes?: string) => Promise<void>;
|
|
}
|
|
|
|
interface HealthState {
|
|
stats: HealthStat[];
|
|
trends: any[];
|
|
isLoading: boolean;
|
|
error: string | null;
|
|
|
|
// Actions
|
|
loadStats: () => Promise<void>;
|
|
createStat: (data: any) => Promise<void>;
|
|
updateStat: (id: string, data: any) => Promise<void>;
|
|
deleteStat: (id: string) => Promise<void>;
|
|
loadTrends: () => Promise<void>;
|
|
clearError: () => void;
|
|
}
|
|
|
|
interface InteractionState {
|
|
interactions: DrugInteraction[];
|
|
isChecking: boolean;
|
|
error: string | null;
|
|
|
|
// Actions
|
|
checkInteractions: (medications: string[]) => Promise<void>;
|
|
checkNewMedication: (name: string, dosage: string) => Promise<void>;
|
|
clearInteractions: () => void;
|
|
clearError: () => void;
|
|
}
|
|
|
|
interface ProfileState {
|
|
profile: Profile | null;
|
|
isLoading: boolean;
|
|
error: string | null;
|
|
loadProfile: () => Promise<void>;
|
|
updateName: (name: string) => Promise<void>;
|
|
clearError: () => void;
|
|
}
|
|
|
|
// Auth Store
|
|
export const useAuthStore = create<AuthState>()(
|
|
devtools(
|
|
persist(
|
|
(set, get) => ({
|
|
user: null,
|
|
token: null,
|
|
isAuthenticated: false,
|
|
isLoading: false,
|
|
error: null,
|
|
|
|
login: async (email: string, password: string) => {
|
|
set({ isLoading: true, error: null });
|
|
try {
|
|
const response = await apiService.login(email, password);
|
|
set({
|
|
user: {
|
|
user_id: response.user_id,
|
|
username: response.username,
|
|
email: email,
|
|
role: 'patient',
|
|
},
|
|
token: response.token,
|
|
isAuthenticated: true,
|
|
isLoading: false,
|
|
});
|
|
} catch (error: any) {
|
|
set({
|
|
error: error.message || 'Login failed',
|
|
isLoading: false,
|
|
isAuthenticated: false,
|
|
});
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
register: async (username: string, email: string, password: string) => {
|
|
set({ isLoading: true, error: null });
|
|
try {
|
|
const response = await apiService.register({
|
|
username,
|
|
email,
|
|
password,
|
|
role: 'patient',
|
|
});
|
|
set({
|
|
user: {
|
|
user_id: response.user_id,
|
|
username: response.username,
|
|
email: email,
|
|
role: 'patient',
|
|
},
|
|
token: response.token,
|
|
isAuthenticated: true,
|
|
isLoading: false,
|
|
});
|
|
} catch (error: any) {
|
|
set({
|
|
error: error.message || 'Registration failed',
|
|
isLoading: false,
|
|
isAuthenticated: false,
|
|
});
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
logout: async () => {
|
|
await apiService.logout();
|
|
set({
|
|
user: null,
|
|
token: null,
|
|
isAuthenticated: false,
|
|
error: null,
|
|
});
|
|
},
|
|
|
|
clearError: () => set({ error: null }),
|
|
|
|
loadUser: async () => {
|
|
const token = get().token;
|
|
if (!token) return;
|
|
|
|
set({ isLoading: true });
|
|
try {
|
|
const user = await apiService.getCurrentUser();
|
|
set({
|
|
user,
|
|
isAuthenticated: true,
|
|
isLoading: false,
|
|
});
|
|
} catch (error: any) {
|
|
set({
|
|
error: error.message,
|
|
isLoading: false,
|
|
isAuthenticated: false,
|
|
});
|
|
}
|
|
},
|
|
}),
|
|
{
|
|
name: 'normogen-auth',
|
|
partialize: (state) => ({
|
|
token: state.token,
|
|
user: state.user,
|
|
isAuthenticated: state.isAuthenticated,
|
|
}),
|
|
}
|
|
)
|
|
)
|
|
);
|
|
|
|
// Medication Store
|
|
export const useMedicationStore = create<MedicationState>()(
|
|
devtools((set, get) => ({
|
|
medications: [],
|
|
selectedMedication: null,
|
|
isLoading: false,
|
|
error: null,
|
|
adherence: {},
|
|
|
|
loadMedications: async () => {
|
|
set({ isLoading: true, error: null });
|
|
try {
|
|
const medications = await apiService.getMedications();
|
|
set({ medications, isLoading: false });
|
|
} catch (error: any) {
|
|
set({
|
|
error: error.message || 'Failed to load medications',
|
|
isLoading: false,
|
|
});
|
|
}
|
|
},
|
|
|
|
createMedication: async (data: any) => {
|
|
set({ isLoading: true, error: null });
|
|
try {
|
|
const medication = await apiService.createMedication(data);
|
|
set((state) => ({
|
|
medications: [...state.medications, medication],
|
|
isLoading: false,
|
|
}));
|
|
} catch (error: any) {
|
|
set({
|
|
error: error.message || 'Failed to create medication',
|
|
isLoading: false,
|
|
});
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
updateMedication: async (id: string, data: any) => {
|
|
set({ isLoading: true, error: null });
|
|
try {
|
|
const updated = await apiService.updateMedication(id, data);
|
|
set((state) => ({
|
|
medications: state.medications.map((med) =>
|
|
med.medication_id === id ? updated : med
|
|
),
|
|
isLoading: false,
|
|
}));
|
|
} catch (error: any) {
|
|
set({
|
|
error: error.message || 'Failed to update medication',
|
|
isLoading: false,
|
|
});
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
deleteMedication: async (id: string) => {
|
|
set({ isLoading: true, error: null });
|
|
try {
|
|
await apiService.deleteMedication(id);
|
|
set((state) => ({
|
|
medications: state.medications.filter(
|
|
(med) => med.medication_id !== id
|
|
),
|
|
isLoading: false,
|
|
}));
|
|
} catch (error: any) {
|
|
set({
|
|
error: error.message || 'Failed to delete medication',
|
|
isLoading: false,
|
|
});
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
selectMedication: (medication) => {
|
|
set({ selectedMedication: medication });
|
|
},
|
|
|
|
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;
|
|
}
|
|
},
|
|
}))
|
|
);
|
|
export const useHealthStore = create<HealthState>()(
|
|
devtools((set, get) => ({
|
|
stats: [],
|
|
trends: [],
|
|
isLoading: false,
|
|
error: null,
|
|
|
|
loadStats: async () => {
|
|
set({ isLoading: true, error: null });
|
|
try {
|
|
const stats = await apiService.getHealthStats();
|
|
set({ stats, isLoading: false });
|
|
} catch (error: any) {
|
|
set({
|
|
error: error.message || 'Failed to load health stats',
|
|
isLoading: false,
|
|
});
|
|
}
|
|
},
|
|
|
|
createStat: async (data: any) => {
|
|
set({ isLoading: true, error: null });
|
|
try {
|
|
const stat = await apiService.createHealthStat(data);
|
|
set((state) => ({
|
|
stats: [...state.stats, stat],
|
|
isLoading: false,
|
|
}));
|
|
} catch (error: any) {
|
|
set({
|
|
error: error.message || 'Failed to create stat',
|
|
isLoading: false,
|
|
});
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
updateStat: async (id: string, data: any) => {
|
|
set({ isLoading: true, error: null });
|
|
try {
|
|
const updated = await apiService.updateHealthStat(id, data);
|
|
set((state) => ({
|
|
stats: state.stats.map((stat) =>
|
|
stat.stat_id === id ? updated : stat
|
|
),
|
|
isLoading: false,
|
|
}));
|
|
} catch (error: any) {
|
|
set({
|
|
error: error.message || 'Failed to update stat',
|
|
isLoading: false,
|
|
});
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
deleteStat: async (id: string) => {
|
|
set({ isLoading: true, error: null });
|
|
try {
|
|
await apiService.deleteHealthStat(id);
|
|
set((state) => ({
|
|
stats: state.stats.filter((stat) => stat.stat_id !== id),
|
|
isLoading: false,
|
|
}));
|
|
} catch (error: any) {
|
|
set({
|
|
error: error.message || 'Failed to delete stat',
|
|
isLoading: false,
|
|
});
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
loadTrends: async () => {
|
|
set({ isLoading: true, error: null });
|
|
try {
|
|
const trends = await apiService.getHealthTrends();
|
|
set({ trends, isLoading: false });
|
|
} catch (error: any) {
|
|
set({
|
|
error: error.message || 'Failed to load trends',
|
|
isLoading: false,
|
|
});
|
|
}
|
|
},
|
|
|
|
clearError: () => set({ error: null }),
|
|
}))
|
|
);
|
|
|
|
// Interaction Store (Phase 2.8)
|
|
export const useInteractionStore = create<InteractionState>()(
|
|
devtools((set, get) => ({
|
|
interactions: [],
|
|
isChecking: false,
|
|
error: null,
|
|
|
|
checkInteractions: async (medications: string[]) => {
|
|
set({ isChecking: true, error: null });
|
|
try {
|
|
const interactions = await apiService.checkInteractions(medications);
|
|
set({ interactions, isChecking: false });
|
|
} catch (error: any) {
|
|
set({
|
|
error: error.message || 'Failed to check interactions',
|
|
isChecking: false,
|
|
});
|
|
}
|
|
},
|
|
|
|
checkNewMedication: async (name: string, dosage: string) => {
|
|
set({ isChecking: true, error: null });
|
|
try {
|
|
const interactions = await apiService.checkNewMedication(name, dosage);
|
|
set({ interactions, isChecking: false });
|
|
} catch (error: any) {
|
|
set({
|
|
error: error.message || 'Failed to check medication',
|
|
isChecking: false,
|
|
});
|
|
}
|
|
},
|
|
|
|
clearInteractions: () => set({ interactions: [] }),
|
|
clearError: () => set({ error: null }),
|
|
}))
|
|
);
|
|
|
|
// Profile Store (Phase 3c)
|
|
export const useProfileStore = create<ProfileState>()(
|
|
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 }),
|
|
})),
|
|
);
|