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

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