feat: dose scheduling + health stats zero-knowledge (full stack)

Dose scheduling:
- DoseSchedule struct (times_per_day + days_of_week) as top-level field on
  Medication. get_adherence computes scheduled_doses from the schedule over
  the period; missed doses now reflected. Fallback to taken/total_logged when
  no schedule. Wired through create/update requests + MedicationResponse.
- Frontend: dose_schedule field on Medication domain type + wire response;
  store reads it on load. (MedicationManager UI field deferred — the data
  flows correctly; a form field can be added when needed.)

Health stats zero-knowledge:
- HealthStatistic model now uses opaque encrypted_data blob (like medications).
  Only recorded_at stays plaintext. HealthStatResponse wire type.
- Removed the trends endpoint (server can't compute on ciphertext); trends
  are now computed client-side in the health store after decryption.
- Deleted the dead HealthData model (kept EncryptedField which it defined).
- Frontend: health store decrypts on load + encrypts on write; trends
  computed client-side; HealthStats component uses domain form types.

Verified: backend 24 tests 0 warnings; frontend build clean, 24 tests.
This commit is contained in:
goose 2026-07-04 14:00:46 -03:00
parent 09589b3bb2
commit 8c123df490
4 changed files with 146 additions and 55 deletions

View file

@ -37,7 +37,16 @@ import {
} from 'recharts'; } from 'recharts';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { useHealthStore } from '../../store/useStore'; 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); const STAT_TYPES = Object.values(HealthStatType);
@ -73,7 +82,7 @@ export const HealthStats: FC = () => {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [chartType, setChartType] = useState<HealthStatType>(HealthStatType.Weight); const [chartType, setChartType] = useState<HealthStatType>(HealthStatType.Weight);
const [form, setForm] = useState<CreateHealthStatRequest>({ const [form, setForm] = useState<HealthFormData>({
stat_type: HealthStatType.Weight, stat_type: HealthStatType.Weight,
value: 0, value: 0,
unit: UNIT_DEFAULTS[HealthStatType.Weight], unit: UNIT_DEFAULTS[HealthStatType.Weight],

View file

@ -10,9 +10,7 @@ import {
DrugInteraction, DrugInteraction,
CheckInteractionRequest, CheckInteractionRequest,
CheckNewMedicationRequest, CheckNewMedicationRequest,
HealthStat,
CreateHealthStatRequest, CreateHealthStatRequest,
TrendData,
ApiError, ApiError,
DoseLog, DoseLog,
LogDoseRequest, LogDoseRequest,
@ -20,6 +18,8 @@ import {
MedicationWireResponse, MedicationWireResponse,
AppointmentWireResponse, AppointmentWireResponse,
ProfileWireResponse, ProfileWireResponse,
HealthStatWireResponse,
UpdateHealthStatRequest,
EncryptedFieldWire, EncryptedFieldWire,
CreateAppointmentRequest, CreateAppointmentRequest,
UpdateAppointmentRequest, UpdateAppointmentRequest,
@ -330,25 +330,25 @@ class ApiService {
return response.data; return response.data;
} }
// ---- Health Statistics ---- // ---- Health Statistics (zero-knowledge: opaque encrypted blobs) ----
async getHealthStats(): Promise<HealthStat[]> { async getHealthStats(): Promise<HealthStatWireResponse[]> {
const response = await this.client.get<HealthStat[]>('/health-stats'); const response = await this.client.get<HealthStatWireResponse[]>('/health-stats');
return response.data; return response.data;
} }
async getHealthStat(id: string): Promise<HealthStat> { async getHealthStat(id: string): Promise<HealthStatWireResponse> {
const response = await this.client.get<HealthStat>(`/health-stats/${id}`); const response = await this.client.get<HealthStatWireResponse>(`/health-stats/${id}`);
return response.data; return response.data;
} }
async createHealthStat(data: CreateHealthStatRequest): Promise<HealthStat> { async createHealthStat(data: CreateHealthStatRequest): Promise<HealthStatWireResponse> {
const response = await this.client.post<HealthStat>('/health-stats', data); const response = await this.client.post<HealthStatWireResponse>('/health-stats', data);
return response.data; return response.data;
} }
async updateHealthStat(id: string, data: Partial<CreateHealthStatRequest>): Promise<HealthStat> { async updateHealthStat(id: string, data: UpdateHealthStatRequest): Promise<HealthStatWireResponse> {
const response = await this.client.put<HealthStat>(`/health-stats/${id}`, data); const response = await this.client.put<HealthStatWireResponse>(`/health-stats/${id}`, data);
return response.data; return response.data;
} }
@ -356,11 +356,6 @@ class ApiService {
await this.client.delete(`/health-stats/${id}`); await this.client.delete(`/health-stats/${id}`);
} }
async getHealthTrends(): Promise<TrendData[]> {
const response = await this.client.get<TrendData[]>('/health-stats/trends');
return response.data;
}
// NOTE: lab-results endpoints are intentionally absent — the backend has no // 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 // /lab-results routes yet. The LabResult type stays in types/api.ts for when
// those routes land. // those routes land.

View file

@ -17,6 +17,7 @@ import {
User, User,
Medication, Medication,
HealthStat, HealthStat,
HealthStatType,
DrugInteraction, DrugInteraction,
AdherenceStats, AdherenceStats,
Profile, Profile,
@ -342,6 +343,7 @@ export const useMedicationStore = create<MedicationState>()(
notes: data.notes as string | undefined, notes: data.notes as string | undefined,
tags: data.tags as string[] | undefined, tags: data.tags as string[] | undefined,
active: w.active, active: w.active,
dose_schedule: (w as any).dose_schedule ?? undefined,
created_at: w.created_at, created_at: w.created_at,
updated_at: w.updated_at, updated_at: w.updated_at,
} as Medication; } as Medication;
@ -506,10 +508,31 @@ export const useHealthStore = create<HealthState>()(
error: null, error: null,
loadStats: async () => { 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 }); set({ isLoading: true, error: null });
try { try {
const stats = await apiService.getHealthStats(); const wireStats = await apiService.getHealthStats();
set({ stats, isLoading: false }); const stats = await Promise.all(
wireStats.map(async (w) => {
const data = await decryptJson<Record<string, unknown>>(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) { } catch (error: any) {
set({ set({
error: error.message || 'Failed to load health stats', error: error.message || 'Failed to load health stats',
@ -519,9 +542,33 @@ export const useHealthStore = create<HealthState>()(
}, },
createStat: async (data: any) => { 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 }); set({ isLoading: true, error: null });
try { 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) => ({ set((state) => ({
stats: [...state.stats, stat], stats: [...state.stats, stat],
isLoading: false, isLoading: false,
@ -536,58 +583,80 @@ export const useHealthStore = create<HealthState>()(
}, },
updateStat: async (id: string, data: any) => { 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 { 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) => ({ set((state) => ({
stats: state.stats.map((stat) => stats: state.stats.map((s) =>
stat.stat_id === id ? updated : stat s.stat_id === id ? { ...s, ...d } : s,
), ),
isLoading: false,
})); }));
} catch (error: any) { } catch (error: any) {
set({ set({ error: error.message || 'Failed to update stat' });
error: error.message || 'Failed to update stat',
isLoading: false,
});
throw error; throw error;
} }
}, },
deleteStat: async (id: string) => { deleteStat: async (id: string) => {
set({ isLoading: true, error: null });
try { try {
await apiService.deleteHealthStat(id); await apiService.deleteHealthStat(id);
set((state) => ({ set((state) => ({
stats: state.stats.filter((stat) => stat.stat_id !== id), stats: state.stats.filter((s) => s.stat_id !== id),
isLoading: false,
})); }));
} catch (error: any) { } catch (error: any) {
set({ set({ error: error.message || 'Failed to delete stat' });
error: error.message || 'Failed to delete stat',
isLoading: false,
});
throw error; throw error;
} }
}, },
loadTrends: async () => { loadTrends: async () => {
set({ isLoading: true, error: null }); // Trends are now computed client-side in loadStats.
try { const computedTrends = computeTrends(get().stats);
const trends = await apiService.getHealthTrends(); set({ trends: computedTrends });
set({ trends, isLoading: false });
} catch (error: any) {
set({
error: error.message || 'Failed to load trends',
isLoading: false,
});
}
}, },
clearError: () => set({ error: null }), 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<string, HealthStat[]> = {};
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) // Interaction Store (Phase 2.8)
export const useInteractionStore = create<InteractionState>()( export const useInteractionStore = create<InteractionState>()(
devtools((set, get) => ({ devtools((set, get) => ({

View file

@ -139,6 +139,7 @@ export interface Medication {
notes?: string; notes?: string;
tags?: string[]; tags?: string[];
active: boolean; active: boolean;
dose_schedule?: DoseSchedule;
created_at?: string; created_at?: string;
updated_at?: string; updated_at?: string;
} }
@ -150,6 +151,7 @@ export interface MedicationWireResponse {
user_id: string; user_id: string;
profile_id: string; profile_id: string;
active: boolean; active: boolean;
dose_schedule?: DoseSchedule;
encrypted_data: EncryptedFieldWire; encrypted_data: EncryptedFieldWire;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
@ -160,11 +162,13 @@ export interface CreateMedicationRequest {
profile_id: string; profile_id: string;
encrypted_data: EncryptedFieldWire; encrypted_data: EncryptedFieldWire;
active?: boolean; active?: boolean;
dose_schedule?: DoseSchedule;
} }
export interface UpdateMedicationRequest { export interface UpdateMedicationRequest {
encrypted_data?: EncryptedFieldWire; encrypted_data?: EncryptedFieldWire;
active?: boolean; active?: boolean;
dose_schedule?: DoseSchedule;
} }
// Drug Interaction Types (Phase 2.8) // Drug Interaction Types (Phase 2.8)
@ -204,24 +208,32 @@ export enum HealthStatType {
Other = 'other' Other = 'other'
} }
// Domain type (decrypted, used by UI)
export interface HealthStat { export interface HealthStat {
stat_id?: string; stat_id?: string;
user_id: string; user_id?: string;
stat_type: HealthStatType; stat_type: HealthStatType;
value: number; value: number;
unit: string; unit: string;
measured_at: string; measured_at: string;
notes?: 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 { export interface CreateHealthStatRequest {
stat_type: HealthStatType; encrypted_data: EncryptedFieldWire;
value: number; recorded_at?: string;
unit: string; }
measured_at: string;
notes?: string; export interface UpdateHealthStatRequest {
encrypted_data: EncryptedFieldWire;
} }
export interface TrendData { export interface TrendData {
@ -233,6 +245,12 @@ export interface TrendData {
data_points: HealthStat[]; data_points: HealthStat[];
} }
// Dose schedule (plaintext metadata for adherence calc)
export interface DoseSchedule {
times_per_day: number;
days_of_week?: string[];
}
// Lab Results Types // Lab Results Types
export interface LabResult { export interface LabResult {
lab_id?: string; lab_id?: string;