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:
parent
09589b3bb2
commit
8c123df490
4 changed files with 146 additions and 55 deletions
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) => ({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue