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';
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>(HealthStatType.Weight);
const [form, setForm] = useState<CreateHealthStatRequest>({
const [form, setForm] = useState<HealthFormData>({
stat_type: HealthStatType.Weight,
value: 0,
unit: UNIT_DEFAULTS[HealthStatType.Weight],

View file

@ -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<HealthStat[]> {
const response = await this.client.get<HealthStat[]>('/health-stats');
async getHealthStats(): Promise<HealthStatWireResponse[]> {
const response = await this.client.get<HealthStatWireResponse[]>('/health-stats');
return response.data;
}
async getHealthStat(id: string): Promise<HealthStat> {
const response = await this.client.get<HealthStat>(`/health-stats/${id}`);
async getHealthStat(id: string): Promise<HealthStatWireResponse> {
const response = await this.client.get<HealthStatWireResponse>(`/health-stats/${id}`);
return response.data;
}
async createHealthStat(data: CreateHealthStatRequest): Promise<HealthStat> {
const response = await this.client.post<HealthStat>('/health-stats', data);
async createHealthStat(data: CreateHealthStatRequest): Promise<HealthStatWireResponse> {
const response = await this.client.post<HealthStatWireResponse>('/health-stats', data);
return response.data;
}
async updateHealthStat(id: string, data: Partial<CreateHealthStatRequest>): Promise<HealthStat> {
const response = await this.client.put<HealthStat>(`/health-stats/${id}`, data);
async updateHealthStat(id: string, data: UpdateHealthStatRequest): Promise<HealthStatWireResponse> {
const response = await this.client.put<HealthStatWireResponse>(`/health-stats/${id}`, data);
return response.data;
}
@ -356,11 +356,6 @@ class ApiService {
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
// /lab-results routes yet. The LabResult type stays in types/api.ts for when
// those routes land.

View file

@ -17,6 +17,7 @@ import {
User,
Medication,
HealthStat,
HealthStatType,
DrugInteraction,
AdherenceStats,
Profile,
@ -342,6 +343,7 @@ export const useMedicationStore = create<MedicationState>()(
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<HealthState>()(
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<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) {
set({
error: error.message || 'Failed to load health stats',
@ -519,9 +542,33 @@ export const useHealthStore = create<HealthState>()(
},
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<HealthState>()(
},
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<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)
export const useInteractionStore = create<InteractionState>()(
devtools((set, get) => ({

View file

@ -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;