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';
|
} 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],
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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) => ({
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue