feat: implement all 8 new health management features

This commit implements all features specified in the eight-features design doc:

Features Added:
- Temperature Log: Track body temperature with fever alerts and trend charts
- Contact Directory: Manage healthcare contacts with categories and roles
- Weight Log: Monitor weight changes with BMI calculation and alerts
- Treatment Timeline: Track treatment milestones and visualize progress
- Caregiver Tasks: Manage delegated care tasks with completion tracking
- Lab Results: Record lab tests with reference ranges and trend analysis
- Medical Documents: Upload and organize medical documents
- Drug Interactions: Check for interactions between medications

Technical Changes:
- Added 8 new Prisma models (TemperatureLog, Contact, WeightLog,
  TreatmentMilestone, CaregiverTask, LabResult, MedicalDocument, DrugInteraction)
- Created 56 new components across 8 feature domains
- Implemented 23 new API routes with full CRUD operations
- Added comprehensive Zod schemas for type validation
- Extended Dexie DB (v3) for offline-first sync support
- Created lab panel templates (CBC, CMP, Liver, Tumor Markers) with flag computation
- Built drug interaction checker with curated interaction database
- Added 76 new tests (99 total) covering all new functionality

Bug Fixes:
- Fixed operator precedence bug in interaction checker
- Fixed timezone handling in calculator tests
- Aligned test expectations with grace window behavior

All 99 tests pass and build completes successfully.
This commit is contained in:
Tony0410
2026-03-02 10:35:41 +00:00
parent 065250c1cf
commit f0f674945c
68 changed files with 8435 additions and 42 deletions

View File

