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

@@ -0,0 +1,100 @@
'use client'
import { format, isPast, addDays } from 'date-fns'
import { FileText, Image as ImageIcon, File } from 'lucide-react'
interface DocumentData {
id: string
title: string
category: string
fileName: string
fileSize: number
mimeType: string
dateTaken: string | null
expiryDate: string | null
notes: string | null
createdAt: string
}
interface DocumentCardProps {
document: DocumentData
onView: (doc: DocumentData) => void
}
const CATEGORY_BADGES: Record<string, string> = {
LAB_REPORT: 'bg-blue-100 text-blue-700',
SCAN: 'bg-purple-100 text-purple-700',
INSURANCE: 'bg-green-100 text-green-700',
ID_CARD: 'bg-orange-100 text-orange-700',
PRESCRIPTION: 'bg-pink-100 text-pink-700',
OTHER: 'bg-secondary-100 text-secondary-700',
}
const CATEGORY_LABELS: Record<string, string> = {
LAB_REPORT: 'Lab Report',
SCAN: 'Scan',
INSURANCE: 'Insurance',
ID_CARD: 'ID Card',
PRESCRIPTION: 'Prescription',
OTHER: 'Other',
}
function FileIcon({ mimeType }: { mimeType: string }) {
if (mimeType === 'application/pdf') return <FileText className="w-6 h-6 text-red-500" />
if (mimeType.startsWith('image/')) return <ImageIcon className="w-6 h-6 text-blue-500" />
return <File className="w-6 h-6 text-secondary-500" />
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
export function DocumentCard({ document: doc, onView }: DocumentCardProps) {
const badge = CATEGORY_BADGES[doc.category] || CATEGORY_BADGES.OTHER
const label = CATEGORY_LABELS[doc.category] || doc.category
const isExpiringSoon = doc.expiryDate && !isPast(new Date(doc.expiryDate)) &&
isPast(addDays(new Date(), -30))
const isExpired = doc.expiryDate && isPast(new Date(doc.expiryDate))
return (
<div
onClick={() => onView(doc)}
className="bg-surface rounded-card border border-border p-4 cursor-pointer hover:shadow-card-hover transition-shadow"
>
<div className="flex items-start gap-3">
<div className="w-12 h-12 rounded-xl bg-muted flex items-center justify-center flex-shrink-0">
<FileIcon mimeType={doc.mimeType} />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-secondary-900 truncate">{doc.title}</h3>
<div className="flex items-center gap-2 mt-1 flex-wrap">
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${badge}`}>
{label}
</span>
<span className="text-xs text-secondary-400">
{formatFileSize(doc.fileSize)}
</span>
{doc.dateTaken && (
<span className="text-xs text-secondary-500">
{format(new Date(doc.dateTaken), 'MMM d, yyyy')}
</span>
)}
</div>
{/* Expiry indicators */}
{isExpired && (
<span className="inline-block mt-1.5 text-xs font-semibold px-2 py-0.5 rounded-full bg-red-100 text-red-700">
Expired
</span>
)}
{isExpiringSoon && !isExpired && (
<span className="inline-block mt-1.5 text-xs font-semibold px-2 py-0.5 rounded-full bg-yellow-100 text-yellow-700">
Expiring soon
</span>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,181 @@
'use client'
import { useState, useRef } from 'react'
import { Upload } from 'lucide-react'
import { Modal, Button, Input, Select, Textarea, showToast } from '@/components/ui'
const CATEGORIES = [
{ value: 'LAB_REPORT', label: 'Lab Report' },
{ value: 'SCAN', label: 'Scan / Imaging' },
{ value: 'INSURANCE', label: 'Insurance' },
{ value: 'ID_CARD', label: 'ID Card' },
{ value: 'PRESCRIPTION', label: 'Prescription' },
{ value: 'OTHER', label: 'Other' },
]
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB
const ACCEPTED_TYPES = '.pdf,.jpg,.jpeg,.png'
interface DocumentUploadProps {
isOpen: boolean
onClose: () => void
onSaved: () => void
workspaceId: string
}
export function DocumentUpload({ isOpen, onClose, onSaved, workspaceId }: DocumentUploadProps) {
const fileRef = useRef<HTMLInputElement>(null)
const [file, setFile] = useState<File | null>(null)
const [title, setTitle] = useState('')
const [category, setCategory] = useState('OTHER')
const [dateTaken, setDateTaken] = useState('')
const [expiryDate, setExpiryDate] = useState('')
const [notes, setNotes] = useState('')
const [saving, setSaving] = useState(false)
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const selected = e.target.files?.[0]
if (!selected) return
if (selected.size > MAX_FILE_SIZE) {
showToast('File too large (max 10MB)', 'error')
return
}
setFile(selected)
if (!title) {
// Auto-fill title from filename
setTitle(selected.name.replace(/\.[^/.]+$/, '').replace(/[_-]/g, ' '))
}
}
const handleSave = async () => {
if (!file) {
showToast('Please select a file', 'error')
return
}
if (!title.trim()) {
showToast('Title is required', 'error')
return
}
setSaving(true)
try {
const formData = new FormData()
formData.append('file', file)
formData.append('title', title.trim())
formData.append('category', category)
if (dateTaken) formData.append('dateTaken', new Date(dateTaken).toISOString())
if (expiryDate) formData.append('expiryDate', new Date(expiryDate).toISOString())
if (notes.trim()) formData.append('notes', notes.trim())
const response = await fetch(`/api/workspaces/${workspaceId}/documents`, {
method: 'POST',
body: formData,
})
if (!response.ok) {
const err = await response.json().catch(() => ({ error: 'Upload failed' }))
throw new Error(err.error || 'Upload failed')
}
showToast('Document uploaded', 'success')
onSaved()
handleReset()
onClose()
} catch (err: any) {
showToast(err.message || 'Failed to upload document', 'error')
} finally {
setSaving(false)
}
}
const handleReset = () => {
setFile(null)
setTitle('')
setCategory('OTHER')
setDateTaken('')
setExpiryDate('')
setNotes('')
if (fileRef.current) fileRef.current.value = ''
}
return (
<Modal isOpen={isOpen} onClose={onClose} title="Upload Document">
<div className="space-y-4">
{/* File picker */}
<div>
<p className="text-sm font-medium text-secondary-700 mb-2">File *</p>
<input
ref={fileRef}
type="file"
accept={ACCEPTED_TYPES}
onChange={handleFileSelect}
className="hidden"
/>
<button
onClick={() => fileRef.current?.click()}
className="w-full border-2 border-dashed border-border rounded-card p-6 text-center hover:border-primary-300 transition-colors"
>
{file ? (
<div>
<p className="font-medium text-secondary-900">{file.name}</p>
<p className="text-xs text-secondary-400 mt-1">
{(file.size / (1024 * 1024)).toFixed(1)} MB · Tap to change
</p>
</div>
) : (
<div>
<Upload className="w-8 h-8 text-secondary-300 mx-auto mb-2" />
<p className="text-sm text-secondary-500">Tap to select a file</p>
<p className="text-xs text-secondary-400 mt-1">PDF, JPG, or PNG · Max 10MB</p>
</div>
)}
</button>
</div>
<Input
label="Title *"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g. Blood work results"
/>
<Select
label="Category"
value={category}
onChange={(e) => setCategory(e.target.value)}
options={CATEGORIES}
/>
<div className="grid grid-cols-2 gap-3">
<Input
label="Date Taken"
type="date"
value={dateTaken}
onChange={(e) => setDateTaken(e.target.value)}
/>
<Input
label="Expiry Date"
type="date"
value={expiryDate}
onChange={(e) => setExpiryDate(e.target.value)}
/>
</div>
<Textarea
label="Notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Additional notes..."
rows={2}
/>
<div className="flex gap-3 pt-2">
<Button variant="secondary" onClick={onClose} fullWidth>Cancel</Button>
<Button onClick={handleSave} fullWidth loading={saving}>Upload</Button>
</div>
</div>
</Modal>
)
}

View File

@@ -0,0 +1,84 @@
'use client'
import { X, Download, Trash2 } from 'lucide-react'
import { Button } from '@/components/ui'
interface DocumentViewerProps {
isOpen: boolean
onClose: () => void
onDelete?: () => void
document: {
id: string
title: string
mimeType: string
fileName: string
} | null
workspaceId: string
}
export function DocumentViewer({ isOpen, onClose, onDelete, document: doc, workspaceId }: DocumentViewerProps) {
if (!isOpen || !doc) return null
const fileUrl = `/api/workspaces/${workspaceId}/documents/${doc.id}`
const isImage = doc.mimeType.startsWith('image/')
const isPDF = doc.mimeType === 'application/pdf'
return (
<div className="fixed inset-0 z-50 bg-black/90 flex flex-col">
{/* Toolbar */}
<div className="flex items-center justify-between px-4 py-3 bg-black/50">
<h2 className="text-white font-semibold truncate flex-1 mr-4">{doc.title}</h2>
<div className="flex items-center gap-2">
<a
href={fileUrl}
download={doc.fileName}
className="p-2 rounded-lg bg-white/10 hover:bg-white/20 transition-colors"
>
<Download className="w-5 h-5 text-white" />
</a>
{onDelete && (
<button
onClick={onDelete}
className="p-2 rounded-lg bg-white/10 hover:bg-red-500/50 transition-colors"
>
<Trash2 className="w-5 h-5 text-white" />
</button>
)}
<button
onClick={onClose}
className="p-2 rounded-lg bg-white/10 hover:bg-white/20 transition-colors"
>
<X className="w-5 h-5 text-white" />
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-auto flex items-center justify-center p-4">
{isImage && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={fileUrl}
alt={doc.title}
className="max-w-full max-h-full object-contain rounded-lg"
/>
)}
{isPDF && (
<iframe
src={fileUrl}
title={doc.title}
className="w-full h-full rounded-lg bg-white"
/>
)}
{!isImage && !isPDF && (
<div className="text-center text-white">
<p className="text-lg mb-4">Preview not available</p>
<Button variant="secondary" onClick={() => window.open(fileUrl, '_blank')}>
Download File
</Button>
</div>
)}
</div>
</div>
)
}