diff --git a/docs/designs/2026-03-01-eight-features-design.md b/docs/designs/2026-03-01-eight-features-design.md new file mode 100644 index 0000000..2f98f8b --- /dev/null +++ b/docs/designs/2026-03-01-eight-features-design.md @@ -0,0 +1,998 @@ +# Design: 8 New Features for Next Step + +**Date:** 2026-03-01 +**Priority:** Urgent +**Scope:** Medium (3-5 days) +**Target Users:** End users (patients & family caregivers) + +--- + +## Established Patterns (must follow) + +All new features must follow these exact conventions from the codebase: + +### File Patterns +- **Pages:** `src/app/(app)//page.tsx` — `'use client'`, uses `useApp()` for workspace, `Header` + `PageContainer` layout +- **API routes:** `src/app/api/workspaces/[id]//route.ts` — uses `withAuth`, `checkWorkspaceAccess`, `canEdit`, Zod validation, audit log on writes +- **Components:** `src/components//ComponentName.tsx` — `'use client'`, uses UI kit (`Card`, `Button`, `showToast`) +- **Validation:** `src/lib/validation/schemas.ts` — Zod schemas with type exports +- **Dexie tables:** `src/lib/sync/db.ts` — interface + table definition, bump version +- **Sync ops:** `src/lib/sync/manager.ts` — add entity types and op handlers + +### UI Patterns +- Colors: `primary-*` (sage green), `secondary-*` (warm stone), `accent-*` (terracotta), `alert-*` (soft red), `cream-*` (warm neutral) +- Semantic: `bg-background`, `bg-surface`, `bg-muted`, `border-border` +- Cards: `` with `shadow-card`, `rounded-card` (20px) +- Touch: `min-h-touch` (48px), large tap targets +- Typography: `font-display` for headings, `text-secondary-900` for titles, `text-secondary-500` for meta +- Icons: `lucide-react`, 6x6 default, stroke color matching text +- States: `LoadingState`, `EmptyState`, `ErrorState` from `@/components/ui` +- Toast: `showToast('message', 'success'|'error')` +- Page structure: `
` then `` + +### API Patterns +```typescript +export const GET = withAuth(async (req: AuthenticatedRequest, { params }) => { + const { id: workspaceId } = await params + const access = await checkWorkspaceAccess(workspaceId, req.session.user.id) + if (!access) return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + // ... logic +}) + +export const POST = withAuth(async (req: AuthenticatedRequest, { params }) => { + const { id: workspaceId } = await params + const access = await checkWorkspaceAccess(workspaceId, req.session.user.id) + if (!access || !canEdit(access.role)) return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + const body = await req.json() + const result = schema.safeParse(body) + if (!result.success) return NextResponse.json({ error: 'Invalid input', details: result.error.flatten() }, { status: 400 }) + // ... create + audit log +}) +``` + +### Data fetch pattern (pages) +```typescript +// 1. useLiveQuery from Dexie for offline-first +const localData = useLiveQuery(() => db.table.where('workspaceId').equals(id)..., [id]) +// 2. Also fetch from server +const fetchData = useCallback(async () => { ... }, [currentWorkspace.id]) +// 3. Combine: prefer server, fallback to local +const data = serverData.length > 0 ? serverData : localData || [] +``` + +--- + +## Prisma Schema Additions + +All 8 features in a single migration: + +```prisma +// ============================================ +// 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]) +} +``` + +### User model additions (relations) +```prisma +// Add to User model: + 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") + +// Add to Workspace model: + temperatureLogs TemperatureLog[] + contacts Contact[] + weightLogs WeightLog[] + milestones TreatmentMilestone[] + caregiverTasks CaregiverTask[] + labResults LabResult[] + medicalDocuments MedicalDocument[] + drugInteractions DrugInteraction[] + +// Add to Medication model: + interactions1 DrugInteraction[] @relation("Interaction1") + interactions2 DrugInteraction[] @relation("Interaction2") +``` + +--- + +## Dexie DB (Version 3) + +Add to `src/lib/sync/db.ts`: + +```typescript +// New interfaces +export interface LocalTemperatureLog { + id: string + workspaceId: string + recordedAt: string + tempCelsius: number + method: string | null + notes: string | null + deletedAt: string | null + version: number + syncedAt: string + createdBy?: { id: string; name: string } +} + +export interface LocalContact { + id: string + workspaceId: string + name: string + role: string + category: string + phone: string + phone2: string | null + email: string | null + address: string | null + hours: string | null + notes: string | null + isEmergency: boolean + sortOrder: number + deletedAt: string | null + version: number + syncedAt: string +} + +export interface LocalWeightLog { + id: string + workspaceId: string + recordedAt: string + weightKg: number + notes: string | null + deletedAt: string | null + version: number + syncedAt: string + createdBy?: { id: string; name: string } +} + +export interface LocalMilestone { + id: string + workspaceId: string + type: string + title: string + description: string | null + plannedDate: string + actualDate: string | null + status: string + sortOrder: number + notes: string | null + deletedAt: string | null + version: number + syncedAt: string +} + +export interface LocalCaregiverTask { + id: string + workspaceId: string + title: string + description: string | null + category: string + priority: string + status: string + assignedToId: string | null + dueDate: string | null + completedAt: string | null + deletedAt: string | null + version: number + syncedAt: string + assignedTo?: { id: string; name: string } + createdBy?: { id: string; name: string } +} + +export interface LocalLabResult { + id: string + workspaceId: string + testDate: string + panelName: string + labName: string | null + results: Array<{ + marker: string + value: number + unit: string + refMin: number | null + refMax: number | null + flag: string | null // "LOW", "HIGH", "CRITICAL_LOW", "CRITICAL_HIGH", null + }> + notes: string | null + deletedAt: string | null + version: number + syncedAt: string +} + +// Version 3 stores +this.version(3).stores({ + appointments: 'id, workspaceId, datetime, deletedAt', + medications: 'id, workspaceId, active, deletedAt', + notes: 'id, workspaceId, type, deletedAt', + doseLogs: 'id, medicationId, workspaceId, takenAt', + workspaces: 'id', + symptoms: 'id, workspaceId, type, recordedAt, deletedAt', + temperatureLogs: 'id, workspaceId, recordedAt, deletedAt', + contacts: 'id, workspaceId, category, deletedAt', + weightLogs: 'id, workspaceId, recordedAt, deletedAt', + milestones: 'id, workspaceId, plannedDate, status, deletedAt', + caregiverTasks: 'id, workspaceId, status, assignedToId, deletedAt', + labResults: 'id, workspaceId, testDate, deletedAt', + outbox: 'id, workspaceId, timestamp', + syncMeta: 'id, workspaceId', +}) +``` + +Note: Medical documents are NOT stored in Dexie (too large for IndexedDB). They are server-only. + +--- + +## Zod Validation Schemas + +Add to `src/lib/validation/schemas.ts`: + +```typescript +// Temperature Log +export const temperatureLogSchema = z.object({ + tempCelsius: z.number().min(30).max(45), + method: z.enum(['oral', 'forehead', 'ear', 'armpit']).nullable().optional(), + notes: z.string().max(500).nullable().optional(), + recordedAt: z.string().datetime().optional(), +}) + +// Contact +export const contactSchema = z.object({ + name: z.string().min(1, 'Name is required').max(200), + role: z.string().min(1, 'Role is required').max(100), + category: z.enum(['ONCOLOGY', 'HOSPITAL', 'PHARMACY', 'INSURANCE', 'FAMILY', 'OTHER']), + phone: z.string().min(1, 'Phone is required').max(50), + phone2: z.string().max(50).nullable().optional(), + email: z.string().email().max(200).nullable().optional(), + address: z.string().max(500).nullable().optional(), + hours: z.string().max(200).nullable().optional(), + notes: z.string().max(1000).nullable().optional(), + isEmergency: z.boolean().default(false), + sortOrder: z.number().int().min(0).default(0), +}) + +// Weight Log +export const weightLogSchema = z.object({ + weightKg: z.number().min(1).max(500), + notes: z.string().max(500).nullable().optional(), + recordedAt: z.string().datetime().optional(), +}) + +// Treatment Milestone +export const milestoneSchema = z.object({ + type: z.enum(['CHEMO_CYCLE', 'SURGERY', 'RADIATION', 'SCAN', 'CONSULTATION', 'OTHER']), + title: z.string().min(1, 'Title is required').max(200), + description: z.string().max(1000).nullable().optional(), + plannedDate: z.string().datetime(), + actualDate: z.string().datetime().nullable().optional(), + status: z.enum(['SCHEDULED', 'COMPLETED', 'DELAYED', 'CANCELLED']).default('SCHEDULED'), + notes: z.string().max(2000).nullable().optional(), +}) + +// Caregiver Task +export const caregiverTaskSchema = z.object({ + title: z.string().min(1, 'Title is required').max(200), + description: z.string().max(2000).nullable().optional(), + category: z.enum(['MEDICAL', 'ERRANDS', 'MEALS', 'EMOTIONAL', 'OTHER']), + priority: z.enum(['URGENT', 'HIGH', 'NORMAL', 'LOW']).default('NORMAL'), + status: z.enum(['TODO', 'IN_PROGRESS', 'DONE', 'CANCELLED']).default('TODO'), + assignedToId: z.string().cuid().nullable().optional(), + dueDate: z.string().datetime().nullable().optional(), +}) + +// Lab Result +const labMarkerSchema = z.object({ + marker: z.string().min(1).max(50), + value: z.number(), + unit: z.string().max(20), + refMin: z.number().nullable().optional(), + refMax: z.number().nullable().optional(), + flag: z.enum(['LOW', 'HIGH', 'CRITICAL_LOW', 'CRITICAL_HIGH']).nullable().optional(), +}) + +export const labResultSchema = z.object({ + testDate: z.string().datetime(), + panelName: z.string().min(1).max(200), + labName: z.string().max(200).nullable().optional(), + results: z.array(labMarkerSchema).min(1), + notes: z.string().max(2000).nullable().optional(), +}) + +// Medical Document (metadata only — file sent as multipart) +export const medicalDocumentSchema = z.object({ + title: z.string().min(1, 'Title is required').max(200), + category: z.enum(['LAB_REPORT', 'SCAN', 'INSURANCE', 'ID_CARD', 'PRESCRIPTION', 'OTHER']), + dateTaken: z.string().datetime().nullable().optional(), + expiryDate: z.string().datetime().nullable().optional(), + notes: z.string().max(1000).nullable().optional(), +}) + +// Drug Interaction Check +export const interactionCheckSchema = z.object({ + medicationIds: z.array(z.string().cuid()).min(2).max(20), +}) + +// Type exports +export type TemperatureLogInput = z.infer +export type ContactInput = z.infer +export type WeightLogInput = z.infer +export type MilestoneInput = z.infer +export type CaregiverTaskInput = z.infer +export type LabMarker = z.infer +export type LabResultInput = z.infer +export type MedicalDocumentInput = z.infer +export type InteractionCheckInput = z.infer +``` + +--- + +## Sync Ops Extensions + +Add to `syncOpSchema.type`: +``` +'LOG_TEMP', 'DELETE_TEMP', +'CREATE_CONTACT', 'UPDATE_CONTACT', 'DELETE_CONTACT', +'LOG_WEIGHT', 'DELETE_WEIGHT', +'CREATE_MILESTONE', 'UPDATE_MILESTONE', 'DELETE_MILESTONE', +'CREATE_TASK', 'UPDATE_TASK', 'DELETE_TASK', 'COMPLETE_TASK', +'CREATE_LAB', 'UPDATE_LAB', 'DELETE_LAB' +``` + +Add to `syncOpSchema.entityType`: +``` +'TEMPERATURE_LOG', 'CONTACT', 'WEIGHT_LOG', 'MILESTONE', 'CAREGIVER_TASK', 'LAB_RESULT' +``` + +--- + +## API Endpoints + +### Feature 1: Temperature Log +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/workspaces/[id]/temperature` | List logs (query: from, to, limit) | +| POST | `/api/workspaces/[id]/temperature` | Create log | +| DELETE | `/api/workspaces/[id]/temperature/[tempId]` | Soft delete | + +### Feature 2: Contact Directory +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/workspaces/[id]/contacts` | List contacts (query: category) | +| POST | `/api/workspaces/[id]/contacts` | Create contact | +| PATCH | `/api/workspaces/[id]/contacts/[contactId]` | Update contact | +| DELETE | `/api/workspaces/[id]/contacts/[contactId]` | Soft delete | + +### Feature 3: Weight Log +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/workspaces/[id]/weight` | List logs (query: from, to, limit) | +| POST | `/api/workspaces/[id]/weight` | Create log | +| DELETE | `/api/workspaces/[id]/weight/[weightId]` | Soft delete | + +### Feature 4: Treatment Timeline +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/workspaces/[id]/milestones` | List milestones | +| POST | `/api/workspaces/[id]/milestones` | Create milestone | +| PATCH | `/api/workspaces/[id]/milestones/[milestoneId]` | Update (inc. status) | +| DELETE | `/api/workspaces/[id]/milestones/[milestoneId]` | Soft delete | + +### Feature 5: Caregiver Tasks +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/workspaces/[id]/tasks` | List tasks (query: status, assignedTo) | +| POST | `/api/workspaces/[id]/tasks` | Create task | +| PATCH | `/api/workspaces/[id]/tasks/[taskId]` | Update task | +| POST | `/api/workspaces/[id]/tasks/[taskId]/complete` | Mark complete | +| DELETE | `/api/workspaces/[id]/tasks/[taskId]` | Soft delete | + +### Feature 6: Lab Results +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/workspaces/[id]/lab-results` | List results (query: from, to) | +| GET | `/api/workspaces/[id]/lab-results/trends` | Trend data for specific marker | +| POST | `/api/workspaces/[id]/lab-results` | Create result | +| PATCH | `/api/workspaces/[id]/lab-results/[labId]` | Update result | +| DELETE | `/api/workspaces/[id]/lab-results/[labId]` | Soft delete | + +### Feature 7: Medical Documents +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/workspaces/[id]/documents` | List documents (metadata only) | +| POST | `/api/workspaces/[id]/documents` | Upload (multipart/form-data) | +| GET | `/api/workspaces/[id]/documents/[docId]` | Download file | +| DELETE | `/api/workspaces/[id]/documents/[docId]` | Soft delete | + +### Feature 8: Drug Interactions +| Method | Path | Description | +|--------|------|-------------| +| POST | `/api/workspaces/[id]/medications/check-interactions` | Check all meds | +| GET | `/api/workspaces/[id]/medications/interactions` | Get cached results | + +--- + +## UI/Page Design + +### Navigation Change + +The bottom nav currently has 5 items. With 8 new features, the "More" (Settings) tab becomes a hub. The new features are accessed via: + +- **Today page** surfaces: Temperature, Weight (quick log cards), upcoming tasks, next milestone +- **Bottom nav** stays as-is (Today, Appts, Meds, Symptoms, More) +- **"More" page** (`/settings`) becomes a menu with sections: + - Account & Settings (existing) + - **Health Tracking**: Temperature, Weight, Lab Results + - **Care Team**: Contact Directory, Caregiver Tasks + - **Treatment**: Timeline, Medical Documents + - **Safety**: Drug Interactions + +### Feature 1: Temperature Log — `/temperature` + +**Page structure:** +- Header: "Temperature" with History icon (right) +- Quick log card: big number input (keyboard type=decimal), method selector (4 pill buttons: Oral/Forehead/Ear/Armpit), optional notes, "Log Temperature" button +- **Fever alert banner**: if last reading >= 38.0°C, show red alert card: "FEVER DETECTED — 38.3°C" with "Call Clinic" button using `tel:` link from workspace.clinicPhone +- Last 7 days: mini chart (horizontal bar or sparkline showing daily temps) +- Recent readings: list of TemperatureCard components + +**Components:** +- `src/components/temperature/TempQuickLog.tsx` — number input, method pills, submit +- `src/components/temperature/TempCard.tsx` — single reading display +- `src/components/temperature/TempChart.tsx` — 7-day chart (pure CSS bars, no lib needed) +- `src/components/temperature/FeverAlert.tsx` — red alert banner with call button + +**Key UX:** +- Default unit based on locale (°C for AU). Display toggle °C/°F. +- 38.0°C threshold → yellow warning. 38.5°C → red emergency. +- Number input: show decimal keyboard on mobile via `inputMode="decimal"` + +### Feature 2: Contact Directory — `/contacts` + +**Page structure:** +- Header: "Care Team" with Plus icon (right) +- Category filter tabs: All / Oncology / Hospital / Pharmacy / Insurance / Family +- Emergency contacts section at top (if any marked isEmergency) +- Contact cards: avatar circle (first letter), name, role, big green CALL button +- Tap card → expand to show all details + +**Components:** +- `src/components/contacts/ContactCard.tsx` — name, role, call button, expandable +- `src/components/contacts/ContactForm.tsx` — modal form for create/edit +- `src/components/contacts/CategoryTabs.tsx` — horizontal scroll filter + +### Feature 3: Weight Log — `/weight` + +**Page structure:** +- Header: "Weight" with History icon +- Quick log: large number input (kg), small toggle for kg/lbs, notes, "Log Weight" button +- Trend card: 30-day line chart (CSS-based or simple SVG) +- Alert card: if weight changed >2kg in 24hrs, show warning +- Recent readings: list + +**Components:** +- `src/components/weight/WeightQuickLog.tsx` +- `src/components/weight/WeightCard.tsx` +- `src/components/weight/WeightChart.tsx` — simple SVG line chart +- `src/components/weight/WeightAlert.tsx` — rapid change warning + +### Feature 4: Treatment Timeline — `/timeline` + +**Page structure:** +- Header: "Treatment Journey" with Plus icon +- Progress bar at top: "Cycle 4 of 6 — 67% Complete" (calculated from completed/total milestones) +- Vertical timeline: milestones sorted by plannedDate + - Left: date + - Center: dot (green=completed, blue=scheduled, orange=delayed, gray=cancelled) + - Right: title, type badge, notes +- Bottom: "Add Milestone" button + +**Components:** +- `src/components/timeline/TimelineView.tsx` — vertical timeline layout +- `src/components/timeline/MilestoneCard.tsx` — single milestone +- `src/components/timeline/ProgressBar.tsx` — overall progress +- `src/components/timeline/MilestoneForm.tsx` — modal for create/edit + +**Key UX:** +- Completed milestones have a subtle celebration effect (checkmark) +- Auto-scroll to "now" position in timeline +- Color by type: blue=chemo, orange=surgery, purple=radiation, green=scan + +### Feature 5: Caregiver Tasks — `/tasks` + +**Page structure:** +- Header: "Tasks" with Plus icon +- Filter tabs: My Tasks / All / Done +- Task list grouped by priority (Urgent at top) +- Each task: title, assignee avatar, due date, category chip, priority indicator +- Swipe right to complete (or tap checkbox) +- FAB or bottom "Add Task" button + +**Components:** +- `src/components/tasks/TaskCard.tsx` — task with checkbox, assignee, due date +- `src/components/tasks/TaskForm.tsx` — modal with assignee picker (workspace members) +- `src/components/tasks/TaskFilters.tsx` — status/assignee filter + +**Key UX:** +- "Quick add" templates: "Pick up prescription", "Drive to appointment", "Prepare meals" +- Overdue tasks highlighted in accent/red +- Completion shows brief success animation + +### Feature 6: Lab Results — `/lab-results` + +**Page structure:** +- Header: "Lab Results" with Plus icon +- Tab: Recent / Trends +- **Recent tab:** List of lab result cards sorted by date, showing panel name, date, flag count +- **Trends tab:** Marker selector (dropdown: WBC, RBC, Platelets, Hemoglobin, etc.) → SVG line chart with reference range shaded +- Add result: modal with panel template selector (CBC template pre-fills common markers) + +**Components:** +- `src/components/labs/LabResultCard.tsx` — panel summary with flagged values highlighted +- `src/components/labs/LabResultForm.tsx` — panel selector + marker rows (marker/value/unit/range) +- `src/components/labs/LabTrendChart.tsx` — SVG chart with ref range shading +- `src/components/labs/MarkerRow.tsx` — single marker with flag coloring + +**Key UX:** +- Pre-built panel templates: CBC (WBC, RBC, Hemoglobin, Hematocrit, Platelets), CMP, Liver, Tumor Markers +- Flag colors: green=normal, yellow=borderline, red=out of range, dark red=critical +- "Share with doctor" → links to print page + +### Feature 7: Medical Documents — `/documents` + +**Page structure:** +- Header: "Documents" with Plus icon (upload) +- Category filter: All / Lab Reports / Scans / Insurance / Prescriptions +- Document grid: 2 columns, thumbnail (icon by type), title, date, category badge +- Tap → full-screen viewer (PDF in iframe, images native) +- Upload: file picker, category select, title, date, notes + +**Components:** +- `src/components/documents/DocumentCard.tsx` — thumbnail, title, category badge +- `src/components/documents/DocumentUpload.tsx` — file picker modal +- `src/components/documents/DocumentViewer.tsx` — full-screen view + +**Key UX:** +- Max file size: 10MB +- Accepted types: PDF, JPG, PNG +- Expiry badge on insurance cards approaching expiry +- No offline sync for documents (too large) — show "Requires internet" badge + +### Feature 8: Drug Interaction Checker — `/meds` (integrated) + +**Not a separate page.** Integrated into the medications section: +- "Check Interactions" button on meds list page +- Results shown as a modal/sheet with severity-colored cards +- Warning banner on individual medication detail pages if interactions exist + +**Implementation approach (simplified, no external API for v1):** +- Ship with a local lookup table of ~200 common chemo drug interactions (JSON file) +- `src/lib/interactions/checker.ts` — pure function that takes med names, returns known interactions +- `src/lib/interactions/data.ts` — curated interaction database +- Can upgrade to external API (OpenFDA/RxNorm) later + +**Components:** +- `src/components/medications/InteractionCheck.tsx` — button + results modal +- `src/components/medications/InteractionCard.tsx` — severity badge, description +- `src/components/medications/InteractionBanner.tsx` — warning on med detail + +--- + +## Implementation Order & Tasks + +### Batch 1: Schema & Infrastructure (do first) + +- [ ] **Prisma schema migration** `priority:1` `time:30min` + - files: `prisma/schema.prisma` + - Add all 8 models + User/Workspace/Medication relation updates + - Run `npx prisma migrate dev --name add-eight-features` + - Verify migration succeeds + +- [ ] **Zod validation schemas** `priority:1` `time:20min` + - files: `src/lib/validation/schemas.ts` + - Add all 8 schema definitions and type exports + +- [ ] **Dexie DB version 3** `priority:1` `time:20min` + - files: `src/lib/sync/db.ts` + - Add interfaces and version 3 stores + +- [ ] **Sync ops expansion** `priority:1` `time:15min` + - files: `src/lib/sync/manager.ts`, `src/lib/validation/schemas.ts` + - Add new entity types and op types to sync schema + +### Batch 2: Low Complexity Features (build fast) + +- [ ] **Feature 1: Temperature Log** `priority:2` `deps:Batch 1` `time:3hr` + - API: `src/app/api/workspaces/[id]/temperature/route.ts` + - API: `src/app/api/workspaces/[id]/temperature/[tempId]/route.ts` + - Components: `src/components/temperature/TempQuickLog.tsx` + - Components: `src/components/temperature/TempCard.tsx` + - Components: `src/components/temperature/TempChart.tsx` + - Components: `src/components/temperature/FeverAlert.tsx` + - Page: `src/app/(app)/temperature/page.tsx` + - Page: `src/app/(app)/temperature/history/page.tsx` + +- [ ] **Feature 2: Contact Directory** `priority:2` `deps:Batch 1` `time:3hr` + - API: `src/app/api/workspaces/[id]/contacts/route.ts` + - API: `src/app/api/workspaces/[id]/contacts/[contactId]/route.ts` + - Components: `src/components/contacts/ContactCard.tsx` + - Components: `src/components/contacts/ContactForm.tsx` + - Components: `src/components/contacts/CategoryTabs.tsx` + - Page: `src/app/(app)/contacts/page.tsx` + +- [ ] **Feature 3: Weight Log** `priority:2` `deps:Batch 1` `time:2.5hr` + - API: `src/app/api/workspaces/[id]/weight/route.ts` + - API: `src/app/api/workspaces/[id]/weight/[weightId]/route.ts` + - Components: `src/components/weight/WeightQuickLog.tsx` + - Components: `src/components/weight/WeightCard.tsx` + - Components: `src/components/weight/WeightChart.tsx` + - Components: `src/components/weight/WeightAlert.tsx` + - Page: `src/app/(app)/weight/page.tsx` + - Page: `src/app/(app)/weight/history/page.tsx` + +### Batch 3: Medium Complexity Features + +- [ ] **Feature 4: Treatment Timeline** `priority:3` `deps:Batch 1` `time:4hr` + - API: `src/app/api/workspaces/[id]/milestones/route.ts` + - API: `src/app/api/workspaces/[id]/milestones/[milestoneId]/route.ts` + - Components: `src/components/timeline/TimelineView.tsx` + - Components: `src/components/timeline/MilestoneCard.tsx` + - Components: `src/components/timeline/ProgressBar.tsx` + - Components: `src/components/timeline/MilestoneForm.tsx` + - Page: `src/app/(app)/timeline/page.tsx` + +- [ ] **Feature 5: Caregiver Tasks** `priority:3` `deps:Batch 1` `time:4hr` + - API: `src/app/api/workspaces/[id]/tasks/route.ts` + - API: `src/app/api/workspaces/[id]/tasks/[taskId]/route.ts` + - API: `src/app/api/workspaces/[id]/tasks/[taskId]/complete/route.ts` + - Components: `src/components/tasks/TaskCard.tsx` + - Components: `src/components/tasks/TaskForm.tsx` + - Components: `src/components/tasks/TaskFilters.tsx` + - Page: `src/app/(app)/tasks/page.tsx` + +### Batch 4: High Complexity Features + +- [ ] **Feature 6: Lab Results** `priority:4` `deps:Batch 1` `time:5hr` + - Lib: `src/lib/labs/panels.ts` (CBC, CMP, Liver panel templates) + - API: `src/app/api/workspaces/[id]/lab-results/route.ts` + - API: `src/app/api/workspaces/[id]/lab-results/trends/route.ts` + - API: `src/app/api/workspaces/[id]/lab-results/[labId]/route.ts` + - Components: `src/components/labs/LabResultCard.tsx` + - Components: `src/components/labs/LabResultForm.tsx` + - Components: `src/components/labs/LabTrendChart.tsx` + - Components: `src/components/labs/MarkerRow.tsx` + - Page: `src/app/(app)/lab-results/page.tsx` + +- [ ] **Feature 7: Medical Documents** `priority:4` `deps:Batch 1` `time:4hr` + - API: `src/app/api/workspaces/[id]/documents/route.ts` (multipart upload) + - API: `src/app/api/workspaces/[id]/documents/[docId]/route.ts` (download + delete) + - Components: `src/components/documents/DocumentCard.tsx` + - Components: `src/components/documents/DocumentUpload.tsx` + - Components: `src/components/documents/DocumentViewer.tsx` + - Page: `src/app/(app)/documents/page.tsx` + +- [ ] **Feature 8: Drug Interactions** `priority:4` `deps:Batch 1` `time:3hr` + - Lib: `src/lib/interactions/data.ts` (curated interaction database) + - Lib: `src/lib/interactions/checker.ts` (lookup logic) + - API: `src/app/api/workspaces/[id]/medications/check-interactions/route.ts` + - Components: `src/components/medications/InteractionCheck.tsx` + - Components: `src/components/medications/InteractionCard.tsx` + - Components: `src/components/medications/InteractionBanner.tsx` + +### Batch 5: Integration & Polish + +- [ ] **Update Settings/More page** `priority:5` `time:1hr` + - files: `src/app/(app)/settings/page.tsx` + - Add navigation links to all new features grouped by section + +- [ ] **Update Today dashboard** `priority:5` `time:2hr` + - files: `src/app/(app)/today/page.tsx` + - Add cards: latest temp, pending tasks, next milestone, weight trend + - Fever alert banner at top if applicable + +- [ ] **Update EmptyState component** `priority:5` `time:15min` + - files: `src/components/ui/states.tsx` + - Add new icon types: temperature, contacts, weight, timeline, tasks, labs, documents + +- [ ] **Tests** `priority:5` `time:2hr` + - Temperature threshold logic + - Weight change alert calculations + - Lab result flag detection + - Drug interaction checker + - All Zod schemas validation + +--- + +## Total Estimated Time + +| Batch | Time | +|-------|------| +| Batch 1: Schema & Infrastructure | ~1.5hr | +| Batch 2: Temperature + Contacts + Weight | ~8.5hr | +| Batch 3: Timeline + Tasks | ~8hr | +| Batch 4: Labs + Documents + Interactions | ~12hr | +| Batch 5: Integration & Polish | ~5hr | +| **Total** | **~35hr** | + +With parallel work on independent features, achievable in 4-5 focused days. diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1db2b63..e6b8141 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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]) +} diff --git a/src/app/(app)/contacts/page.tsx b/src/app/(app)/contacts/page.tsx new file mode 100644 index 0000000..238b679 --- /dev/null +++ b/src/app/(app)/contacts/page.tsx @@ -0,0 +1,138 @@ +'use client' + +import { useState, useEffect, useCallback } from 'react' +import { Plus, Users } from 'lucide-react' +import { useLiveQuery } from 'dexie-react-hooks' + +import { db } from '@/lib/sync' +import { Card, LoadingState } from '@/components/ui' +import { Header, PageContainer } from '@/components/layout/header' +import { ContactCard } from '@/components/contacts/ContactCard' +import { ContactForm } from '@/components/contacts/ContactForm' +import { CategoryTabs } from '@/components/contacts/CategoryTabs' +import { useApp } from '../provider' + +export default function ContactsPage() { + const { currentWorkspace, refreshData } = useApp() + const [serverContacts, setServerContacts] = useState([]) + const [loading, setLoading] = useState(true) + const [showForm, setShowForm] = useState(false) + const [editContact, setEditContact] = useState(null) + const [category, setCategory] = useState('') + + const localContacts = useLiveQuery( + () => + db.contacts + .where('workspaceId') + .equals(currentWorkspace.id) + .and((c) => !c.deletedAt) + .toArray(), + [currentWorkspace.id] + ) + + const fetchContacts = useCallback(async () => { + try { + const url = `/api/workspaces/${currentWorkspace.id}/contacts${category ? `?category=${category}` : ''}` + const response = await fetch(url) + if (response.ok) { + const data = await response.json() + setServerContacts(data.contacts) + } + } catch (err) { + console.error('Failed to fetch contacts:', err) + } finally { + setLoading(false) + } + }, [currentWorkspace.id, category]) + + useEffect(() => { + fetchContacts() + }, [fetchContacts]) + + const handleSaved = () => { + fetchContacts() + refreshData() + setEditContact(null) + } + + const contacts = serverContacts.length > 0 ? serverContacts : localContacts || [] + const filteredContacts = category + ? contacts.filter((c: any) => c.category === category) + : contacts + + // Separate emergency contacts + const emergencyContacts = filteredContacts.filter((c: any) => c.isEmergency) + const regularContacts = filteredContacts.filter((c: any) => !c.isEmergency) + + if (loading && !localContacts) { + return ( + <> +
+ + + ) + } + + return ( + <> +
, + label: 'Add', + onClick: () => setShowForm(true), + }} + /> + + {/* Category Filter */} + + + {/* Emergency Contacts */} + {emergencyContacts.length > 0 && ( +
+

Emergency Contacts

+
+ {emergencyContacts.map((contact: any) => ( + { setEditContact(contact); setShowForm(true) }} + /> + ))} +
+
+ )} + + {/* All Contacts */} +
+ {regularContacts.length === 0 && emergencyContacts.length === 0 ? ( + + +

No contacts yet

+

Add your care team members and important contacts

+
+ ) : ( +
+ {regularContacts.map((contact: any) => ( + { setEditContact(contact); setShowForm(true) }} + /> + ))} +
+ )} +
+
+ + {/* Contact Form Modal */} + { setShowForm(false); setEditContact(null) }} + onSaved={handleSaved} + workspaceId={currentWorkspace.id} + initialData={editContact || undefined} + /> + + ) +} diff --git a/src/app/(app)/documents/page.tsx b/src/app/(app)/documents/page.tsx new file mode 100644 index 0000000..a561d90 --- /dev/null +++ b/src/app/(app)/documents/page.tsx @@ -0,0 +1,171 @@ +'use client' + +import { useState, useEffect, useCallback } from 'react' +import { Plus, FolderOpen } from 'lucide-react' + +import { Card, LoadingState, ConfirmModal, showToast } from '@/components/ui' +import { Header, PageContainer } from '@/components/layout/header' +import { DocumentCard } from '@/components/documents/DocumentCard' +import { DocumentUpload } from '@/components/documents/DocumentUpload' +import { DocumentViewer } from '@/components/documents/DocumentViewer' +import { useApp } from '../provider' + +const CATEGORY_FILTERS = [ + { value: '', label: 'All' }, + { value: 'LAB_REPORT', label: 'Lab Reports' }, + { value: 'SCAN', label: 'Scans' }, + { value: 'INSURANCE', label: 'Insurance' }, + { value: 'PRESCRIPTION', label: 'Prescriptions' }, + { value: 'OTHER', label: 'Other' }, +] + +export default function DocumentsPage() { + const { currentWorkspace, refreshData } = useApp() + const [documents, setDocuments] = useState([]) + const [loading, setLoading] = useState(true) + const [showUpload, setShowUpload] = useState(false) + const [viewDoc, setViewDoc] = useState(null) + const [category, setCategory] = useState('') + const [deleteId, setDeleteId] = useState(null) + const [deleting, setDeleting] = useState(false) + + const fetchDocuments = useCallback(async () => { + try { + const url = `/api/workspaces/${currentWorkspace.id}/documents${category ? `?category=${category}` : ''}` + const response = await fetch(url) + if (response.ok) { + const data = await response.json() + setDocuments(data.documents) + } + } catch (err) { + console.error('Failed to fetch documents:', err) + } finally { + setLoading(false) + } + }, [currentWorkspace.id, category]) + + useEffect(() => { + fetchDocuments() + }, [fetchDocuments]) + + const handleSaved = () => { + fetchDocuments() + refreshData() + } + + const handleDelete = async () => { + if (!deleteId) return + setDeleting(true) + try { + const response = await fetch( + `/api/workspaces/${currentWorkspace.id}/documents/${deleteId}`, + { method: 'DELETE' } + ) + if (!response.ok) throw new Error('Failed to delete') + showToast('Document deleted', 'success') + setViewDoc(null) + setDeleteId(null) + fetchDocuments() + refreshData() + } catch { + showToast('Failed to delete document', 'error') + } finally { + setDeleting(false) + } + } + + if (loading) { + return ( + <> +
+ + + ) + } + + return ( + <> +
, + label: 'Upload', + onClick: () => setShowUpload(true), + }} + /> + + {/* Requires internet notice */} +
+ Documents require an internet connection and are not available offline. +
+ + {/* Category filters */} +
+ {CATEGORY_FILTERS.map((f) => ( + + ))} +
+ + {/* Document list */} + {documents.length === 0 ? ( + + +

No documents yet

+

+ Upload lab reports, scans, insurance cards, and more +

+
+ ) : ( +
+ {documents.map((doc: any) => ( + + ))} +
+ )} +
+ + {/* Upload Modal */} + setShowUpload(false)} + onSaved={handleSaved} + workspaceId={currentWorkspace.id} + /> + + {/* Document Viewer */} + setViewDoc(null)} + onDelete={() => { setDeleteId(viewDoc?.id); }} + document={viewDoc} + workspaceId={currentWorkspace.id} + /> + + {/* Delete Confirmation */} + setDeleteId(null)} + onConfirm={handleDelete} + title="Delete Document" + message="Are you sure you want to delete this document? This cannot be undone." + confirmText="Delete" + variant="danger" + loading={deleting} + /> + + ) +} diff --git a/src/app/(app)/lab-results/page.tsx b/src/app/(app)/lab-results/page.tsx new file mode 100644 index 0000000..d8eef94 --- /dev/null +++ b/src/app/(app)/lab-results/page.tsx @@ -0,0 +1,200 @@ +'use client' + +import { useState, useEffect, useCallback } from 'react' +import { Plus, TestTubes } from 'lucide-react' +import { useLiveQuery } from 'dexie-react-hooks' + +import { db } from '@/lib/sync' +import { Card, LoadingState, ConfirmModal, showToast, Select } from '@/components/ui' +import { Header, PageContainer } from '@/components/layout/header' +import { LabResultCard } from '@/components/labs/LabResultCard' +import { LabResultForm } from '@/components/labs/LabResultForm' +import { LabTrendChart } from '@/components/labs/LabTrendChart' +import { useApp } from '../provider' + +const COMMON_MARKERS = [ + 'WBC', 'RBC', 'Hemoglobin', 'Hematocrit', 'Platelets', + 'Neutrophils', 'Glucose', 'Creatinine', 'AST', 'ALT', + 'CEA', 'CA 19-9', +] + +export default function LabResultsPage() { + const { currentWorkspace, refreshData } = useApp() + const [serverData, setServerData] = useState([]) + const [loading, setLoading] = useState(true) + const [showForm, setShowForm] = useState(false) + const [editResult, setEditResult] = useState(null) + const [tab, setTab] = useState<'recent' | 'trends'>('recent') + const [trendMarker, setTrendMarker] = useState(COMMON_MARKERS[0]) + const [deleteId, setDeleteId] = useState(null) + const [deleting, setDeleting] = useState(false) + + const localData = useLiveQuery( + () => + db.labResults + .where('workspaceId') + .equals(currentWorkspace.id) + .and((r) => !r.deletedAt) + .reverse() + .toArray(), + [currentWorkspace.id] + ) + + const fetchData = useCallback(async () => { + try { + const response = await fetch(`/api/workspaces/${currentWorkspace.id}/lab-results?limit=50`) + if (response.ok) { + const data = await response.json() + setServerData(data.labResults) + } + } catch (err) { + console.error('Failed to fetch lab results:', err) + } finally { + setLoading(false) + } + }, [currentWorkspace.id]) + + useEffect(() => { + fetchData() + }, [fetchData]) + + const handleSaved = () => { + fetchData() + refreshData() + setEditResult(null) + } + + const handleEdit = (result: any) => { + setEditResult(result) + setShowForm(true) + } + + const handleDelete = async () => { + if (!deleteId) return + setDeleting(true) + try { + const response = await fetch( + `/api/workspaces/${currentWorkspace.id}/lab-results/${deleteId}`, + { method: 'DELETE' } + ) + if (!response.ok) throw new Error('Failed to delete') + showToast('Lab result deleted', 'success') + fetchData() + refreshData() + setDeleteId(null) + } catch { + showToast('Failed to delete lab result', 'error') + } finally { + setDeleting(false) + } + } + + const results = serverData.length > 0 ? serverData : localData || [] + + // Collect unique markers from results for the trend selector + const allMarkers = new Set() + results.forEach((r: any) => { + (r.results || []).forEach((m: any) => allMarkers.add(m.marker)) + }) + const markerOptions = Array.from(allMarkers).map((m) => ({ value: m, label: m })) + if (markerOptions.length === 0) { + COMMON_MARKERS.forEach((m) => markerOptions.push({ value: m, label: m })) + } + + if (loading && !localData) { + return ( + <> +
+ + + ) + } + + return ( + <> +
, + label: 'Add', + onClick: () => { setEditResult(null); setShowForm(true) }, + }} + /> + + {/* Tabs */} +
+ {(['recent', 'trends'] as const).map((t) => ( + + ))} +
+ + {tab === 'recent' ? ( + /* Recent results */ +
+ {results.length === 0 ? ( + + +

No lab results yet

+

+ Tap + to record your blood work results +

+
+ ) : ( +
+ {results.map((result: any) => ( + + ))} +
+ )} +
+ ) : ( + /* Trends view */ +
+ update('name', e.target.value)} placeholder="Dr. Smith" /> + update('role', e.target.value)} placeholder="Oncologist" /> + update('phone', e.target.value)} placeholder="+61 2 1234 5678" type="tel" /> + update('phone2', e.target.value)} placeholder="Optional" type="tel" /> + update('email', e.target.value)} placeholder="Optional" type="email" /> + update('address', e.target.value)} placeholder="Optional" /> + update('hours', e.target.value)} placeholder="Mon-Fri 8am-5pm" /> +
+ +