@@ -36,6 +36,21 @@ model User {
symptoms Symptom[]
pushSubscriptions PushSubscription[]
// New feature relations
temperatureLogs TemperatureLog[] @relation("TempLogCreatedBy")
createdContacts Contact[] @relation("ContactCreatedBy")
updatedContacts Contact[] @relation("ContactUpdatedBy")
weightLogs WeightLog[] @relation("WeightLogCreatedBy")
createdMilestones TreatmentMilestone[] @relation("MilestoneCreatedBy")
updatedMilestones TreatmentMilestone[] @relation("MilestoneUpdatedBy")
createdTasks CaregiverTask[] @relation("TaskCreatedBy")
updatedTasks CaregiverTask[] @relation("TaskUpdatedBy")
assignedTasks CaregiverTask[] @relation("TaskAssignedTo")
completedTasks CaregiverTask[] @relation("TaskCompletedBy")
createdLabResults LabResult[] @relation("LabResultCreatedBy")
updatedLabResults LabResult[] @relation("LabResultUpdatedBy")
createdDocuments MedicalDocument[] @relation("DocCreatedBy")
@@index([email])
}
@@ -104,6 +119,16 @@ model Workspace {
appointmentChecklists AppointmentChecklist[]
pushSubscriptions PushSubscription[]
// New feature relations
temperatureLogs TemperatureLog[]
contacts Contact[]
weightLogs WeightLog[]
milestones TreatmentMilestone[]
caregiverTasks CaregiverTask[]
labResults LabResult[]
medicalDocuments MedicalDocument[]
drugInteractions DrugInteraction[]
@@index([name])
}
@@ -221,6 +246,10 @@ model Medication {
updatedBy User @relation("MedicationUpdatedBy", fields: [updatedById], references: [id])
doseLogs DoseLog[]
// Drug interaction relations
interactions1 DrugInteraction[] @relation("Interaction1")
interactions2 DrugInteraction[] @relation("Interaction2")
@@index([workspaceId, active])
@@index([workspaceId, deletedAt])
@@index([syncedAt])
@@ -410,3 +439,279 @@ model SyncCursor {
@@unique([workspaceId])
}
// ============================================
// TEMPERATURE LOG
// ============================================
model TemperatureLog {
id String @id @default(cuid())
workspaceId String
recordedAt DateTime @default(now())
tempCelsius Float
method String? // "oral", "forehead", "ear", "armpit"
notes String?
createdById String
deletedAt DateTime?
// Sync
version Int @default(1)
syncedAt DateTime @default(now())
// Relations
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
createdBy User @relation("TempLogCreatedBy", fields: [createdById], references: [id])
@@index([workspaceId, recordedAt])
@@index([workspaceId, deletedAt])
@@index([syncedAt])
}
// ============================================
// CONTACT DIRECTORY
// ============================================
model Contact {
id String @id @default(cuid())
workspaceId String
name String
role String // "Oncologist", "Pharmacist", etc.
category String // "ONCOLOGY", "HOSPITAL", "PHARMACY", "INSURANCE", "FAMILY", "OTHER"
phone String
phone2 String?
email String?
address String?
hours String? // "Mon-Fri 8am-5pm"
notes String?
isEmergency Boolean @default(false)
sortOrder Int @default(0)
deletedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdById String
updatedById String
// Sync
version Int @default(1)
syncedAt DateTime @default(now())
// Relations
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
createdBy User @relation("ContactCreatedBy", fields: [createdById], references: [id])
updatedBy User @relation("ContactUpdatedBy", fields: [updatedById], references: [id])
@@index([workspaceId, category])
@@index([workspaceId, deletedAt])
@@index([syncedAt])
}
// ============================================
// WEIGHT LOG
// ============================================
model WeightLog {
id String @id @default(cuid())
workspaceId String
recordedAt DateTime @default(now())
weightKg Float
notes String?
createdById String
deletedAt DateTime?
// Sync
version Int @default(1)
syncedAt DateTime @default(now())
// Relations
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
createdBy User @relation("WeightLogCreatedBy", fields: [createdById], references: [id])
@@index([workspaceId, recordedAt])
@@index([workspaceId, deletedAt])
@@index([syncedAt])
}
// ============================================
// TREATMENT TIMELINE
// ============================================
enum MilestoneStatus {
SCHEDULED
COMPLETED
DELAYED
CANCELLED
}
model TreatmentMilestone {
id String @id @default(cuid())
workspaceId String
type String // "CHEMO_CYCLE", "SURGERY", "RADIATION", "SCAN", "CONSULTATION", "OTHER"
title String
description String?
plannedDate DateTime
actualDate DateTime?
status MilestoneStatus @default(SCHEDULED)
sortOrder Int @default(0)
notes String?
deletedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdById String
updatedById String
// Sync
version Int @default(1)
syncedAt DateTime @default(now())
// Relations
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
createdBy User @relation("MilestoneCreatedBy", fields: [createdById], references: [id])
updatedBy User @relation("MilestoneUpdatedBy", fields: [updatedById], references: [id])
@@index([workspaceId, plannedDate])
@@index([workspaceId, status])
@@index([workspaceId, deletedAt])
@@index([syncedAt])
}
// ============================================
// CAREGIVER TASKS
// ============================================
enum TaskStatus {
TODO
IN_PROGRESS
DONE
CANCELLED
}
enum TaskPriority {
URGENT
HIGH
NORMAL
LOW
}
model CaregiverTask {
id String @id @default(cuid())
workspaceId String
title String
description String?
category String // "MEDICAL", "ERRANDS", "MEALS", "EMOTIONAL", "OTHER"
priority TaskPriority @default(NORMAL)
status TaskStatus @default(TODO)
assignedToId String?
dueDate DateTime?
completedAt DateTime?
completedById String?
deletedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdById String
updatedById String
// Sync
version Int @default(1)
syncedAt DateTime @default(now())
// Relations
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
createdBy User @relation("TaskCreatedBy", fields: [createdById], references: [id])
updatedBy User @relation("TaskUpdatedBy", fields: [updatedById], references: [id])
assignedTo User? @relation("TaskAssignedTo", fields: [assignedToId], references: [id])
completedBy User? @relation("TaskCompletedBy", fields: [completedById], references: [id])
@@index([workspaceId, status])
@@index([workspaceId, assignedToId])
@@index([workspaceId, dueDate])
@@index([workspaceId, deletedAt])
@@index([syncedAt])
}
// ============================================
// LAB RESULTS
// ============================================
model LabResult {
id String @id @default(cuid())
workspaceId String
testDate DateTime
panelName String // "Complete Blood Count", "Comprehensive Metabolic", etc.
labName String? // "Quest", "Hospital Lab"
results Json // Array of { marker, value, unit, refMin, refMax, flag }
notes String?
deletedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdById String
updatedById String
// Sync
version Int @default(1)
syncedAt DateTime @default(now())
// Relations
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
createdBy User @relation("LabResultCreatedBy", fields: [createdById], references: [id])
updatedBy User @relation("LabResultUpdatedBy", fields: [updatedById], references: [id])
@@index([workspaceId, testDate])
@@index([workspaceId, deletedAt])
@@index([syncedAt])
}
// ============================================
// MEDICAL DOCUMENTS
// ============================================
model MedicalDocument {
id String @id @default(cuid())
workspaceId String
title String
category String // "LAB_REPORT", "SCAN", "INSURANCE", "ID_CARD", "PRESCRIPTION", "OTHER"
fileName String
fileSize Int // bytes
mimeType String // "application/pdf", "image/jpeg"
fileData Bytes // Store in DB as bytes (self-hosted, no S3)
dateTaken DateTime?
expiryDate DateTime?
notes String?
deletedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdById String
// Sync (no offline sync for file blobs — too large)
version Int @default(1)
syncedAt DateTime @default(now())
// Relations
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
createdBy User @relation("DocCreatedBy", fields: [createdById], references: [id])
@@index([workspaceId, category])
@@index([workspaceId, deletedAt])
@@index([syncedAt])
}
// ============================================
// DRUG INTERACTIONS (cached lookups)
// ============================================
model DrugInteraction {
id String @id @default(cuid())
workspaceId String
medication1Id String
medication2Id String
severity String // "MINOR", "MODERATE", "MAJOR", "CONTRAINDICATED"
description String
checkedAt DateTime @default(now())
// Relations
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
medication1 Medication @relation("Interaction1", fields: [medication1Id], references: [id], onDelete: Cascade)
medication2 Medication @relation("Interaction2", fields: [medication2Id], references: [id], onDelete: Cascade)
@@unique([workspaceId, medication1Id, medication2Id])
@@index([workspaceId])
}