diff --git a/web/normogen-web/src/components/health/HealthStats.tsx b/web/normogen-web/src/components/health/HealthStats.tsx index a76284d..f014765 100644 --- a/web/normogen-web/src/components/health/HealthStats.tsx +++ b/web/normogen-web/src/components/health/HealthStats.tsx @@ -37,7 +37,16 @@ import { } from 'recharts'; import { format } from 'date-fns'; import { useHealthStore } from '../../store/useStore'; -import { HealthStatType, type CreateHealthStatRequest, type TrendData } from '../../types/api'; +import { HealthStatType, type TrendData } from '../../types/api'; + +// Domain form type (decrypted fields the UI collects; the store encrypts them). +interface HealthFormData { + stat_type: HealthStatType; + value: number; + unit: string; + measured_at: string; + notes?: string; +} const STAT_TYPES = Object.values(HealthStatType); @@ -73,7 +82,7 @@ export const HealthStats: FC = () => { const [saving, setSaving] = useState(false); const [chartType, setChartType] = useState(HealthStatType.Weight); - const [form, setForm] = useState({ + const [form, setForm] = useState({ stat_type: HealthStatType.Weight, value: 0, unit: UNIT_DEFAULTS[HealthStatType.Weight], diff --git a/web/normogen-web/src/services/api.ts b/web/normogen-web/src/services/api.ts index eb8f6be..344586c 100644 --- a/web/normogen-web/src/services/api.ts +++ b/web/normogen-web/src/services/api.ts @@ -10,9 +10,7 @@ import { DrugInteraction, CheckInteractionRequest, CheckNewMedicationRequest, - HealthStat, CreateHealthStatRequest, - TrendData, ApiError, DoseLog, LogDoseRequest, @@ -20,6 +18,8 @@ import { MedicationWireResponse, AppointmentWireResponse, ProfileWireResponse, + HealthStatWireResponse, + UpdateHealthStatRequest, EncryptedFieldWire, CreateAppointmentRequest, UpdateAppointmentRequest, @@ -330,25 +330,25 @@ class ApiService { return response.data; } - // ---- Health Statistics ---- + // ---- Health Statistics (zero-knowledge: opaque encrypted blobs) ---- - async getHealthStats(): Promise { - const response = await this.client.get('/health-stats'); + async getHealthStats(): Promise { + const response = await this.client.get('/health-stats'); return response.data; } - async getHealthStat(id: string): Promise { - const response = await this.client.get(`/health-stats/${id}`); + async getHealthStat(id: string): Promise { + const response = await this.client.get(`/health-stats/${id}`); return response.data; } - async createHealthStat(data: CreateHealthStatRequest): Promise { - const response = await this.client.post('/health-stats', data); + async createHealthStat(data: CreateHealthStatRequest): Promise { + const response = await this.client.post('/health-stats', data); return response.data; } - async updateHealthStat(id: string, data: Partial): Promise { - const response = await this.client.put(`/health-stats/${id}`, data); + async updateHealthStat(id: string, data: UpdateHealthStatRequest): Promise { + const response = await this.client.put(`/health-stats/${id}`, data); return response.data; } @@ -356,11 +356,6 @@ class ApiService { await this.client.delete(`/health-stats/${id}`); } - async getHealthTrends(): Promise { - const response = await this.client.get('/health-stats/trends'); - return response.data; - } - // NOTE: lab-results endpoints are intentionally absent — the backend has no // /lab-results routes yet. The LabResult type stays in types/api.ts for when // those routes land. diff --git a/web/normogen-web/src/store/useStore.ts b/web/normogen-web/src/store/useStore.ts index 1bcfda5..69211ee 100644 --- a/web/normogen-web/src/store/useStore.ts +++ b/web/normogen-web/src/store/useStore.ts @@ -17,6 +17,7 @@ import { User, Medication, HealthStat, + HealthStatType, DrugInteraction, AdherenceStats, Profile, @@ -342,6 +343,7 @@ export const useMedicationStore = create()( notes: data.notes as string | undefined, tags: data.tags as string[] | undefined, active: w.active, + dose_schedule: (w as any).dose_schedule ?? undefined, created_at: w.created_at, updated_at: w.updated_at, } as Medication; @@ -506,10 +508,31 @@ export const useHealthStore = create()( error: null, loadStats: async () => { + const key = getEncKey(); + if (!key) { + set({ error: 'No encryption key — log in to decrypt data', isLoading: false }); + return; + } set({ isLoading: true, error: null }); try { - const stats = await apiService.getHealthStats(); - set({ stats, isLoading: false }); + const wireStats = await apiService.getHealthStats(); + const stats = await Promise.all( + wireStats.map(async (w) => { + const data = await decryptJson>(w.encrypted_data, key); + return { + stat_id: w.id, + user_id: w.user_id, + stat_type: (data.stat_type as HealthStatType) ?? HealthStatType.Other, + value: (data.value as number) ?? 0, + unit: (data.unit as string) ?? '', + measured_at: w.recorded_at, + notes: data.notes as string | undefined, + } as HealthStat; + }), + ); + // Compute trends client-side (server can't compute on ciphertext). + const computedTrends = computeTrends(stats); + set({ stats, trends: computedTrends, isLoading: false }); } catch (error: any) { set({ error: error.message || 'Failed to load health stats', @@ -519,9 +542,33 @@ export const useHealthStore = create()( }, createStat: async (data: any) => { + const key = getEncKey(); + if (!key) { + set({ error: 'No encryption key — cannot encrypt data' }); + throw new Error('No encryption key'); + } set({ isLoading: true, error: null }); try { - const stat = await apiService.createHealthStat(data); + const d = data as any; + const encrypted_data = await encryptJson({ + stat_type: d.stat_type, + value: d.value, + unit: d.unit, + notes: d.notes, + }, key); + const wire = await apiService.createHealthStat({ + encrypted_data, + recorded_at: d.measured_at, + }); + const stat: HealthStat = { + stat_id: wire.id, + user_id: wire.user_id, + stat_type: d.stat_type, + value: d.value, + unit: d.unit, + measured_at: wire.recorded_at, + notes: d.notes, + }; set((state) => ({ stats: [...state.stats, stat], isLoading: false, @@ -536,58 +583,80 @@ export const useHealthStore = create()( }, updateStat: async (id: string, data: any) => { - set({ isLoading: true, error: null }); + const key = getEncKey(); + if (!key) { + set({ error: 'No encryption key — cannot encrypt data' }); + throw new Error('No encryption key'); + } try { - const updated = await apiService.updateHealthStat(id, data); + const d = data as any; + const encrypted_data = await encryptJson({ + stat_type: d.stat_type, + value: d.value, + unit: d.unit, + notes: d.notes, + }, key); + await apiService.updateHealthStat(id, { encrypted_data }); set((state) => ({ - stats: state.stats.map((stat) => - stat.stat_id === id ? updated : stat + stats: state.stats.map((s) => + s.stat_id === id ? { ...s, ...d } : s, ), - isLoading: false, })); } catch (error: any) { - set({ - error: error.message || 'Failed to update stat', - isLoading: false, - }); + set({ error: error.message || 'Failed to update stat' }); 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, + stats: state.stats.filter((s) => s.stat_id !== id), })); } catch (error: any) { - set({ - error: error.message || 'Failed to delete stat', - isLoading: false, - }); + set({ error: error.message || 'Failed to delete stat' }); 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, - }); - } + // Trends are now computed client-side in loadStats. + const computedTrends = computeTrends(get().stats); + set({ trends: computedTrends }); }, clearError: () => set({ error: null }), })) ); +/** Compute trend data from decrypted stats (client-side, since the server + * can't compute trends on ciphertext). */ +function computeTrends(stats: HealthStat[]): any[] { + const byType: Record = {}; + for (const s of stats) { + const key = String(s.stat_type); + (byType[key] ??= []).push(s); + } + const trends: any[] = []; + for (const [type, items] of Object.entries(byType)) { + const values = items.map((s) => s.value); + if (values.length === 0) continue; + const avg = values.reduce((a, b) => a + b, 0) / values.length; + const min = Math.min(...values); + const max = Math.max(...values); + trends.push({ + stat_type: type, + average: avg, + min, + max, + trend: 'stable' as const, + data_points: items, + }); + } + return trends; +} + // Interaction Store (Phase 2.8) export const useInteractionStore = create()( devtools((set, get) => ({ diff --git a/web/normogen-web/src/types/api.ts b/web/normogen-web/src/types/api.ts index cb52fc7..67ccf49 100644 --- a/web/normogen-web/src/types/api.ts +++ b/web/normogen-web/src/types/api.ts @@ -139,6 +139,7 @@ export interface Medication { notes?: string; tags?: string[]; active: boolean; + dose_schedule?: DoseSchedule; created_at?: string; updated_at?: string; } @@ -150,6 +151,7 @@ export interface MedicationWireResponse { user_id: string; profile_id: string; active: boolean; + dose_schedule?: DoseSchedule; encrypted_data: EncryptedFieldWire; created_at: string; updated_at: string; @@ -160,11 +162,13 @@ export interface CreateMedicationRequest { profile_id: string; encrypted_data: EncryptedFieldWire; active?: boolean; + dose_schedule?: DoseSchedule; } export interface UpdateMedicationRequest { encrypted_data?: EncryptedFieldWire; active?: boolean; + dose_schedule?: DoseSchedule; } // Drug Interaction Types (Phase 2.8) @@ -204,24 +208,32 @@ export enum HealthStatType { Other = 'other' } +// Domain type (decrypted, used by UI) export interface HealthStat { stat_id?: string; - user_id: string; + user_id?: string; stat_type: HealthStatType; value: number; unit: string; measured_at: string; notes?: string; - created_at?: string; - updated_at?: string; +} + +// Wire response (opaque blob from the server) +export interface HealthStatWireResponse { + id: string; + user_id: string; + encrypted_data: EncryptedFieldWire; + recorded_at: string; } export interface CreateHealthStatRequest { - stat_type: HealthStatType; - value: number; - unit: string; - measured_at: string; - notes?: string; + encrypted_data: EncryptedFieldWire; + recorded_at?: string; +} + +export interface UpdateHealthStatRequest { + encrypted_data: EncryptedFieldWire; } export interface TrendData { @@ -233,6 +245,12 @@ export interface TrendData { data_points: HealthStat[]; } +// Dose schedule (plaintext metadata for adherence calc) +export interface DoseSchedule { + times_per_day: number; + days_of_week?: string[]; +} + // Lab Results Types export interface LabResult { lab_id?: string;