15 Commits

Author SHA1 Message Date
Tony0410
1bb88288f4 fix: login loop and repeated medication notifications
- Fix login loop: secure cookie detection now uses x-forwarded-proto/origin
  headers to correctly identify HTTPS requests through Tailscale Funnel
- Add credentials: include to login/register fetch calls
- Verify session after login/registration before redirecting to prevent race conditions
- Fix repeated medication reminders: isDue() now matches exact minute instead of
  5-minute tolerance window, preventing duplicate notifications when sender runs
  every minute
- Add tests for cookie security and notification scheduling
- Extract isDue() to separate module for better testability
2026-03-15 12:17:42 +00:00
Tony0410
f0f674945c 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.
2026-03-02 11:17:38 +00:00
Gemini Agent
065250c1cf Redesign: Warm Sanctuary aesthetic for core pages
- Implement cohesive 'Warm Sanctuary' design system
- Add Playfair Display + Source Sans 3 typography
- Create paper texture background and warm color palette
- Redesign Today Dashboard with elegant cards and animations
- Redesign Medication Form with step-by-step visual flow
- Redesign Emergency Card with clear visual hierarchy
- Redesign Onboarding with floating blobs and welcoming feel
- Update Tailwind config with new colors, shadows, and animations
2026-03-01 07:06:58 +00:00
Gemini Agent
a5181cf6fe Add timezone support and auto-sync push subscriptions
- Install tzdata in Docker for proper Australia/Perth timezone handling
- Update VAPID email to standard placeholder
- Auto-sync browser push subscriptions to server on page load

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 14:29:42 +00:00
Gemini Agent
cae436a20d Add scheduler service for push notifications 2026-01-25 02:25:48 +00:00
Gemini Agent
8c9ae06360 Implement Edit Medication feature, refactor medication form, and fix build issues 2026-01-25 02:20:16 +00:00
Gemini Agent
7fa95c058e Fix medication scheduling bugs and add delete dose feature 2026-01-25 02:13:51 +00:00
Gemini Agent
f598f6138e Add test notification feature for push notification debugging
- Add POST /api/notifications/test endpoint to send test notifications
- Add "Send Test Notification" button to notifications settings page
- Shows success/failure feedback and removes expired subscriptions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 22:44:05 +00:00
Gemini Agent
66cb1ea095 Add admin panel with member management and password reset
Features:
- Admin panel at /settings/members for workspace owners
- View all workspace members with roles and last login
- Create new users directly (with temporary password)
- Change member roles (Owner/Editor/Viewer)
- Reset user passwords (forces change on next login)
- Remove members from workspace
- Force password reset flow on login
- Track last login timestamp for users

API Routes:
- GET/POST /api/workspaces/[id]/members
- GET/PATCH/DELETE /api/workspaces/[id]/members/[memberId]
- POST /api/workspaces/[id]/members/[memberId]/reset-password
- POST /api/auth/change-password

Schema changes:
- Added lastLoginAt DateTime? to User model
- Added forcePasswordReset Boolean to User model

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 01:22:10 +00:00
Gemini Agent
f9a7b68a99 Fix service worker install failure due to missing icon files
The service worker was failing to install because it tried to cache
icon files that don't exist (icon-192.png, icon-512.png).

Simplified the service worker to focus only on push notifications:
- Removed caching during install (was causing "redundant" state)
- Removed fetch handler caching
- Removed references to non-existent icon files

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 21:26:40 +00:00
Gemini Agent
4753216b56 Fix service worker registration for push notifications
Instead of waiting on navigator.serviceWorker.ready (which may never
resolve if registration hasn't completed), explicitly register the
service worker and wait for it to activate.

This fixes the "service worker not ready" error on iOS Safari PWAs.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 21:00:39 +00:00
Gemini Agent
54900b65c8 Add timeouts and better error handling for push notifications
- Add 10s timeout for service worker ready state
- Add 15s timeout for push subscription
- Check for PushManager support early (shows unsupported on incompatible devices)
- Provide specific error messages for different failure modes

This prevents the enable button from spinning forever on iOS devices
where push subscription may hang silently.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 20:54:40 +00:00
Gemini Agent
c9f3402e48 Configure VAPID keys for push notifications
Added VAPID key configuration to enable PWA push notifications:
- Generated VAPID public/private key pair
- Added build arg for NEXT_PUBLIC_VAPID_PUBLIC_KEY (needed at Next.js build time)
- Added runtime env vars for VAPID keys in docker-compose.yml

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 20:45:38 +00:00
Gemini Agent
e5db48f82b Fix iCal endpoint crash from non-ASCII characters in workspace name
The Content-Disposition header was including the workspace name directly,
causing a "Cannot convert argument to ByteString" error when workspace
names contained smart apostrophes or other non-ASCII characters (e.g.,
"Grace's Plan" with curly apostrophe U+2019).

Sanitize filename by removing non-ASCII characters before using in header.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 20:34:00 +00:00
Gemini Agent
3034d52884 Fix appointment edit page and iCal timezone issues
- Create appointment edit page at /appointments/[id]/edit
- Fix iCal calendar timezone handling:
  - Add VTIMEZONE block for Australia/Perth
  - Use TZID parameter for DTSTART/DTEND
  - Properly format local times without Z suffix
- Appointments now appear correctly in Google Calendar

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 20:22:23 +00:00
108 changed files with 12321 additions and 1191 deletions

0
! Normal file
View File

View File

@@ -23,12 +23,18 @@ RUN npx prisma generate
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
# Build args for NEXT_PUBLIC_* variables (needed at build time)
ARG NEXT_PUBLIC_VAPID_PUBLIC_KEY
ENV NEXT_PUBLIC_VAPID_PUBLIC_KEY=${NEXT_PUBLIC_VAPID_PUBLIC_KEY}
RUN npm run build
# Stage 3: Runner (using slim Debian for better OpenSSL compatibility)
FROM node:20-slim AS runner
WORKDIR /app
RUN apt-get update && apt-get install -y tzdata && rm -rf /var/lib/apt/lists/*
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV TZ=Australia/Perth
@@ -37,7 +43,7 @@ RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Install OpenSSL and CA certificates for Prisma
RUN apt-get update && apt-get install -y openssl ca-certificates && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y openssl ca-certificates wget && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/public ./public
COPY --from=builder /app/prisma ./prisma

View File

@@ -3,6 +3,8 @@ services:
build:
context: .
dockerfile: Dockerfile
args:
- NEXT_PUBLIC_VAPID_PUBLIC_KEY=BEFs_VtoxY7SpNnd-ubz1ioliESlRI4sY6ny7Qp3rm7V1cm0gqyZX8TAHp4AaQ81yKC4LfWtJFQz_aHc25G-Tww
container_name: nextstep-app
restart: unless-stopped
ports:
@@ -13,6 +15,10 @@ services:
- NEXT_PUBLIC_APP_URL=https://debianvm.kangaroo-eel.ts.net:10000
- TZ=Australia/Perth
- NODE_ENV=production
# Push notification VAPID keys
- NEXT_PUBLIC_VAPID_PUBLIC_KEY=BEFs_VtoxY7SpNnd-ubz1ioliESlRI4sY6ny7Qp3rm7V1cm0gqyZX8TAHp4AaQ81yKC4LfWtJFQz_aHc25G-Tww
- VAPID_PRIVATE_KEY=KgVQVO7XhfCklrJ3o9wowzK90AxI6Exg9pXPq76Qx4A
- VAPID_EMAIL=mailto:admin@example.com
depends_on:
db:
condition: service_healthy
@@ -25,6 +31,24 @@ services:
retries: 3
start_period: 40s
scheduler:
image: alpine
restart: unless-stopped
depends_on:
app:
condition: service_healthy
entrypoint: /bin/sh
command: >
-c "apk add --no-cache curl &&
while true; do
echo 'Triggering notification check...' &&
curl -s -X POST http://app:3000/api/notifications/send &&
echo '' &&
sleep 60;
done"
networks:
- nextstep-network
db:
image: postgres:16-alpine
container_name: nextstep-db

View File

@@ -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)/<feature>/page.tsx``'use client'`, uses `useApp()` for workspace, `Header` + `PageContainer` layout
- **API routes:** `src/app/api/workspaces/[id]/<feature>/route.ts` — uses `withAuth`, `checkWorkspaceAccess`, `canEdit`, Zod validation, audit log on writes
- **Components:** `src/components/<feature>/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: `<Card>` 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: `<Header title="X" />` then `<PageContainer className="pt-4 space-y-6">`
### 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<typeof temperatureLogSchema>
export type ContactInput = z.infer<typeof contactSchema>
export type WeightLogInput = z.infer<typeof weightLogSchema>
export type MilestoneInput = z.infer<typeof milestoneSchema>
export type CaregiverTaskInput = z.infer<typeof caregiverTaskSchema>
export type LabMarker = z.infer<typeof labMarkerSchema>
export type LabResultInput = z.infer<typeof labResultSchema>
export type MedicalDocumentInput = z.infer<typeof medicalDocumentSchema>
export type InteractionCheckInput = z.infer<typeof interactionCheckSchema>
```
---
## 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.

1032
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -50,7 +50,7 @@
"tailwindcss": "^3.4.17",
"tsx": "^4.19.2",
"typescript": "^5.7.3",
"vitest": "^4.0.18"
"vitest": "^2.1.8"
},
"engines": {
"node": ">=18.0.0"

View File

@@ -12,12 +12,14 @@ datasource db {
// ============================================
model User {
id String @id @default(cuid())
email String @unique
passwordHash String
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
email String @unique
passwordHash String
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lastLoginAt DateTime?
forcePasswordReset Boolean @default(false)
// Relations
sessions Session[]
@@ -34,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])
}
@@ -102,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])
}
@@ -219,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])
@@ -408,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])
}

View File

@@ -1,66 +1,16 @@
// NextStep Service Worker for Push Notifications
const CACHE_NAME = 'nextstep-v1'
// Install event - cache critical assets
// Install event - activate immediately
self.addEventListener('install', (event) => {
console.log('Service Worker: Installing...')
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll([
'/',
'/today',
'/meds',
'/icon-192.png',
'/icon-512.png',
])
})
)
// Skip waiting to activate immediately
self.skipWaiting()
})
// Activate event - clean up old caches
// Activate event - claim clients immediately
self.addEventListener('activate', (event) => {
console.log('Service Worker: Activating...')
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => caches.delete(name))
)
})
)
self.clients.claim()
})
// Fetch event - serve from cache when offline
self.addEventListener('fetch', (event) => {
// Only cache GET requests
if (event.request.method !== 'GET') return
event.respondWith(
caches.match(event.request).then((cached) => {
// Return cached version or fetch from network
return (
cached ||
fetch(event.request).then((response) => {
// Don't cache API responses
if (event.request.url.includes('/api/')) {
return response
}
// Cache successful responses
if (response.status === 200) {
const clone = response.clone()
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, clone)
})
}
return response
})
)
})
)
event.waitUntil(self.clients.claim())
})
// Push event - handle incoming push notifications
@@ -70,8 +20,6 @@ self.addEventListener('push', (event) => {
let data = {
title: 'Medication Reminder',
body: 'Time to take your medication',
icon: '/icon-192.png',
badge: '/badge-72.png',
tag: 'medication-reminder',
data: {
url: '/meds',
@@ -88,15 +36,9 @@ self.addEventListener('push', (event) => {
const options = {
body: data.body,
icon: data.icon || '/icon-192.png',
badge: data.badge || '/badge-72.png',
tag: data.tag || 'default',
vibrate: [100, 50, 100],
data: data.data || {},
actions: data.actions || [
{ action: 'take', title: 'Taken' },
{ action: 'snooze', title: 'Snooze' },
],
requireInteraction: true,
}

View File

@@ -0,0 +1,215 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter, useParams } from 'next/navigation'
import { format, parseISO } from 'date-fns'
import { toZonedTime } from 'date-fns-tz'
import { Button, Input, Textarea, Card, LoadingState, showToast } from '@/components/ui'
import { Header, PageContainer } from '@/components/layout/header'
import { useApp } from '../../../provider'
const TIMEZONE = 'Australia/Perth'
interface Appointment {
id: string
title: string
datetime: string
location: string | null
mapUrl: string | null
notes: string | null
}
export default function EditAppointmentPage() {
const router = useRouter()
const params = useParams()
const appointmentId = params.id as string
const { currentWorkspace, refreshData } = useApp()
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [title, setTitle] = useState('')
const [date, setDate] = useState('')
const [time, setTime] = useState('')
const [location, setLocation] = useState('')
const [mapUrl, setMapUrl] = useState('')
const [notes, setNotes] = useState('')
useEffect(() => {
async function fetchAppointment() {
try {
const response = await fetch(
`/api/workspaces/${currentWorkspace.id}/appointments/${appointmentId}`
)
if (response.ok) {
const data = await response.json()
const appt: Appointment = data.appointment
// Parse datetime and convert to local timezone
const apptDate = toZonedTime(parseISO(appt.datetime), TIMEZONE)
setTitle(appt.title)
setDate(format(apptDate, 'yyyy-MM-dd'))
setTime(format(apptDate, 'HH:mm'))
setLocation(appt.location || '')
setMapUrl(appt.mapUrl || '')
setNotes(appt.notes || '')
} else {
setError('Appointment not found')
}
} catch (err) {
console.error('Failed to fetch appointment:', err)
setError('Failed to load appointment')
} finally {
setLoading(false)
}
}
fetchAppointment()
}, [currentWorkspace.id, appointmentId])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setSaving(true)
try {
// Combine date and time
const datetime = new Date(`${date}T${time}:00`)
const response = await fetch(
`/api/workspaces/${currentWorkspace.id}/appointments/${appointmentId}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title,
datetime: datetime.toISOString(),
location: location || null,
mapUrl: mapUrl || null,
notes: notes || null,
}),
}
)
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || 'Failed to update appointment')
}
await refreshData()
showToast('Appointment updated', 'success')
router.push(`/appointments/${appointmentId}`)
} catch (err) {
setError(err instanceof Error ? err.message : 'Something went wrong')
} finally {
setSaving(false)
}
}
if (loading) {
return (
<>
<Header title="Edit Appointment" showBack />
<PageContainer>
<LoadingState message="Loading appointment..." />
</PageContainer>
</>
)
}
if (error && !title) {
return (
<>
<Header title="Edit Appointment" showBack />
<PageContainer className="pt-4">
<Card className="text-center py-8">
<p className="text-secondary-500">{error}</p>
</Card>
</PageContainer>
</>
)
}
return (
<>
<Header title="Edit Appointment" showBack backHref={`/appointments/${appointmentId}`} />
<PageContainer className="pt-4">
<Card>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label="Title"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g., Oncology Appointment"
required
/>
<div className="grid grid-cols-2 gap-3">
<Input
label="Date"
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
required
/>
<Input
label="Time"
type="time"
value={time}
onChange={(e) => setTime(e.target.value)}
required
/>
</div>
<Input
label="Location"
type="text"
value={location}
onChange={(e) => setLocation(e.target.value)}
placeholder="e.g., Level 3, Cancer Centre"
/>
<Input
label="Map Link (optional)"
type="url"
value={mapUrl}
onChange={(e) => setMapUrl(e.target.value)}
placeholder="https://maps.google.com/..."
helperText="Paste a Google Maps or Apple Maps link"
/>
<Textarea
label="Notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Any notes for this appointment..."
rows={3}
/>
{error && (
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-button">
{error}
</p>
)}
<div className="flex gap-3 pt-2">
<Button
type="button"
variant="secondary"
fullWidth
onClick={() => router.back()}
>
Cancel
</Button>
<Button type="submit" fullWidth loading={saving}>
Save Changes
</Button>
</div>
</form>
</Card>
</PageContainer>
</>
)
}

View File

@@ -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<any[]>([])
const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
const [editContact, setEditContact] = useState<any>(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 (
<>
<Header title="Care Team" />
<PageContainer><LoadingState message="Loading contacts..." /></PageContainer>
</>
)
}
return (
<>
<Header
title="Care Team"
rightAction={{
icon: <Plus className="w-6 h-6 text-secondary-700" />,
label: 'Add',
onClick: () => setShowForm(true),
}}
/>
<PageContainer className="pt-4 space-y-6">
{/* Category Filter */}
<CategoryTabs selected={category} onChange={setCategory} />
{/* Emergency Contacts */}
{emergencyContacts.length > 0 && (
<section>
<h2 className="text-sm font-semibold text-red-600 uppercase tracking-wide mb-3">Emergency Contacts</h2>
<div className="space-y-3">
{emergencyContacts.map((contact: any) => (
<ContactCard
key={contact.id}
contact={contact}
onEdit={() => { setEditContact(contact); setShowForm(true) }}
/>
))}
</div>
</section>
)}
{/* All Contacts */}
<section>
{regularContacts.length === 0 && emergencyContacts.length === 0 ? (
<Card variant="outline" className="text-center py-8">
<Users className="w-10 h-10 text-secondary-300 mx-auto mb-3" />
<p className="text-secondary-500">No contacts yet</p>
<p className="text-sm text-secondary-400 mt-1">Add your care team members and important contacts</p>
</Card>
) : (
<div className="space-y-3">
{regularContacts.map((contact: any) => (
<ContactCard
key={contact.id}
contact={contact}
onEdit={() => { setEditContact(contact); setShowForm(true) }}
/>
))}
</div>
)}
</section>
</PageContainer>
{/* Contact Form Modal */}
<ContactForm
open={showForm}
onClose={() => { setShowForm(false); setEditContact(null) }}
onSaved={handleSaved}
workspaceId={currentWorkspace.id}
initialData={editContact || undefined}
/>
</>
)
}

View File

@@ -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<any[]>([])
const [loading, setLoading] = useState(true)
const [showUpload, setShowUpload] = useState(false)
const [viewDoc, setViewDoc] = useState<any>(null)
const [category, setCategory] = useState('')
const [deleteId, setDeleteId] = useState<string | null>(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 (
<>
<Header title="Documents" />
<PageContainer><LoadingState message="Loading documents..." /></PageContainer>
</>
)
}
return (
<>
<Header
title="Documents"
rightAction={{
icon: <Plus className="w-6 h-6 text-secondary-700" />,
label: 'Upload',
onClick: () => setShowUpload(true),
}}
/>
<PageContainer className="pt-4 space-y-6">
{/* Requires internet notice */}
<div className="bg-blue-50 border border-blue-200 rounded-lg px-3 py-2 text-xs text-blue-700">
Documents require an internet connection and are not available offline.
</div>
{/* Category filters */}
<div className="flex gap-2 overflow-x-auto pb-2 -mx-1 px-1 scrollbar-hide">
{CATEGORY_FILTERS.map((f) => (
<button
key={f.value}
onClick={() => setCategory(f.value)}
className={`flex-shrink-0 px-4 py-2 rounded-full text-sm font-medium transition-all min-h-touch ${
category === f.value
? 'bg-primary-500 text-white'
: 'bg-secondary-100 text-secondary-600'
}`}
>
{f.label}
</button>
))}
</div>
{/* Document list */}
{documents.length === 0 ? (
<Card variant="outline" className="text-center py-8">
<FolderOpen className="w-10 h-10 text-secondary-300 mx-auto mb-3" />
<p className="text-secondary-500">No documents yet</p>
<p className="text-sm text-secondary-400 mt-1">
Upload lab reports, scans, insurance cards, and more
</p>
</Card>
) : (
<div className="space-y-3">
{documents.map((doc: any) => (
<DocumentCard
key={doc.id}
document={doc}
onView={setViewDoc}
/>
))}
</div>
)}
</PageContainer>
{/* Upload Modal */}
<DocumentUpload
isOpen={showUpload}
onClose={() => setShowUpload(false)}
onSaved={handleSaved}
workspaceId={currentWorkspace.id}
/>
{/* Document Viewer */}
<DocumentViewer
isOpen={!!viewDoc}
onClose={() => setViewDoc(null)}
onDelete={() => { setDeleteId(viewDoc?.id); }}
document={viewDoc}
workspaceId={currentWorkspace.id}
/>
{/* Delete Confirmation */}
<ConfirmModal
isOpen={!!deleteId}
onClose={() => 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}
/>
</>
)
}

View File

@@ -1,18 +1,23 @@
'use client'
import { useEffect, useState } from 'react'
import { ArrowLeft, Edit2 } from 'lucide-react'
import { ArrowLeft, Edit2, Heart } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { useLiveQuery } from 'dexie-react-hooks'
import { db } from '@/lib/sync'
import { EmergencyCard } from '@/components/emergency/EmergencyCard'
import { Button, LoadingState } from '@/components/ui'
import { LoadingState } from '@/components/ui'
import { useApp } from '../provider'
export default function EmergencyPage() {
const router = useRouter()
const { currentWorkspace } = useApp()
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
// Fetch workspace from IndexedDB for offline access
const workspace = useLiveQuery(
@@ -32,7 +37,11 @@ export default function EmergencyPage() {
)
if (!workspace) {
return <LoadingState message="Loading emergency info..." />
return (
<div className="min-h-screen paper-texture flex items-center justify-center">
<LoadingState message="Loading emergency info..." />
</div>
)
}
const emergencyInfo = {
@@ -56,58 +65,74 @@ export default function EmergencyPage() {
})) || []
return (
<div className="min-h-screen bg-red-50">
<div className={`min-h-screen paper-texture transition-opacity duration-500 ${mounted ? 'opacity-100' : 'opacity-0'}`}>
{/* Header */}
<div className="bg-red-600 text-white safe-top">
<div className="flex items-center justify-between px-4 py-3">
<div className="bg-gradient-to-r from-alert-500 to-alert-600 text-white safe-area-top sticky top-0 z-10">
<div className="flex items-center justify-between px-6 py-4">
<button
onClick={() => router.back()}
className="flex items-center gap-2 text-white/90 hover:text-white"
className="flex items-center gap-2 text-white/90 hover:text-white transition-colors"
>
<ArrowLeft className="w-5 h-5" />
<span>Back</span>
<div className="w-10 h-10 rounded-full bg-white/10 flex items-center justify-center">
<ArrowLeft className="w-5 h-5" />
</div>
<span className="font-medium">Back</span>
</button>
{currentWorkspace.role !== 'VIEWER' && (
<button
onClick={() => router.push('/settings/emergency')}
className="flex items-center gap-2 text-white/90 hover:text-white"
className="flex items-center gap-2 text-white/90 hover:text-white transition-colors"
>
<Edit2 className="w-4 h-4" />
<span>Edit</span>
<span className="font-medium">Edit</span>
<div className="w-10 h-10 rounded-full bg-white/10 flex items-center justify-center">
<Edit2 className="w-5 h-5" />
</div>
</button>
)}
</div>
</div>
<div className="p-4">
<div className="p-6 pb-24">
{hasInfo ? (
<EmergencyCard info={emergencyInfo} medications={medsList} />
<div className="animate-fade-up">
<EmergencyCard info={emergencyInfo} medications={medsList} />
</div>
) : (
<div className="text-center py-12">
<div className="w-16 h-16 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-4">
<Edit2 className="w-8 h-8 text-red-400" />
<div className="section-warm text-center py-12 animate-fade-up">
<div className="w-20 h-20 rounded-full bg-alert-100 flex items-center justify-center mx-auto mb-6">
<Heart className="w-10 h-10 text-alert-400" />
</div>
<h2 className="text-lg font-semibold text-secondary-900 mb-2">
<h2 className="font-display text-2xl text-secondary-900 mb-3">
No Emergency Info Set
</h2>
<p className="text-secondary-600 mb-4">
Add important medical information for emergencies.
<p className="text-secondary-600 mb-8 max-w-sm mx-auto">
Add important medical information that could be crucial in an emergency situation.
</p>
{currentWorkspace.role !== 'VIEWER' && (
<Button onClick={() => router.push('/settings/emergency')}>
<button
onClick={() => router.push('/settings/emergency')}
className="btn-primary"
>
Add Emergency Info
</Button>
</button>
)}
</div>
)}
</div>
{/* Offline indicator */}
<div className="fixed bottom-4 left-4 right-4">
<div className="bg-green-100 border border-green-300 rounded-lg p-3 text-center">
<p className="text-sm text-green-800 font-medium">
This information is available offline
</p>
<div className="fixed bottom-6 left-6 right-6">
<div className="bg-primary-50 border border-primary-200 rounded-card p-4 text-center shadow-elevated">
<div className="flex items-center justify-center gap-2">
<div className="w-2 h-2 rounded-full bg-primary-500 animate-pulse" />
<p className="text-sm text-primary-700 font-medium">
This information is available offline
</p>
</div>
</div>
</div>
</div>

View File

@@ -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<any[]>([])
const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
const [editResult, setEditResult] = useState<any>(null)
const [tab, setTab] = useState<'recent' | 'trends'>('recent')
const [trendMarker, setTrendMarker] = useState(COMMON_MARKERS[0])
const [deleteId, setDeleteId] = useState<string | null>(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<string>()
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 (
<>
<Header title="Lab Results" />
<PageContainer><LoadingState message="Loading lab results..." /></PageContainer>
</>
)
}
return (
<>
<Header
title="Lab Results"
rightAction={{
icon: <Plus className="w-6 h-6 text-secondary-700" />,
label: 'Add',
onClick: () => { setEditResult(null); setShowForm(true) },
}}
/>
<PageContainer className="pt-4 space-y-6">
{/* Tabs */}
<div className="flex gap-2">
{(['recent', 'trends'] as const).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`flex-1 py-2.5 rounded-full text-sm font-medium transition-all min-h-touch ${
tab === t
? 'bg-primary-500 text-white'
: 'bg-secondary-100 text-secondary-600'
}`}
>
{t === 'recent' ? 'Recent' : 'Trends'}
</button>
))}
</div>
{tab === 'recent' ? (
/* Recent results */
<section>
{results.length === 0 ? (
<Card variant="outline" className="text-center py-8">
<TestTubes className="w-10 h-10 text-secondary-300 mx-auto mb-3" />
<p className="text-secondary-500">No lab results yet</p>
<p className="text-sm text-secondary-400 mt-1">
Tap + to record your blood work results
</p>
</Card>
) : (
<div className="space-y-4">
{results.map((result: any) => (
<LabResultCard key={result.id} result={result} onEdit={handleEdit} />
))}
</div>
)}
</section>
) : (
/* Trends view */
<section className="space-y-4">
<Select
label="Select Marker"
value={trendMarker}
onChange={(e) => setTrendMarker(e.target.value)}
options={markerOptions}
/>
<Card>
<div className="p-4">
<LabTrendChart marker={trendMarker} workspaceId={currentWorkspace.id} />
</div>
</Card>
</section>
)}
</PageContainer>
{/* Form Modal */}
<LabResultForm
isOpen={showForm}
onClose={() => { setShowForm(false); setEditResult(null) }}
onSaved={handleSaved}
workspaceId={currentWorkspace.id}
initialData={editResult || undefined}
/>
{/* Delete Confirmation */}
<ConfirmModal
isOpen={!!deleteId}
onClose={() => setDeleteId(null)}
onConfirm={handleDelete}
title="Delete Lab Result"
message="Are you sure you want to delete this lab result?"
confirmText="Delete"
variant="danger"
loading={deleting}
/>
</>
)
}

View File

@@ -0,0 +1,46 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useLiveQuery } from 'dexie-react-hooks'
import { db } from '@/lib/sync'
import { Header, PageContainer } from '@/components/layout/header'
import { MedicationForm } from '@/components/medications/MedicationForm'
import { LoadingState } from '@/components/ui'
export default function EditMedicationPage({ params }: { params: { id: string } | Promise<{ id: string }> }) {
const [medicationId, setMedicationId] = useState<string>('')
useEffect(() => {
if (params instanceof Promise) {
params.then((p) => setMedicationId(p.id))
} else {
setMedicationId(params.id)
}
}, [params])
const medication = useLiveQuery(
() => (medicationId ? db.medications.get(medicationId) : undefined),
[medicationId]
)
if (!medicationId || !medication) {
return (
<>
<Header title="Edit Medication" showBack />
<PageContainer>
<LoadingState message="Loading medication..." />
</PageContainer>
</>
)
}
return (
<>
<Header title="Edit Medication" showBack />
<PageContainer className="pt-4">
<MedicationForm initialData={medication} isEditing />
</PageContainer>
</>
)
}

View File

@@ -1,19 +1,41 @@
'use client'
import { use, useEffect, useState, useCallback } from 'react'
import { useEffect, useState, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { format } from 'date-fns'
import { Pill, Clock, Edit2, Trash2, History } from 'lucide-react'
import { Pill, Clock, Trash2, History, X, Edit2 } from 'lucide-react'
import { useLiveQuery } from 'dexie-react-hooks'
import { db, logDose, undoDose } from '@/lib/sync'
import type { LocalDoseLog } from '@/lib/sync'
import { Card, Button, LoadingState, Modal, showToast, showUndoToast } from '@/components/ui'
import { Header, PageContainer } from '@/components/layout/header'
import { RefillTracker } from '@/components/medications/RefillTracker'
import { useApp } from '../../provider'
export default function MedicationDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id: medicationId } = use(params)
// Unwrapping params for Next.js 14/15 compatibility
// In Next.js 15 params is a Promise, in 14 it's an object.
// We can use a simple `use` polyfill or just await it if we were in an async component,
// but this is a client component.
// For client components, params is passed as is.
// If types say Promise, we might need to use `use` but `use` is experimental in React 18.
// Let's assume params is an object for now as per Next 14 standard behavior for pages.
// If it is a promise (Next 15), we need `use`.
// Safest way: check if it has .then?
// Actually, let's just assume object for Next 14.
export default function MedicationDetailPage({ params }: { params: { id: string } | Promise<{ id: string }> }) {
// Simple unwrap if it's a promise (though likely it's an object in Next 14)
const [medicationId, setMedicationId] = useState<string>('')
useEffect(() => {
if (params instanceof Promise) {
params.then((p) => setMedicationId(p.id))
} else {
setMedicationId(params.id)
}
}, [params])
const router = useRouter()
const { currentWorkspace, refreshData } = useApp()
const [showDeleteModal, setShowDeleteModal] = useState(false)
@@ -21,19 +43,21 @@ export default function MedicationDetailPage({ params }: { params: Promise<{ id:
// Fetch medication from IndexedDB
const medication = useLiveQuery(
() => db.medications.get(medicationId),
() => (medicationId ? db.medications.get(medicationId) : undefined),
[medicationId]
)
// Fetch recent dose logs
const doseLogs = useLiveQuery(
() =>
db.doseLogs
.where('medicationId')
.equals(medicationId)
.reverse()
.limit(10)
.toArray(),
medicationId
? db.doseLogs
.where('medicationId')
.equals(medicationId)
.reverse()
.limit(10)
.toArray()
: [],
[medicationId]
)
@@ -58,6 +82,15 @@ export default function MedicationDetailPage({ params }: { params: Promise<{ id:
}
}, [medication, currentWorkspace.id])
const handleDeleteDose = async (dose: LocalDoseLog) => {
try {
await undoDose(dose)
showToast('Dose removed', 'success')
} catch {
showToast('Failed to remove dose', 'error')
}
}
const handleDelete = async () => {
if (!medication) return
setDeleting(true)
@@ -79,25 +112,27 @@ export default function MedicationDetailPage({ params }: { params: Promise<{ id:
}
const formatSchedule = () => {
if (!medication) return ''
if (!medication || !medication.scheduleData) return ''
const data = medication.scheduleData as Record<string, unknown>
switch (medication.scheduleType) {
case 'FIXED_TIMES':
return `Daily at ${(data.times as string[]).join(', ')}`
return `Daily at ${(Array.isArray(data.times) ? data.times : []).join(', ')}`
case 'INTERVAL':
return `Every ${data.hours} hours (starting ${data.startTime})`
return `Every ${data.hours || '?'} hours (starting ${data.startTime || '?'})`
case 'WEEKDAYS':
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
const selectedDays = (data.days as number[]).map(d => days[d]).join(', ')
return `${selectedDays} at ${data.time}`
const selectedDays = (Array.isArray(data.days) ? data.days : [])
.map((d: number) => days[d])
.join(', ')
return `${selectedDays} at ${data.time || '?'}`
case 'PRN':
return `As needed (min ${data.minHoursBetween}h between doses)`
return `As needed (min ${data.minHoursBetween || '?'}h between doses)`
default:
return medication.scheduleType
}
}
if (!medication) {
if (!medicationId || !medication) {
return (
<>
<Header title="Medication" showBack />
@@ -118,9 +153,9 @@ export default function MedicationDetailPage({ params }: { params: Promise<{ id:
rightAction={
currentWorkspace.role !== 'VIEWER'
? {
icon: <Trash2 className="w-6 h-6 text-red-600" />,
label: 'Delete',
onClick: () => setShowDeleteModal(true),
icon: <Edit2 className="w-5 h-5 text-primary-600" />,
label: 'Edit',
onClick: () => router.push(`/meds/${medication.id}/edit`),
}
: undefined
}
@@ -193,7 +228,7 @@ export default function MedicationDetailPage({ params }: { params: Promise<{ id:
<Card padding="none">
<ul className="divide-y divide-border">
{recentDoses.map((dose) => (
<li key={dose.id} className="px-4 py-3 flex items-center justify-between">
<li key={dose.id} className="px-4 py-3 flex items-center justify-between group">
<div>
<p className="text-sm font-medium text-secondary-900">
{format(new Date(dose.takenAt), 'EEEE, MMM d')}
@@ -203,6 +238,15 @@ export default function MedicationDetailPage({ params }: { params: Promise<{ id:
{dose.loggedBy && ` by ${dose.loggedBy.name}`}
</p>
</div>
{currentWorkspace.role !== 'VIEWER' && (
<button
onClick={() => handleDeleteDose(dose)}
className="p-2 text-secondary-400 hover:text-red-600 hover:bg-red-50 rounded-full transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100"
title="Remove dose"
>
<X className="w-4 h-4" />
</button>
)}
</li>
))}
</ul>
@@ -213,6 +257,20 @@ export default function MedicationDetailPage({ params }: { params: Promise<{ id:
</Card>
)}
</section>
{/* Delete Action */}
{currentWorkspace.role !== 'VIEWER' && (
<div className="pt-4 pb-8">
<Button
variant="ghost"
className="text-red-600 hover:bg-red-50 hover:text-red-700 w-full"
onClick={() => setShowDeleteModal(true)}
>
<Trash2 className="w-4 h-4 mr-2" />
Delete Medication
</Button>
</div>
)}
</PageContainer>
{/* Delete Confirmation Modal */}

View File

@@ -1,334 +1,15 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Button, Input, Textarea, Select, Card, showToast } from '@/components/ui'
import { Header, PageContainer } from '@/components/layout/header'
import { useApp } from '../../provider'
type ScheduleType = 'FIXED_TIMES' | 'INTERVAL' | 'WEEKDAYS' | 'PRN'
const scheduleTypeOptions = [
{ value: 'FIXED_TIMES', label: 'Fixed times daily' },
{ value: 'INTERVAL', label: 'Every X hours' },
{ value: 'WEEKDAYS', label: 'Specific days of week' },
{ value: 'PRN', label: 'As needed (PRN)' },
]
const weekdays = [
{ value: 0, label: 'Sun' },
{ value: 1, label: 'Mon' },
{ value: 2, label: 'Tue' },
{ value: 3, label: 'Wed' },
{ value: 4, label: 'Thu' },
{ value: 5, label: 'Fri' },
{ value: 6, label: 'Sat' },
]
import { MedicationForm } from '@/components/medications/MedicationForm'
export default function NewMedicationPage() {
const router = useRouter()
const { currentWorkspace, refreshData } = useApp()
const [name, setName] = useState('')
const [instructions, setInstructions] = useState('')
const [scheduleType, setScheduleType] = useState<ScheduleType>('FIXED_TIMES')
// Fixed times
const [times, setTimes] = useState(['08:00'])
// Interval
const [intervalHours, setIntervalHours] = useState(8)
const [startTime, setStartTime] = useState('08:00')
// Weekdays
const [selectedDays, setSelectedDays] = useState<number[]>([1, 3, 5])
const [weekdayTime, setWeekdayTime] = useState('09:00')
// PRN
const [minHoursBetween, setMinHoursBetween] = useState(4)
// Refill tracking (optional)
const [trackRefills, setTrackRefills] = useState(false)
const [pillCount, setPillCount] = useState<number | ''>('')
const [pillsPerDose, setPillsPerDose] = useState(1)
const [refillThreshold, setRefillThreshold] = useState(7)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const addTime = () => {
setTimes([...times, '12:00'])
}
const removeTime = (index: number) => {
setTimes(times.filter((_, i) => i !== index))
}
const updateTime = (index: number, value: string) => {
const newTimes = [...times]
newTimes[index] = value
setTimes(newTimes)
}
const toggleDay = (day: number) => {
if (selectedDays.includes(day)) {
setSelectedDays(selectedDays.filter((d) => d !== day))
} else {
setSelectedDays([...selectedDays, day].sort())
}
}
const buildScheduleData = () => {
switch (scheduleType) {
case 'FIXED_TIMES':
return { type: 'FIXED_TIMES', times }
case 'INTERVAL':
return { type: 'INTERVAL', hours: intervalHours, startTime }
case 'WEEKDAYS':
return { type: 'WEEKDAYS', days: selectedDays, time: weekdayTime }
case 'PRN':
return { type: 'PRN', minHoursBetween }
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
const response = await fetch(
`/api/workspaces/${currentWorkspace.id}/medications`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
instructions: instructions || null,
scheduleType,
scheduleData: buildScheduleData(),
active: true,
// Refill tracking (optional)
...(trackRefills && pillCount !== '' && {
pillCount: Number(pillCount),
pillsPerDose,
refillThreshold,
}),
}),
}
)
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || 'Failed to add medication')
}
await refreshData()
showToast('Medication added', 'success')
router.push('/meds')
} catch (err) {
setError(err instanceof Error ? err.message : 'Something went wrong')
} finally {
setLoading(false)
}
}
return (
<>
<Header title="New Medication" showBack backHref="/meds" />
<PageContainer className="pt-4">
<Card>
<form onSubmit={handleSubmit} className="space-y-5">
<Input
label="Medication Name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Paracetamol 500mg"
required
/>
<Textarea
label="Instructions (optional)"
value={instructions}
onChange={(e) => setInstructions(e.target.value)}
placeholder="e.g., Take with food"
rows={2}
/>
<Select
label="Schedule Type"
value={scheduleType}
onChange={(e) => setScheduleType(e.target.value as ScheduleType)}
options={scheduleTypeOptions}
/>
{/* Schedule-specific options */}
{scheduleType === 'FIXED_TIMES' && (
<div className="space-y-3">
<label className="block text-sm font-medium text-secondary-700">
Times to take
</label>
{times.map((time, index) => (
<div key={index} className="flex gap-2">
<Input
type="time"
value={time}
onChange={(e) => updateTime(index, e.target.value)}
className="flex-1"
/>
{times.length > 1 && (
<Button
type="button"
variant="ghost"
onClick={() => removeTime(index)}
>
Remove
</Button>
)}
</div>
))}
<Button type="button" variant="secondary" onClick={addTime} size="sm">
+ Add time
</Button>
</div>
)}
{scheduleType === 'INTERVAL' && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<Input
label="Every (hours)"
type="number"
min={1}
max={72}
value={intervalHours}
onChange={(e) => setIntervalHours(parseInt(e.target.value) || 1)}
/>
<Input
label="Starting at"
type="time"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
/>
</div>
</div>
)}
{scheduleType === 'WEEKDAYS' && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-secondary-700 mb-2">
Days
</label>
<div className="flex gap-2 flex-wrap">
{weekdays.map((day) => (
<button
key={day.value}
type="button"
onClick={() => toggleDay(day.value)}
className={`px-3 py-2 rounded-button text-sm font-medium transition-colors ${
selectedDays.includes(day.value)
? 'bg-primary-500 text-white'
: 'bg-muted text-secondary-600 hover:bg-secondary-200'
}`}
>
{day.label}
</button>
))}
</div>
</div>
<Input
label="Time"
type="time"
value={weekdayTime}
onChange={(e) => setWeekdayTime(e.target.value)}
/>
</div>
)}
{scheduleType === 'PRN' && (
<Input
label="Minimum hours between doses"
type="number"
min={0.5}
max={72}
step={0.5}
value={minHoursBetween}
onChange={(e) => setMinHoursBetween(parseFloat(e.target.value) || 4)}
helperText="Shows 'Available' when enough time has passed since last dose"
/>
)}
{/* Refill Tracking (optional) */}
<div className="border-t border-border pt-5">
<div className="flex items-center gap-3 mb-4">
<input
type="checkbox"
id="trackRefills"
checked={trackRefills}
onChange={(e) => setTrackRefills(e.target.checked)}
className="w-5 h-5 rounded border-border text-primary-600 focus:ring-primary-500"
/>
<label htmlFor="trackRefills" className="text-sm font-medium text-secondary-700">
Track pill count for refill reminders (optional)
</label>
</div>
{trackRefills && (
<div className="space-y-4 pl-8">
<Input
label="Current pill count"
type="number"
min={0}
value={pillCount}
onChange={(e) => setPillCount(e.target.value === '' ? '' : parseInt(e.target.value))}
placeholder="e.g., 30"
helperText="How many pills do you have now?"
/>
<div className="grid grid-cols-2 gap-3">
<Input
label="Pills per dose"
type="number"
min={1}
value={pillsPerDose}
onChange={(e) => setPillsPerDose(parseInt(e.target.value) || 1)}
/>
<Input
label="Alert when below"
type="number"
min={0}
value={refillThreshold}
onChange={(e) => setRefillThreshold(parseInt(e.target.value) || 7)}
helperText="pills"
/>
</div>
</div>
)}
</div>
{error && (
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-button">
{error}
</p>
)}
<div className="flex gap-3 pt-2">
<Button
type="button"
variant="secondary"
fullWidth
onClick={() => router.back()}
>
Cancel
</Button>
<Button type="submit" fullWidth loading={loading}>
Save Medication
</Button>
</div>
</form>
</Card>
<MedicationForm />
</PageContainer>
</>
)
}
}

View File

@@ -11,6 +11,7 @@ import type { Medication, DoseLog, MedicationDueStatus } from '@/lib/schedule'
import { Card, Button, LoadingState, EmptyState, showUndoToast, showToast } from '@/components/ui'
import { Header, PageContainer } from '@/components/layout/header'
import { RefillAlert } from '@/components/medications/RefillAlert'
import { InteractionCheck } from '@/components/medications/InteractionCheck'
import { useApp } from '../provider'
export default function MedsPage() {
@@ -140,6 +141,11 @@ export default function MedsPage() {
}))}
/>
{/* Drug Interaction Checker */}
{medications.filter(m => m.active).length >= 2 && (
<InteractionCheck workspaceId={currentWorkspace.id} />
)}
{medications.length === 0 ? (
<EmptyState
type="medications"

View File

@@ -0,0 +1,493 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { format } from 'date-fns'
import {
Users,
UserPlus,
Trash2,
Key,
Shield,
Edit2,
Loader,
AlertTriangle,
} from 'lucide-react'
import { Button, Card, Input, Modal, showToast } from '@/components/ui'
import { Header, PageContainer } from '@/components/layout/header'
import { useApp } from '../../provider'
interface Member {
id: string
role: 'OWNER' | 'EDITOR' | 'VIEWER'
joinedAt: string
user: {
id: string
name: string
email: string
lastLoginAt: string | null
forcePasswordReset: boolean
createdAt: string
}
}
export default function MembersPage() {
const router = useRouter()
const { currentWorkspace, user } = useApp()
const [members, setMembers] = useState<Member[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
// Modals
const [showAddUser, setShowAddUser] = useState(false)
const [showEditRole, setShowEditRole] = useState<Member | null>(null)
const [showResetPassword, setShowResetPassword] = useState<Member | null>(null)
const [showRemove, setShowRemove] = useState<Member | null>(null)
// Form states
const [addUserForm, setAddUserForm] = useState({
name: '',
email: '',
password: '',
role: 'VIEWER' as 'OWNER' | 'EDITOR' | 'VIEWER',
forcePasswordReset: true,
})
const [resetPasswordForm, setResetPasswordForm] = useState({
newPassword: '',
forceChange: true,
})
const [newRole, setNewRole] = useState<'OWNER' | 'EDITOR' | 'VIEWER'>('VIEWER')
const [actionLoading, setActionLoading] = useState(false)
const fetchMembers = useCallback(async () => {
try {
const response = await fetch(`/api/workspaces/${currentWorkspace.id}/members`)
if (!response.ok) throw new Error('Failed to fetch members')
const data = await response.json()
setMembers(data.members)
} catch (err) {
setError('Failed to load members')
console.error(err)
} finally {
setLoading(false)
}
}, [currentWorkspace.id])
useEffect(() => {
if (currentWorkspace.role !== 'OWNER') {
router.push('/settings')
return
}
fetchMembers()
}, [currentWorkspace.role, fetchMembers, router])
const handleAddUser = async () => {
setActionLoading(true)
try {
const response = await fetch(`/api/workspaces/${currentWorkspace.id}/members`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(addUserForm),
})
const data = await response.json()
if (!response.ok) throw new Error(data.error)
showToast(data.message || 'User added', 'success')
setShowAddUser(false)
setAddUserForm({
name: '',
email: '',
password: '',
role: 'VIEWER',
forcePasswordReset: true,
})
fetchMembers()
} catch (err) {
showToast(err instanceof Error ? err.message : 'Failed to add user', 'error')
} finally {
setActionLoading(false)
}
}
const handleUpdateRole = async () => {
if (!showEditRole) return
setActionLoading(true)
try {
const response = await fetch(
`/api/workspaces/${currentWorkspace.id}/members/${showEditRole.id}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role: newRole }),
}
)
const data = await response.json()
if (!response.ok) throw new Error(data.error)
showToast('Role updated', 'success')
setShowEditRole(null)
fetchMembers()
} catch (err) {
showToast(err instanceof Error ? err.message : 'Failed to update role', 'error')
} finally {
setActionLoading(false)
}
}
const handleResetPassword = async () => {
if (!showResetPassword) return
setActionLoading(true)
try {
const response = await fetch(
`/api/workspaces/${currentWorkspace.id}/members/${showResetPassword.id}/reset-password`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(resetPasswordForm),
}
)
const data = await response.json()
if (!response.ok) throw new Error(data.error)
showToast(data.message || 'Password reset', 'success')
setShowResetPassword(null)
setResetPasswordForm({ newPassword: '', forceChange: true })
fetchMembers()
} catch (err) {
showToast(err instanceof Error ? err.message : 'Failed to reset password', 'error')
} finally {
setActionLoading(false)
}
}
const handleRemoveMember = async () => {
if (!showRemove) return
setActionLoading(true)
try {
const response = await fetch(
`/api/workspaces/${currentWorkspace.id}/members/${showRemove.id}`,
{ method: 'DELETE' }
)
const data = await response.json()
if (!response.ok) throw new Error(data.error)
showToast('Member removed', 'success')
setShowRemove(null)
fetchMembers()
} catch (err) {
showToast(err instanceof Error ? err.message : 'Failed to remove member', 'error')
} finally {
setActionLoading(false)
}
}
const getRoleBadgeColor = (role: string) => {
switch (role) {
case 'OWNER':
return 'bg-purple-100 text-purple-800'
case 'EDITOR':
return 'bg-blue-100 text-blue-800'
default:
return 'bg-secondary-100 text-secondary-800'
}
}
if (loading) {
return (
<>
<Header title="Manage Members" showBack />
<PageContainer className="pt-4">
<div className="flex items-center justify-center py-12">
<Loader className="w-6 h-6 animate-spin text-secondary-400" />
</div>
</PageContainer>
</>
)
}
if (error) {
return (
<>
<Header title="Manage Members" showBack />
<PageContainer className="pt-4">
<Card className="text-center py-8">
<p className="text-secondary-500">{error}</p>
</Card>
</PageContainer>
</>
)
}
return (
<>
<Header title="Manage Members" showBack />
<PageContainer className="pt-4 space-y-4">
{/* Add user button */}
<Button onClick={() => setShowAddUser(true)} className="w-full">
<UserPlus className="w-4 h-4 mr-2" />
Add User
</Button>
{/* Members list */}
<div className="space-y-3">
{members.map((member) => {
const isCurrentUser = member.user.id === user.id
return (
<Card key={member.id} padding="none">
<div className="p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<p className="font-medium text-secondary-900">
{member.user.name}
{isCurrentUser && (
<span className="text-secondary-500 font-normal"> (you)</span>
)}
</p>
<span
className={`text-xs px-2 py-0.5 rounded-full font-medium ${getRoleBadgeColor(member.role)}`}
>
{member.role}
</span>
</div>
<p className="text-sm text-secondary-500 mt-0.5">{member.user.email}</p>
<div className="flex items-center gap-4 mt-2 text-xs text-secondary-400">
<span>
Joined {format(new Date(member.joinedAt), 'MMM d, yyyy')}
</span>
{member.user.lastLoginAt && (
<span>
Last login{' '}
{format(new Date(member.user.lastLoginAt), 'MMM d, yyyy')}
</span>
)}
</div>
{member.user.forcePasswordReset && (
<div className="flex items-center gap-1 mt-2 text-xs text-amber-600">
<AlertTriangle className="w-3 h-3" />
<span>Must change password on next login</span>
</div>
)}
</div>
</div>
{/* Actions */}
{!isCurrentUser && (
<div className="flex gap-2 mt-3 pt-3 border-t border-border">
<Button
variant="ghost"
size="sm"
onClick={() => {
setShowEditRole(member)
setNewRole(member.role)
}}
>
<Edit2 className="w-3.5 h-3.5 mr-1" />
Role
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setShowResetPassword(member)
setResetPasswordForm({ newPassword: '', forceChange: true })
}}
>
<Key className="w-3.5 h-3.5 mr-1" />
Reset Password
</Button>
<Button
variant="ghost"
size="sm"
className="text-red-600 hover:bg-red-50"
onClick={() => setShowRemove(member)}
>
<Trash2 className="w-3.5 h-3.5 mr-1" />
Remove
</Button>
</div>
)}
</div>
</Card>
)
})}
</div>
{members.length === 0 && (
<Card className="text-center py-8">
<Users className="w-12 h-12 text-secondary-300 mx-auto mb-3" />
<p className="text-secondary-500">No members yet</p>
</Card>
)}
</PageContainer>
{/* Add User Modal */}
<Modal isOpen={showAddUser} onClose={() => setShowAddUser(false)} title="Add User">
<div className="space-y-4">
<Input
label="Name"
value={addUserForm.name}
onChange={(e) => setAddUserForm((f) => ({ ...f, name: e.target.value }))}
placeholder="Enter name"
/>
<Input
label="Email"
type="email"
value={addUserForm.email}
onChange={(e) => setAddUserForm((f) => ({ ...f, email: e.target.value }))}
placeholder="Enter email"
/>
<Input
label="Temporary Password"
type="text"
value={addUserForm.password}
onChange={(e) => setAddUserForm((f) => ({ ...f, password: e.target.value }))}
placeholder="At least 8 characters"
helperText="User will be required to change this on first login"
/>
<div>
<label className="block text-sm font-medium text-secondary-700 mb-2">Role</label>
<div className="flex gap-2">
{(['VIEWER', 'EDITOR', 'OWNER'] as const).map((role) => (
<button
key={role}
onClick={() => setAddUserForm((f) => ({ ...f, role }))}
className={`flex-1 py-2 px-3 rounded-button text-sm font-medium transition-colors ${
addUserForm.role === role
? 'bg-primary-500 text-white'
: 'bg-muted text-secondary-600'
}`}
>
{role}
</button>
))}
</div>
</div>
<Button
onClick={handleAddUser}
fullWidth
loading={actionLoading}
disabled={!addUserForm.name || !addUserForm.email || addUserForm.password.length < 8}
>
Add User
</Button>
</div>
</Modal>
{/* Edit Role Modal */}
<Modal
isOpen={!!showEditRole}
onClose={() => setShowEditRole(null)}
title="Change Role"
>
<div className="space-y-4">
<p className="text-secondary-600">
Change role for <strong>{showEditRole?.user.name}</strong>
</p>
<div className="flex gap-2">
{(['VIEWER', 'EDITOR', 'OWNER'] as const).map((role) => (
<button
key={role}
onClick={() => setNewRole(role)}
className={`flex-1 py-2 px-3 rounded-button text-sm font-medium transition-colors ${
newRole === role
? 'bg-primary-500 text-white'
: 'bg-muted text-secondary-600'
}`}
>
{role}
</button>
))}
</div>
<div className="text-sm text-secondary-500">
<p><strong>Viewer:</strong> Can view everything but not make changes</p>
<p><strong>Editor:</strong> Can add and edit appointments, medications, notes</p>
<p><strong>Owner:</strong> Full access including member management</p>
</div>
<Button onClick={handleUpdateRole} fullWidth loading={actionLoading}>
Update Role
</Button>
</div>
</Modal>
{/* Reset Password Modal */}
<Modal
isOpen={!!showResetPassword}
onClose={() => setShowResetPassword(null)}
title="Reset Password"
>
<div className="space-y-4">
<p className="text-secondary-600">
Reset password for <strong>{showResetPassword?.user.name}</strong>
</p>
<Input
label="New Password"
type="text"
value={resetPasswordForm.newPassword}
onChange={(e) =>
setResetPasswordForm((f) => ({ ...f, newPassword: e.target.value }))
}
placeholder="At least 8 characters"
/>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={resetPasswordForm.forceChange}
onChange={(e) =>
setResetPasswordForm((f) => ({ ...f, forceChange: e.target.checked }))
}
className="w-4 h-4 rounded border-secondary-300"
/>
<span className="text-sm text-secondary-700">
Require password change on next login
</span>
</label>
<p className="text-sm text-amber-600 bg-amber-50 p-3 rounded-lg">
This will log the user out of all devices.
</p>
<Button
onClick={handleResetPassword}
fullWidth
loading={actionLoading}
disabled={resetPasswordForm.newPassword.length < 8}
>
Reset Password
</Button>
</div>
</Modal>
{/* Remove Member Modal */}
<Modal
isOpen={!!showRemove}
onClose={() => setShowRemove(null)}
title="Remove Member"
>
<div className="space-y-4">
<p className="text-secondary-600">
Are you sure you want to remove <strong>{showRemove?.user.name}</strong> from
this workspace? They will lose access to all data.
</p>
<div className="flex gap-3">
<Button variant="secondary" fullWidth onClick={() => setShowRemove(null)}>
Cancel
</Button>
<Button
className="bg-red-600 hover:bg-red-700"
fullWidth
loading={actionLoading}
onClick={handleRemoveMember}
>
Remove
</Button>
</div>
</div>
</Modal>
</>
)
}

View File

@@ -1,7 +1,7 @@
'use client'
import { useState } from 'react'
import { Bell, Clock, Moon } from 'lucide-react'
import { Bell, Clock, Moon, Send } from 'lucide-react'
import { Card, Button, Input, showToast } from '@/components/ui'
import { Header, PageContainer } from '@/components/layout/header'
@@ -13,6 +13,7 @@ export default function NotificationsSettingsPage() {
const [quietStart, setQuietStart] = useState(currentWorkspace.quietHoursStart || '22:00')
const [quietEnd, setQuietEnd] = useState(currentWorkspace.quietHoursEnd || '07:00')
const [saving, setSaving] = useState(false)
const [testingSending, setTestingSending] = useState(false)
const handleSaveQuietHours = async () => {
setSaving(true)
@@ -37,6 +38,29 @@ export default function NotificationsSettingsPage() {
}
}
const handleTestNotification = async () => {
setTestingSending(true)
try {
const response = await fetch('/api/notifications/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workspaceId: currentWorkspace.id }),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to send test notification')
}
showToast(data.message, data.success ? 'success' : 'error')
} catch (err) {
showToast(err instanceof Error ? err.message : 'Failed to send test', 'error')
} finally {
setTestingSending(false)
}
}
return (
<>
<Header title="Notifications" showBack />
@@ -51,6 +75,37 @@ export default function NotificationsSettingsPage() {
</Card>
</section>
{/* Test Notification */}
<section>
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
Test Notifications
</h2>
<Card>
<div className="flex items-start gap-3 mb-4">
<div className="p-2 bg-blue-100 rounded-lg">
<Send className="w-5 h-5 text-blue-600" />
</div>
<div>
<p className="font-medium text-secondary-900">
Send Test Notification
</p>
<p className="text-sm text-secondary-500">
Verify that notifications are working on your device.
</p>
</div>
</div>
<Button
onClick={handleTestNotification}
loading={testingSending}
fullWidth
variant="secondary"
>
<Send className="w-4 h-4 mr-2" />
Send Test Notification
</Button>
</Card>
</section>
{/* Quiet Hours */}
<section>
<h2 className="text-sm font-semibold text-secondary-600 mb-3">

View File

@@ -18,6 +18,13 @@ import {
Calendar,
FileText,
Bell,
Thermometer,
Weight,
TestTubes,
FolderOpen,
ClipboardList,
Milestone,
Pill,
} from 'lucide-react'
import { useLiveQuery } from 'dexie-react-hooks'
@@ -281,6 +288,138 @@ export default function SettingsPage() {
</Card>
</section>
{/* Health Tracking */}
<section>
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
Health Tracking
</h2>
<Card padding="none">
<button
onClick={() => router.push('/temperature')}
className="w-full flex items-center gap-3 p-4 hover:bg-muted transition-colors"
>
<Thermometer className="w-5 h-5 text-secondary-500" />
<div className="flex-1 text-left">
<p className="font-medium text-secondary-900">Temperature Log</p>
<p className="text-sm text-secondary-500">Track fever and temperature</p>
</div>
<ChevronRight className="w-5 h-5 text-secondary-300" />
</button>
<div className="border-t border-border">
<button
onClick={() => router.push('/weight')}
className="w-full flex items-center gap-3 p-4 hover:bg-muted transition-colors"
>
<Weight className="w-5 h-5 text-secondary-500" />
<div className="flex-1 text-left">
<p className="font-medium text-secondary-900">Weight Tracking</p>
<p className="text-sm text-secondary-500">Monitor weight changes</p>
</div>
<ChevronRight className="w-5 h-5 text-secondary-300" />
</button>
</div>
<div className="border-t border-border">
<button
onClick={() => router.push('/lab-results')}
className="w-full flex items-center gap-3 p-4 hover:bg-muted transition-colors"
>
<TestTubes className="w-5 h-5 text-secondary-500" />
<div className="flex-1 text-left">
<p className="font-medium text-secondary-900">Lab Results</p>
<p className="text-sm text-secondary-500">Blood work and test results</p>
</div>
<ChevronRight className="w-5 h-5 text-secondary-300" />
</button>
</div>
</Card>
</section>
{/* Care Team */}
<section>
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
Care Team
</h2>
<Card padding="none">
<button
onClick={() => router.push('/contacts')}
className="w-full flex items-center gap-3 p-4 hover:bg-muted transition-colors"
>
<Phone className="w-5 h-5 text-secondary-500" />
<div className="flex-1 text-left">
<p className="font-medium text-secondary-900">Contact Directory</p>
<p className="text-sm text-secondary-500">Doctors, nurses, pharmacists</p>
</div>
<ChevronRight className="w-5 h-5 text-secondary-300" />
</button>
<div className="border-t border-border">
<button
onClick={() => router.push('/tasks')}
className="w-full flex items-center gap-3 p-4 hover:bg-muted transition-colors"
>
<ClipboardList className="w-5 h-5 text-secondary-500" />
<div className="flex-1 text-left">
<p className="font-medium text-secondary-900">Caregiver Tasks</p>
<p className="text-sm text-secondary-500">Family task coordination</p>
</div>
<ChevronRight className="w-5 h-5 text-secondary-300" />
</button>
</div>
</Card>
</section>
{/* Treatment */}
<section>
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
Treatment
</h2>
<Card padding="none">
<button
onClick={() => router.push('/timeline')}
className="w-full flex items-center gap-3 p-4 hover:bg-muted transition-colors"
>
<Milestone className="w-5 h-5 text-secondary-500" />
<div className="flex-1 text-left">
<p className="font-medium text-secondary-900">Treatment Timeline</p>
<p className="text-sm text-secondary-500">Milestones and progress</p>
</div>
<ChevronRight className="w-5 h-5 text-secondary-300" />
</button>
<div className="border-t border-border">
<button
onClick={() => router.push('/documents')}
className="w-full flex items-center gap-3 p-4 hover:bg-muted transition-colors"
>
<FolderOpen className="w-5 h-5 text-secondary-500" />
<div className="flex-1 text-left">
<p className="font-medium text-secondary-900">Medical Documents</p>
<p className="text-sm text-secondary-500">Upload and store files</p>
</div>
<ChevronRight className="w-5 h-5 text-secondary-300" />
</button>
</div>
</Card>
</section>
{/* Safety */}
<section>
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
Safety
</h2>
<Card padding="none">
<button
onClick={() => router.push('/meds')}
className="w-full flex items-center gap-3 p-4 hover:bg-muted transition-colors"
>
<Pill className="w-5 h-5 text-secondary-500" />
<div className="flex-1 text-left">
<p className="font-medium text-secondary-900">Drug Interactions</p>
<p className="text-sm text-secondary-500">Check medication safety</p>
</div>
<ChevronRight className="w-5 h-5 text-secondary-300" />
</button>
</Card>
</section>
{/* Family members */}
{currentWorkspace.role === 'OWNER' && (
<section>
@@ -289,16 +428,29 @@ export default function SettingsPage() {
</h2>
<Card padding="none">
<button
onClick={() => setShowInvite(true)}
onClick={() => router.push('/settings/members')}
className="w-full flex items-center gap-3 p-4 hover:bg-muted transition-colors"
>
<Users className="w-5 h-5 text-secondary-500" />
<div className="flex-1 text-left">
<p className="font-medium text-secondary-900">Invite Family Member</p>
<p className="text-sm text-secondary-500">Share access to this workspace</p>
<p className="font-medium text-secondary-900">Manage Members</p>
<p className="text-sm text-secondary-500">View and manage workspace access</p>
</div>
<ChevronRight className="w-5 h-5 text-secondary-300" />
</button>
<div className="border-t border-border">
<button
onClick={() => setShowInvite(true)}
className="w-full flex items-center gap-3 p-4 hover:bg-muted transition-colors"
>
<Users className="w-5 h-5 text-secondary-500" />
<div className="flex-1 text-left">
<p className="font-medium text-secondary-900">Invite Family Member</p>
<p className="text-sm text-secondary-500">Share access to this workspace</p>
</div>
<ChevronRight className="w-5 h-5 text-secondary-300" />
</button>
</div>
</Card>
</section>
)}

View File

@@ -0,0 +1,292 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { Plus, ClipboardList } from 'lucide-react'
import { useLiveQuery } from 'dexie-react-hooks'
import { db } from '@/lib/sync'
import { Card, LoadingState, ConfirmModal, showToast } from '@/components/ui'
import { Header, PageContainer } from '@/components/layout/header'
import { TaskCard } from '@/components/tasks/TaskCard'
import { TaskForm } from '@/components/tasks/TaskForm'
import { TaskFilters } from '@/components/tasks/TaskFilters'
import { useApp } from '../provider'
export default function TasksPage() {
const { currentWorkspace, refreshData } = useApp()
const [serverTasks, setServerTasks] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
const [editTask, setEditTask] = useState<any>(null)
const [filter, setFilter] = useState('all')
const [members, setMembers] = useState<Array<{ id: string; name: string }>>([])
const [deleteId, setDeleteId] = useState<string | null>(null)
const [deleting, setDeleting] = useState(false)
const localTasks = useLiveQuery(
() =>
db.caregiverTasks
.where('workspaceId')
.equals(currentWorkspace.id)
.and((t) => !t.deletedAt)
.toArray(),
[currentWorkspace.id]
)
const fetchTasks = useCallback(async () => {
try {
const response = await fetch(`/api/workspaces/${currentWorkspace.id}/tasks?limit=200`)
if (response.ok) {
const data = await response.json()
setServerTasks(data.tasks)
}
} catch (err) {
console.error('Failed to fetch tasks:', err)
} finally {
setLoading(false)
}
}, [currentWorkspace.id])
const fetchMembers = useCallback(async () => {
try {
const response = await fetch(`/api/workspaces/${currentWorkspace.id}/members`)
if (response.ok) {
const data = await response.json()
setMembers(data.members?.map((m: any) => ({ id: m.userId || m.id, name: m.user?.name || m.name })) || [])
}
} catch (err) {
console.error('Failed to fetch members:', err)
}
}, [currentWorkspace.id])
useEffect(() => {
fetchTasks()
fetchMembers()
}, [fetchTasks, fetchMembers])
const handleSaved = () => {
fetchTasks()
refreshData()
setEditTask(null)
}
const handleComplete = async (taskId: string) => {
try {
const response = await fetch(
`/api/workspaces/${currentWorkspace.id}/tasks/${taskId}/complete`,
{ method: 'POST' }
)
if (!response.ok) throw new Error('Failed to complete task')
showToast('Task completed!', 'success')
fetchTasks()
refreshData()
} catch {
showToast('Failed to complete task', 'error')
}
}
const handleDelete = async () => {
if (!deleteId) return
setDeleting(true)
try {
const response = await fetch(
`/api/workspaces/${currentWorkspace.id}/tasks/${deleteId}`,
{ method: 'DELETE' }
)
if (!response.ok) throw new Error('Failed to delete task')
showToast('Task deleted', 'success')
fetchTasks()
refreshData()
setDeleteId(null)
} catch {
showToast('Failed to delete task', 'error')
} finally {
setDeleting(false)
}
}
const handleEdit = (task: any) => {
setEditTask(task)
setShowForm(true)
}
const allTasks = serverTasks.length > 0 ? serverTasks : localTasks || []
// Apply filters
const filteredTasks = allTasks.filter((t: any) => {
if (filter === 'done') return t.status === 'DONE'
if (filter === 'mine') return t.status !== 'DONE' && t.status !== 'CANCELLED'
// 'all' shows active tasks (not done/cancelled)
return t.status !== 'DONE' && t.status !== 'CANCELLED'
})
// Group active tasks by priority
const urgentTasks = filteredTasks.filter((t: any) => t.priority === 'URGENT')
const highTasks = filteredTasks.filter((t: any) => t.priority === 'HIGH')
const normalTasks = filteredTasks.filter((t: any) => t.priority === 'NORMAL')
const lowTasks = filteredTasks.filter((t: any) => t.priority === 'LOW')
const doneTasks = filter === 'done' ? filteredTasks : []
const activeCount = allTasks.filter((t: any) => t.status !== 'DONE' && t.status !== 'CANCELLED').length
const doneCount = allTasks.filter((t: any) => t.status === 'DONE').length
if (loading && !localTasks) {
return (
<>
<Header title="Tasks" />
<PageContainer><LoadingState message="Loading tasks..." /></PageContainer>
</>
)
}
return (
<>
<Header
title="Tasks"
rightAction={{
icon: <Plus className="w-6 h-6 text-secondary-700" />,
label: 'Add',
onClick: () => { setEditTask(null); setShowForm(true) },
}}
/>
<PageContainer className="pt-4 space-y-6">
{/* Summary */}
<div className="flex gap-3">
<Card className="flex-1 text-center py-3">
<p className="text-2xl font-bold text-primary-600">{activeCount}</p>
<p className="text-xs text-secondary-500">Active</p>
</Card>
<Card className="flex-1 text-center py-3">
<p className="text-2xl font-bold text-green-600">{doneCount}</p>
<p className="text-xs text-secondary-500">Done</p>
</Card>
</div>
{/* Filters */}
<TaskFilters filter={filter} onFilterChange={setFilter} />
{/* Task Lists */}
{filter === 'done' ? (
<section>
{doneTasks.length === 0 ? (
<Card variant="outline" className="text-center py-8">
<p className="text-secondary-500">No completed tasks yet</p>
</Card>
) : (
<div className="space-y-3">
{doneTasks.map((task: any) => (
<TaskCard key={task.id} task={task} onEdit={handleEdit} />
))}
</div>
)}
</section>
) : (
<>
{filteredTasks.length === 0 ? (
<Card variant="outline" className="text-center py-8">
<ClipboardList className="w-10 h-10 text-secondary-300 mx-auto mb-3" />
<p className="text-secondary-500">No tasks yet</p>
<p className="text-sm text-secondary-400 mt-1">
Add tasks to coordinate care with your team
</p>
</Card>
) : (
<div className="space-y-5">
{urgentTasks.length > 0 && (
<section>
<h2 className="text-sm font-semibold text-red-600 uppercase tracking-wide mb-3">
Urgent
</h2>
<div className="space-y-3">
{urgentTasks.map((task: any) => (
<TaskCard
key={task.id}
task={task}
onComplete={handleComplete}
onEdit={handleEdit}
/>
))}
</div>
</section>
)}
{highTasks.length > 0 && (
<section>
<h2 className="text-sm font-semibold text-orange-600 uppercase tracking-wide mb-3">
High Priority
</h2>
<div className="space-y-3">
{highTasks.map((task: any) => (
<TaskCard
key={task.id}
task={task}
onComplete={handleComplete}
onEdit={handleEdit}
/>
))}
</div>
</section>
)}
{normalTasks.length > 0 && (
<section>
<h2 className="text-sm font-semibold text-secondary-600 uppercase tracking-wide mb-3">
Normal
</h2>
<div className="space-y-3">
{normalTasks.map((task: any) => (
<TaskCard
key={task.id}
task={task}
onComplete={handleComplete}
onEdit={handleEdit}
/>
))}
</div>
</section>
)}
{lowTasks.length > 0 && (
<section>
<h2 className="text-sm font-semibold text-secondary-400 uppercase tracking-wide mb-3">
Low Priority
</h2>
<div className="space-y-3">
{lowTasks.map((task: any) => (
<TaskCard
key={task.id}
task={task}
onComplete={handleComplete}
onEdit={handleEdit}
/>
))}
</div>
</section>
)}
</div>
)}
</>
)}
</PageContainer>
{/* Task Form Modal */}
<TaskForm
isOpen={showForm}
onClose={() => { setShowForm(false); setEditTask(null) }}
onSaved={handleSaved}
workspaceId={currentWorkspace.id}
members={members}
initialData={editTask || undefined}
/>
{/* Delete Confirmation */}
<ConfirmModal
isOpen={!!deleteId}
onClose={() => setDeleteId(null)}
onConfirm={handleDelete}
title="Delete Task"
message="Are you sure you want to delete this task? This cannot be undone."
confirmText="Delete"
variant="danger"
loading={deleting}
/>
</>
)
}

View File

@@ -0,0 +1,126 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { History, Thermometer } 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 { TempQuickLog } from '@/components/temperature/TempQuickLog'
import { TempCard } from '@/components/temperature/TempCard'
import { TempChart } from '@/components/temperature/TempChart'
import { FeverAlert } from '@/components/temperature/FeverAlert'
import { useApp } from '../provider'
export default function TemperaturePage() {
const router = useRouter()
const { currentWorkspace, refreshData } = useApp()
const [serverData, setServerData] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const localData = useLiveQuery(
() =>
db.temperatureLogs
.where('workspaceId')
.equals(currentWorkspace.id)
.and((t) => !t.deletedAt)
.reverse()
.limit(50)
.toArray(),
[currentWorkspace.id]
)
const fetchData = useCallback(async () => {
try {
const response = await fetch(`/api/workspaces/${currentWorkspace.id}/temperature?limit=50`)
if (response.ok) {
const data = await response.json()
setServerData(data.temperatureLogs)
}
} catch (err) {
console.error('Failed to fetch temperature logs:', err)
} finally {
setLoading(false)
}
}, [currentWorkspace.id])
useEffect(() => {
fetchData()
}, [fetchData])
const handleLogged = () => {
fetchData()
refreshData()
}
const readings = serverData.length > 0 ? serverData : localData || []
const latestTemp = readings[0]?.tempCelsius ?? null
if (loading && !localData) {
return (
<>
<Header title="Temperature" />
<PageContainer><LoadingState message="Loading temperature logs..." /></PageContainer>
</>
)
}
return (
<>
<Header
title="Temperature"
rightAction={{
icon: <History className="w-6 h-6 text-secondary-700" />,
label: 'History',
onClick: () => router.push('/temperature/history'),
}}
/>
<PageContainer className="pt-4 space-y-6">
{/* Fever Alert */}
{latestTemp !== null && latestTemp >= 38.0 && (
<FeverAlert tempCelsius={latestTemp} clinicPhone={currentWorkspace.clinicPhone} />
)}
{/* Quick Log */}
<section>
<h2 className="text-lg font-semibold text-secondary-900 mb-3">Log Temperature</h2>
<Card>
<TempQuickLog workspaceId={currentWorkspace.id} onLogged={handleLogged} />
</Card>
</section>
{/* 7-Day Chart */}
{readings.length > 0 && (
<section>
<h2 className="text-lg font-semibold text-secondary-900 mb-3">Last 7 Days</h2>
<Card>
<TempChart readings={readings.map((r: any) => ({ tempCelsius: r.tempCelsius, recordedAt: r.recordedAt }))} />
</Card>
</section>
)}
{/* Recent Readings */}
<section>
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-secondary-900">Recent</h2>
</div>
{readings.length === 0 ? (
<Card variant="outline" className="text-center py-8">
<Thermometer className="w-10 h-10 text-secondary-300 mx-auto mb-3" />
<p className="text-secondary-500">No temperature readings yet</p>
<p className="text-sm text-secondary-400 mt-1">Use the form above to log your temperature</p>
</Card>
) : (
<div className="space-y-3">
{readings.slice(0, 5).map((reading: any) => (
<TempCard key={reading.id} reading={reading} />
))}
</div>
)}
</section>
</PageContainer>
</>
)
}

View File

@@ -0,0 +1,145 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { Plus, Milestone } 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 { ProgressBar } from '@/components/timeline/ProgressBar'
import { TimelineView } from '@/components/timeline/TimelineView'
import { MilestoneForm } from '@/components/timeline/MilestoneForm'
import { useApp } from '../provider'
import { showToast } from '@/components/ui'
export default function TimelinePage() {
const { currentWorkspace, refreshData } = useApp()
const [serverData, setServerData] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
const [editingMilestone, setEditingMilestone] = useState<any | null>(null)
const localData = useLiveQuery(
() =>
db.milestones
.where('workspaceId')
.equals(currentWorkspace.id)
.and((m) => !m.deletedAt)
.toArray(),
[currentWorkspace.id]
)
const fetchData = useCallback(async () => {
try {
const response = await fetch(`/api/workspaces/${currentWorkspace.id}/milestones`)
if (response.ok) {
const data = await response.json()
setServerData(data.milestones)
}
} catch (err) {
console.error('Failed to fetch milestones:', err)
} finally {
setLoading(false)
}
}, [currentWorkspace.id])
useEffect(() => {
fetchData()
}, [fetchData])
const handleSaved = () => {
fetchData()
refreshData()
setShowForm(false)
setEditingMilestone(null)
}
const handleEdit = (milestone: any) => {
setEditingMilestone(milestone)
setShowForm(true)
}
const handleStatusChange = async (id: string, status: string) => {
try {
const response = await fetch(
`/api/workspaces/${currentWorkspace.id}/milestones/${id}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status }),
}
)
if (!response.ok) throw new Error('Failed to update status')
showToast('Status updated', 'success')
fetchData()
refreshData()
} catch {
showToast('Failed to update status', 'error')
}
}
const milestones = serverData.length > 0 ? serverData : localData || []
if (loading && !localData) {
return (
<>
<Header title="Treatment Journey" />
<PageContainer><LoadingState message="Loading milestones..." /></PageContainer>
</>
)
}
return (
<>
<Header
title="Treatment Journey"
rightAction={{
icon: <Plus className="w-6 h-6 text-secondary-700" />,
label: 'Add Milestone',
onClick: () => {
setEditingMilestone(null)
setShowForm(true)
},
}}
/>
<PageContainer className="pt-4 space-y-6">
{/* Progress Bar */}
{milestones.length > 0 && (
<section>
<ProgressBar milestones={milestones} />
</section>
)}
{/* Timeline */}
{milestones.length === 0 ? (
<Card variant="outline" className="text-center py-8">
<Milestone className="w-10 h-10 text-secondary-300 mx-auto mb-3" />
<p className="text-secondary-500">No milestones yet</p>
<p className="text-sm text-secondary-400 mt-1">Track your treatment journey by adding milestones</p>
</Card>
) : (
<section>
<TimelineView
milestones={milestones}
onEdit={handleEdit}
onStatusChange={handleStatusChange}
/>
</section>
)}
</PageContainer>
{/* Form Modal */}
<MilestoneForm
isOpen={showForm}
onClose={() => {
setShowForm(false)
setEditingMilestone(null)
}}
onSaved={handleSaved}
workspaceId={currentWorkspace.id}
initialData={editingMilestone}
/>
</>
)
}

View File

@@ -4,7 +4,7 @@ import { useEffect, useState, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { format, isToday, isTomorrow } from 'date-fns'
import { toZonedTime } from 'date-fns-tz'
import { Phone, MapPin, Clock, ChevronRight, Pill, Calendar, Plus, AlertTriangle, ClipboardCheck } from 'lucide-react'
import { Phone, MapPin, Clock, ChevronRight, Pill, Calendar, Plus, AlertTriangle, ClipboardCheck, Heart, Thermometer, Weight, Milestone } from 'lucide-react'
import { useLiveQuery } from 'dexie-react-hooks'
import { db, logDose, undoDose } from '@/lib/sync'
@@ -23,6 +23,11 @@ export default function TodayPage() {
const [now, setNow] = useState(() => new Date())
const [quickNote, setQuickNote] = useState('')
const [isAddingNote, setIsAddingNote] = useState(false)
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
// Update time every minute
useEffect(() => {
@@ -60,6 +65,43 @@ export default function TodayPage() {
[currentWorkspace.id]
)
// Latest temperature reading
const latestTemp = useLiveQuery(
() =>
db.temperatureLogs
.where('workspaceId')
.equals(currentWorkspace.id)
.and((t) => !t.deletedAt)
.reverse()
.sortBy('recordedAt')
.then((logs) => logs[0] ?? null),
[currentWorkspace.id]
)
// Latest weight reading
const latestWeight = useLiveQuery(
() =>
db.weightLogs
.where('workspaceId')
.equals(currentWorkspace.id)
.and((w) => !w.deletedAt)
.reverse()
.sortBy('recordedAt')
.then((logs) => logs[0] ?? null),
[currentWorkspace.id]
)
// Pending caregiver tasks (due today or overdue)
const pendingTasks = useLiveQuery(
() =>
db.caregiverTasks
.where('workspaceId')
.equals(currentWorkspace.id)
.and((t) => !t.deletedAt && t.status !== 'DONE')
.toArray(),
[currentWorkspace.id]
)
// Calculate medication due statuses
const [medStatuses, setMedStatuses] = useState<MedicationDueStatus[]>([])
@@ -149,7 +191,14 @@ export default function TodayPage() {
const date = toZonedTime(new Date(datetime), TIMEZONE)
if (isToday(date)) return `Today at ${format(date, 'h:mm a')}`
if (isTomorrow(date)) return `Tomorrow at ${format(date, 'h:mm a')}`
return format(date, 'EEE, MMM d \'at\' h:mm a')
return format(date, "EEE, MMM d 'at' h:mm a")
}
const getGreeting = () => {
const hour = now.getHours()
if (hour < 12) return 'Good morning'
if (hour < 17) return 'Good afternoon'
return 'Good evening'
}
if (!appointments || !medications) {
@@ -166,27 +215,41 @@ export default function TodayPage() {
return (
<>
<Header title="Today" />
<PageContainer className="pt-4 space-y-6">
{/* Greeting */}
<div className="mb-2">
<p className="text-secondary-500 text-sm">
{format(toZonedTime(now, TIMEZONE), 'EEEE, MMMM d')}
</p>
<PageContainer className="pt-6 pb-24 space-y-8">
{/* Greeting Section with decorative elements */}
<div className={`relative transition-all duration-700 ${mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'}`}>
{/* Decorative blob */}
<div className="blob blob-primary w-32 h-32 -top-4 -left-4" />
<div className="relative">
<p className="text-secondary-500 text-sm font-medium tracking-wide uppercase mb-1">
{format(toZonedTime(now, TIMEZONE), 'EEEE, MMMM d')}
</p>
<h1 className="font-display text-display-sm text-secondary-900">
{getGreeting()}
</h1>
<p className="text-secondary-600 mt-2 flex items-center gap-2">
<Heart className="w-4 h-4 text-accent-500" />
<span>Take it one step at a time</span>
</p>
</div>
</div>
{/* Emergency & Call Clinic Buttons */}
<div className="flex gap-3">
{/* Emergency & Call Clinic Buttons - Floating cards */}
<div className={`flex gap-3 transition-all duration-700 delay-100 ${mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'}`}>
{/* Emergency Info Button */}
<button
onClick={() => router.push('/emergency')}
className="flex items-center gap-3 p-4 bg-red-50 rounded-card border border-red-200 hover:bg-red-100 transition-colors flex-1"
className="flex-1 group relative overflow-hidden"
>
<div className="w-10 h-10 rounded-full bg-red-600 flex items-center justify-center">
<AlertTriangle className="w-5 h-5 text-white" />
</div>
<div className="text-left">
<p className="font-medium text-red-800">Emergency</p>
<p className="text-sm text-red-600">Medical info</p>
<div className="relative flex items-center gap-3 p-4 bg-alert-50/80 backdrop-blur-sm rounded-card border border-alert-200/60 hover:border-alert-300 hover:shadow-elevated transition-all duration-300">
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-alert-500 to-alert-600 flex items-center justify-center shadow-lg group-hover:scale-105 transition-transform duration-300">
<AlertTriangle className="w-6 h-6 text-white" />
</div>
<div className="text-left">
<p className="font-semibold text-alert-800">Emergency</p>
<p className="text-sm text-alert-600">Medical info</p>
</div>
</div>
</button>
@@ -194,26 +257,28 @@ export default function TodayPage() {
{currentWorkspace.clinicPhone && (
<a
href={`tel:${currentWorkspace.clinicPhone}`}
className="flex items-center gap-3 p-4 bg-primary-50 rounded-card border border-primary-100 hover:bg-primary-100 transition-colors flex-1"
className="flex-1 group"
>
<div className="w-10 h-10 rounded-full bg-primary-500 flex items-center justify-center">
<Phone className="w-5 h-5 text-white" />
</div>
<div className="text-left">
<p className="font-medium text-primary-800">Call Clinic</p>
<p className="text-sm text-primary-600 truncate">{currentWorkspace.clinicPhone}</p>
<div className="flex items-center gap-3 p-4 bg-primary-50/80 backdrop-blur-sm rounded-card border border-primary-200/60 hover:border-primary-300 hover:shadow-elevated transition-all duration-300">
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-primary-500 to-primary-600 flex items-center justify-center shadow-lg group-hover:scale-105 transition-transform duration-300">
<Phone className="w-6 h-6 text-white" />
</div>
<div className="text-left">
<p className="font-semibold text-primary-800">Call Clinic</p>
<p className="text-sm text-primary-600 truncate max-w-[100px]">{currentWorkspace.clinicPhone}</p>
</div>
</div>
</a>
)}
</div>
{/* Next Appointment */}
<section>
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-secondary-900">Next Appointment</h2>
{/* Next Appointment - Hero Card */}
<section className={`transition-all duration-700 delay-200 ${mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'}`}>
<div className="flex items-center justify-between mb-4">
<h2 className="font-display text-xl text-secondary-900">Next Appointment</h2>
<button
onClick={() => router.push('/appointments')}
className="text-sm text-primary-600 font-medium flex items-center"
className="text-sm text-primary-600 font-medium flex items-center gap-0.5 hover:text-primary-700 transition-colors"
>
View all
<ChevronRight className="w-4 h-4" />
@@ -221,30 +286,30 @@ export default function TodayPage() {
</div>
{nextAppointment ? (
<Card
className="card-appointment"
<div
className="card-appointment cursor-pointer group"
onClick={() => router.push(`/appointments/${nextAppointment.id}`)}
>
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center flex-shrink-0">
<Calendar className="w-5 h-5 text-primary-600" />
<div className="flex items-start gap-4">
<div className="w-14 h-14 rounded-full bg-gradient-to-br from-primary-100 to-primary-200 flex items-center justify-center flex-shrink-0 shadow-inner">
<Calendar className="w-7 h-7 text-primary-600" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-secondary-900 truncate">
<h3 className="font-display text-lg text-secondary-900 truncate group-hover:text-primary-700 transition-colors">
{nextAppointment.title}
</h3>
<p className="text-sm text-secondary-600 flex items-center gap-1 mt-1">
<Clock className="w-4 h-4" />
<p className="text-sm text-secondary-600 flex items-center gap-1.5 mt-1.5">
<Clock className="w-4 h-4 text-primary-500" />
{formatAppointmentDate(nextAppointment.datetime)}
</p>
{nextAppointment.location && (
<p className="text-sm text-secondary-500 flex items-center gap-1 mt-0.5">
<MapPin className="w-4 h-4" />
<p className="text-sm text-secondary-500 flex items-center gap-1.5 mt-1">
<MapPin className="w-4 h-4 text-cream-600" />
<span className="truncate">{nextAppointment.location}</span>
</p>
)}
</div>
<ChevronRight className="w-5 h-5 text-secondary-400" />
<ChevronRight className="w-5 h-5 text-secondary-300 group-hover:text-primary-500 group-hover:translate-x-1 transition-all" />
</div>
{nextAppointment.mapUrl && (
<a
@@ -252,27 +317,27 @@ export default function TodayPage() {
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1.5 mt-3 text-sm text-primary-600 font-medium hover:text-primary-700"
className="inline-flex items-center gap-1.5 mt-4 text-sm text-primary-600 font-medium hover:text-primary-700 hover:underline"
>
<MapPin className="w-4 h-4" />
Open in Maps
</a>
)}
</Card>
</div>
) : (
<Card variant="outline" className="text-center py-6">
<Calendar className="w-8 h-8 text-secondary-300 mx-auto mb-2" />
<p className="text-secondary-500">No upcoming appointments</p>
<Button
variant="ghost"
size="sm"
className="mt-2"
<div className="section-warm text-center py-8">
<div className="w-16 h-16 rounded-full bg-cream-100 flex items-center justify-center mx-auto mb-4">
<Calendar className="w-8 h-8 text-cream-600" />
</div>
<p className="text-secondary-600 font-medium">No upcoming appointments</p>
<button
onClick={() => router.push('/appointments/new')}
className="mt-4 inline-flex items-center gap-2 text-primary-600 font-medium hover:text-primary-700"
>
<Plus className="w-4 h-4 mr-1" />
<Plus className="w-4 h-4" />
Add one
</Button>
</Card>
</button>
</div>
)}
</section>
@@ -283,26 +348,26 @@ export default function TodayPage() {
)
if (tomorrowAppt) {
return (
<section>
<Card
className="bg-green-50 border border-green-200 cursor-pointer hover:bg-green-100 transition-colors"
<section className={`transition-all duration-700 delay-300 ${mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'}`}>
<div
className="bg-gradient-to-r from-cream-100 to-cream-50 border border-cream-200 rounded-card p-5 cursor-pointer hover:shadow-elevated transition-all duration-300 group"
onClick={() => router.push(`/appointments/${tomorrowAppt.id}/prep`)}
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-green-500 flex items-center justify-center flex-shrink-0">
<ClipboardCheck className="w-5 h-5 text-white" />
<div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-full bg-gradient-to-br from-accent-400 to-accent-500 flex items-center justify-center flex-shrink-0 shadow-lg group-hover:scale-105 transition-transform">
<ClipboardCheck className="w-7 h-7 text-white" />
</div>
<div className="flex-1">
<p className="font-medium text-green-800">
<p className="font-display text-lg text-secondary-900">
Prepare for tomorrow
</p>
<p className="text-sm text-green-600">
<p className="text-sm text-secondary-600">
{tomorrowAppt.title} at {format(toZonedTime(new Date(tomorrowAppt.datetime), TIMEZONE), 'h:mm a')}
</p>
</div>
<ChevronRight className="w-5 h-5 text-green-500" />
<ChevronRight className="w-5 h-5 text-accent-500 group-hover:translate-x-1 transition-transform" />
</div>
</Card>
</div>
</section>
)
}
@@ -311,23 +376,25 @@ export default function TodayPage() {
{/* Refill Alerts */}
{medications && medications.length > 0 && (
<RefillAlert
medications={medications.map(m => ({
id: m.id,
name: m.name,
pillCount: m.pillCount,
refillThreshold: m.refillThreshold,
}))}
/>
<div className={`transition-all duration-700 delay-400 ${mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'}`}>
<RefillAlert
medications={medications.map(m => ({
id: m.id,
name: m.name,
pillCount: m.pillCount,
refillThreshold: m.refillThreshold,
}))}
/>
</div>
)}
{/* Meds Due */}
<section>
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-secondary-900">Medications</h2>
<section className={`transition-all duration-700 delay-500 ${mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'}`}>
<div className="flex items-center justify-between mb-4">
<h2 className="font-display text-xl text-secondary-900">Medications</h2>
<button
onClick={() => router.push('/meds')}
className="text-sm text-primary-600 font-medium flex items-center"
className="text-sm text-primary-600 font-medium flex items-center gap-0.5 hover:text-primary-700 transition-colors"
>
View all
<ChevronRight className="w-4 h-4" />
@@ -335,21 +402,25 @@ export default function TodayPage() {
</div>
{medsDueSoon.length > 0 ? (
<div className="space-y-3">
{medsDueSoon.map((status) => (
<div className="space-y-4">
{medsDueSoon.map((status, index) => (
<MedicationCard
key={status.medication.id}
status={status}
now={now}
onTake={() => handleTakeMed(status)}
index={index}
/>
))}
</div>
) : medications.length > 0 ? (
<Card variant="outline" className="text-center py-6">
<Pill className="w-8 h-8 text-secondary-300 mx-auto mb-2" />
<p className="text-secondary-500">All caught up! No meds due soon.</p>
</Card>
<div className="section-warm text-center py-8">
<div className="w-16 h-16 rounded-full bg-primary-50 flex items-center justify-center mx-auto mb-4">
<Pill className="w-8 h-8 text-primary-400" />
</div>
<p className="text-secondary-600 font-medium">All caught up!</p>
<p className="text-sm text-secondary-400 mt-1">No medications due soon</p>
</div>
) : (
<EmptyState
type="medications"
@@ -363,17 +434,91 @@ export default function TodayPage() {
)}
</section>
{/* Health Snapshot Cards */}
<section className={`transition-all duration-700 delay-[600ms] ${mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'}`}>
<h2 className="font-display text-xl text-secondary-900 mb-4">Health Snapshot</h2>
<div className="grid grid-cols-2 gap-3">
{/* Temperature Card */}
<button
onClick={() => router.push('/temperature')}
className="bg-surface border border-border rounded-card p-4 text-left hover:shadow-elevated transition-all group"
>
<div className="w-10 h-10 rounded-full bg-red-50 flex items-center justify-center mb-3 group-hover:scale-105 transition-transform">
<Thermometer className="w-5 h-5 text-red-500" />
</div>
<p className="text-sm text-secondary-500">Temperature</p>
{latestTemp ? (
<p className={`text-xl font-display mt-0.5 ${
latestTemp.tempCelsius >= 38.0 ? 'text-red-600' : 'text-secondary-900'
}`}>
{latestTemp.tempCelsius}°C
</p>
) : (
<p className="text-sm text-secondary-400 mt-0.5">No readings</p>
)}
</button>
{/* Weight Card */}
<button
onClick={() => router.push('/weight')}
className="bg-surface border border-border rounded-card p-4 text-left hover:shadow-elevated transition-all group"
>
<div className="w-10 h-10 rounded-full bg-blue-50 flex items-center justify-center mb-3 group-hover:scale-105 transition-transform">
<Weight className="w-5 h-5 text-blue-500" />
</div>
<p className="text-sm text-secondary-500">Weight</p>
{latestWeight ? (
<p className="text-xl font-display text-secondary-900 mt-0.5">
{latestWeight.weightKg} kg
</p>
) : (
<p className="text-sm text-secondary-400 mt-0.5">No readings</p>
)}
</button>
{/* Tasks Card */}
<button
onClick={() => router.push('/tasks')}
className="bg-surface border border-border rounded-card p-4 text-left hover:shadow-elevated transition-all group"
>
<div className="w-10 h-10 rounded-full bg-accent-50 flex items-center justify-center mb-3 group-hover:scale-105 transition-transform">
<ClipboardCheck className="w-5 h-5 text-accent-500" />
</div>
<p className="text-sm text-secondary-500">Tasks</p>
{pendingTasks && pendingTasks.length > 0 ? (
<p className="text-xl font-display text-secondary-900 mt-0.5">
{pendingTasks.length} pending
</p>
) : (
<p className="text-sm text-secondary-400 mt-0.5">All done</p>
)}
</button>
{/* Timeline Card */}
<button
onClick={() => router.push('/timeline')}
className="bg-surface border border-border rounded-card p-4 text-left hover:shadow-elevated transition-all group"
>
<div className="w-10 h-10 rounded-full bg-primary-50 flex items-center justify-center mb-3 group-hover:scale-105 transition-transform">
<Milestone className="w-5 h-5 text-primary-500" />
</div>
<p className="text-sm text-secondary-500">Timeline</p>
<p className="text-sm text-primary-600 font-medium mt-0.5">View progress</p>
</button>
</div>
</section>
{/* Quick Note */}
<section>
<h2 className="text-lg font-semibold text-secondary-900 mb-3">Quick Note</h2>
<Card padding="sm">
<div className="flex gap-2">
<section className={`transition-all duration-700 delay-[700ms] ${mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'}`}>
<h2 className="font-display text-xl text-secondary-900 mb-4">Quick Note</h2>
<div className="section-warm">
<div className="flex gap-3">
<input
type="text"
value={quickNote}
onChange={(e) => setQuickNote(e.target.value)}
placeholder="Jot down a thought..."
className="flex-1 px-3 py-2.5 border border-border rounded-button text-base focus:outline-none focus:ring-2 focus:ring-primary-200 focus:border-primary-500"
className="input-sanctuary flex-1"
onKeyDown={(e) => {
if (e.key === 'Enter' && quickNote.trim()) {
handleAddQuickNote()
@@ -384,11 +529,12 @@ export default function TodayPage() {
onClick={handleAddQuickNote}
disabled={!quickNote.trim() || isAddingNote}
loading={isAddingNote}
className="btn-primary whitespace-nowrap"
>
Add
</Button>
</div>
</Card>
</div>
</section>
</PageContainer>
</>
@@ -399,9 +545,10 @@ interface MedicationCardProps {
status: MedicationDueStatus
now: Date
onTake: () => void
index: number
}
function MedicationCard({ status, now, onTake }: MedicationCardProps) {
function MedicationCard({ status, now, onTake, index }: MedicationCardProps) {
const { medication, isOverdue, isPRN, prnAvailable, prnAvailableAt, nextDueAt } = status
const getTimeLabel = () => {
@@ -426,35 +573,38 @@ function MedicationCard({ status, now, onTake }: MedicationCardProps) {
const canTake = !isPRN || prnAvailable
return (
<Card className={isOverdue ? 'overdue' : ''}>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center flex-shrink-0">
<Pill className="w-5 h-5 text-primary-600" />
<div className={`card-medication ${isOverdue ? 'overdue' : ''} animate-fade-up`} style={{ animationDelay: `${index * 0.1}s` }}>
<div className="flex items-center gap-4">
<div className={`w-14 h-14 rounded-full flex items-center justify-center flex-shrink-0 shadow-inner transition-all duration-300 ${
isOverdue
? 'bg-gradient-to-br from-accent-100 to-accent-200'
: 'bg-gradient-to-br from-primary-100 to-primary-200'
}`}>
<Pill className={`w-7 h-7 ${isOverdue ? 'text-accent-600' : 'text-primary-600'}`} />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-secondary-900">{medication.name}</h3>
<p className={`text-sm ${isOverdue ? 'text-red-600 font-medium' : 'text-secondary-500'}`}>
<h3 className="font-display text-lg text-secondary-900">{medication.name}</h3>
<p className={`text-sm ${isOverdue ? 'text-accent-600 font-medium' : 'text-secondary-500'}`}>
{getTimeLabel()}
{isPRN && ' • As needed'}
</p>
</div>
<Button
<button
onClick={(e) => {
e.stopPropagation()
onTake()
}}
variant="success"
size="md"
disabled={!canTake}
className={`btn-primary text-sm px-5 py-2.5 ${!canTake ? 'opacity-50 cursor-not-allowed' : ''}`}
>
Taken
</Button>
</button>
</div>
{medication.instructions && (
<p className="text-sm text-secondary-500 mt-2 ml-13">
<p className="text-sm text-secondary-500 mt-3 ml-[72px]">
{medication.instructions}
</p>
)}
</Card>
</div>
)
}

View File

@@ -0,0 +1,148 @@
'use client'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { History, Scale } 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 { WeightQuickLog } from '@/components/weight/WeightQuickLog'
import { WeightCard } from '@/components/weight/WeightCard'
import { WeightChart } from '@/components/weight/WeightChart'
import { WeightAlert } from '@/components/weight/WeightAlert'
import { useApp } from '../provider'
export default function WeightPage() {
const router = useRouter()
const { currentWorkspace, refreshData } = useApp()
const [serverData, setServerData] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const localData = useLiveQuery(
() =>
db.weightLogs
.where('workspaceId')
.equals(currentWorkspace.id)
.and((w) => !w.deletedAt)
.reverse()
.limit(100)
.toArray(),
[currentWorkspace.id]
)
const fetchData = useCallback(async () => {
try {
const response = await fetch(`/api/workspaces/${currentWorkspace.id}/weight?limit=100`)
if (response.ok) {
const data = await response.json()
setServerData(data.weightLogs)
}
} catch (err) {
console.error('Failed to fetch weight logs:', err)
} finally {
setLoading(false)
}
}, [currentWorkspace.id])
useEffect(() => {
fetchData()
}, [fetchData])
const handleLogged = () => {
fetchData()
refreshData()
}
const readings = useMemo(
() => (serverData.length > 0 ? serverData : localData || []),
[serverData, localData]
)
// Check for rapid weight change
const rapidChange = useMemo(() => {
if (readings.length < 2) return null
const latest = readings[0]
const previous = readings[1]
const hoursDiff = (new Date(latest.recordedAt).getTime() - new Date(previous.recordedAt).getTime()) / (1000 * 60 * 60)
if (hoursDiff <= 48 && Math.abs(latest.weightKg - previous.weightKg) >= 2) {
return { currentKg: latest.weightKg, previousKg: previous.weightKg, timeframeHours: Math.round(hoursDiff) }
}
return null
}, [readings])
if (loading && !localData) {
return (
<>
<Header title="Weight" />
<PageContainer><LoadingState message="Loading weight logs..." /></PageContainer>
</>
)
}
return (
<>
<Header
title="Weight"
rightAction={{
icon: <History className="w-6 h-6 text-secondary-700" />,
label: 'History',
onClick: () => router.push('/weight/history'),
}}
/>
<PageContainer className="pt-4 space-y-6">
{/* Rapid Change Alert */}
{rapidChange && (
<WeightAlert
currentKg={rapidChange.currentKg}
previousKg={rapidChange.previousKg}
timeframeHours={rapidChange.timeframeHours}
/>
)}
{/* Quick Log */}
<section>
<h2 className="text-lg font-semibold text-secondary-900 mb-3">Log Weight</h2>
<Card>
<WeightQuickLog workspaceId={currentWorkspace.id} onLogged={handleLogged} />
</Card>
</section>
{/* 30-Day Trend */}
{readings.length >= 2 && (
<section>
<h2 className="text-lg font-semibold text-secondary-900 mb-3">30-Day Trend</h2>
<Card>
<WeightChart readings={readings.map((r: any) => ({ weightKg: r.weightKg, recordedAt: r.recordedAt }))} />
</Card>
</section>
)}
{/* Recent Readings */}
<section>
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-secondary-900">Recent</h2>
</div>
{readings.length === 0 ? (
<Card variant="outline" className="text-center py-8">
<Scale className="w-10 h-10 text-secondary-300 mx-auto mb-3" />
<p className="text-secondary-500">No weight readings yet</p>
<p className="text-sm text-secondary-400 mt-1">Use the form above to track your weight</p>
</Card>
) : (
<div className="space-y-3">
{readings.slice(0, 5).map((reading: any, i: number) => (
<WeightCard
key={reading.id}
reading={reading}
previousKg={i < readings.length - 1 ? readings[i + 1]?.weightKg : null}
/>
))}
</div>
)}
</section>
</PageContainer>
</>
)
}

View File

@@ -0,0 +1,57 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { hashPassword, verifyPassword, withAuth, type AuthenticatedRequest } from '@/lib/auth'
import { z } from 'zod'
const changePasswordSchema = z.object({
currentPassword: z.string().min(1, 'Current password is required'),
newPassword: z.string().min(8, 'Password must be at least 8 characters'),
})
export const POST = withAuth(async (req: AuthenticatedRequest) => {
try {
const body = await req.json()
const result = changePasswordSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{ error: 'Invalid input', details: result.error.flatten() },
{ status: 400 }
)
}
const { currentPassword, newPassword } = result.data
const userId = req.session.user.id
// Get current user with password hash
const user = await prisma.user.findUnique({
where: { id: userId },
select: { passwordHash: true },
})
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 })
}
// Verify current password
const validPassword = await verifyPassword(user.passwordHash, currentPassword)
if (!validPassword) {
return NextResponse.json({ error: 'Current password is incorrect' }, { status: 401 })
}
// Hash new password and update user
const newPasswordHash = await hashPassword(newPassword)
await prisma.user.update({
where: { id: userId },
data: {
passwordHash: newPasswordHash,
forcePasswordReset: false, // Clear the forced reset flag
},
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Change password error:', error)
return NextResponse.json({ error: 'Failed to change password' }, { status: 500 })
}
})

View File

@@ -44,6 +44,7 @@ async function handler(req: NextRequest) {
email: true,
name: true,
passwordHash: true,
forcePasswordReset: true,
},
})
@@ -65,13 +66,23 @@ async function handler(req: NextRequest) {
)
}
// Record successful login
await recordLoginAttempt(email.toLowerCase(), true, ipAddress)
// Record successful login and update lastLoginAt
await Promise.all([
recordLoginAttempt(email.toLowerCase(), true, ipAddress),
prisma.user.update({
where: { id: user.id },
data: { lastLoginAt: new Date() },
}),
])
// Create session
const userAgent = req.headers.get('user-agent') || undefined
const token = await createSession(user.id, userAgent, ipAddress)
const cookieConfig = getSessionCookieConfig(token)
const cookieConfig = getSessionCookieConfig(token, {
forwardedProto: req.headers.get('x-forwarded-proto'),
origin: req.headers.get('origin'),
referer: req.headers.get('referer'),
})
const response = NextResponse.json({
user: {
@@ -79,6 +90,7 @@ async function handler(req: NextRequest) {
email: user.email,
name: user.name,
},
forcePasswordReset: user.forcePasswordReset,
})
response.cookies.set(cookieConfig)

View File

@@ -1,7 +1,7 @@
import { NextResponse } from 'next/server'
import { NextRequest, NextResponse } from 'next/server'
import { getSession, deleteSession, getSessionCookieClearConfig } from '@/lib/auth'
export async function POST() {
export async function POST(req: NextRequest) {
try {
const session = await getSession()
@@ -9,7 +9,11 @@ export async function POST() {
await deleteSession(session.sessionId)
}
const cookieConfig = getSessionCookieClearConfig()
const cookieConfig = getSessionCookieClearConfig({
forwardedProto: req.headers.get('x-forwarded-proto'),
origin: req.headers.get('origin'),
referer: req.headers.get('referer'),
})
const response = NextResponse.json({ message: 'Logged out successfully' })
response.cookies.set(cookieConfig)
@@ -17,7 +21,11 @@ export async function POST() {
} catch (error) {
console.error('Logout error:', error)
// Still clear the cookie even on error
const cookieConfig = getSessionCookieClearConfig()
const cookieConfig = getSessionCookieClearConfig({
forwardedProto: req.headers.get('x-forwarded-proto'),
origin: req.headers.get('origin'),
referer: req.headers.get('referer'),
})
const response = NextResponse.json({ message: 'Logged out' })
response.cookies.set(cookieConfig)
return response

View File

@@ -49,7 +49,11 @@ async function handler(req: NextRequest) {
const userAgent = req.headers.get('user-agent') || undefined
const ipAddress = req.headers.get('x-forwarded-for')?.split(',')[0]
const token = await createSession(user.id, userAgent, ipAddress)
const cookieConfig = getSessionCookieConfig(token)
const cookieConfig = getSessionCookieConfig(token, {
forwardedProto: req.headers.get('x-forwarded-proto'),
origin: req.headers.get('origin'),
referer: req.headers.get('referer'),
})
const response = NextResponse.json({
user,

View File

@@ -0,0 +1,93 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
import { checkWorkspaceAccess } from '@/lib/db/workspace-access'
import { sendPushNotification } from '@/lib/notifications/push'
// POST /api/notifications/test - Send a test notification
export const POST = withAuth(async (req: AuthenticatedRequest) => {
try {
const body = await req.json()
const { workspaceId } = body
if (!workspaceId) {
return NextResponse.json({ error: 'workspaceId required' }, { status: 400 })
}
// Check access
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
// Get push subscriptions for this user in this workspace
const subscriptions = await prisma.pushSubscription.findMany({
where: {
userId: req.session.user.id,
workspaceId,
},
})
if (subscriptions.length === 0) {
return NextResponse.json(
{ error: 'No push subscriptions found. Please enable notifications first.' },
{ status: 404 }
)
}
let sent = 0
let failed = 0
const errors: string[] = []
for (const sub of subscriptions) {
try {
const success = await sendPushNotification(
{
endpoint: sub.endpoint,
p256dh: sub.p256dh,
auth: sub.auth,
},
{
title: 'Test Notification',
body: 'If you see this, notifications are working!',
tag: 'test-notification',
data: {
url: '/settings/notifications',
action: 'test',
},
}
)
if (success) {
sent++
} else {
// Subscription expired, remove it
await prisma.pushSubscription.delete({ where: { id: sub.id } })
failed++
errors.push('Subscription expired and was removed')
}
} catch (error: any) {
console.error('Test notification error:', error)
failed++
errors.push(error.message || 'Unknown error')
}
}
return NextResponse.json({
success: sent > 0,
sent,
failed,
total: subscriptions.length,
errors: errors.length > 0 ? errors : undefined,
message: sent > 0
? `Test notification sent! Check your device.`
: `Failed to send notification: ${errors.join(', ')}`,
})
} catch (error) {
console.error('Test notification error:', error)
return NextResponse.json(
{ error: 'Failed to send test notification' },
{ status: 500 }
)
}
})

View File

@@ -56,11 +56,17 @@ export async function GET(
membership.workspace.name
)
// Sanitize filename for HTTP headers (remove non-ASCII characters)
const safeFilename = membership.workspace.name
.replace(/[^\x00-\x7F]/g, '') // Remove non-ASCII
.replace(/[<>:"/\\|?*]/g, '-') // Replace invalid filename chars
.trim() || 'appointments'
return new NextResponse(icalContent, {
status: 200,
headers: {
'Content-Type': 'text/calendar; charset=utf-8',
'Content-Disposition': `attachment; filename="${membership.workspace.name}-appointments.ics"`,
'Content-Disposition': `attachment; filename="${safeFilename}-appointments.ics"`,
'Cache-Control': 'no-cache, no-store, must-revalidate',
},
})

View File

@@ -0,0 +1,73 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { checkWorkspaceAccess, canEdit } from '@/lib/db/workspace-access'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
import { contactSchema } from '@/lib/validation'
export const PATCH = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId, contactId } = 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 existing = await prisma.contact.findFirst({ where: { id: contactId, workspaceId, deletedAt: null } })
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 })
const body = await req.json()
const result = contactSchema.partial().safeParse(body)
if (!result.success) return NextResponse.json({ error: 'Invalid input', details: result.error.flatten() }, { status: 400 })
const contact = await prisma.contact.update({
where: { id: contactId },
data: { ...result.data, updatedById: req.session.user.id },
include: {
createdBy: { select: { id: true, name: true } },
updatedBy: { select: { id: true, name: true } },
},
})
await prisma.auditLog.create({
data: {
workspaceId, userId: req.session.user.id,
action: 'UPDATE', entityType: 'CONTACT', entityId: contactId,
details: result.data,
},
})
return NextResponse.json({ contact })
} catch (error) {
console.error('Update contact error:', error)
return NextResponse.json({ error: 'Failed to update contact' }, { status: 500 })
}
})
export const DELETE = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId, contactId } = 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 existing = await prisma.contact.findFirst({ where: { id: contactId, workspaceId, deletedAt: null } })
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 })
await prisma.contact.update({ where: { id: contactId }, data: { deletedAt: new Date() } })
await prisma.auditLog.create({
data: {
workspaceId, userId: req.session.user.id,
action: 'DELETE', entityType: 'CONTACT', entityId: contactId,
},
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Delete contact error:', error)
return NextResponse.json({ error: 'Failed to delete contact' }, { status: 500 })
}
})

View File

@@ -0,0 +1,87 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { checkWorkspaceAccess, canEdit } from '@/lib/db/workspace-access'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
import { contactSchema } from '@/lib/validation'
export const GET = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
const { searchParams } = new URL(req.url)
const category = searchParams.get('category')
const where: Record<string, unknown> = { workspaceId, deletedAt: null }
if (category) where.category = category
const contacts = await prisma.contact.findMany({
where,
orderBy: [{ isEmergency: 'desc' }, { sortOrder: 'asc' }, { name: 'asc' }],
include: {
createdBy: { select: { id: true, name: true } },
updatedBy: { select: { id: true, name: true } },
},
})
return NextResponse.json({ contacts })
} catch (error) {
console.error('List contacts error:', error)
return NextResponse.json({ error: 'Failed to list contacts' }, { status: 500 })
}
})
export const POST = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
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 = contactSchema.safeParse(body)
if (!result.success) return NextResponse.json({ error: 'Invalid input', details: result.error.flatten() }, { status: 400 })
const contact = await prisma.contact.create({
data: {
workspaceId,
name: result.data.name,
role: result.data.role,
category: result.data.category,
phone: result.data.phone,
phone2: result.data.phone2 || null,
email: result.data.email || null,
address: result.data.address || null,
hours: result.data.hours || null,
notes: result.data.notes || null,
isEmergency: result.data.isEmergency,
sortOrder: result.data.sortOrder,
createdById: req.session.user.id,
updatedById: req.session.user.id,
},
include: {
createdBy: { select: { id: true, name: true } },
updatedBy: { select: { id: true, name: true } },
},
})
await prisma.auditLog.create({
data: {
workspaceId, userId: req.session.user.id,
action: 'CREATE', entityType: 'CONTACT', entityId: contact.id,
details: { name: contact.name, category: contact.category },
},
})
return NextResponse.json({ contact }, { status: 201 })
} catch (error) {
console.error('Create contact error:', error)
return NextResponse.json({ error: 'Failed to create contact' }, { status: 500 })
}
})

View File

@@ -0,0 +1,68 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { checkWorkspaceAccess, canEdit } from '@/lib/db/workspace-access'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
export const GET = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId, docId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
const doc = await prisma.medicalDocument.findFirst({
where: { id: docId, workspaceId, deletedAt: null },
})
if (!doc) return NextResponse.json({ error: 'Not found' }, { status: 404 })
// Return the file data as a downloadable response
const uint8 = new Uint8Array(doc.fileData)
return new NextResponse(uint8, {
headers: {
'Content-Type': doc.mimeType,
'Content-Disposition': `inline; filename="${doc.fileName}"`,
'Content-Length': String(doc.fileSize),
},
})
} catch (error) {
console.error('Download document error:', error)
return NextResponse.json({ error: 'Failed to download document' }, { status: 500 })
}
})
export const DELETE = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId, docId } = 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 existing = await prisma.medicalDocument.findFirst({
where: { id: docId, workspaceId, deletedAt: null },
select: { id: true, title: true, category: true },
})
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 })
await prisma.medicalDocument.update({
where: { id: docId },
data: { deletedAt: new Date() },
})
await prisma.auditLog.create({
data: {
workspaceId, userId: req.session.user.id,
action: 'DELETE', entityType: 'MEDICAL_DOCUMENT', entityId: docId,
details: { title: existing.title },
},
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Delete document error:', error)
return NextResponse.json({ error: 'Failed to delete document' }, { status: 500 })
}
})

View File

@@ -0,0 +1,129 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { checkWorkspaceAccess, canEdit } from '@/lib/db/workspace-access'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB
const ALLOWED_TYPES = ['application/pdf', 'image/jpeg', 'image/png']
const VALID_CATEGORIES = ['LAB_REPORT', 'SCAN', 'INSURANCE', 'ID_CARD', 'PRESCRIPTION', 'OTHER']
export const GET = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
const { searchParams } = new URL(req.url)
const category = searchParams.get('category')
const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 200)
const where: Record<string, unknown> = { workspaceId, deletedAt: null }
if (category && VALID_CATEGORIES.includes(category)) where.category = category
// Return metadata only — no file data in list
const documents = await prisma.medicalDocument.findMany({
where,
orderBy: { createdAt: 'desc' },
take: limit,
select: {
id: true,
workspaceId: true,
title: true,
category: true,
fileName: true,
fileSize: true,
mimeType: true,
dateTaken: true,
expiryDate: true,
notes: true,
createdAt: true,
createdBy: { select: { id: true, name: true } },
},
})
return NextResponse.json({ documents })
} catch (error) {
console.error('List documents error:', error)
return NextResponse.json({ error: 'Failed to list documents' }, { status: 500 })
}
})
export const POST = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
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 formData = await req.formData()
const file = formData.get('file') as File | null
const title = formData.get('title') as string | null
const category = formData.get('category') as string | null
const dateTaken = formData.get('dateTaken') as string | null
const expiryDate = formData.get('expiryDate') as string | null
const notes = formData.get('notes') as string | null
if (!file) return NextResponse.json({ error: 'File is required' }, { status: 400 })
if (!title?.trim()) return NextResponse.json({ error: 'Title is required' }, { status: 400 })
if (!category || !VALID_CATEGORIES.includes(category)) {
return NextResponse.json({ error: 'Valid category is required' }, { status: 400 })
}
if (file.size > MAX_FILE_SIZE) {
return NextResponse.json({ error: 'File too large (max 10MB)' }, { status: 400 })
}
if (!ALLOWED_TYPES.includes(file.type)) {
return NextResponse.json({ error: 'Only PDF, JPG, and PNG files allowed' }, { status: 400 })
}
// Read file into buffer
const arrayBuffer = await file.arrayBuffer()
const fileData = Buffer.from(arrayBuffer)
const doc = await prisma.medicalDocument.create({
data: {
workspaceId,
title: title.trim(),
category,
fileName: file.name,
fileSize: file.size,
mimeType: file.type,
fileData,
dateTaken: dateTaken ? new Date(dateTaken) : null,
expiryDate: expiryDate ? new Date(expiryDate) : null,
notes: notes?.trim() || null,
createdById: req.session.user.id,
},
select: {
id: true,
title: true,
category: true,
fileName: true,
fileSize: true,
mimeType: true,
dateTaken: true,
expiryDate: true,
notes: true,
createdAt: true,
createdBy: { select: { id: true, name: true } },
},
})
await prisma.auditLog.create({
data: {
workspaceId, userId: req.session.user.id,
action: 'CREATE', entityType: 'MEDICAL_DOCUMENT', entityId: doc.id,
details: { title: doc.title, category: doc.category, fileSize: file.size },
},
})
return NextResponse.json({ document: doc }, { status: 201 })
} catch (error) {
console.error('Upload document error:', error)
return NextResponse.json({ error: 'Failed to upload document' }, { status: 500 })
}
})

View File

@@ -0,0 +1,83 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { checkWorkspaceAccess, canEdit } from '@/lib/db/workspace-access'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
import { labResultSchema } from '@/lib/validation'
export const PATCH = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId, labId } = 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 existing = await prisma.labResult.findFirst({ where: { id: labId, workspaceId, deletedAt: null } })
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 })
const body = await req.json()
const result = labResultSchema.partial().safeParse(body)
if (!result.success) return NextResponse.json({ error: 'Invalid input', details: result.error.flatten() }, { status: 400 })
const updateData: Record<string, unknown> = { updatedById: req.session.user.id }
if (result.data.testDate) updateData.testDate = new Date(result.data.testDate)
if (result.data.panelName !== undefined) updateData.panelName = result.data.panelName
if (result.data.labName !== undefined) updateData.labName = result.data.labName || null
if (result.data.results !== undefined) updateData.results = result.data.results as any
if (result.data.notes !== undefined) updateData.notes = result.data.notes || null
const labResult = await prisma.labResult.update({
where: { id: labId },
data: updateData,
include: {
createdBy: { select: { id: true, name: true } },
},
})
await prisma.auditLog.create({
data: {
workspaceId, userId: req.session.user.id,
action: 'UPDATE', entityType: 'LAB_RESULT', entityId: labId,
details: { panelName: labResult.panelName },
},
})
return NextResponse.json({ labResult })
} catch (error) {
console.error('Update lab result error:', error)
return NextResponse.json({ error: 'Failed to update lab result' }, { status: 500 })
}
})
export const DELETE = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId, labId } = 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 existing = await prisma.labResult.findFirst({ where: { id: labId, workspaceId, deletedAt: null } })
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 })
await prisma.labResult.update({
where: { id: labId },
data: { deletedAt: new Date(), updatedById: req.session.user.id },
})
await prisma.auditLog.create({
data: {
workspaceId, userId: req.session.user.id,
action: 'DELETE', entityType: 'LAB_RESULT', entityId: labId,
details: { panelName: existing.panelName },
},
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Delete lab result error:', error)
return NextResponse.json({ error: 'Failed to delete lab result' }, { status: 500 })
}
})

View File

@@ -0,0 +1,87 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { checkWorkspaceAccess, canEdit } from '@/lib/db/workspace-access'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
import { labResultSchema } from '@/lib/validation'
export const GET = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
const { searchParams } = new URL(req.url)
const from = searchParams.get('from')
const to = searchParams.get('to')
const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 200)
const where: Record<string, unknown> = { workspaceId, deletedAt: null }
if (from || to) {
const dateFilter: Record<string, Date> = {}
if (from) dateFilter.gte = new Date(from)
if (to) dateFilter.lte = new Date(to)
where.testDate = dateFilter
}
const labResults = await prisma.labResult.findMany({
where,
orderBy: { testDate: 'desc' },
take: limit,
include: {
createdBy: { select: { id: true, name: true } },
},
})
return NextResponse.json({ labResults })
} catch (error) {
console.error('List lab results error:', error)
return NextResponse.json({ error: 'Failed to list lab results' }, { status: 500 })
}
})
export const POST = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
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 = labResultSchema.safeParse(body)
if (!result.success) return NextResponse.json({ error: 'Invalid input', details: result.error.flatten() }, { status: 400 })
const labResult = await prisma.labResult.create({
data: {
workspaceId,
testDate: new Date(result.data.testDate),
panelName: result.data.panelName,
labName: result.data.labName || null,
results: result.data.results as any,
notes: result.data.notes || null,
createdById: req.session.user.id,
updatedById: req.session.user.id,
},
include: {
createdBy: { select: { id: true, name: true } },
},
})
await prisma.auditLog.create({
data: {
workspaceId, userId: req.session.user.id,
action: 'CREATE', entityType: 'LAB_RESULT', entityId: labResult.id,
details: { panelName: labResult.panelName, markerCount: result.data.results.length },
},
})
return NextResponse.json({ labResult }, { status: 201 })
} catch (error) {
console.error('Create lab result error:', error)
return NextResponse.json({ error: 'Failed to create lab result' }, { status: 500 })
}
})

View File

@@ -0,0 +1,66 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { checkWorkspaceAccess } from '@/lib/db/workspace-access'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
interface StoredMarker {
marker: string
value: number
unit: string
refMin: number | null
refMax: number | null
flag: string | null
}
export const GET = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
const { searchParams } = new URL(req.url)
const markerName = searchParams.get('marker')
if (!markerName) return NextResponse.json({ error: 'marker query param required' }, { status: 400 })
// Fetch all lab results with this marker
const labResults = await prisma.labResult.findMany({
where: { workspaceId, deletedAt: null },
orderBy: { testDate: 'asc' },
select: { testDate: true, results: true },
})
// Extract the specific marker from each result
const trendData: Array<{
date: string
value: number
unit: string
refMin: number | null
refMax: number | null
}> = []
for (const lr of labResults) {
const markers = lr.results as unknown as StoredMarker[]
if (!Array.isArray(markers)) continue
const found = markers.find(
(m) => m.marker.toLowerCase() === markerName.toLowerCase()
)
if (found) {
trendData.push({
date: lr.testDate.toISOString(),
value: found.value,
unit: found.unit,
refMin: found.refMin ?? null,
refMax: found.refMax ?? null,
})
}
}
return NextResponse.json({ marker: markerName, trendData })
} catch (error) {
console.error('Lab result trends error:', error)
return NextResponse.json({ error: 'Failed to fetch trends' }, { status: 500 })
}
})

View File

@@ -0,0 +1,78 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { checkWorkspaceAccess } from '@/lib/db/workspace-access'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
import { checkInteractions } from '@/lib/interactions/checker'
export const POST = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
// Get all active medications for this workspace
const medications = await prisma.medication.findMany({
where: { workspaceId, active: true, deletedAt: null },
select: { id: true, name: true },
})
if (medications.length < 2) {
return NextResponse.json({
interactions: [],
message: 'Need at least 2 active medications to check for interactions.',
medicationCount: medications.length,
})
}
const medNames = medications.map((m) => m.name)
const interactions = checkInteractions(medNames)
// Cache results in DB for quick retrieval
// Clear old interactions for this workspace first
await prisma.drugInteraction.deleteMany({ where: { workspaceId } })
// Save new interactions
if (interactions.length > 0) {
// Map drug names back to medication IDs
const nameToId = new Map(medications.map((m) => [m.name.toLowerCase(), m.id]))
for (const interaction of interactions) {
const med1Id = nameToId.get(interaction.drug1Name.toLowerCase())
const med2Id = nameToId.get(interaction.drug2Name.toLowerCase())
if (med1Id && med2Id) {
await prisma.drugInteraction.create({
data: {
workspaceId,
medication1Id: med1Id,
medication2Id: med2Id,
severity: interaction.severity,
description: interaction.description,
},
}).catch(() => {
// Ignore duplicate key errors
})
}
}
}
await prisma.auditLog.create({
data: {
workspaceId, userId: req.session.user.id,
action: 'CREATE', entityType: 'DRUG_INTERACTION', entityId: workspaceId,
details: { medicationCount: medications.length, interactionsFound: interactions.length },
},
})
return NextResponse.json({
interactions,
medicationCount: medications.length,
checkedAt: new Date().toISOString(),
})
} catch (error) {
console.error('Check interactions error:', error)
return NextResponse.json({ error: 'Failed to check interactions' }, { status: 500 })
}
})

View File

@@ -0,0 +1,74 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { withAuth, type AuthenticatedRequest, hashPassword } from '@/lib/auth'
import { checkWorkspaceAccess } from '@/lib/db/workspace-access'
import { z } from 'zod'
const resetPasswordSchema = z.object({
newPassword: z.string().min(8, 'Password must be at least 8 characters'),
forceChange: z.boolean().default(true),
})
// POST /api/workspaces/[id]/members/[memberId]/reset-password - Reset user password
export const POST = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId, memberId } = await params
// Check access (must be owner)
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access || access.role !== 'OWNER') {
return NextResponse.json({ error: 'Only owners can reset passwords' }, { status: 403 })
}
const body = await req.json()
const result = resetPasswordSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{ error: 'Invalid input', details: result.error.flatten() },
{ status: 400 }
)
}
const { newPassword, forceChange } = result.data
// Get the member
const member = await prisma.workspaceMember.findFirst({
where: { id: memberId, workspaceId },
include: { user: true },
})
if (!member) {
return NextResponse.json({ error: 'Member not found' }, { status: 404 })
}
// Hash new password and update user
const passwordHash = await hashPassword(newPassword)
await prisma.user.update({
where: { id: member.userId },
data: {
passwordHash,
forcePasswordReset: forceChange,
},
})
// Invalidate all existing sessions for this user
await prisma.session.deleteMany({
where: { userId: member.userId },
})
return NextResponse.json({
success: true,
message: forceChange
? 'Password reset. User must change password on next login.'
: 'Password reset successfully.',
})
} catch (error) {
console.error('Reset password error:', error)
return NextResponse.json({ error: 'Failed to reset password' }, { status: 500 })
}
})

View File

@@ -0,0 +1,161 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
import { checkWorkspaceAccess } from '@/lib/db/workspace-access'
import { z } from 'zod'
// GET /api/workspaces/[id]/members/[memberId] - Get member details
export const GET = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId, memberId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
const member = await prisma.workspaceMember.findFirst({
where: { id: memberId, workspaceId },
include: {
user: {
select: {
id: true,
name: true,
email: true,
lastLoginAt: true,
forcePasswordReset: true,
createdAt: true,
},
},
},
})
if (!member) {
return NextResponse.json({ error: 'Member not found' }, { status: 404 })
}
return NextResponse.json({ member })
} catch (error) {
console.error('Get member error:', error)
return NextResponse.json({ error: 'Failed to get member' }, { status: 500 })
}
})
const updateMemberSchema = z.object({
role: z.enum(['OWNER', 'EDITOR', 'VIEWER']).optional(),
})
// PATCH /api/workspaces/[id]/members/[memberId] - Update member role
export const PATCH = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId, memberId } = await params
// Check access (must be owner)
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access || access.role !== 'OWNER') {
return NextResponse.json({ error: 'Only owners can update members' }, { status: 403 })
}
const body = await req.json()
const result = updateMemberSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{ error: 'Invalid input', details: result.error.flatten() },
{ status: 400 }
)
}
const { role } = result.data
// Get the member
const member = await prisma.workspaceMember.findFirst({
where: { id: memberId, workspaceId },
})
if (!member) {
return NextResponse.json({ error: 'Member not found' }, { status: 404 })
}
// Prevent changing own role
if (member.userId === req.session.user.id) {
return NextResponse.json({ error: 'Cannot change your own role' }, { status: 400 })
}
// Update member
const updatedMember = await prisma.workspaceMember.update({
where: { id: memberId },
data: { role },
include: {
user: {
select: {
id: true,
name: true,
email: true,
lastLoginAt: true,
forcePasswordReset: true,
createdAt: true,
},
},
},
})
return NextResponse.json({
member: {
id: updatedMember.id,
role: updatedMember.role,
joinedAt: updatedMember.createdAt,
user: updatedMember.user,
},
})
} catch (error) {
console.error('Update member error:', error)
return NextResponse.json({ error: 'Failed to update member' }, { status: 500 })
}
})
// DELETE /api/workspaces/[id]/members/[memberId] - Remove member from workspace
export const DELETE = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId, memberId } = await params
// Check access (must be owner)
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access || access.role !== 'OWNER') {
return NextResponse.json({ error: 'Only owners can remove members' }, { status: 403 })
}
// Get the member
const member = await prisma.workspaceMember.findFirst({
where: { id: memberId, workspaceId },
})
if (!member) {
return NextResponse.json({ error: 'Member not found' }, { status: 404 })
}
// Prevent removing self
if (member.userId === req.session.user.id) {
return NextResponse.json({ error: 'Cannot remove yourself from workspace' }, { status: 400 })
}
// Delete member
await prisma.workspaceMember.delete({
where: { id: memberId },
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Remove member error:', error)
return NextResponse.json({ error: 'Failed to remove member' }, { status: 500 })
}
})

View File

@@ -0,0 +1,197 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { withAuth, type AuthenticatedRequest, hashPassword } from '@/lib/auth'
import { checkWorkspaceAccess } from '@/lib/db/workspace-access'
import { z } from 'zod'
// GET /api/workspaces/[id]/members - List all members
export const GET = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId } = await params
// Check access (must be at least a member)
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
const members = await prisma.workspaceMember.findMany({
where: { workspaceId },
include: {
user: {
select: {
id: true,
name: true,
email: true,
lastLoginAt: true,
forcePasswordReset: true,
createdAt: true,
},
},
},
orderBy: { createdAt: 'asc' },
})
return NextResponse.json({
members: members.map((m) => ({
id: m.id,
role: m.role,
joinedAt: m.createdAt,
user: m.user,
})),
})
} catch (error) {
console.error('List members error:', error)
return NextResponse.json({ error: 'Failed to list members' }, { status: 500 })
}
})
const createUserSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email'),
password: z.string().min(8, 'Password must be at least 8 characters'),
role: z.enum(['OWNER', 'EDITOR', 'VIEWER']).default('VIEWER'),
forcePasswordReset: z.boolean().default(true),
})
// POST /api/workspaces/[id]/members - Create a new user and add to workspace
export const POST = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId } = await params
// Check access (must be owner)
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access || access.role !== 'OWNER') {
return NextResponse.json({ error: 'Only owners can create users' }, { status: 403 })
}
const body = await req.json()
const result = createUserSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{ error: 'Invalid input', details: result.error.flatten() },
{ status: 400 }
)
}
const { name, email, password, role, forcePasswordReset } = result.data
// Check if user already exists
const existingUser = await prisma.user.findUnique({
where: { email: email.toLowerCase() },
})
if (existingUser) {
// Check if already a member
const existingMember = await prisma.workspaceMember.findUnique({
where: {
workspaceId_userId: {
workspaceId,
userId: existingUser.id,
},
},
})
if (existingMember) {
return NextResponse.json(
{ error: 'User is already a member of this workspace' },
{ status: 400 }
)
}
// Add existing user to workspace
const member = await prisma.workspaceMember.create({
data: {
workspaceId,
userId: existingUser.id,
role,
},
include: {
user: {
select: {
id: true,
name: true,
email: true,
lastLoginAt: true,
forcePasswordReset: true,
createdAt: true,
},
},
},
})
return NextResponse.json({
member: {
id: member.id,
role: member.role,
joinedAt: member.createdAt,
user: member.user,
},
message: 'Existing user added to workspace',
})
}
// Create new user and add to workspace
const passwordHash = await hashPassword(password)
const user = await prisma.user.create({
data: {
name,
email: email.toLowerCase(),
passwordHash,
forcePasswordReset,
workspaceMembers: {
create: {
workspaceId,
role,
},
},
},
select: {
id: true,
name: true,
email: true,
lastLoginAt: true,
forcePasswordReset: true,
createdAt: true,
workspaceMembers: {
where: { workspaceId },
select: {
id: true,
role: true,
createdAt: true,
},
},
},
})
const member = user.workspaceMembers[0]
return NextResponse.json({
member: {
id: member.id,
role: member.role,
joinedAt: member.createdAt,
user: {
id: user.id,
name: user.name,
email: user.email,
lastLoginAt: user.lastLoginAt,
forcePasswordReset: user.forcePasswordReset,
createdAt: user.createdAt,
},
},
message: 'User created and added to workspace',
}, { status: 201 })
} catch (error) {
console.error('Create user error:', error)
return NextResponse.json({ error: 'Failed to create user' }, { status: 500 })
}
})

View File

@@ -0,0 +1,91 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { checkWorkspaceAccess, canEdit } from '@/lib/db/workspace-access'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
import { milestoneSchema } from '@/lib/validation'
export const PATCH = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId, milestoneId } = 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 existing = await prisma.treatmentMilestone.findFirst({ where: { id: milestoneId, workspaceId, deletedAt: null } })
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 })
const body = await req.json()
const result = milestoneSchema.partial().safeParse(body)
if (!result.success) return NextResponse.json({ error: 'Invalid input', details: result.error.flatten() }, { status: 400 })
const updateData: Record<string, unknown> = {
...result.data,
updatedById: req.session.user.id,
}
// Convert date strings to Date objects
if (result.data.plannedDate) {
updateData.plannedDate = new Date(result.data.plannedDate)
}
if (result.data.actualDate !== undefined) {
updateData.actualDate = result.data.actualDate ? new Date(result.data.actualDate) : null
}
// Auto-set actualDate when completing
if (result.data.status === 'COMPLETED' && !existing.actualDate && !result.data.actualDate) {
updateData.actualDate = new Date()
}
const milestone = await prisma.treatmentMilestone.update({
where: { id: milestoneId },
data: updateData,
include: {
createdBy: { select: { id: true, name: true } },
updatedBy: { select: { id: true, name: true } },
},
})
await prisma.auditLog.create({
data: {
workspaceId, userId: req.session.user.id,
action: 'UPDATE', entityType: 'MILESTONE', entityId: milestoneId,
details: result.data,
},
})
return NextResponse.json({ milestone })
} catch (error) {
console.error('Update milestone error:', error)
return NextResponse.json({ error: 'Failed to update milestone' }, { status: 500 })
}
})
export const DELETE = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId, milestoneId } = 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 existing = await prisma.treatmentMilestone.findFirst({ where: { id: milestoneId, workspaceId, deletedAt: null } })
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 })
await prisma.treatmentMilestone.update({ where: { id: milestoneId }, data: { deletedAt: new Date() } })
await prisma.auditLog.create({
data: {
workspaceId, userId: req.session.user.id,
action: 'DELETE', entityType: 'MILESTONE', entityId: milestoneId,
},
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Delete milestone error:', error)
return NextResponse.json({ error: 'Failed to delete milestone' }, { status: 500 })
}
})

View File

@@ -0,0 +1,84 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { checkWorkspaceAccess, canEdit } from '@/lib/db/workspace-access'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
import { milestoneSchema } from '@/lib/validation'
export const GET = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
const { searchParams } = new URL(req.url)
const status = searchParams.get('status')
const limit = Math.min(parseInt(searchParams.get('limit') || '100'), 500)
const where: Record<string, unknown> = { workspaceId, deletedAt: null }
if (status) {
where.status = status
}
const milestones = await prisma.treatmentMilestone.findMany({
where, orderBy: { plannedDate: 'asc' }, take: limit,
include: { createdBy: { select: { id: true, name: true } } },
})
return NextResponse.json({ milestones })
} catch (error) {
console.error('List milestones error:', error)
return NextResponse.json({ error: 'Failed to list milestones' }, { status: 500 })
}
})
export const POST = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
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 = milestoneSchema.safeParse(body)
if (!result.success) return NextResponse.json({ error: 'Invalid input', details: result.error.flatten() }, { status: 400 })
const existingCount = await prisma.treatmentMilestone.count({
where: { workspaceId, deletedAt: null },
})
const milestone = await prisma.treatmentMilestone.create({
data: {
workspaceId,
type: result.data.type,
title: result.data.title,
description: result.data.description || null,
plannedDate: new Date(result.data.plannedDate),
actualDate: result.data.actualDate ? new Date(result.data.actualDate) : null,
status: result.data.status || 'SCHEDULED',
notes: result.data.notes || null,
sortOrder: existingCount,
createdById: req.session.user.id,
updatedById: req.session.user.id,
},
include: { createdBy: { select: { id: true, name: true } } },
})
await prisma.auditLog.create({
data: {
workspaceId, userId: req.session.user.id,
action: 'CREATE', entityType: 'MILESTONE', entityId: milestone.id,
details: { type: milestone.type, title: milestone.title },
},
})
return NextResponse.json({ milestone }, { status: 201 })
} catch (error) {
console.error('Create milestone error:', error)
return NextResponse.json({ error: 'Failed to create milestone' }, { status: 500 })
}
})

View File

@@ -0,0 +1,45 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { checkWorkspaceAccess, canEdit } from '@/lib/db/workspace-access'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
export const POST = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId, taskId } = 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 existing = await prisma.caregiverTask.findFirst({ where: { id: taskId, workspaceId, deletedAt: null } })
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 })
const task = await prisma.caregiverTask.update({
where: { id: taskId },
data: {
status: 'DONE',
completedAt: new Date(),
completedById: req.session.user.id,
updatedById: req.session.user.id,
},
include: {
assignedTo: { select: { id: true, name: true } },
createdBy: { select: { id: true, name: true } },
},
})
await prisma.auditLog.create({
data: {
workspaceId, userId: req.session.user.id,
action: 'UPDATE', entityType: 'CAREGIVER_TASK', entityId: taskId,
details: { status: 'DONE' },
},
})
return NextResponse.json({ task })
} catch (error) {
console.error('Complete caregiver task error:', error)
return NextResponse.json({ error: 'Failed to complete task' }, { status: 500 })
}
})

View File

@@ -0,0 +1,93 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { checkWorkspaceAccess, canEdit } from '@/lib/db/workspace-access'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
import { caregiverTaskSchema } from '@/lib/validation'
export const PATCH = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId, taskId } = 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 existing = await prisma.caregiverTask.findFirst({ where: { id: taskId, workspaceId, deletedAt: null } })
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 })
const body = await req.json()
const result = caregiverTaskSchema.partial().safeParse(body)
if (!result.success) return NextResponse.json({ error: 'Invalid input', details: result.error.flatten() }, { status: 400 })
// Build update data
const updateData: Record<string, unknown> = {
...result.data,
updatedById: req.session.user.id,
}
// Convert dueDate string to Date if provided
if (result.data.dueDate !== undefined) {
updateData.dueDate = result.data.dueDate ? new Date(result.data.dueDate) : null
}
// Handle completedAt based on status changes
if (result.data.status === 'DONE' && existing.status !== 'DONE' && !existing.completedAt) {
updateData.completedAt = new Date()
updateData.completedById = req.session.user.id
} else if (result.data.status && result.data.status !== 'DONE' && existing.status === 'DONE') {
updateData.completedAt = null
updateData.completedById = null
}
const task = await prisma.caregiverTask.update({
where: { id: taskId },
data: updateData,
include: {
assignedTo: { select: { id: true, name: true } },
createdBy: { select: { id: true, name: true } },
},
})
await prisma.auditLog.create({
data: {
workspaceId, userId: req.session.user.id,
action: 'UPDATE', entityType: 'CAREGIVER_TASK', entityId: taskId,
details: result.data,
},
})
return NextResponse.json({ task })
} catch (error) {
console.error('Update caregiver task error:', error)
return NextResponse.json({ error: 'Failed to update task' }, { status: 500 })
}
})
export const DELETE = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId, taskId } = 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 existing = await prisma.caregiverTask.findFirst({ where: { id: taskId, workspaceId, deletedAt: null } })
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 })
await prisma.caregiverTask.update({ where: { id: taskId }, data: { deletedAt: new Date() } })
await prisma.auditLog.create({
data: {
workspaceId, userId: req.session.user.id,
action: 'DELETE', entityType: 'CAREGIVER_TASK', entityId: taskId,
},
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Delete caregiver task error:', error)
return NextResponse.json({ error: 'Failed to delete task' }, { status: 500 })
}
})

View File

@@ -0,0 +1,101 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { checkWorkspaceAccess, canEdit } from '@/lib/db/workspace-access'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
import { caregiverTaskSchema } from '@/lib/validation'
const PRIORITY_ORDER: Record<string, number> = {
URGENT: 0,
HIGH: 1,
NORMAL: 2,
LOW: 3,
}
export const GET = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
const { searchParams } = new URL(req.url)
const status = searchParams.get('status')
const assignedTo = searchParams.get('assignedTo')
const limit = Math.min(parseInt(searchParams.get('limit') || '100'), 500)
const where: Record<string, unknown> = { workspaceId, deletedAt: null }
if (status) where.status = status
if (assignedTo) where.assignedToId = assignedTo
const tasks = await prisma.caregiverTask.findMany({
where,
orderBy: { createdAt: 'desc' },
take: limit,
include: {
assignedTo: { select: { id: true, name: true } },
createdBy: { select: { id: true, name: true } },
},
})
// Sort by priority order (URGENT first), then by createdAt desc
tasks.sort((a: { priority: string; createdAt: Date }, b: { priority: string; createdAt: Date }) => {
const priorityDiff = (PRIORITY_ORDER[a.priority] ?? 99) - (PRIORITY_ORDER[b.priority] ?? 99)
if (priorityDiff !== 0) return priorityDiff
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
})
return NextResponse.json({ tasks })
} catch (error) {
console.error('List caregiver tasks error:', error)
return NextResponse.json({ error: 'Failed to list tasks' }, { status: 500 })
}
})
export const POST = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
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 = caregiverTaskSchema.safeParse(body)
if (!result.success) return NextResponse.json({ error: 'Invalid input', details: result.error.flatten() }, { status: 400 })
const task = await prisma.caregiverTask.create({
data: {
workspaceId,
title: result.data.title,
description: result.data.description || null,
category: result.data.category,
priority: result.data.priority || 'NORMAL',
status: result.data.status || 'TODO',
assignedToId: result.data.assignedToId || null,
dueDate: result.data.dueDate ? new Date(result.data.dueDate) : null,
createdById: req.session.user.id,
updatedById: req.session.user.id,
},
include: {
assignedTo: { select: { id: true, name: true } },
createdBy: { select: { id: true, name: true } },
},
})
await prisma.auditLog.create({
data: {
workspaceId, userId: req.session.user.id,
action: 'CREATE', entityType: 'CAREGIVER_TASK', entityId: task.id,
details: { title: task.title, category: task.category, priority: task.priority },
},
})
return NextResponse.json({ task }, { status: 201 })
} catch (error) {
console.error('Create caregiver task error:', error)
return NextResponse.json({ error: 'Failed to create task' }, { status: 500 })
}
})

View File

@@ -0,0 +1,32 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { checkWorkspaceAccess, canEdit } from '@/lib/db/workspace-access'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
export const DELETE = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId, tempId } = 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 existing = await prisma.temperatureLog.findFirst({ where: { id: tempId, workspaceId, deletedAt: null } })
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 })
await prisma.temperatureLog.update({ where: { id: tempId }, data: { deletedAt: new Date() } })
await prisma.auditLog.create({
data: {
workspaceId, userId: req.session.user.id,
action: 'DELETE', entityType: 'TEMPERATURE_LOG', entityId: tempId,
},
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Delete temperature log error:', error)
return NextResponse.json({ error: 'Failed to delete temperature log' }, { status: 500 })
}
})

View File

@@ -0,0 +1,78 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { checkWorkspaceAccess, canEdit } from '@/lib/db/workspace-access'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
import { temperatureLogSchema } from '@/lib/validation'
export const GET = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
const { searchParams } = new URL(req.url)
const from = searchParams.get('from')
const to = searchParams.get('to')
const limit = Math.min(parseInt(searchParams.get('limit') || '100'), 500)
const where: Record<string, unknown> = { workspaceId, deletedAt: null }
if (from || to) {
where.recordedAt = {}
if (from) (where.recordedAt as Record<string, unknown>).gte = new Date(from)
if (to) (where.recordedAt as Record<string, unknown>).lte = new Date(to)
}
const temperatureLogs = await prisma.temperatureLog.findMany({
where, orderBy: { recordedAt: 'desc' }, take: limit,
include: { createdBy: { select: { id: true, name: true } } },
})
return NextResponse.json({ temperatureLogs })
} catch (error) {
console.error('List temperature logs error:', error)
return NextResponse.json({ error: 'Failed to list temperature logs' }, { status: 500 })
}
})
export const POST = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
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 = temperatureLogSchema.safeParse(body)
if (!result.success) return NextResponse.json({ error: 'Invalid input', details: result.error.flatten() }, { status: 400 })
const temperatureLog = await prisma.temperatureLog.create({
data: {
workspaceId,
tempCelsius: result.data.tempCelsius,
method: result.data.method || null,
notes: result.data.notes || null,
recordedAt: result.data.recordedAt ? new Date(result.data.recordedAt) : new Date(),
createdById: req.session.user.id,
},
include: { createdBy: { select: { id: true, name: true } } },
})
await prisma.auditLog.create({
data: {
workspaceId, userId: req.session.user.id,
action: 'CREATE', entityType: 'TEMPERATURE_LOG', entityId: temperatureLog.id,
details: { tempCelsius: temperatureLog.tempCelsius, method: temperatureLog.method },
},
})
return NextResponse.json({ temperatureLog }, { status: 201 })
} catch (error) {
console.error('Create temperature log error:', error)
return NextResponse.json({ error: 'Failed to create temperature log' }, { status: 500 })
}
})

View File

@@ -0,0 +1,32 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { checkWorkspaceAccess, canEdit } from '@/lib/db/workspace-access'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
export const DELETE = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId, weightId } = 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 existing = await prisma.weightLog.findFirst({ where: { id: weightId, workspaceId, deletedAt: null } })
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 })
await prisma.weightLog.update({ where: { id: weightId }, data: { deletedAt: new Date() } })
await prisma.auditLog.create({
data: {
workspaceId, userId: req.session.user.id,
action: 'DELETE', entityType: 'WEIGHT_LOG', entityId: weightId,
},
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Delete weight log error:', error)
return NextResponse.json({ error: 'Failed to delete weight log' }, { status: 500 })
}
})

View File

@@ -0,0 +1,77 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { checkWorkspaceAccess, canEdit } from '@/lib/db/workspace-access'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
import { weightLogSchema } from '@/lib/validation'
export const GET = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
const { searchParams } = new URL(req.url)
const from = searchParams.get('from')
const to = searchParams.get('to')
const limit = Math.min(parseInt(searchParams.get('limit') || '100'), 500)
const where: Record<string, unknown> = { workspaceId, deletedAt: null }
if (from || to) {
where.recordedAt = {}
if (from) (where.recordedAt as Record<string, unknown>).gte = new Date(from)
if (to) (where.recordedAt as Record<string, unknown>).lte = new Date(to)
}
const weightLogs = await prisma.weightLog.findMany({
where, orderBy: { recordedAt: 'desc' }, take: limit,
include: { createdBy: { select: { id: true, name: true } } },
})
return NextResponse.json({ weightLogs })
} catch (error) {
console.error('List weight logs error:', error)
return NextResponse.json({ error: 'Failed to list weight logs' }, { status: 500 })
}
})
export const POST = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
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 = weightLogSchema.safeParse(body)
if (!result.success) return NextResponse.json({ error: 'Invalid input', details: result.error.flatten() }, { status: 400 })
const weightLog = await prisma.weightLog.create({
data: {
workspaceId,
weightKg: result.data.weightKg,
notes: result.data.notes || null,
recordedAt: result.data.recordedAt ? new Date(result.data.recordedAt) : new Date(),
createdById: req.session.user.id,
},
include: { createdBy: { select: { id: true, name: true } } },
})
await prisma.auditLog.create({
data: {
workspaceId, userId: req.session.user.id,
action: 'CREATE', entityType: 'WEIGHT_LOG', entityId: weightLog.id,
details: { weightKg: weightLog.weightKg },
},
})
return NextResponse.json({ weightLog }, { status: 201 })
} catch (error) {
console.error('Create weight log error:', error)
return NextResponse.json({ error: 'Failed to create weight log' }, { status: 500 })
}
})

View File

@@ -0,0 +1,111 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Heart } from 'lucide-react'
import { Button, Input, Card, showToast } from '@/components/ui'
export default function ChangePasswordPage() {
const router = useRouter()
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
if (newPassword !== confirmPassword) {
setError('New passwords do not match')
return
}
if (newPassword.length < 8) {
setError('New password must be at least 8 characters')
return
}
setLoading(true)
try {
const response = await fetch('/api/auth/change-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPassword, newPassword }),
})
const data = await response.json()
if (!response.ok) {
setError(data.error || 'Failed to change password')
return
}
showToast('Password changed successfully!', 'success')
router.push('/today')
router.refresh()
} catch {
setError('Something went wrong. Please try again.')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-background flex flex-col items-center justify-center p-4">
<div className="w-full max-w-sm">
<div className="text-center mb-8">
<div className="w-16 h-16 bg-primary-500 rounded-2xl flex items-center justify-center mx-auto mb-4">
<Heart className="w-8 h-8 text-white" />
</div>
<h1 className="text-2xl font-bold text-secondary-900">Change Password</h1>
<p className="text-secondary-500 mt-1">Please set a new password to continue</p>
</div>
<Card className="mb-6">
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label="Current Password"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
placeholder="Enter current password"
required
autoComplete="current-password"
/>
<Input
label="New Password"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="At least 8 characters"
required
autoComplete="new-password"
/>
<Input
label="Confirm New Password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Confirm new password"
required
autoComplete="new-password"
/>
{error && (
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-button">
{error}
</p>
)}
<Button type="submit" fullWidth loading={loading}>
Change Password
</Button>
</form>
</Card>
</div>
</div>
)
}

View File

@@ -4,10 +4,14 @@
@tailwind components;
@tailwind utilities;
/* Google Fonts */
@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,500;0,600;0,700;1,400;1,500&family=Source+Sans+3:ital,wght@0,300;0,400;0,500;0,600;0,700;1,400&display=swap');
@layer base {
:root {
--background: 250 251 252;
--foreground: 31 38 49;
--background: 250 247 242;
--foreground: 38 35 32;
--surface: 255 255 255;
}
html {
@@ -17,6 +21,16 @@
body {
@apply bg-background text-secondary-900 antialiased;
font-feature-settings: 'rlig' 1, 'calt' 1;
font-family: 'Source Sans 3', system-ui, sans-serif;
}
/* Paper texture background */
.paper-texture {
background-color: #faf7f2;
background-image:
url("data:image/svg+xml,%3Csvg viewBox='0 0 400 400' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
background-blend-mode: soft-light;
background-size: 200px 200px;
}
/* Large text mode */
@@ -38,12 +52,12 @@
}
.large-text .text-sm {
font-size: 1rem; /* text-base equivalent */
font-size: 1rem;
line-height: 1.5rem;
}
.large-text .text-xs {
font-size: 0.875rem; /* text-sm equivalent */
font-size: 0.875rem;
line-height: 1.25rem;
}
@@ -56,9 +70,9 @@
padding-bottom: env(safe-area-inset-bottom);
}
/* Focus styles for accessibility */
/* Focus styles for accessibility - warm glow */
*:focus-visible {
@apply outline-none ring-2 ring-primary-500 ring-offset-2;
@apply outline-none ring-2 ring-primary-300 ring-offset-2 ring-offset-background;
}
/* Better touch targets */
@@ -79,64 +93,151 @@
}
@layer components {
/* Primary taken button */
.btn-taken {
/* Warm sanctuary card styles */
.card-sanctuary {
@apply bg-surface rounded-card shadow-card;
@apply border border-cream-200/50;
@apply transition-all duration-300 ease-sanctuary;
}
.card-sanctuary:hover {
@apply shadow-card-hover;
@apply border-cream-300;
}
/* Primary action button - warm green */
.btn-primary {
@apply bg-primary-500 hover:bg-primary-600 text-white font-semibold;
@apply py-3 px-6 rounded-button min-h-touch;
@apply shadow-button transition-all duration-200;
@apply active:scale-95;
@apply py-3.5 px-6 rounded-button min-h-touch;
@apply shadow-button transition-all duration-300 ease-sanctuary;
@apply active:scale-[0.98] hover:shadow-button-hover;
}
/* Card styles */
/* Secondary button - cream */
.btn-secondary {
@apply bg-cream-100 hover:bg-cream-200 text-secondary-800 font-medium;
@apply py-3.5 px-6 rounded-button min-h-touch;
@apply border border-cream-300;
@apply transition-all duration-300 ease-sanctuary;
@apply active:scale-[0.98];
}
/* Accent button - terracotta */
.btn-accent {
@apply bg-accent-500 hover:bg-accent-600 text-white font-semibold;
@apply py-3.5 px-6 rounded-button min-h-touch;
@apply shadow-button transition-all duration-300 ease-sanctuary;
@apply active:scale-[0.98] hover:shadow-button-hover;
}
/* Ghost button */
.btn-ghost {
@apply bg-transparent hover:bg-cream-100 text-secondary-700 font-medium;
@apply py-3.5 px-6 rounded-button min-h-touch;
@apply transition-all duration-300 ease-sanctuary;
@apply active:scale-[0.98];
}
/* Taken button */
.btn-taken {
@apply btn-primary;
}
/* Appointment card */
.card-appointment {
@apply bg-surface rounded-card shadow-card p-4;
@apply border-l-4 border-primary-500;
@apply card-sanctuary p-5;
@apply border-l-[6px] border-l-primary-400;
}
/* Medication card */
.card-medication {
@apply bg-surface rounded-card shadow-card p-4;
@apply flex items-center justify-between;
@apply card-sanctuary p-5;
}
/* Overdue styles */
/* Overdue styles - warm terracotta */
.overdue {
@apply border-l-4 border-red-500 bg-red-50;
@apply border-l-[6px] border-l-accent-500;
@apply bg-accent-50/50;
}
/* Emergency card - alert red but softened */
.card-emergency {
@apply bg-alert-50 border-2 border-alert-200 rounded-card-lg;
@apply overflow-hidden;
}
/* Section styling */
.section-warm {
@apply bg-surface rounded-card-lg shadow-soft p-6;
@apply border border-cream-200/60;
}
/* Input styling */
.input-sanctuary {
@apply bg-surface border border-cream-300 rounded-button;
@apply px-4 py-3.5 text-secondary-900 placeholder:text-secondary-400;
@apply focus:outline-none focus:border-primary-400 focus:ring-2 focus:ring-primary-200;
@apply transition-all duration-200;
}
/* Timeline styles */
.timeline-item {
@apply relative pl-6 pb-4;
@apply relative pl-8 pb-6;
}
.timeline-item::before {
content: '';
@apply absolute left-0 top-2 w-2 h-2 rounded-full bg-primary-400;
@apply absolute left-0 top-2 w-3 h-3 rounded-full bg-primary-300;
@apply ring-4 ring-primary-100;
}
.timeline-item::after {
content: '';
@apply absolute left-[3px] top-4 w-0.5 h-full bg-border;
@apply absolute left-[5px] top-5 w-0.5 h-full bg-cream-300;
}
.timeline-item:last-child::after {
@apply hidden;
}
/* Glass effect for overlays */
.glass {
@apply bg-surface/80 backdrop-blur-md;
@apply border border-cream-200/50;
}
/* Decorative blob shapes */
.blob {
@apply absolute rounded-full blur-3xl opacity-30 pointer-events-none;
}
.blob-primary {
@apply bg-primary-200;
}
.blob-accent {
@apply bg-accent-200;
}
.blob-cream {
@apply bg-cream-300;
}
}
@layer utilities {
/* Animation utilities */
.animate-in {
animation: animateIn 0.2s ease-out;
animation: animateIn 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.animate-out {
animation: animateOut 0.15s ease-in forwards;
animation: animateOut 0.2s ease-in forwards;
}
@keyframes animateIn {
from {
opacity: 0;
transform: translateY(8px);
transform: translateY(12px);
}
to {
opacity: 1;
@@ -151,55 +252,46 @@
}
to {
opacity: 0;
transform: translateY(8px);
transform: translateY(12px);
}
}
.slide-in-from-bottom-4 {
animation: slideInFromBottom 0.2s ease-out;
}
.slide-out-to-bottom-4 {
animation: slideOutToBottom 0.15s ease-in forwards;
}
@keyframes slideInFromBottom {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideOutToBottom {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(16px);
}
}
/* Stagger animation delays */
.stagger-1 { animation-delay: 0.05s; }
.stagger-2 { animation-delay: 0.1s; }
.stagger-3 { animation-delay: 0.15s; }
.stagger-4 { animation-delay: 0.2s; }
.stagger-5 { animation-delay: 0.25s; }
.stagger-6 { animation-delay: 0.3s; }
/* Fade utilities */
.fade-in {
animation: fadeIn 0.2s ease-out;
animation: fadeIn 0.3s ease-out forwards;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.fade-up {
animation: fadeUp 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
@keyframes fadeUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Scale utilities */
.zoom-in-95 {
animation: zoomIn 0.2s ease-out;
animation: zoomIn 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
@keyframes zoomIn {
@@ -212,4 +304,29 @@
transform: scale(1);
}
}
/* Soft pulse for live elements */
.pulse-soft {
animation: pulseSoft 3s ease-in-out infinite;
}
@keyframes pulseSoft {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
/* Gradient text */
.gradient-text {
@apply bg-clip-text text-transparent;
background-image: linear-gradient(135deg, #528252 0%, #3f663f 100%);
}
/* Hide scrollbar but keep functionality */
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
}

View File

@@ -1,10 +1,7 @@
import type { Metadata, Viewport } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
import { Toaster } from '@/components/ui'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'Next Step - Health Management',
description: 'A calm, reliable app to help manage appointments, medications, and notes for health care.',
@@ -21,7 +18,7 @@ export const viewport: Viewport = {
initialScale: 1,
maximumScale: 1,
userScalable: false,
themeColor: '#3a9563',
themeColor: '#528252',
}
export default function RootLayout({
@@ -33,8 +30,11 @@ export default function RootLayout({
<html lang="en">
<head>
<link rel="apple-touch-icon" href="/icon-192.png" />
{/* Preconnect to Google Fonts for performance */}
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
</head>
<body className={inter.className}>
<body className="paper-texture">
{children}
<Toaster />
</body>

View File

@@ -24,6 +24,7 @@ function LoginForm() {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ email, password }),
})
@@ -34,6 +35,23 @@ function LoginForm() {
return
}
// Check if user needs to change password
if (data.forcePasswordReset) {
showToast('Please change your password to continue', 'info')
router.push('/change-password')
router.refresh()
return
}
const sessionResponse = await fetch('/api/auth/me', {
credentials: 'include',
cache: 'no-store',
})
if (!sessionResponse.ok) {
throw new Error('Your session was created but is not available yet. Please try again.')
}
showToast('Welcome back!', 'success')
// If there's a redirect param (e.g., from invite link), go there
router.push(redirectTo || '/today')

View File

@@ -1,9 +1,9 @@
'use client'
import { useState } from 'react'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { Heart, Shield, ArrowRight } from 'lucide-react'
import { Button, Input, Card, showToast } from '@/components/ui'
import { Heart, Shield, ArrowRight, Sparkles, Users, Bell } from 'lucide-react'
import { Button, Input, showToast } from '@/components/ui'
export default function OnboardingPage() {
const router = useRouter()
@@ -12,6 +12,11 @@ export default function OnboardingPage() {
const [clinicPhone, setClinicPhone] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
const handleAcceptDisclaimer = () => {
setStep('workspace')
@@ -45,7 +50,7 @@ export default function OnboardingPage() {
})
}
showToast('All set! Welcome to Next Step.', 'success')
showToast('Welcome to Next Step', 'success')
router.push('/today')
router.refresh()
} catch (err) {
@@ -57,101 +62,176 @@ export default function OnboardingPage() {
if (step === 'disclaimer') {
return (
<div className="min-h-screen bg-background flex flex-col items-center justify-center p-4">
<div className="min-h-screen paper-texture flex flex-col items-center justify-center p-6">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<div className="w-16 h-16 bg-amber-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
<Shield className="w-8 h-8 text-amber-600" />
</div>
<h1 className="text-2xl font-bold text-secondary-900">Important Notice</h1>
{/* Decorative blobs */}
<div className="fixed inset-0 overflow-hidden pointer-events-none">
<div className="blob blob-primary w-96 h-96 -top-48 -right-48" />
<div className="blob blob-accent w-80 h-80 bottom-20 -left-40" />
<div className="blob blob-cream w-64 h-64 top-1/2 right-1/4" />
</div>
<Card className="mb-6">
<div className="space-y-4 text-secondary-700">
<p>
<strong>Next Step is a tracking tool only.</strong> It helps you and your family
stay organized with appointments and medications.
<div className={`relative transition-all duration-1000 ${mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}>
{/* Logo/Icon */}
<div className="text-center mb-8">
<div className="w-24 h-24 rounded-card-lg bg-gradient-to-br from-primary-400 to-primary-600 flex items-center justify-center mx-auto mb-6 shadow-elevated">
<Heart className="w-12 h-12 text-white" />
</div>
<h1 className="font-display text-display-md text-secondary-900 mb-2">
Next Step
</h1>
<p className="text-secondary-500 text-lg">
Supporting you through every step
</p>
</div>
<p>
<strong className="text-red-600">This app does not provide medical advice.</strong>{' '}
Always consult your healthcare team for medical decisions.
</p>
{/* Disclaimer Card */}
<div className="section-warm mb-6">
<div className="flex items-center gap-3 mb-6">
<div className="w-12 h-12 rounded-full bg-accent-100 flex items-center justify-center">
<Shield className="w-6 h-6 text-accent-600" />
</div>
<h2 className="font-display text-xl text-secondary-900">Important Notice</h2>
</div>
<p>
<strong>For emergencies:</strong> Call 000 (Australia) or your local emergency
services immediately.
</p>
<div className="space-y-4 text-secondary-700">
<div className="flex gap-3">
<Sparkles className="w-5 h-5 text-primary-500 flex-shrink-0 mt-0.5" />
<p>
<strong className="text-secondary-900">Next Step is a tracking tool only.</strong>{' '}
It helps you and your family stay organized with appointments and medications.
</p>
</div>
<p>
If you have questions about your treatment, contact your clinic directly using the
button we'll help you set up.
</p>
<div className="flex gap-3">
<div className="w-5 h-5 rounded-full bg-alert-100 flex items-center justify-center flex-shrink-0 mt-0.5">
<span className="text-alert-600 text-xs font-bold">!</span>
</div>
<p>
<strong className="text-alert-600">This app does not provide medical advice.</strong>{' '}
Always consult your healthcare team for medical decisions.
</p>
</div>
<div className="pt-4 border-t border-border">
<p className="text-sm text-secondary-500">
By continuing, you acknowledge that Next Step is for tracking purposes only and
does not replace professional medical advice.
<div className="flex gap-3">
<div className="w-5 h-5 rounded-full bg-alert-500 flex items-center justify-center flex-shrink-0 mt-0.5">
<span className="text-white text-xs font-bold">000</span>
</div>
<p>
<strong>For emergencies:</strong> Call 000 (Australia) or your local emergency services immediately.
</p>
</div>
<div className="flex gap-3">
<Users className="w-5 h-5 text-primary-500 flex-shrink-0 mt-0.5" />
<p>
Have questions about your treatment? Contact your clinic directly using the button we'll help you set up.
</p>
</div>
</div>
<div className="mt-6 pt-6 border-t border-cream-200">
<p className="text-sm text-secondary-500 text-center">
By continuing, you acknowledge that Next Step is for tracking purposes only and does not replace professional medical advice.
</p>
</div>
</div>
</Card>
<Button onClick={handleAcceptDisclaimer} fullWidth>
I Understand
<ArrowRight className="w-5 h-5 ml-2" />
</Button>
<button
onClick={handleAcceptDisclaimer}
className="btn-primary w-full flex items-center justify-center gap-2 text-lg py-4"
>
I Understand
<ArrowRight className="w-5 h-5" />
</button>
</div>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-background flex flex-col items-center justify-center p-4">
<div className="w-full max-w-sm">
<div className="text-center mb-8">
<div className="w-16 h-16 bg-primary-500 rounded-2xl flex items-center justify-center mx-auto mb-4">
<Heart className="w-8 h-8 text-white" />
</div>
<h1 className="text-2xl font-bold text-secondary-900">Set Up Your Plan</h1>
<p className="text-secondary-500 mt-1">Create a workspace to get started</p>
<div className="min-h-screen paper-texture flex flex-col items-center justify-center p-6">
<div className="w-full max-w-md">
{/* Decorative blobs */}
<div className="fixed inset-0 overflow-hidden pointer-events-none">
<div className="blob blob-primary w-80 h-80 -top-32 right-0" />
<div className="blob blob-cream w-64 h-64 bottom-0 left-0" />
</div>
<Card className="mb-6">
<form onSubmit={handleCreateWorkspace} className="space-y-4">
<Input
label="Workspace Name"
type="text"
value={workspaceName}
onChange={(e) => setWorkspaceName(e.target.value)}
placeholder="e.g., Grace's Plan"
helperText="This is how family members will identify this workspace"
required
/>
<Input
label="Clinic Phone Number"
type="tel"
value={clinicPhone}
onChange={(e) => setClinicPhone(e.target.value)}
placeholder="e.g., 08 9400 1234"
helperText="We'll add a 'Call Clinic' button for quick access"
/>
<div className={`relative transition-all duration-700 ${mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'}`}>
{/* Header */}
<div className="text-center mb-8">
<div className="w-20 h-20 rounded-card-lg bg-gradient-to-br from-accent-400 to-accent-500 flex items-center justify-center mx-auto mb-6 shadow-elevated">
<Sparkles className="w-10 h-10 text-white" />
</div>
<h1 className="font-display text-display-sm text-secondary-900 mb-2">
Set Up Your Plan
</h1>
<p className="text-secondary-500 text-lg">Create a workspace to get started</p>
</div>
{/* Form */}
<form onSubmit={handleCreateWorkspace} className="section-warm space-y-6">
<div>
<label className="block text-sm font-medium text-secondary-700 mb-2">
Workspace Name
<span className="text-alert-500 ml-1">*</span>
</label>
<input
type="text"
value={workspaceName}
onChange={(e) => setWorkspaceName(e.target.value)}
placeholder="e.g., Grace's Plan"
className="input-sanctuary w-full"
required
/>
<p className="text-xs text-secondary-400 mt-2">
This is how family members will identify this workspace
</p>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 mb-2">
Clinic Phone Number
<span className="text-secondary-400 font-normal ml-1">(optional)</span>
</label>
<div className="relative">
<Bell className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-secondary-400" />
<input
type="tel"
value={clinicPhone}
onChange={(e) => setClinicPhone(e.target.value)}
placeholder="e.g., 08 9400 1234"
className="input-sanctuary w-full pl-10"
/>
</div>
<p className="text-xs text-secondary-400 mt-2">
We'll add a quick "Call Clinic" button for easy access
</p>
</div>
{error && (
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-button">
{error}
</p>
<div className="bg-alert-50 border border-alert-200 rounded-card p-4">
<p className="text-sm text-alert-700">{error}</p>
</div>
)}
<Button type="submit" fullWidth loading={loading}>
Create Workspace
</Button>
<button
type="submit"
disabled={loading}
className="btn-primary w-full text-lg py-4 disabled:opacity-50"
>
{loading ? 'Creating...' : 'Create Workspace'}
</button>
</form>
</Card>
<p className="text-center text-sm text-secondary-500">
You can add family members later from Settings
</p>
<p className="text-center text-sm text-secondary-400 mt-6">
You can add family members later from Settings
</p>
</div>
</div>
</div>
)

View File

@@ -37,6 +37,7 @@ function RegisterForm() {
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ name, email, password }),
})
@@ -47,6 +48,15 @@ function RegisterForm() {
return
}
const sessionResponse = await fetch('/api/auth/me', {
credentials: 'include',
cache: 'no-store',
})
if (!sessionResponse.ok) {
throw new Error('Your account was created, but the session is not available yet. Please sign in again.')
}
showToast('Account created! Let\'s get started.', 'success')
// If there's a redirect param (e.g., from invite link), go there instead of onboarding
router.push(redirectTo || '/onboarding')

View File

@@ -0,0 +1,36 @@
'use client'
const CATEGORIES = [
{ value: '', label: 'All' },
{ value: 'ONCOLOGY', label: 'Oncology' },
{ value: 'HOSPITAL', label: 'Hospital' },
{ value: 'PHARMACY', label: 'Pharmacy' },
{ value: 'INSURANCE', label: 'Insurance' },
{ value: 'FAMILY', label: 'Family' },
{ value: 'OTHER', label: 'Other' },
]
interface CategoryTabsProps {
selected: string
onChange: (category: string) => void
}
export function CategoryTabs({ selected, onChange }: CategoryTabsProps) {
return (
<div className="flex gap-2 overflow-x-auto pb-2 -mx-1 px-1 scrollbar-hide">
{CATEGORIES.map((cat) => (
<button
key={cat.value}
onClick={() => onChange(cat.value)}
className={`flex-shrink-0 px-4 py-2 rounded-button text-sm font-medium transition-all border ${
selected === cat.value
? 'border-primary-500 bg-primary-50 text-primary-700'
: 'border-border text-secondary-600 hover:border-secondary-300'
}`}
>
{cat.label}
</button>
))}
</div>
)
}

View File

@@ -0,0 +1,113 @@
'use client'
import { useState } from 'react'
import { Phone, Mail, MapPin, Clock, ChevronDown, ChevronUp, Star } from 'lucide-react'
interface Contact {
id: 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
}
interface ContactCardProps {
contact: Contact
onEdit?: () => void
}
const CATEGORY_COLORS: Record<string, string> = {
ONCOLOGY: 'bg-purple-100 text-purple-700',
HOSPITAL: 'bg-blue-100 text-blue-700',
PHARMACY: 'bg-green-100 text-green-700',
INSURANCE: 'bg-amber-100 text-amber-700',
FAMILY: 'bg-pink-100 text-pink-700',
OTHER: 'bg-secondary-100 text-secondary-700',
}
export function ContactCard({ contact, onEdit }: ContactCardProps) {
const [expanded, setExpanded] = useState(false)
const initial = contact.name.charAt(0).toUpperCase()
const categoryColor = CATEGORY_COLORS[contact.category] || CATEGORY_COLORS.OTHER
return (
<div className={`bg-surface rounded-card border p-4 ${
contact.isEmergency ? 'border-red-200 bg-red-50/30' : 'border-border'
}`}>
<div className="flex items-center gap-3">
{/* Avatar */}
<div className={`w-12 h-12 rounded-full flex items-center justify-center text-lg font-bold ${categoryColor}`}>
{initial}
</div>
{/* Name & Role */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-secondary-900 truncate">{contact.name}</h3>
{contact.isEmergency && <Star className="w-4 h-4 text-red-500 fill-red-500 flex-shrink-0" />}
</div>
<p className="text-sm text-secondary-500">{contact.role}</p>
</div>
{/* Call Button */}
<a
href={`tel:${contact.phone}`}
className="flex items-center justify-center w-12 h-12 rounded-full bg-primary-500 text-white hover:bg-primary-600 transition-colors flex-shrink-0"
>
<Phone className="w-5 h-5" />
</a>
{/* Expand */}
<button
onClick={() => setExpanded(!expanded)}
className="p-2 text-secondary-400 hover:text-secondary-600"
>
{expanded ? <ChevronUp className="w-5 h-5" /> : <ChevronDown className="w-5 h-5" />}
</button>
</div>
{/* Expanded Details */}
{expanded && (
<div className="mt-4 pt-3 border-t border-border space-y-2">
<div className="flex items-center gap-2 text-sm text-secondary-600">
<Phone className="w-4 h-4 text-secondary-400" />
<a href={`tel:${contact.phone}`} className="text-primary-600 hover:underline">{contact.phone}</a>
</div>
{contact.phone2 && (
<div className="flex items-center gap-2 text-sm text-secondary-600">
<Phone className="w-4 h-4 text-secondary-400" />
<a href={`tel:${contact.phone2}`} className="text-primary-600 hover:underline">{contact.phone2}</a>
</div>
)}
{contact.email && (
<div className="flex items-center gap-2 text-sm text-secondary-600">
<Mail className="w-4 h-4 text-secondary-400" />
<a href={`mailto:${contact.email}`} className="text-primary-600 hover:underline">{contact.email}</a>
</div>
)}
{contact.address && (
<div className="flex items-center gap-2 text-sm text-secondary-600">
<MapPin className="w-4 h-4 text-secondary-400" />
<span>{contact.address}</span>
</div>
)}
{contact.hours && (
<div className="flex items-center gap-2 text-sm text-secondary-600">
<Clock className="w-4 h-4 text-secondary-400" />
<span>{contact.hours}</span>
</div>
)}
{contact.notes && (
<p className="text-sm text-secondary-500 mt-2 pl-6">{contact.notes}</p>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,135 @@
'use client'
import { useState } from 'react'
import { Modal, Button, Input, Select, showToast } from '@/components/ui'
const CATEGORIES = [
{ value: 'ONCOLOGY', label: 'Oncology' },
{ value: 'HOSPITAL', label: 'Hospital' },
{ value: 'PHARMACY', label: 'Pharmacy' },
{ value: 'INSURANCE', label: 'Insurance' },
{ value: 'FAMILY', label: 'Family' },
{ value: 'OTHER', label: 'Other' },
]
interface ContactFormData {
name: string
role: string
category: string
phone: string
phone2: string
email: string
address: string
hours: string
notes: string
isEmergency: boolean
}
interface ContactFormProps {
open: boolean
onClose: () => void
onSaved: () => void
workspaceId: string
initialData?: Partial<ContactFormData> & { id?: string }
}
export function ContactForm({ open, onClose, onSaved, workspaceId, initialData }: ContactFormProps) {
const isEdit = !!initialData?.id
const [form, setForm] = useState<ContactFormData>({
name: initialData?.name || '',
role: initialData?.role || '',
category: initialData?.category || 'ONCOLOGY',
phone: initialData?.phone || '',
phone2: initialData?.phone2 || '',
email: initialData?.email || '',
address: initialData?.address || '',
hours: initialData?.hours || '',
notes: initialData?.notes || '',
isEmergency: initialData?.isEmergency || false,
})
const [saving, setSaving] = useState(false)
const handleSave = async () => {
if (!form.name.trim() || !form.role.trim() || !form.phone.trim()) {
showToast('Name, role, and phone are required', 'error')
return
}
setSaving(true)
try {
const url = isEdit
? `/api/workspaces/${workspaceId}/contacts/${initialData!.id}`
: `/api/workspaces/${workspaceId}/contacts`
const method = isEdit ? 'PATCH' : 'POST'
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: form.name.trim(),
role: form.role.trim(),
category: form.category,
phone: form.phone.trim(),
phone2: form.phone2.trim() || null,
email: form.email.trim() || null,
address: form.address.trim() || null,
hours: form.hours.trim() || null,
notes: form.notes.trim() || null,
isEmergency: form.isEmergency,
}),
})
if (!response.ok) throw new Error('Failed to save contact')
showToast(isEdit ? 'Contact updated' : 'Contact added', 'success')
onSaved()
onClose()
} catch {
showToast('Failed to save contact', 'error')
} finally {
setSaving(false)
}
}
const update = (field: keyof ContactFormData, value: string | boolean) =>
setForm((prev) => ({ ...prev, [field]: value }))
return (
<Modal isOpen={open} onClose={onClose} title={isEdit ? 'Edit Contact' : 'Add Contact'}>
<div className="space-y-4">
<Input label="Name *" value={form.name} onChange={(e) => update('name', e.target.value)} placeholder="Dr. Smith" />
<Input label="Role *" value={form.role} onChange={(e) => update('role', e.target.value)} placeholder="Oncologist" />
<Select label="Category" value={form.category} onChange={(e) => update('category', e.target.value)} options={CATEGORIES} />
<Input label="Phone *" value={form.phone} onChange={(e) => update('phone', e.target.value)} placeholder="+61 2 1234 5678" type="tel" />
<Input label="Secondary Phone" value={form.phone2} onChange={(e) => update('phone2', e.target.value)} placeholder="Optional" type="tel" />
<Input label="Email" value={form.email} onChange={(e) => update('email', e.target.value)} placeholder="Optional" type="email" />
<Input label="Address" value={form.address} onChange={(e) => update('address', e.target.value)} placeholder="Optional" />
<Input label="Hours" value={form.hours} onChange={(e) => update('hours', e.target.value)} placeholder="Mon-Fri 8am-5pm" />
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">Notes</label>
<textarea
value={form.notes}
onChange={(e) => update('notes', e.target.value)}
placeholder="Additional info..."
rows={2}
className="w-full px-3 py-2 border border-border rounded-lg text-base focus:outline-none focus:ring-2 focus:ring-primary-200 focus:border-primary-500 resize-none"
/>
</div>
<label className="flex items-center gap-3 py-2">
<input
type="checkbox"
checked={form.isEmergency}
onChange={(e) => update('isEmergency', e.target.checked)}
className="w-5 h-5 rounded border-border text-primary-500 focus:ring-primary-200"
/>
<span className="text-sm font-medium text-secondary-700">Mark as Emergency Contact</span>
</label>
<div className="flex gap-3 pt-2">
<Button variant="secondary" onClick={onClose} fullWidth>Cancel</Button>
<Button onClick={handleSave} fullWidth loading={saving}>
{isEdit ? 'Update' : 'Add Contact'}
</Button>
</div>
</div>
</Modal>
)
}

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>
)
}

View File

@@ -1,6 +1,6 @@
'use client'
import { Phone, User, Droplets, AlertTriangle, Activity, Stethoscope } from 'lucide-react'
import { Phone, User, Droplets, AlertTriangle, Activity, Stethoscope, HeartPulse, FileText } from 'lucide-react'
import { format } from 'date-fns'
interface EmergencyInfo {
@@ -39,49 +39,66 @@ export function EmergencyCard({ info, medications, variant = 'full' }: Emergency
}
return (
<div className="bg-red-50 border-2 border-red-200 rounded-xl overflow-hidden">
<div className="bg-surface border-2 border-alert-200 rounded-card-lg overflow-hidden shadow-elevated">
{/* Header */}
<div className="bg-red-600 text-white px-4 py-3">
<div className="flex items-center gap-2">
<AlertTriangle className="w-6 h-6" />
<h2 className="text-xl font-bold">Emergency Information</h2>
<div className="bg-gradient-to-r from-alert-500 to-alert-600 text-white px-6 py-5">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full bg-white/20 flex items-center justify-center">
<AlertTriangle className="w-7 h-7" />
</div>
<div>
<h2 className="font-display text-2xl">Emergency Information</h2>
<p className="text-alert-100 text-sm">Critical medical details for emergencies</p>
</div>
</div>
</div>
<div className="p-4 space-y-4">
<div className="p-6 space-y-6">
{/* Patient Info */}
{info.patientName && (
<div className="flex items-start gap-3">
<User className="w-5 h-5 text-red-600 mt-0.5" />
<div>
<p className="text-sm text-red-700 font-medium">Patient Name</p>
<p className="text-lg font-bold text-secondary-900">{info.patientName}</p>
<div className="flex items-start gap-4 bg-cream-50 rounded-card p-4 border border-cream-200">
<div className="w-12 h-12 rounded-full bg-cream-200 flex items-center justify-center flex-shrink-0">
<User className="w-6 h-6 text-cream-700" />
</div>
<div className="flex-1">
<p className="text-xs text-secondary-500 uppercase tracking-wide font-semibold mb-1">
Patient
</p>
<p className="font-display text-xl text-secondary-900">{info.patientName}</p>
{info.patientDOB && (
<p className="text-sm text-secondary-600">DOB: {formatDate(info.patientDOB)}</p>
<p className="text-sm text-secondary-600 mt-1">
Born: {formatDate(info.patientDOB)}
</p>
)}
</div>
</div>
)}
{/* Blood Type */}
{/* Blood Type - Large and prominent */}
{info.bloodType && (
<div className="flex items-start gap-3">
<Droplets className="w-5 h-5 text-red-600 mt-0.5" />
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-alert-100 to-alert-200 flex items-center justify-center flex-shrink-0">
<Droplets className="w-6 h-6 text-alert-600" />
</div>
<div>
<p className="text-sm text-red-700 font-medium">Blood Type</p>
<p className="text-2xl font-bold text-red-600">{info.bloodType}</p>
<p className="text-xs text-secondary-500 uppercase tracking-wide font-semibold">
Blood Type
</p>
<p className="text-3xl font-display text-alert-600">{info.bloodType}</p>
</div>
</div>
)}
{/* Allergies - High visibility */}
{info.allergies && (
<div className="bg-red-100 border border-red-300 rounded-lg p-3">
<div className="bg-alert-50 border-l-4 border-alert-500 rounded-r-card p-5">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-red-600 mt-0.5 flex-shrink-0" />
<AlertTriangle className="w-6 h-6 text-alert-600 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm text-red-700 font-bold uppercase">Allergies</p>
<p className="text-secondary-900 font-medium mt-1">{info.allergies}</p>
<p className="text-sm text-alert-700 font-bold uppercase tracking-wide mb-2">
Allergies
</p>
<p className="text-secondary-900 font-medium text-lg">{info.allergies}</p>
</div>
</div>
</div>
@@ -89,10 +106,14 @@ export function EmergencyCard({ info, medications, variant = 'full' }: Emergency
{/* Medical Conditions */}
{info.medicalConditions && (
<div className="flex items-start gap-3">
<Activity className="w-5 h-5 text-red-600 mt-0.5" />
<div>
<p className="text-sm text-red-700 font-medium">Medical Conditions</p>
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-full bg-primary-100 flex items-center justify-center flex-shrink-0">
<HeartPulse className="w-6 h-6 text-primary-600" />
</div>
<div className="flex-1">
<p className="text-xs text-secondary-500 uppercase tracking-wide font-semibold mb-1">
Medical Conditions
</p>
<p className="text-secondary-900">{info.medicalConditions}</p>
</div>
</div>
@@ -100,34 +121,49 @@ export function EmergencyCard({ info, medications, variant = 'full' }: Emergency
{/* Current Medications */}
{variant === 'full' && medications && medications.length > 0 && (
<div className="border-t border-red-200 pt-4">
<p className="text-sm text-red-700 font-bold mb-2">Current Medications</p>
<ul className="space-y-1">
{medications.map((med, i) => (
<li key={i} className="text-secondary-900">
<span className="font-medium">{med.name}</span>
{med.instructions && (
<span className="text-secondary-600"> - {med.instructions}</span>
)}
</li>
))}
</ul>
<div className="border-t-2 border-cream-200 pt-6">
<div className="flex items-center gap-2 mb-4">
<FileText className="w-5 h-5 text-primary-500" />
<p className="text-sm text-secondary-700 font-semibold uppercase tracking-wide">
Current Medications
</p>
</div>
<div className="bg-cream-50 rounded-card p-4 border border-cream-200">
<ul className="space-y-3">
{medications.map((med, i) => (
<li key={i} className="flex items-start gap-3">
<span className="w-2 h-2 rounded-full bg-primary-400 mt-2 flex-shrink-0" />
<div>
<span className="font-semibold text-secondary-900">{med.name}</span>
{med.instructions && (
<span className="text-secondary-600"> {med.instructions}</span>
)}
</div>
</li>
))}
</ul>
</div>
</div>
)}
{/* Doctor Info */}
{info.primaryPhysician && (
<div className="border-t border-red-200 pt-4">
<div className="flex items-start gap-3">
<Stethoscope className="w-5 h-5 text-red-600 mt-0.5" />
<div>
<p className="text-sm text-red-700 font-medium">Primary Physician</p>
<p className="text-secondary-900 font-medium">{info.primaryPhysician}</p>
<div className="border-t-2 border-cream-200 pt-6">
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-full bg-secondary-100 flex items-center justify-center flex-shrink-0">
<Stethoscope className="w-6 h-6 text-secondary-600" />
</div>
<div className="flex-1">
<p className="text-xs text-secondary-500 uppercase tracking-wide font-semibold mb-1">
Primary Physician
</p>
<p className="font-display text-lg text-secondary-900">{info.primaryPhysician}</p>
{info.physicianPhone && (
<a
href={`tel:${info.physicianPhone}`}
className="text-primary-600 hover:underline"
className="inline-flex items-center gap-2 mt-2 text-primary-600 font-medium hover:text-primary-700 hover:underline"
>
<Phone className="w-4 h-4" />
{info.physicianPhone}
</a>
)}
@@ -138,38 +174,42 @@ export function EmergencyCard({ info, medications, variant = 'full' }: Emergency
{/* Emergency Contacts */}
{(info.clinicPhone || info.emergencyPhone) && (
<div className="border-t border-red-200 pt-4 space-y-3">
<p className="text-sm text-red-700 font-bold">Emergency Contacts</p>
<div className="border-t-2 border-cream-200 pt-6">
<p className="text-sm text-secondary-700 font-semibold uppercase tracking-wide mb-4 flex items-center gap-2">
<Phone className="w-5 h-5 text-alert-500" />
Emergency Contacts
</p>
<div className="grid gap-3">
{info.clinicPhone && (
<a
href={`tel:${info.clinicPhone}`}
className="flex items-center gap-4 p-4 bg-alert-50 rounded-card border border-alert-200 hover:bg-alert-100 hover:border-alert-300 hover:shadow-soft transition-all duration-300 group"
>
<div className="w-14 h-14 rounded-full bg-gradient-to-br from-alert-500 to-alert-600 flex items-center justify-center shadow-lg group-hover:scale-105 transition-transform">
<Phone className="w-7 h-7 text-white" />
</div>
<div>
<p className="font-semibold text-secondary-900 text-lg">Call Clinic</p>
<p className="text-alert-600 font-medium">{info.clinicPhone}</p>
</div>
</a>
)}
{info.clinicPhone && (
<a
href={`tel:${info.clinicPhone}`}
className="flex items-center gap-3 p-3 bg-white rounded-lg border border-red-200 hover:bg-red-50 transition-colors"
>
<div className="w-10 h-10 rounded-full bg-red-600 flex items-center justify-center">
<Phone className="w-5 h-5 text-white" />
</div>
<div>
<p className="font-medium text-secondary-900">Call Clinic</p>
<p className="text-sm text-secondary-600">{info.clinicPhone}</p>
</div>
</a>
)}
{info.emergencyPhone && (
<a
href={`tel:${info.emergencyPhone}`}
className="flex items-center gap-3 p-3 bg-white rounded-lg border border-red-200 hover:bg-red-50 transition-colors"
>
<div className="w-10 h-10 rounded-full bg-red-600 flex items-center justify-center">
<Phone className="w-5 h-5 text-white" />
</div>
<div>
<p className="font-medium text-secondary-900">Emergency Contact</p>
<p className="text-sm text-secondary-600">{info.emergencyPhone}</p>
</div>
</a>
)}
{info.emergencyPhone && (
<a
href={`tel:${info.emergencyPhone}`}
className="flex items-center gap-4 p-4 bg-cream-50 rounded-card border border-cream-200 hover:bg-cream-100 hover:border-cream-300 hover:shadow-soft transition-all duration-300 group"
>
<div className="w-14 h-14 rounded-full bg-gradient-to-br from-secondary-500 to-secondary-600 flex items-center justify-center shadow-lg group-hover:scale-105 transition-transform">
<Phone className="w-7 h-7 text-white" />
</div>
<div>
<p className="font-semibold text-secondary-900 text-lg">Emergency Contact</p>
<p className="text-secondary-600 font-medium">{info.emergencyPhone}</p>
</div>
</a>
)}
</div>
</div>
)}
</div>

View File

@@ -0,0 +1,91 @@
'use client'
import { format } from 'date-fns'
import { FileText } from 'lucide-react'
import { Card } from '@/components/ui'
import { MarkerRow } from './MarkerRow'
interface MarkerData {
marker: string
value: number
unit: string
refMin: number | null
refMax: number | null
flag: string | null
}
interface LabResultData {
id: string
testDate: string
panelName: string
labName: string | null
results: MarkerData[]
notes: string | null
createdBy?: { id: string; name: string }
}
interface LabResultCardProps {
result: LabResultData
onEdit?: (result: LabResultData) => void
}
export function LabResultCard({ result, onEdit }: LabResultCardProps) {
const markers = result.results || []
const flaggedCount = markers.filter((m) => m.flag).length
const criticalCount = markers.filter((m) => m.flag?.startsWith('CRITICAL')).length
return (
<Card
className="cursor-pointer hover:shadow-card-hover transition-shadow"
onClick={() => onEdit?.(result)}
>
<div className="p-4">
{/* Header */}
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-blue-100 flex items-center justify-center">
<FileText className="w-5 h-5 text-blue-600" />
</div>
<div>
<h3 className="font-semibold text-secondary-900">{result.panelName}</h3>
<p className="text-xs text-secondary-500">
{format(new Date(result.testDate), 'MMM d, yyyy')}
{result.labName && ` · ${result.labName}`}
</p>
</div>
</div>
{/* Flag summary */}
<div className="flex gap-1.5">
{criticalCount > 0 && (
<span className="text-xs font-bold px-2 py-0.5 rounded-full bg-red-100 text-red-700">
{criticalCount} critical
</span>
)}
{flaggedCount > 0 && flaggedCount !== criticalCount && (
<span className="text-xs font-bold px-2 py-0.5 rounded-full bg-yellow-100 text-yellow-700">
{flaggedCount - criticalCount} flagged
</span>
)}
{flaggedCount === 0 && markers.length > 0 && (
<span className="text-xs font-bold px-2 py-0.5 rounded-full bg-green-100 text-green-700">
All normal
</span>
)}
</div>
</div>
{/* Marker rows */}
<div className="space-y-1">
{markers.map((m, i) => (
<MarkerRow key={`${m.marker}-${i}`} marker={m} />
))}
</div>
{/* Notes */}
{result.notes && (
<p className="text-xs text-secondary-500 mt-3 italic">{result.notes}</p>
)}
</div>
</Card>
)
}

View File

@@ -0,0 +1,252 @@
'use client'
import { useState } from 'react'
import { Plus, Trash2 } from 'lucide-react'
import { Modal, Button, Input, Select, Textarea, showToast } from '@/components/ui'
import { LAB_PANELS, computeFlag, type PanelMarker } from '@/lib/labs/panels'
interface MarkerFormRow {
marker: string
value: string
unit: string
refMin: string
refMax: string
}
interface LabResultFormProps {
isOpen: boolean
onClose: () => void
onSaved: () => void
workspaceId: string
initialData?: {
id?: 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
}>
notes?: string | null
}
}
function markerToRow(m: PanelMarker): MarkerFormRow {
return {
marker: m.marker,
value: '',
unit: m.unit,
refMin: m.refMin !== null ? String(m.refMin) : '',
refMax: m.refMax !== null ? String(m.refMax) : '',
}
}
function emptyRow(): MarkerFormRow {
return { marker: '', value: '', unit: '', refMin: '', refMax: '' }
}
const panelOptions = LAB_PANELS.map((p) => ({ value: p.name, label: p.name }))
export function LabResultForm({ isOpen, onClose, onSaved, workspaceId, initialData }: LabResultFormProps) {
const isEdit = !!initialData?.id
const [testDate, setTestDate] = useState(
initialData?.testDate
? new Date(initialData.testDate).toISOString().slice(0, 16)
: new Date().toISOString().slice(0, 16)
)
const [panelName, setPanelName] = useState(initialData?.panelName || LAB_PANELS[0].name)
const [labName, setLabName] = useState(initialData?.labName || '')
const [notes, setNotes] = useState(initialData?.notes || '')
const [saving, setSaving] = useState(false)
const [rows, setRows] = useState<MarkerFormRow[]>(() => {
if (initialData?.results) {
return initialData.results.map((m) => ({
marker: m.marker,
value: String(m.value),
unit: m.unit,
refMin: m.refMin !== null ? String(m.refMin) : '',
refMax: m.refMax !== null ? String(m.refMax) : '',
}))
}
const panel = LAB_PANELS.find((p) => p.name === panelName)
return panel?.markers.length ? panel.markers.map(markerToRow) : [emptyRow()]
})
const handlePanelChange = (name: string) => {
setPanelName(name)
const panel = LAB_PANELS.find((p) => p.name === name)
if (panel && panel.markers.length > 0) {
setRows(panel.markers.map(markerToRow))
}
}
const updateRow = (index: number, field: keyof MarkerFormRow, value: string) => {
setRows((prev) => prev.map((r, i) => (i === index ? { ...r, [field]: value } : r)))
}
const addRow = () => setRows((prev) => [...prev, emptyRow()])
const removeRow = (index: number) => setRows((prev) => prev.filter((_, i) => i !== index))
const handleSave = async () => {
// Validate: at least one marker with a value
const filledRows = rows.filter((r) => r.marker.trim() && r.value.trim())
if (filledRows.length === 0) {
showToast('Enter at least one marker value', 'error')
return
}
setSaving(true)
try {
const results = filledRows.map((r) => {
const value = parseFloat(r.value)
const refMin = r.refMin ? parseFloat(r.refMin) : null
const refMax = r.refMax ? parseFloat(r.refMax) : null
const flag = computeFlag(value, refMin, refMax)
return {
marker: r.marker.trim(),
value,
unit: r.unit.trim(),
refMin,
refMax,
flag,
}
})
const url = isEdit
? `/api/workspaces/${workspaceId}/lab-results/${initialData!.id}`
: `/api/workspaces/${workspaceId}/lab-results`
const method = isEdit ? 'PATCH' : 'POST'
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
testDate: new Date(testDate).toISOString(),
panelName,
labName: labName.trim() || null,
results,
notes: notes.trim() || null,
}),
})
if (!response.ok) throw new Error('Failed to save lab result')
showToast(isEdit ? 'Lab result updated' : 'Lab result saved', 'success')
onSaved()
onClose()
} catch {
showToast('Failed to save lab result', 'error')
} finally {
setSaving(false)
}
}
return (
<Modal isOpen={isOpen} onClose={onClose} title={isEdit ? 'Edit Lab Result' : 'New Lab Result'}>
<div className="space-y-4 max-h-[70vh] overflow-y-auto">
{/* Panel selector */}
{!isEdit && (
<Select
label="Panel Template"
value={panelName}
onChange={(e) => handlePanelChange(e.target.value)}
options={panelOptions}
/>
)}
<div className="grid grid-cols-2 gap-3">
<Input
label="Test Date *"
type="datetime-local"
value={testDate}
onChange={(e) => setTestDate(e.target.value)}
/>
<Input
label="Lab Name"
value={labName}
onChange={(e) => setLabName(e.target.value)}
placeholder="e.g. Quest"
/>
</div>
{/* Marker rows */}
<div>
<div className="flex items-center justify-between mb-2">
<p className="text-sm font-semibold text-secondary-700">Markers</p>
<button
onClick={addRow}
className="flex items-center gap-1 text-xs font-medium text-primary-600 hover:text-primary-700"
>
<Plus className="w-3.5 h-3.5" /> Add Row
</button>
</div>
<div className="space-y-2">
{rows.map((row, i) => (
<div key={i} className="bg-muted rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2">
<Input
value={row.marker}
onChange={(e) => updateRow(i, 'marker', e.target.value)}
placeholder="Marker name"
className="flex-1"
/>
{rows.length > 1 && (
<button onClick={() => removeRow(i)} className="text-secondary-400 hover:text-red-500">
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
<div className="grid grid-cols-4 gap-2">
<Input
value={row.value}
onChange={(e) => updateRow(i, 'value', e.target.value)}
placeholder="Value"
inputMode="decimal"
/>
<Input
value={row.unit}
onChange={(e) => updateRow(i, 'unit', e.target.value)}
placeholder="Unit"
/>
<Input
value={row.refMin}
onChange={(e) => updateRow(i, 'refMin', e.target.value)}
placeholder="Min"
inputMode="decimal"
/>
<Input
value={row.refMax}
onChange={(e) => updateRow(i, 'refMax', e.target.value)}
placeholder="Max"
inputMode="decimal"
/>
</div>
</div>
))}
</div>
</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}>
{isEdit ? 'Update' : 'Save Results'}
</Button>
</div>
</div>
</Modal>
)
}

View File

@@ -0,0 +1,154 @@
'use client'
import { useState, useEffect } from 'react'
import { format } from 'date-fns'
interface TrendPoint {
date: string
value: number
unit: string
refMin: number | null
refMax: number | null
}
interface LabTrendChartProps {
marker: string
workspaceId: string
}
export function LabTrendChart({ marker, workspaceId }: LabTrendChartProps) {
const [data, setData] = useState<TrendPoint[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
if (!marker) return
setLoading(true)
fetch(`/api/workspaces/${workspaceId}/lab-results/trends?marker=${encodeURIComponent(marker)}`)
.then((res) => res.ok ? res.json() : null)
.then((json) => {
if (json?.trendData) setData(json.trendData)
})
.catch(() => {})
.finally(() => setLoading(false))
}, [marker, workspaceId])
if (loading) {
return (
<div className="h-48 flex items-center justify-center text-secondary-400 text-sm">
Loading trend data...
</div>
)
}
if (data.length < 2) {
return (
<div className="h-48 flex items-center justify-center text-secondary-400 text-sm">
Need at least 2 data points for a trend chart
</div>
)
}
// Calculate chart dimensions
const chartWidth = 320
const chartHeight = 160
const padding = { top: 15, right: 15, bottom: 30, left: 50 }
const plotWidth = chartWidth - padding.left - padding.right
const plotHeight = chartHeight - padding.top - padding.bottom
// Scale calculations
const values = data.map((d) => d.value)
const refMin = data[0].refMin
const refMax = data[0].refMax
const allValues = [...values]
if (refMin !== null) allValues.push(refMin)
if (refMax !== null) allValues.push(refMax)
const dataMin = Math.min(...allValues)
const dataMax = Math.max(...allValues)
const valueRange = dataMax - dataMin || 1
const yMin = dataMin - valueRange * 0.1
const yMax = dataMax + valueRange * 0.1
const yRange = yMax - yMin
const scaleX = (i: number) => padding.left + (i / (data.length - 1)) * plotWidth
const scaleY = (v: number) => padding.top + plotHeight - ((v - yMin) / yRange) * plotHeight
// Build SVG path
const linePath = data
.map((d, i) => `${i === 0 ? 'M' : 'L'} ${scaleX(i)} ${scaleY(d.value)}`)
.join(' ')
// Reference range rect
const refRangeY = refMax !== null ? scaleY(refMax) : padding.top
const refRangeHeight = refMin !== null && refMax !== null
? scaleY(refMin) - scaleY(refMax)
: 0
const unit = data[0].unit
return (
<div className="overflow-x-auto">
<svg viewBox={`0 0 ${chartWidth} ${chartHeight}`} className="w-full max-w-sm mx-auto">
{/* Reference range band */}
{refMin !== null && refMax !== null && (
<rect
x={padding.left}
y={refRangeY}
width={plotWidth}
height={refRangeHeight}
fill="rgb(34 197 94 / 0.1)"
stroke="rgb(34 197 94 / 0.2)"
strokeWidth="0.5"
/>
)}
{/* Grid lines */}
{[0, 0.25, 0.5, 0.75, 1].map((frac) => {
const y = padding.top + frac * plotHeight
const value = yMax - frac * yRange
return (
<g key={frac}>
<line
x1={padding.left} y1={y}
x2={padding.left + plotWidth} y2={y}
stroke="rgb(0 0 0 / 0.06)" strokeWidth="0.5"
/>
<text
x={padding.left - 5} y={y + 3}
textAnchor="end" className="text-[8px] fill-secondary-400"
>
{value.toFixed(1)}
</text>
</g>
)
})}
{/* Data line */}
<path d={linePath} fill="none" stroke="rgb(59 130 246)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
{/* Data points */}
{data.map((d, i) => (
<g key={i}>
<circle cx={scaleX(i)} cy={scaleY(d.value)} r="4" fill="white" stroke="rgb(59 130 246)" strokeWidth="2" />
{/* Date label on x-axis */}
<text
x={scaleX(i)} y={chartHeight - 5}
textAnchor="middle" className="text-[7px] fill-secondary-400"
>
{format(new Date(d.date), 'M/d')}
</text>
</g>
))}
{/* Unit label */}
<text
x={3} y={padding.top + plotHeight / 2}
textAnchor="middle" className="text-[7px] fill-secondary-400"
transform={`rotate(-90, 8, ${padding.top + plotHeight / 2})`}
>
{unit}
</text>
</svg>
</div>
)
}

View File

@@ -0,0 +1,57 @@
'use client'
interface MarkerData {
marker: string
value: number
unit: string
refMin: number | null
refMax: number | null
flag: string | null
}
interface MarkerRowProps {
marker: MarkerData
}
const FLAG_STYLES: Record<string, { bg: string; text: string; label: string }> = {
LOW: { bg: 'bg-yellow-50', text: 'text-yellow-700', label: 'L' },
HIGH: { bg: 'bg-yellow-50', text: 'text-yellow-700', label: 'H' },
CRITICAL_LOW: { bg: 'bg-red-50', text: 'text-red-700', label: 'LL' },
CRITICAL_HIGH: { bg: 'bg-red-50', text: 'text-red-700', label: 'HH' },
}
export function MarkerRow({ marker: m }: MarkerRowProps) {
const flagStyle = m.flag ? FLAG_STYLES[m.flag] : null
const hasRange = m.refMin !== null || m.refMax !== null
const rangeText = hasRange
? `${m.refMin ?? '—'} ${m.refMax ?? '—'}`
: '—'
return (
<div className={`flex items-center justify-between py-2 px-3 rounded-lg ${flagStyle?.bg || ''}`}>
<div className="flex-1 min-w-0">
<span className={`text-sm font-medium ${flagStyle?.text || 'text-secondary-900'}`}>
{m.marker}
</span>
</div>
<div className="flex items-center gap-3">
<span className={`text-sm font-semibold tabular-nums ${flagStyle?.text || 'text-secondary-900'}`}>
{m.value} {m.unit}
</span>
<span className="text-xs text-secondary-400 w-24 text-right tabular-nums">
{rangeText}
</span>
{flagStyle && (
<span className={`text-xs font-bold px-1.5 py-0.5 rounded ${flagStyle.bg} ${flagStyle.text}`}>
{flagStyle.label}
</span>
)}
{!flagStyle && hasRange && (
<span className="text-xs font-bold px-1.5 py-0.5 rounded bg-green-50 text-green-600">
N
</span>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,34 @@
'use client'
import { AlertTriangle } from 'lucide-react'
interface InteractionBannerProps {
count: number
hasMajor: boolean
onClick?: () => void
}
export function InteractionBanner({ count, hasMajor, onClick }: InteractionBannerProps) {
if (count === 0) return null
const bgColor = hasMajor ? 'bg-red-50 border-red-200' : 'bg-yellow-50 border-yellow-200'
const textColor = hasMajor ? 'text-red-700' : 'text-yellow-700'
const iconColor = hasMajor ? 'text-red-500' : 'text-yellow-500'
return (
<button
onClick={onClick}
className={`w-full flex items-center gap-3 border rounded-card p-3 ${bgColor} transition-shadow hover:shadow-card-hover`}
>
<AlertTriangle className={`w-5 h-5 ${iconColor} flex-shrink-0`} />
<div className="text-left">
<p className={`text-sm font-semibold ${textColor}`}>
{count} Drug Interaction{count !== 1 ? 's' : ''} Found
</p>
<p className="text-xs text-secondary-500">
{hasMajor ? 'Includes major interactions — review with your care team' : 'Tap to review details'}
</p>
</div>
</button>
)
}

View File

@@ -0,0 +1,94 @@
'use client'
import { AlertTriangle, AlertOctagon, Info, XOctagon } from 'lucide-react'
interface InteractionData {
drug1Name: string
drug2Name: string
severity: string
description: string
recommendation: string
}
interface InteractionCardProps {
interaction: InteractionData
}
const SEVERITY_CONFIG: Record<string, {
bg: string
border: string
text: string
badge: string
label: string
Icon: typeof AlertTriangle
}> = {
CONTRAINDICATED: {
bg: 'bg-red-50',
border: 'border-red-200',
text: 'text-red-800',
badge: 'bg-red-600 text-white',
label: 'Contraindicated',
Icon: XOctagon,
},
MAJOR: {
bg: 'bg-orange-50',
border: 'border-orange-200',
text: 'text-orange-800',
badge: 'bg-orange-500 text-white',
label: 'Major',
Icon: AlertOctagon,
},
MODERATE: {
bg: 'bg-yellow-50',
border: 'border-yellow-200',
text: 'text-yellow-800',
badge: 'bg-yellow-500 text-white',
label: 'Moderate',
Icon: AlertTriangle,
},
MINOR: {
bg: 'bg-blue-50',
border: 'border-blue-200',
text: 'text-blue-800',
badge: 'bg-blue-500 text-white',
label: 'Minor',
Icon: Info,
},
}
export function InteractionCard({ interaction }: InteractionCardProps) {
const config = SEVERITY_CONFIG[interaction.severity] || SEVERITY_CONFIG.MINOR
const { Icon } = config
return (
<div className={`rounded-card border ${config.border} ${config.bg} p-4`}>
<div className="flex items-start gap-3">
<Icon className={`w-5 h-5 ${config.text} flex-shrink-0 mt-0.5`} />
<div className="flex-1 min-w-0">
{/* Header with severity badge */}
<div className="flex items-center gap-2 mb-1">
<span className={`text-xs font-bold px-2 py-0.5 rounded-full ${config.badge}`}>
{config.label}
</span>
</div>
{/* Drug names */}
<p className={`font-semibold text-sm ${config.text}`}>
{interaction.drug1Name} + {interaction.drug2Name}
</p>
{/* Description */}
<p className="text-sm text-secondary-700 mt-1">{interaction.description}</p>
{/* Recommendation */}
<div className="mt-2 bg-white/50 rounded-lg px-3 py-2">
<p className="text-xs font-medium text-secondary-600">
<span className="font-semibold">Recommendation:</span>{' '}
{interaction.recommendation}
</p>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,122 @@
'use client'
import { useState } from 'react'
import { Shield, Loader2 } from 'lucide-react'
import { Modal, Button, showToast } from '@/components/ui'
import { InteractionCard } from './InteractionCard'
interface InteractionResult {
drug1Name: string
drug2Name: string
severity: string
description: string
recommendation: string
}
interface InteractionCheckProps {
workspaceId: string
}
export function InteractionCheck({ workspaceId }: InteractionCheckProps) {
const [checking, setChecking] = useState(false)
const [results, setResults] = useState<InteractionResult[] | null>(null)
const [showResults, setShowResults] = useState(false)
const [medCount, setMedCount] = useState(0)
const handleCheck = async () => {
setChecking(true)
try {
const response = await fetch(
`/api/workspaces/${workspaceId}/medications/check-interactions`,
{ method: 'POST' }
)
if (!response.ok) throw new Error('Failed to check interactions')
const data = await response.json()
setResults(data.interactions)
setMedCount(data.medicationCount)
setShowResults(true)
if (data.interactions.length === 0) {
showToast('No interactions found', 'success')
}
} catch {
showToast('Failed to check interactions', 'error')
} finally {
setChecking(false)
}
}
const majorCount = results?.filter(
(r) => r.severity === 'MAJOR' || r.severity === 'CONTRAINDICATED'
).length ?? 0
return (
<>
{/* Check button */}
<button
onClick={handleCheck}
disabled={checking}
className="w-full flex items-center justify-center gap-2 bg-primary-50 hover:bg-primary-100 border border-primary-200 rounded-card p-3 transition-colors disabled:opacity-50"
>
{checking ? (
<Loader2 className="w-5 h-5 text-primary-600 animate-spin" />
) : (
<Shield className="w-5 h-5 text-primary-600" />
)}
<span className="text-sm font-semibold text-primary-700">
{checking ? 'Checking...' : 'Check Drug Interactions'}
</span>
</button>
{/* Results Modal */}
<Modal
isOpen={showResults}
onClose={() => setShowResults(false)}
title="Drug Interactions"
>
<div className="space-y-4 max-h-[70vh] overflow-y-auto">
{/* Summary */}
<div className="text-sm text-secondary-600">
Checked {medCount} active medications.
</div>
{results && results.length === 0 && (
<div className="text-center py-6">
<Shield className="w-12 h-12 text-green-500 mx-auto mb-3" />
<p className="font-semibold text-green-700">No Interactions Found</p>
<p className="text-sm text-secondary-500 mt-1">
No known interactions between your current medications.
</p>
</div>
)}
{results && results.length > 0 && (
<>
{majorCount > 0 && (
<div className="bg-red-50 border border-red-200 rounded-lg px-3 py-2 text-sm text-red-700 font-medium">
{majorCount} major interaction{majorCount !== 1 ? 's' : ''} found discuss with your care team
</div>
)}
<div className="space-y-3">
{results.map((interaction, i) => (
<InteractionCard key={i} interaction={interaction} />
))}
</div>
</>
)}
<div className="pt-2">
<Button variant="secondary" onClick={() => setShowResults(false)} fullWidth>
Close
</Button>
</div>
<p className="text-xs text-secondary-400 text-center">
This is a simplified check using a local database. Always consult your pharmacist or oncologist.
</p>
</div>
</Modal>
</>
)
}

View File

@@ -0,0 +1,503 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { Clock, Calendar, Repeat, Pill, Package, ChevronDown, Plus, X } from 'lucide-react'
import { Button, Input, Textarea, Select, showToast } from '@/components/ui'
import { useApp } from '@/app/(app)/provider'
type ScheduleType = 'FIXED_TIMES' | 'INTERVAL' | 'WEEKDAYS' | 'PRN'
const scheduleTypeOptions = [
{ value: 'FIXED_TIMES', label: 'Fixed times daily', icon: Clock, desc: 'Same times every day' },
{ value: 'INTERVAL', label: 'Every X hours', icon: Repeat, desc: 'Regular intervals' },
{ value: 'WEEKDAYS', label: 'Specific days', icon: Calendar, desc: 'Certain days of the week' },
{ value: 'PRN', label: 'As needed (PRN)', icon: Pill, desc: 'When you need it' },
]
const weekdays = [
{ value: 0, label: 'Sun', full: 'Sunday' },
{ value: 1, label: 'Mon', full: 'Monday' },
{ value: 2, label: 'Tue', full: 'Tuesday' },
{ value: 3, label: 'Wed', full: 'Wednesday' },
{ value: 4, label: 'Thu', full: 'Thursday' },
{ value: 5, label: 'Fri', full: 'Friday' },
{ value: 6, label: 'Sat', full: 'Saturday' },
]
interface MedicationFormProps {
initialData?: {
id?: string
name: string
instructions?: string | null
scheduleType: string
scheduleData: any
active?: boolean
pillCount?: number | null
pillsPerDose?: number | null
refillThreshold?: number | null
}
isEditing?: boolean
}
export function MedicationForm({ initialData, isEditing = false }: MedicationFormProps) {
const router = useRouter()
const { currentWorkspace, refreshData } = useApp()
const [mounted, setMounted] = useState(false)
const [name, setName] = useState(initialData?.name || '')
const [instructions, setInstructions] = useState(initialData?.instructions || '')
const [scheduleType, setScheduleType] = useState<ScheduleType>((initialData?.scheduleType as ScheduleType) || 'FIXED_TIMES')
// Fixed times
const [times, setTimes] = useState<string[]>(initialData?.scheduleData?.times || ['08:00'])
// Interval
const [intervalHours, setIntervalHours] = useState(initialData?.scheduleData?.hours || 8)
const [startTime, setStartTime] = useState(initialData?.scheduleData?.startTime || '08:00')
// Weekdays
const [selectedDays, setSelectedDays] = useState<number[]>(initialData?.scheduleData?.days || [1, 3, 5])
const [weekdayTime, setWeekdayTime] = useState(initialData?.scheduleData?.time || '09:00')
// PRN
const [minHoursBetween, setMinHoursBetween] = useState(initialData?.scheduleData?.minHoursBetween || 4)
// Refill tracking (optional)
const hasRefillInfo = initialData?.pillCount !== null && initialData?.pillCount !== undefined
const [trackRefills, setTrackRefills] = useState(hasRefillInfo)
const [pillCount, setPillCount] = useState<number | ''>(initialData?.pillCount ?? '')
const [pillsPerDose, setPillsPerDose] = useState(initialData?.pillsPerDose || 1)
const [refillThreshold, setRefillThreshold] = useState(initialData?.refillThreshold || 7)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
setMounted(true)
}, [])
useEffect(() => {
if (initialData?.scheduleType !== scheduleType) {
// Keep current state if user is just switching around in new mode
}
}, [scheduleType, initialData])
const addTime = () => {
setTimes([...times, '12:00'])
}
const removeTime = (index: number) => {
setTimes(times.filter((_, i) => i !== index))
}
const updateTime = (index: number, value: string) => {
const newTimes = [...times]
newTimes[index] = value
setTimes(newTimes)
}
const toggleDay = (day: number) => {
if (selectedDays.includes(day)) {
setSelectedDays(selectedDays.filter((d) => d !== day))
} else {
setSelectedDays([...selectedDays, day].sort())
}
}
const buildScheduleData = () => {
switch (scheduleType) {
case 'FIXED_TIMES':
return { type: 'FIXED_TIMES', times }
case 'INTERVAL':
return { type: 'INTERVAL', hours: intervalHours, startTime }
case 'WEEKDAYS':
return { type: 'WEEKDAYS', days: selectedDays, time: weekdayTime }
case 'PRN':
return { type: 'PRN', minHoursBetween }
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
const url = isEditing && initialData?.id
? `/api/workspaces/${currentWorkspace.id}/medications/${initialData.id}`
: `/api/workspaces/${currentWorkspace.id}/medications`
const method = isEditing ? 'PATCH' : 'POST'
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
instructions: instructions || null,
scheduleType,
scheduleData: buildScheduleData(),
active: true,
...(trackRefills && pillCount !== '' && {
pillCount: Number(pillCount),
pillsPerDose,
refillThreshold,
}),
...(isEditing && !trackRefills && {
pillCount: null,
pillsPerDose: null,
refillThreshold: null,
})
}),
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || 'Failed to save medication')
}
await refreshData()
showToast(isEditing ? 'Medication updated' : 'Medication added', 'success')
router.push('/meds')
} catch (err) {
setError(err instanceof Error ? err.message : 'Something went wrong')
} finally {
setLoading(false)
}
}
const currentScheduleOption = scheduleTypeOptions.find(opt => opt.value === scheduleType)
const ScheduleIcon = currentScheduleOption?.icon || Clock
return (
<div className={`space-y-6 transition-all duration-700 ${mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'}`}>
{/* Form Header */}
<div className="text-center mb-8">
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-primary-100 to-primary-200 flex items-center justify-center mx-auto mb-4 shadow-lg">
<Pill className="w-10 h-10 text-primary-600" />
</div>
<h2 className="font-display text-display-sm text-secondary-900">
{isEditing ? 'Edit Medication' : 'Add Medication'}
</h2>
<p className="text-secondary-500 mt-2">
{isEditing ? 'Update your medication details' : 'Keep track of your medications'}
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Basic Info Section */}
<div className="section-warm space-y-5">
<h3 className="font-display text-lg text-secondary-900 flex items-center gap-2">
<span className="w-8 h-8 rounded-full bg-primary-100 flex items-center justify-center text-primary-600 text-sm font-semibold">1</span>
Basic Information
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-secondary-700 mb-2">
Medication Name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Paracetamol 500mg"
className="input-sanctuary w-full"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 mb-2">
Instructions
<span className="text-secondary-400 font-normal ml-1">(optional)</span>
</label>
<textarea
value={instructions}
onChange={(e) => setInstructions(e.target.value)}
placeholder="e.g., Take with food, Avoid grapefruit..."
rows={2}
className="input-sanctuary w-full resize-none"
/>
</div>
</div>
</div>
{/* Schedule Section */}
<div className="section-warm space-y-5">
<h3 className="font-display text-lg text-secondary-900 flex items-center gap-2">
<span className="w-8 h-8 rounded-full bg-primary-100 flex items-center justify-center text-primary-600 text-sm font-semibold">2</span>
Schedule
</h3>
{/* Schedule Type Selector */}
<div className="grid grid-cols-2 gap-3">
{scheduleTypeOptions.map((option) => {
const Icon = option.icon
const isSelected = scheduleType === option.value
return (
<button
key={option.value}
type="button"
onClick={() => setScheduleType(option.value as ScheduleType)}
className={`p-4 rounded-card border-2 text-left transition-all duration-300 ${
isSelected
? 'border-primary-400 bg-primary-50/50 shadow-soft'
: 'border-cream-200 bg-surface hover:border-cream-300 hover:shadow-soft'
}`}
>
<Icon className={`w-6 h-6 mb-2 ${isSelected ? 'text-primary-600' : 'text-secondary-400'}`} />
<p className={`font-semibold text-sm ${isSelected ? 'text-primary-800' : 'text-secondary-700'}`}>
{option.label}
</p>
<p className="text-xs text-secondary-400 mt-0.5">{option.desc}</p>
</button>
)
})}
</div>
{/* Schedule-specific options */}
<div className="bg-cream-50/50 rounded-card p-5 border border-cream-200/60">
{scheduleType === 'FIXED_TIMES' && (
<div className="space-y-4">
<label className="block text-sm font-medium text-secondary-700">
Times to take each day
</label>
<div className="space-y-3">
{times.map((time, index) => (
<div key={index} className="flex gap-2 items-center">
<div className="flex-1 relative">
<Clock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-secondary-400" />
<input
type="time"
value={time}
onChange={(e) => updateTime(index, e.target.value)}
className="input-sanctuary w-full pl-10"
/>
</div>
{times.length > 1 && (
<button
type="button"
onClick={() => removeTime(index)}
className="w-10 h-10 rounded-button bg-cream-100 hover:bg-cream-200 flex items-center justify-center text-secondary-500 transition-colors"
>
<X className="w-5 h-5" />
</button>
)}
</div>
))}
</div>
<button
type="button"
onClick={addTime}
className="btn-secondary w-full flex items-center justify-center gap-2 text-sm"
>
<Plus className="w-4 h-4" />
Add another time
</button>
</div>
)}
{scheduleType === 'INTERVAL' && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-secondary-700 mb-2">
Every (hours)
</label>
<input
type="number"
min={1}
max={72}
value={intervalHours}
onChange={(e) => setIntervalHours(parseInt(e.target.value) || 1)}
className="input-sanctuary w-full"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 mb-2">
Starting at
</label>
<div className="relative">
<Clock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-secondary-400" />
<input
type="time"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
className="input-sanctuary w-full pl-10"
/>
</div>
</div>
</div>
<p className="text-sm text-secondary-500">
Example: Every {intervalHours} hours starting at {startTime}
</p>
</div>
)}
{scheduleType === 'WEEKDAYS' && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-secondary-700 mb-3">
Which days?
</label>
<div className="grid grid-cols-7 gap-2">
{weekdays.map((day) => {
const isSelected = selectedDays.includes(day.value)
return (
<button
key={day.value}
type="button"
onClick={() => toggleDay(day.value)}
className={`aspect-square rounded-button text-sm font-medium transition-all duration-200 ${
isSelected
? 'bg-primary-500 text-white shadow-lg scale-105'
: 'bg-cream-100 text-secondary-600 hover:bg-cream-200'
}`}
title={day.full}
>
{day.label}
</button>
)
})}
</div>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 mb-2">
At what time?
</label>
<div className="relative">
<Clock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-secondary-400" />
<input
type="time"
value={weekdayTime}
onChange={(e) => setWeekdayTime(e.target.value)}
className="input-sanctuary w-full pl-10"
/>
</div>
</div>
</div>
)}
{scheduleType === 'PRN' && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-secondary-700 mb-2">
Minimum hours between doses
</label>
<input
type="number"
min={0.5}
max={72}
step={0.5}
value={minHoursBetween}
onChange={(e) => setMinHoursBetween(parseFloat(e.target.value) || 4)}
className="input-sanctuary w-full"
/>
</div>
<p className="text-sm text-secondary-500">
Shows "Available" when enough time has passed since your last dose
</p>
</div>
)}
</div>
</div>
{/* Refill Tracking Section */}
<div className="section-warm space-y-5">
<h3 className="font-display text-lg text-secondary-900 flex items-center gap-2">
<span className="w-8 h-8 rounded-full bg-cream-200 flex items-center justify-center text-secondary-600 text-sm font-semibold">
<Package className="w-4 h-4" />
</span>
Refill Tracking
<span className="text-sm font-normal text-secondary-400 ml-auto">Optional</span>
</h3>
<div className="flex items-center gap-3">
<input
type="checkbox"
id="trackRefills"
checked={trackRefills}
onChange={(e) => setTrackRefills(e.target.checked)}
className="w-5 h-5 rounded border-cream-300 text-primary-500 focus:ring-primary-400"
/>
<label htmlFor="trackRefills" className="text-sm text-secondary-700">
Track pill count and get refill reminders
</label>
</div>
{trackRefills && (
<div className="space-y-4 pt-2 pl-8 border-l-2 border-cream-200">
<div>
<label className="block text-sm font-medium text-secondary-700 mb-2">
Current pill count
</label>
<input
type="number"
min={0}
value={pillCount}
onChange={(e) => setPillCount(e.target.value === '' ? '' : parseInt(e.target.value))}
placeholder="e.g., 30"
className="input-sanctuary w-full"
/>
<p className="text-xs text-secondary-400 mt-1.5">How many pills do you have now?</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-secondary-700 mb-2">
Pills per dose
</label>
<input
type="number"
min={1}
value={pillsPerDose}
onChange={(e) => setPillsPerDose(parseInt(e.target.value) || 1)}
className="input-sanctuary w-full"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 mb-2">
Alert when below
</label>
<input
type="number"
min={0}
value={refillThreshold}
onChange={(e) => setRefillThreshold(parseInt(e.target.value) || 7)}
className="input-sanctuary w-full"
/>
<p className="text-xs text-secondary-400 mt-1.5">pills remaining</p>
</div>
</div>
</div>
)}
</div>
{error && (
<div className="bg-alert-50 border border-alert-200 rounded-card p-4">
<p className="text-sm text-alert-700">{error}</p>
</div>
)}
{/* Action Buttons */}
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={() => router.back()}
className="btn-secondary flex-1"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="btn-primary flex-1 disabled:opacity-50"
>
{loading ? 'Saving...' : isEditing ? 'Update Medication' : 'Save Medication'}
</button>
</div>
</form>
</div>
)
}

View File

@@ -31,15 +31,40 @@ export function NotificationPermission({ workspaceId }: NotificationPermissionPr
return
}
// Check if PushManager is available (not available in all browsers/contexts)
if (!('PushManager' in window)) {
setPermission('unsupported')
return
}
const perm = Notification.permission as PermissionState
setPermission(perm)
if (perm === 'granted') {
// Check if already subscribed
// Check if already subscribed with timeout
try {
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.getSubscription()
setIsSubscribed(!!subscription)
const registrationPromise = navigator.serviceWorker.ready
const timeoutPromise = new Promise<ServiceWorkerRegistration>((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), 5000)
)
const registration = await Promise.race([registrationPromise, timeoutPromise])
if (registration.pushManager) {
const subscription = await registration.pushManager.getSubscription()
setIsSubscribed(!!subscription)
// Auto-sync subscription to server to ensure it exists
if (subscription) {
fetch('/api/notifications/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
subscription: subscription.toJSON(),
workspaceId,
}),
}).catch(console.error)
}
}
} catch (err) {
console.error('Failed to check subscription:', err)
}
@@ -66,14 +91,54 @@ export function NotificationPermission({ workspaceId }: NotificationPermissionPr
}
const { publicKey } = await keyResponse.json()
// Register service worker if not already registered
const registration = await navigator.serviceWorker.ready
// Ensure service worker is registered and active
let registration: ServiceWorkerRegistration
// Subscribe to push
const subscription = await registration.pushManager.subscribe({
// First, try to register the service worker (in case it wasn't registered yet)
try {
registration = await navigator.serviceWorker.register('/sw.js', { scope: '/' })
// Wait for it to be active
if (registration.installing || registration.waiting) {
await new Promise<void>((resolve, reject) => {
const sw = registration.installing || registration.waiting
if (!sw) {
resolve()
return
}
const timeout = setTimeout(() => reject(new Error('Service worker activation timeout')), 10000)
sw.addEventListener('statechange', () => {
if (sw.state === 'activated') {
clearTimeout(timeout)
resolve()
} else if (sw.state === 'redundant') {
clearTimeout(timeout)
reject(new Error('Service worker became redundant'))
}
})
})
}
} catch (regError: any) {
console.error('Service worker registration error:', regError)
throw new Error('Failed to register service worker: ' + regError.message)
}
// Check if push manager is available
if (!registration.pushManager) {
throw new Error('Push notifications not supported on this device')
}
// Subscribe to push with timeout
const subscribePromise = registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey),
})
const subscribeTimeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Push subscription timed out - this device may not support web push')), 15000)
)
const subscription = await Promise.race([subscribePromise, subscribeTimeout])
// Send subscription to server
const response = await fetch('/api/notifications/subscribe', {

View File

@@ -0,0 +1,122 @@
'use client'
import { format, isPast } from 'date-fns'
import { CheckCircle2, Circle, User } from 'lucide-react'
interface TaskData {
id: string
title: string
description: string | null
category: string
priority: string
status: string
dueDate: string | null
completedAt: string | null
assignedTo?: { id: string; name: string } | null
createdBy?: { id: string; name: string }
}
interface TaskCardProps {
task: TaskData
onComplete?: (id: string) => void
onEdit?: (task: TaskData) => void
}
const CATEGORY_BADGES: Record<string, string> = {
MEDICAL: 'bg-blue-100 text-blue-700',
ERRANDS: 'bg-purple-100 text-purple-700',
MEALS: 'bg-orange-100 text-orange-700',
EMOTIONAL: 'bg-pink-100 text-pink-700',
OTHER: 'bg-secondary-100 text-secondary-700',
}
const CATEGORY_LABELS: Record<string, string> = {
MEDICAL: 'Medical',
ERRANDS: 'Errands',
MEALS: 'Meals',
EMOTIONAL: 'Emotional',
OTHER: 'Other',
}
const PRIORITY_DOTS: Record<string, string> = {
URGENT: 'bg-red-500',
HIGH: 'bg-orange-500',
NORMAL: 'bg-blue-500',
LOW: 'bg-secondary-400',
}
const PRIORITY_BORDERS: Record<string, string> = {
URGENT: 'border-l-red-500',
HIGH: 'border-l-orange-500',
NORMAL: 'border-l-blue-500',
LOW: 'border-l-secondary-300',
}
export function TaskCard({ task, onComplete, onEdit }: TaskCardProps) {
const isDone = task.status === 'DONE'
const isOverdue = task.dueDate && !isDone && isPast(new Date(task.dueDate))
const priorityDot = PRIORITY_DOTS[task.priority] || PRIORITY_DOTS.NORMAL
const priorityBorder = PRIORITY_BORDERS[task.priority] || PRIORITY_BORDERS.NORMAL
const categoryBadge = CATEGORY_BADGES[task.category] || CATEGORY_BADGES.OTHER
const categoryLabel = CATEGORY_LABELS[task.category] || task.category
return (
<div
className={`bg-surface rounded-lg border border-border border-l-4 ${priorityBorder} p-4 cursor-pointer hover:shadow-card-hover transition-shadow`}
onClick={() => onEdit?.(task)}
>
<div className="flex items-start gap-3">
{/* Checkbox */}
<button
onClick={(e) => {
e.stopPropagation()
if (!isDone) onComplete?.(task.id)
}}
className="flex-shrink-0 mt-0.5"
aria-label={isDone ? 'Completed' : 'Mark as done'}
>
{isDone ? (
<CheckCircle2 className="w-6 h-6 text-green-500" />
) : (
<Circle className="w-6 h-6 text-secondary-300" />
)}
</button>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className={`font-semibold truncate ${isDone ? 'line-through text-secondary-400' : 'text-secondary-900'}`}>
{task.title}
</h3>
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${priorityDot}`} />
</div>
<div className="flex items-center gap-2 flex-wrap">
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${categoryBadge}`}>
{categoryLabel}
</span>
{task.dueDate && (
<span className={`text-xs ${isOverdue ? 'text-red-600 font-semibold' : 'text-secondary-500'}`}>
{isOverdue ? 'Overdue: ' : 'Due: '}
{format(new Date(task.dueDate), 'MMM d')}
</span>
)}
</div>
{/* Assignee */}
{task.assignedTo && (
<div className="flex items-center gap-1.5 mt-2">
<div className="w-5 h-5 rounded-full bg-primary-100 flex items-center justify-center">
<span className="text-xs font-medium text-primary-700">
{task.assignedTo.name.charAt(0).toUpperCase()}
</span>
</div>
<span className="text-xs text-secondary-500">{task.assignedTo.name}</span>
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,32 @@
'use client'
const FILTERS = [
{ value: 'mine', label: 'My Tasks' },
{ value: 'all', label: 'All' },
{ value: 'done', label: 'Done' },
]
interface TaskFiltersProps {
filter: string
onFilterChange: (filter: string) => void
}
export function TaskFilters({ filter, onFilterChange }: TaskFiltersProps) {
return (
<div className="flex gap-2 overflow-x-auto pb-2 -mx-1 px-1 scrollbar-hide">
{FILTERS.map((f) => (
<button
key={f.value}
onClick={() => onFilterChange(f.value)}
className={`flex-shrink-0 px-4 py-2 rounded-full text-sm font-medium transition-all min-h-touch ${
filter === f.value
? 'bg-primary-500 text-white'
: 'bg-secondary-100 text-secondary-600'
}`}
>
{f.label}
</button>
))}
</div>
)
}

View File

@@ -0,0 +1,204 @@
'use client'
import { useState } from 'react'
import { Modal, Button, Input, Select, Textarea, showToast } from '@/components/ui'
const CATEGORIES = [
{ value: 'MEDICAL', label: 'Medical' },
{ value: 'ERRANDS', label: 'Errands' },
{ value: 'MEALS', label: 'Meals' },
{ value: 'EMOTIONAL', label: 'Emotional Support' },
{ value: 'OTHER', label: 'Other' },
]
const PRIORITIES = [
{ value: 'URGENT', label: 'Urgent' },
{ value: 'HIGH', label: 'High' },
{ value: 'NORMAL', label: 'Normal' },
{ value: 'LOW', label: 'Low' },
]
const STATUSES = [
{ value: 'TODO', label: 'To Do' },
{ value: 'IN_PROGRESS', label: 'In Progress' },
{ value: 'DONE', label: 'Done' },
{ value: 'CANCELLED', label: 'Cancelled' },
]
const QUICK_TEMPLATES = [
{ title: 'Pick up prescription', category: 'ERRANDS' },
{ title: 'Drive to appointment', category: 'ERRANDS' },
{ title: 'Prepare meals', category: 'MEALS' },
]
interface TaskFormData {
title: string
description: string
category: string
priority: string
status: string
assignedToId: string
dueDate: string
}
interface TaskFormProps {
isOpen: boolean
onClose: () => void
onSaved: () => void
workspaceId: string
members?: Array<{ id: string; name: string }>
initialData?: Partial<TaskFormData> & { id?: string }
}
export function TaskForm({ isOpen, onClose, onSaved, workspaceId, members = [], initialData }: TaskFormProps) {
const isEdit = !!initialData?.id
const [form, setForm] = useState<TaskFormData>({
title: initialData?.title || '',
description: initialData?.description || '',
category: initialData?.category || 'OTHER',
priority: initialData?.priority || 'NORMAL',
status: initialData?.status || 'TODO',
assignedToId: initialData?.assignedToId || '',
dueDate: initialData?.dueDate
? new Date(initialData.dueDate).toISOString().slice(0, 16)
: '',
})
const [saving, setSaving] = useState(false)
const handleQuickTemplate = (template: { title: string; category: string }) => {
setForm((prev) => ({
...prev,
title: template.title,
category: template.category,
}))
}
const handleSave = async () => {
if (!form.title.trim()) {
showToast('Title is required', 'error')
return
}
setSaving(true)
try {
const url = isEdit
? `/api/workspaces/${workspaceId}/tasks/${initialData!.id}`
: `/api/workspaces/${workspaceId}/tasks`
const method = isEdit ? 'PATCH' : 'POST'
const payload: Record<string, unknown> = {
title: form.title.trim(),
description: form.description.trim() || null,
category: form.category,
priority: form.priority,
assignedToId: form.assignedToId || null,
dueDate: form.dueDate ? new Date(form.dueDate).toISOString() : null,
}
if (isEdit) {
payload.status = form.status
}
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!response.ok) throw new Error('Failed to save task')
showToast(isEdit ? 'Task updated' : 'Task created', 'success')
onSaved()
onClose()
} catch {
showToast('Failed to save task', 'error')
} finally {
setSaving(false)
}
}
const update = (field: keyof TaskFormData, value: string) =>
setForm((prev) => ({ ...prev, [field]: value }))
const assigneeOptions = [
{ value: '', label: 'Unassigned' },
...members.map((m) => ({ value: m.id, label: m.name })),
]
return (
<Modal isOpen={isOpen} onClose={onClose} title={isEdit ? 'Edit Task' : 'New Task'}>
<div className="space-y-4">
{/* Quick templates (only for new tasks) */}
{!isEdit && (
<div>
<p className="text-xs font-medium text-secondary-500 mb-2">Quick Add</p>
<div className="flex gap-2 flex-wrap">
{QUICK_TEMPLATES.map((t) => (
<button
key={t.title}
onClick={() => handleQuickTemplate(t)}
className="px-3 py-1.5 text-xs font-medium rounded-full bg-primary-50 text-primary-700 hover:bg-primary-100 transition-colors"
>
{t.title}
</button>
))}
</div>
</div>
)}
<Input
label="Title *"
value={form.title}
onChange={(e) => update('title', e.target.value)}
placeholder="What needs to be done?"
/>
<Textarea
label="Description"
value={form.description}
onChange={(e) => update('description', e.target.value)}
placeholder="Additional details..."
rows={2}
/>
<div className="grid grid-cols-2 gap-3">
<Select
label="Category"
value={form.category}
onChange={(e) => update('category', e.target.value)}
options={CATEGORIES}
/>
<Select
label="Priority"
value={form.priority}
onChange={(e) => update('priority', e.target.value)}
options={PRIORITIES}
/>
</div>
<Select
label="Assign To"
value={form.assignedToId}
onChange={(e) => update('assignedToId', e.target.value)}
options={assigneeOptions}
/>
<Input
label="Due Date"
type="datetime-local"
value={form.dueDate}
onChange={(e) => update('dueDate', e.target.value)}
/>
{isEdit && (
<Select
label="Status"
value={form.status}
onChange={(e) => update('status', e.target.value)}
options={STATUSES}
/>
)}
<div className="flex gap-3 pt-2">
<Button variant="secondary" onClick={onClose} fullWidth>Cancel</Button>
<Button onClick={handleSave} fullWidth loading={saving}>
{isEdit ? 'Update' : 'Create Task'}
</Button>
</div>
</div>
</Modal>
)
}

View File

@@ -0,0 +1,54 @@
'use client'
import { AlertTriangle, Phone } from 'lucide-react'
import { Button } from '@/components/ui'
interface FeverAlertProps {
tempCelsius: number
clinicPhone?: string | null
}
export function FeverAlert({ tempCelsius, clinicPhone }: FeverAlertProps) {
if (tempCelsius < 38.0) return null
const isCritical = tempCelsius >= 38.5
return (
<div className={`rounded-card p-4 border-2 ${
isCritical
? 'bg-red-50 border-red-300'
: 'bg-orange-50 border-orange-300'
}`}>
<div className="flex items-start gap-3">
<AlertTriangle className={`w-6 h-6 flex-shrink-0 mt-0.5 ${
isCritical ? 'text-red-600' : 'text-orange-600'
}`} />
<div className="flex-1">
<h3 className={`font-bold text-lg ${
isCritical ? 'text-red-800' : 'text-orange-800'
}`}>
{isCritical ? 'HIGH FEVER DETECTED' : 'FEVER DETECTED'}
</h3>
<p className={`text-sm mt-1 ${
isCritical ? 'text-red-700' : 'text-orange-700'
}`}>
{tempCelsius.toFixed(1)}°C {isCritical
? 'Contact your care team immediately.'
: 'Monitor closely and contact your clinic if it persists.'}
</p>
{clinicPhone && (
<Button
variant={isCritical ? 'danger' : 'primary'}
size="sm"
className="mt-3"
onClick={() => window.location.href = `tel:${clinicPhone}`}
>
<Phone className="w-4 h-4 mr-2" />
Call Clinic
</Button>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,57 @@
'use client'
import { format } from 'date-fns'
import { Thermometer } from 'lucide-react'
interface TempReading {
id: string
tempCelsius: number
method: string | null
notes: string | null
recordedAt: string
createdBy?: { id: string; name: string }
}
interface TempCardProps {
reading: TempReading
}
function getTempColor(temp: number): string {
if (temp >= 38.5) return 'text-red-600 bg-red-50 border-red-200'
if (temp >= 38.0) return 'text-orange-600 bg-orange-50 border-orange-200'
if (temp >= 37.5) return 'text-yellow-600 bg-yellow-50 border-yellow-200'
return 'text-green-600 bg-green-50 border-green-200'
}
export function TempCard({ reading }: TempCardProps) {
const colorClass = getTempColor(reading.tempCelsius)
return (
<div className="bg-surface rounded-lg border border-border p-4">
<div className="flex items-start gap-3">
<div className={`w-12 h-12 rounded-full flex items-center justify-center border ${colorClass}`}>
<Thermometer className="w-6 h-6" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={`text-xl font-bold ${reading.tempCelsius >= 38.0 ? 'text-red-600' : 'text-secondary-900'}`}>
{reading.tempCelsius.toFixed(1)}°C
</span>
{reading.method && (
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-secondary-100 text-secondary-600 capitalize">
{reading.method}
</span>
)}
</div>
<p className="text-sm text-secondary-500 mt-0.5">
{format(new Date(reading.recordedAt), "EEEE, MMM d 'at' h:mm a")}
{reading.createdBy && `${reading.createdBy.name}`}
</p>
{reading.notes && (
<p className="text-sm text-secondary-600 mt-2">{reading.notes}</p>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,84 @@
'use client'
import { useMemo } from 'react'
import { format, subDays, startOfDay, endOfDay } from 'date-fns'
interface TempReading {
tempCelsius: number
recordedAt: string
}
interface TempChartProps {
readings: TempReading[]
days?: number
}
export function TempChart({ readings, days = 7 }: TempChartProps) {
const chartData = useMemo(() => {
const now = new Date()
const result = []
for (let i = days - 1; i >= 0; i--) {
const date = subDays(now, i)
const dayStart = startOfDay(date)
const dayEnd = endOfDay(date)
const dayReadings = readings.filter((r) => {
const d = new Date(r.recordedAt)
return d >= dayStart && d <= dayEnd
})
const avg = dayReadings.length > 0
? dayReadings.reduce((sum, r) => sum + r.tempCelsius, 0) / dayReadings.length
: null
result.push({
label: format(date, 'EEE'),
date: format(date, 'MMM d'),
avg,
count: dayReadings.length,
})
}
return result
}, [readings, days])
const maxTemp = 40
const minTemp = 35
return (
<div className="py-2">
<div className="flex items-end gap-2 h-32">
{chartData.map((day, i) => {
const heightPercent = day.avg
? Math.max(5, ((day.avg - minTemp) / (maxTemp - minTemp)) * 100)
: 0
const barColor = day.avg
? day.avg >= 38.5 ? 'bg-red-400' : day.avg >= 38.0 ? 'bg-orange-400' : day.avg >= 37.5 ? 'bg-yellow-400' : 'bg-primary-400'
: 'bg-secondary-100'
return (
<div key={i} className="flex-1 flex flex-col items-center gap-1">
<span className="text-xs font-medium text-secondary-600">
{day.avg ? `${day.avg.toFixed(1)}°` : '—'}
</span>
<div className="w-full flex items-end" style={{ height: '80px' }}>
<div
className={`w-full rounded-t-md transition-all ${barColor}`}
style={{ height: `${heightPercent}%`, minHeight: day.avg ? '4px' : '0' }}
/>
</div>
<span className="text-xs text-secondary-500">{day.label}</span>
</div>
)
})}
</div>
{/* Fever threshold line label */}
<div className="flex items-center gap-2 mt-3 text-xs text-secondary-400">
<div className="w-3 h-0.5 bg-red-300" />
<span>38.0°C fever threshold</span>
</div>
</div>
)
}

View File

@@ -0,0 +1,114 @@
'use client'
import { useState } from 'react'
import { Thermometer } from 'lucide-react'
import { Button, showToast } from '@/components/ui'
const METHODS = [
{ value: 'oral', label: 'Oral' },
{ value: 'forehead', label: 'Forehead' },
{ value: 'ear', label: 'Ear' },
{ value: 'armpit', label: 'Armpit' },
]
interface TempQuickLogProps {
workspaceId: string
onLogged?: () => void
}
export function TempQuickLog({ workspaceId, onLogged }: TempQuickLogProps) {
const [temp, setTemp] = useState('')
const [method, setMethod] = useState<string | null>(null)
const [notes, setNotes] = useState('')
const [saving, setSaving] = useState(false)
const handleSubmit = async () => {
const tempValue = parseFloat(temp)
if (isNaN(tempValue) || tempValue < 30 || tempValue > 45) {
showToast('Enter a valid temperature (30-45°C)', 'error')
return
}
setSaving(true)
try {
const response = await fetch(`/api/workspaces/${workspaceId}/temperature`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tempCelsius: tempValue,
method: method || null,
notes: notes.trim() || null,
}),
})
if (!response.ok) throw new Error('Failed to log temperature')
showToast('Temperature logged', 'success')
setTemp('')
setMethod(null)
setNotes('')
onLogged?.()
} catch {
showToast('Failed to log temperature', 'error')
} finally {
setSaving(false)
}
}
return (
<div className="space-y-4">
{/* Temperature Input */}
<div>
<label className="block text-sm font-medium text-secondary-700 mb-2">Temperature</label>
<div className="relative">
<input
type="text"
inputMode="decimal"
value={temp}
onChange={(e) => setTemp(e.target.value)}
placeholder="36.5"
className="w-full px-4 py-4 text-3xl font-bold text-center border border-border rounded-card focus:outline-none focus:ring-2 focus:ring-primary-200 focus:border-primary-500"
/>
<span className="absolute right-4 top-1/2 -translate-y-1/2 text-xl text-secondary-400">°C</span>
</div>
</div>
{/* Method Selection */}
<div>
<label className="block text-sm font-medium text-secondary-700 mb-2">Method</label>
<div className="grid grid-cols-4 gap-2">
{METHODS.map((m) => (
<button
key={m.value}
type="button"
onClick={() => setMethod(method === m.value ? null : m.value)}
className={`py-2 px-3 rounded-button text-sm font-medium transition-all border ${
method === m.value
? 'border-primary-500 bg-primary-50 text-primary-700'
: 'border-border text-secondary-600 hover:border-secondary-300'
}`}
>
{m.label}
</button>
))}
</div>
</div>
{/* Notes */}
<div>
<label className="block text-sm font-medium text-secondary-700 mb-2">Notes (optional)</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Any symptoms, time of day..."
rows={2}
className="w-full px-3 py-2 border border-border rounded-lg text-base focus:outline-none focus:ring-2 focus:ring-primary-200 focus:border-primary-500 resize-none"
/>
</div>
{/* Submit */}
<Button onClick={handleSubmit} fullWidth loading={saving}>
<Thermometer className="w-5 h-5 mr-2" />
Log Temperature
</Button>
</div>
)
}

View File

@@ -0,0 +1,131 @@
'use client'
import { format } from 'date-fns'
import { Check, ChevronDown } from 'lucide-react'
import { useState } from 'react'
interface MilestoneData {
id: string
type: string
title: string
description: string | null
plannedDate: string
actualDate: string | null
status: string
notes: string | null
}
interface MilestoneCardProps {
milestone: MilestoneData
onEdit?: (milestone: MilestoneData) => void
onStatusChange?: (id: string, status: string) => void
}
const STATUS_COLORS: Record<string, string> = {
COMPLETED: 'bg-green-500',
SCHEDULED: 'bg-blue-500',
DELAYED: 'bg-orange-500',
CANCELLED: 'bg-secondary-400',
}
const TYPE_BADGES: Record<string, string> = {
CHEMO_CYCLE: 'bg-blue-100 text-blue-700',
SURGERY: 'bg-orange-100 text-orange-700',
RADIATION: 'bg-purple-100 text-purple-700',
SCAN: 'bg-green-100 text-green-700',
CONSULTATION: 'bg-secondary-100 text-secondary-700',
OTHER: 'bg-secondary-100 text-secondary-700',
}
const TYPE_LABELS: Record<string, string> = {
CHEMO_CYCLE: 'Chemo Cycle',
SURGERY: 'Surgery',
RADIATION: 'Radiation',
SCAN: 'Scan',
CONSULTATION: 'Consultation',
OTHER: 'Other',
}
const STATUS_OPTIONS = [
{ value: 'SCHEDULED', label: 'Scheduled' },
{ value: 'COMPLETED', label: 'Completed' },
{ value: 'DELAYED', label: 'Delayed' },
{ value: 'CANCELLED', label: 'Cancelled' },
]
export function MilestoneCard({ milestone, onEdit, onStatusChange }: MilestoneCardProps) {
const [showStatusMenu, setShowStatusMenu] = useState(false)
const statusColor = STATUS_COLORS[milestone.status] || STATUS_COLORS.SCHEDULED
const typeBadge = TYPE_BADGES[milestone.type] || TYPE_BADGES.OTHER
const typeLabel = TYPE_LABELS[milestone.type] || milestone.type
const dateStr = format(new Date(milestone.plannedDate), 'MMM d, yyyy')
return (
<div
className="bg-surface rounded-lg border border-border p-4 cursor-pointer hover:shadow-card-hover transition-shadow"
onClick={() => onEdit?.(milestone)}
>
<div className="flex items-start gap-3">
{/* Status indicator */}
<div className="flex-shrink-0 mt-1">
{milestone.status === 'COMPLETED' ? (
<div className="w-6 h-6 rounded-full bg-green-500 flex items-center justify-center">
<Check className="w-4 h-4 text-white" />
</div>
) : (
<div className={`w-6 h-6 rounded-full ${statusColor} opacity-60`} />
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold text-secondary-900 truncate">{milestone.title}</h3>
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${typeBadge}`}>
{typeLabel}
</span>
</div>
<p className="text-sm text-secondary-500">{dateStr}</p>
{milestone.notes && (
<p className="text-sm text-secondary-500 mt-1 line-clamp-2">{milestone.notes}</p>
)}
</div>
{/* Quick status change */}
{onStatusChange && (
<div className="relative flex-shrink-0">
<button
onClick={(e) => {
e.stopPropagation()
setShowStatusMenu(!showStatusMenu)
}}
className="p-1.5 rounded-full hover:bg-muted transition-colors"
aria-label="Change status"
>
<ChevronDown className="w-4 h-4 text-secondary-400" />
</button>
{showStatusMenu && (
<div className="absolute right-0 top-8 z-10 bg-surface border border-border rounded-lg shadow-lg py-1 min-w-[140px]">
{STATUS_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={(e) => {
e.stopPropagation()
onStatusChange(milestone.id, opt.value)
setShowStatusMenu(false)
}}
className={`w-full text-left px-3 py-2 text-sm hover:bg-muted transition-colors ${
milestone.status === opt.value ? 'font-semibold text-primary-600' : 'text-secondary-700'
}`}
>
{opt.label}
</button>
))}
</div>
)}
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,148 @@
'use client'
import { useState } from 'react'
import { Modal, Button, Input, Select, showToast } from '@/components/ui'
import { Textarea } from '@/components/ui/input'
const TYPES = [
{ value: 'CHEMO_CYCLE', label: 'Chemo Cycle' },
{ value: 'SURGERY', label: 'Surgery' },
{ value: 'RADIATION', label: 'Radiation' },
{ value: 'SCAN', label: 'Scan' },
{ value: 'CONSULTATION', label: 'Consultation' },
{ value: 'OTHER', label: 'Other' },
]
const STATUSES = [
{ value: 'SCHEDULED', label: 'Scheduled' },
{ value: 'COMPLETED', label: 'Completed' },
{ value: 'DELAYED', label: 'Delayed' },
{ value: 'CANCELLED', label: 'Cancelled' },
]
interface MilestoneFormData {
title: string
type: string
plannedDate: string
status: string
description: string
notes: string
}
interface MilestoneFormProps {
isOpen: boolean
onClose: () => void
onSaved: () => void
workspaceId: string
initialData?: Partial<MilestoneFormData> & { id?: string }
}
export function MilestoneForm({ isOpen, onClose, onSaved, workspaceId, initialData }: MilestoneFormProps) {
const isEdit = !!initialData?.id
const [form, setForm] = useState<MilestoneFormData>({
title: initialData?.title || '',
type: initialData?.type || 'CHEMO_CYCLE',
plannedDate: initialData?.plannedDate
? new Date(initialData.plannedDate).toISOString().slice(0, 16)
: '',
status: initialData?.status || 'SCHEDULED',
description: initialData?.description || '',
notes: initialData?.notes || '',
})
const [saving, setSaving] = useState(false)
const handleSave = async () => {
if (!form.title.trim()) {
showToast('Title is required', 'error')
return
}
if (!form.plannedDate) {
showToast('Planned date is required', 'error')
return
}
setSaving(true)
try {
const url = isEdit
? `/api/workspaces/${workspaceId}/milestones/${initialData!.id}`
: `/api/workspaces/${workspaceId}/milestones`
const method = isEdit ? 'PATCH' : 'POST'
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: form.title.trim(),
type: form.type,
plannedDate: new Date(form.plannedDate).toISOString(),
status: form.status,
description: form.description.trim() || null,
notes: form.notes.trim() || null,
}),
})
if (!response.ok) throw new Error('Failed to save milestone')
showToast(isEdit ? 'Milestone updated' : 'Milestone added', 'success')
onSaved()
onClose()
} catch {
showToast('Failed to save milestone', 'error')
} finally {
setSaving(false)
}
}
const update = (field: keyof MilestoneFormData, value: string) =>
setForm((prev) => ({ ...prev, [field]: value }))
return (
<Modal isOpen={isOpen} onClose={onClose} title={isEdit ? 'Edit Milestone' : 'Add Milestone'}>
<div className="space-y-4">
<Input
label="Title *"
value={form.title}
onChange={(e) => update('title', e.target.value)}
placeholder="Chemo Cycle 3"
/>
<Select
label="Type"
value={form.type}
onChange={(e) => update('type', e.target.value)}
options={TYPES}
/>
<Input
label="Planned Date *"
type="datetime-local"
value={form.plannedDate}
onChange={(e) => update('plannedDate', e.target.value)}
/>
<Select
label="Status"
value={form.status}
onChange={(e) => update('status', e.target.value)}
options={STATUSES}
/>
<Textarea
label="Description"
value={form.description}
onChange={(e) => update('description', e.target.value)}
placeholder="Details about this milestone..."
rows={2}
/>
<Textarea
label="Notes"
value={form.notes}
onChange={(e) => update('notes', 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}>
{isEdit ? 'Update' : 'Add Milestone'}
</Button>
</div>
</div>
</Modal>
)
}

View File

@@ -0,0 +1,28 @@
'use client'
interface ProgressBarProps {
milestones: Array<{ status: string }>
}
export function ProgressBar({ milestones }: ProgressBarProps) {
const active = milestones.filter((m) => m.status !== 'CANCELLED')
const completed = active.filter((m) => m.status === 'COMPLETED')
const total = active.length
const percent = total > 0 ? Math.round((completed.length / total) * 100) : 0
return (
<div className="bg-surface rounded-lg border border-border p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-secondary-700">
Cycle {completed.length} of {total} {percent}% Complete
</span>
</div>
<div className="w-full h-3 bg-secondary-100 rounded-full overflow-hidden">
<div
className="h-full bg-primary-500 rounded-full transition-all duration-500"
style={{ width: `${percent}%` }}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,67 @@
'use client'
import { format } from 'date-fns'
import { MilestoneCard } from './MilestoneCard'
interface MilestoneData {
id: string
type: string
title: string
description: string | null
plannedDate: string
actualDate: string | null
status: string
notes: string | null
}
interface TimelineViewProps {
milestones: MilestoneData[]
onEdit?: (milestone: MilestoneData) => void
onStatusChange?: (id: string, status: string) => void
}
const STATUS_DOT_COLORS: Record<string, string> = {
COMPLETED: 'bg-green-500',
SCHEDULED: 'bg-blue-500',
DELAYED: 'bg-orange-500',
CANCELLED: 'bg-secondary-400',
}
export function TimelineView({ milestones, onEdit, onStatusChange }: TimelineViewProps) {
const sorted = [...milestones].sort(
(a, b) => new Date(a.plannedDate).getTime() - new Date(b.plannedDate).getTime()
)
return (
<div className="relative">
{/* Vertical line */}
<div className="absolute left-[19px] top-0 bottom-0 w-0.5 bg-border" />
<div className="space-y-6">
{sorted.map((milestone) => {
const dotColor = STATUS_DOT_COLORS[milestone.status] || STATUS_DOT_COLORS.SCHEDULED
const dateStr = format(new Date(milestone.plannedDate), 'MMM d, yyyy')
return (
<div key={milestone.id} className="relative flex gap-4">
{/* Date + dot */}
<div className="flex-shrink-0 w-10 flex flex-col items-center">
<div className={`w-4 h-4 rounded-full ${dotColor} border-2 border-surface z-10`} />
</div>
{/* Card */}
<div className="flex-1 -mt-1">
<p className="text-xs text-secondary-400 mb-1">{dateStr}</p>
<MilestoneCard
milestone={milestone}
onEdit={onEdit}
onStatusChange={onStatusChange}
/>
</div>
</div>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,31 @@
'use client'
import { AlertTriangle } from 'lucide-react'
interface WeightAlertProps {
currentKg: number
previousKg: number
timeframeHours: number
}
export function WeightAlert({ currentKg, previousKg, timeframeHours }: WeightAlertProps) {
const diff = Math.abs(currentKg - previousKg)
if (diff < 2) return null
const direction = currentKg > previousKg ? 'gained' : 'lost'
return (
<div className="rounded-card p-4 border-2 bg-orange-50 border-orange-300">
<div className="flex items-start gap-3">
<AlertTriangle className="w-6 h-6 text-orange-600 flex-shrink-0 mt-0.5" />
<div>
<h3 className="font-bold text-orange-800">Rapid Weight Change</h3>
<p className="text-sm text-orange-700 mt-1">
{direction} {diff.toFixed(1)} kg in the last {timeframeHours < 24 ? `${timeframeHours} hours` : `${Math.round(timeframeHours / 24)} days`}.
Rapid changes may indicate fluid retention or other concerns consider contacting your care team.
</p>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,52 @@
'use client'
import { format } from 'date-fns'
import { Scale } from 'lucide-react'
interface WeightReading {
id: string
weightKg: number
notes: string | null
recordedAt: string
createdBy?: { id: string; name: string }
}
interface WeightCardProps {
reading: WeightReading
previousKg?: number | null
}
export function WeightCard({ reading, previousKg }: WeightCardProps) {
const diff = previousKg != null ? reading.weightKg - previousKg : null
const lbs = (reading.weightKg * 2.20462).toFixed(1)
return (
<div className="bg-surface rounded-lg border border-border p-4">
<div className="flex items-start gap-3">
<div className="w-12 h-12 rounded-full flex items-center justify-center bg-primary-50 border border-primary-200 text-primary-600">
<Scale className="w-6 h-6" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xl font-bold text-secondary-900">
{reading.weightKg.toFixed(1)} kg
</span>
<span className="text-sm text-secondary-400">({lbs} lbs)</span>
{diff !== null && diff !== 0 && (
<span className={`text-sm font-medium ${diff > 0 ? 'text-orange-600' : 'text-green-600'}`}>
{diff > 0 ? '+' : ''}{diff.toFixed(1)} kg
</span>
)}
</div>
<p className="text-sm text-secondary-500 mt-0.5">
{format(new Date(reading.recordedAt), "EEEE, MMM d 'at' h:mm a")}
{reading.createdBy && `${reading.createdBy.name}`}
</p>
{reading.notes && (
<p className="text-sm text-secondary-600 mt-2">{reading.notes}</p>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,91 @@
'use client'
import { useMemo } from 'react'
import { format, subDays, startOfDay, endOfDay } from 'date-fns'
interface WeightReading {
weightKg: number
recordedAt: string
}
interface WeightChartProps {
readings: WeightReading[]
days?: number
}
export function WeightChart({ readings, days = 30 }: WeightChartProps) {
const chartData = useMemo(() => {
const now = new Date()
const points: { date: string; label: string; weight: number | null }[] = []
for (let i = days - 1; i >= 0; i--) {
const date = subDays(now, i)
const dayStart = startOfDay(date)
const dayEnd = endOfDay(date)
const dayReadings = readings.filter((r) => {
const d = new Date(r.recordedAt)
return d >= dayStart && d <= dayEnd
})
const avg = dayReadings.length > 0
? dayReadings.reduce((sum, r) => sum + r.weightKg, 0) / dayReadings.length
: null
points.push({ date: format(date, 'MMM d'), label: format(date, 'd'), weight: avg })
}
return points
}, [readings, days])
const validPoints = chartData.filter((p) => p.weight !== null)
if (validPoints.length < 2) {
return (
<div className="py-8 text-center text-sm text-secondary-400">
Need at least 2 readings to show trend
</div>
)
}
const weights = validPoints.map((p) => p.weight!)
const minW = Math.min(...weights) - 1
const maxW = Math.max(...weights) + 1
const range = maxW - minW || 1
const width = 300
const height = 120
const padding = { top: 10, right: 10, bottom: 20, left: 10 }
const chartW = width - padding.left - padding.right
const chartH = height - padding.top - padding.bottom
// Build SVG path from valid points
let pathPoints: { x: number; y: number; weight: number }[] = []
validPoints.forEach((p) => {
const idx = chartData.indexOf(p)
const x = padding.left + (idx / (chartData.length - 1)) * chartW
const y = padding.top + chartH - ((p.weight! - minW) / range) * chartH
pathPoints.push({ x, y, weight: p.weight! })
})
const pathD = pathPoints.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ')
return (
<div className="py-2">
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-auto" preserveAspectRatio="xMidYMid meet">
{/* Line */}
<path d={pathD} fill="none" stroke="currentColor" className="text-primary-500" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
{/* Points */}
{pathPoints.map((p, i) => (
<circle key={i} cx={p.x} cy={p.y} r="3" className="fill-primary-500" />
))}
{/* Min/Max labels */}
<text x={padding.left} y={height - 2} className="text-[8px] fill-secondary-400">{minW.toFixed(0)}kg</text>
<text x={width - padding.right} y={height - 2} className="text-[8px] fill-secondary-400" textAnchor="end">{maxW.toFixed(0)}kg</text>
</svg>
<div className="flex justify-between text-xs text-secondary-400 mt-1 px-1">
<span>{chartData[0]?.date}</span>
<span>{chartData[chartData.length - 1]?.date}</span>
</div>
</div>
)
}

View File

@@ -0,0 +1,97 @@
'use client'
import { useState } from 'react'
import { Scale } from 'lucide-react'
import { Button, showToast } from '@/components/ui'
interface WeightQuickLogProps {
workspaceId: string
onLogged?: () => void
}
export function WeightQuickLog({ workspaceId, onLogged }: WeightQuickLogProps) {
const [weight, setWeight] = useState('')
const [unit, setUnit] = useState<'kg' | 'lbs'>('kg')
const [notes, setNotes] = useState('')
const [saving, setSaving] = useState(false)
const handleSubmit = async () => {
const weightValue = parseFloat(weight)
if (isNaN(weightValue) || weightValue <= 0) {
showToast('Enter a valid weight', 'error')
return
}
const weightKg = unit === 'lbs' ? weightValue * 0.453592 : weightValue
setSaving(true)
try {
const response = await fetch(`/api/workspaces/${workspaceId}/weight`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ weightKg, notes: notes.trim() || null }),
})
if (!response.ok) throw new Error('Failed to log weight')
showToast('Weight logged', 'success')
setWeight('')
setNotes('')
onLogged?.()
} catch {
showToast('Failed to log weight', 'error')
} finally {
setSaving(false)
}
}
return (
<div className="space-y-4">
{/* Weight Input */}
<div>
<label className="block text-sm font-medium text-secondary-700 mb-2">Weight</label>
<div className="relative">
<input
type="text"
inputMode="decimal"
value={weight}
onChange={(e) => setWeight(e.target.value)}
placeholder={unit === 'kg' ? '70.0' : '154.0'}
className="w-full px-4 py-4 text-3xl font-bold text-center border border-border rounded-card focus:outline-none focus:ring-2 focus:ring-primary-200 focus:border-primary-500"
/>
<div className="absolute right-3 top-1/2 -translate-y-1/2 flex rounded-lg border border-border overflow-hidden">
<button
type="button"
onClick={() => setUnit('kg')}
className={`px-3 py-1 text-sm font-medium ${unit === 'kg' ? 'bg-primary-500 text-white' : 'text-secondary-500'}`}
>
kg
</button>
<button
type="button"
onClick={() => setUnit('lbs')}
className={`px-3 py-1 text-sm font-medium ${unit === 'lbs' ? 'bg-primary-500 text-white' : 'text-secondary-500'}`}
>
lbs
</button>
</div>
</div>
</div>
{/* Notes */}
<div>
<label className="block text-sm font-medium text-secondary-700 mb-2">Notes (optional)</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Morning weight, before meals..."
rows={2}
className="w-full px-3 py-2 border border-border rounded-lg text-base focus:outline-none focus:ring-2 focus:ring-primary-200 focus:border-primary-500 resize-none"
/>
</div>
<Button onClick={handleSubmit} fullWidth loading={saving}>
<Scale className="w-5 h-5 mr-2" />
Log Weight
</Button>
</div>
)
}

View File

@@ -0,0 +1,25 @@
import { afterEach, describe, expect, it } from 'vitest'
import { shouldUseSecureCookies } from './cookies'
const originalCookieSecure = process.env.COOKIE_SECURE
afterEach(() => {
process.env.COOKIE_SECURE = originalCookieSecure
})
describe('shouldUseSecureCookies', () => {
it('uses secure cookies for forwarded https requests even in development', () => {
expect(shouldUseSecureCookies({ forwardedProto: 'https' })).toBe(true)
})
it('uses secure cookies when the request origin is https', () => {
expect(
shouldUseSecureCookies({ origin: 'https://debianvm.kangaroo-eel.ts.net:10000' })
).toBe(true)
})
it('allows an explicit insecure override', () => {
process.env.COOKIE_SECURE = 'false'
expect(shouldUseSecureCookies({ forwardedProto: 'https' })).toBe(false)
})
})

22
src/lib/auth/cookies.ts Normal file
View File

@@ -0,0 +1,22 @@
interface CookieRequestMetadata {
forwardedProto?: string | null
origin?: string | null
referer?: string | null
}
export function shouldUseSecureCookies(metadata: CookieRequestMetadata = {}): boolean {
if (process.env.COOKIE_SECURE === 'false') {
return false
}
const forwardedProto = metadata.forwardedProto?.split(',')[0]?.trim().toLowerCase()
if (forwardedProto) {
return forwardedProto === 'https'
}
if (metadata.origin?.startsWith('https://') || metadata.referer?.startsWith('https://')) {
return true
}
return process.env.NODE_ENV === 'production'
}

View File

@@ -1,6 +1,7 @@
import { cookies } from 'next/headers'
import { prisma } from '@/lib/db/prisma'
import { nanoid } from 'nanoid'
import { shouldUseSecureCookies } from './cookies'
const SESSION_COOKIE_NAME = 'nextstep_session'
const SESSION_MAX_AGE_DAYS = parseInt(process.env.SESSION_MAX_AGE_DAYS || '30', 10)
@@ -89,32 +90,33 @@ export function setSessionCookie(token: string): void {
// happens in the API route response
}
export function getSessionCookieConfig(token: string) {
interface CookieRequestMetadata {
forwardedProto?: string | null
origin?: string | null
referer?: string | null
}
export function getSessionCookieConfig(token: string, metadata?: CookieRequestMetadata) {
const expiresAt = new Date()
expiresAt.setDate(expiresAt.getDate() + SESSION_MAX_AGE_DAYS)
// Allow disabling secure cookies for internal/Tailscale networks
const requireHttps = process.env.COOKIE_SECURE !== 'false' && process.env.NODE_ENV === 'production'
return {
name: SESSION_COOKIE_NAME,
value: token,
httpOnly: true,
secure: requireHttps,
secure: shouldUseSecureCookies(metadata),
sameSite: 'lax' as const,
expires: expiresAt,
path: '/',
}
}
export function getSessionCookieClearConfig() {
const requireHttps = process.env.COOKIE_SECURE !== 'false' && process.env.NODE_ENV === 'production'
export function getSessionCookieClearConfig(metadata?: CookieRequestMetadata) {
return {
name: SESSION_COOKIE_NAME,
value: '',
httpOnly: true,
secure: requireHttps,
secure: shouldUseSecureCookies(metadata),
sameSite: 'lax' as const,
expires: new Date(0),
path: '/',

View File

@@ -1,5 +1,7 @@
import { format, addHours } from 'date-fns'
const TIMEZONE = 'Australia/Perth'
interface Appointment {
id: string
title: string
@@ -35,8 +37,19 @@ export function generateICalendar(
'VERSION:2.0',
'PRODID:-//NextStep//Health Management//EN',
`X-WR-CALNAME:${escapeICalText(workspaceName)}`,
`X-WR-TIMEZONE:${TIMEZONE}`,
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
// Add timezone definition for Australia/Perth
'BEGIN:VTIMEZONE',
`TZID:${TIMEZONE}`,
'BEGIN:STANDARD',
'DTSTART:19700101T000000',
'TZOFFSETFROM:+0800',
'TZOFFSETTO:+0800',
'TZNAME:AWST',
'END:STANDARD',
'END:VTIMEZONE',
]
for (const appt of appointments) {
@@ -45,9 +58,9 @@ export function generateICalendar(
lines.push('BEGIN:VEVENT')
lines.push(`UID:${appt.id}@nextstep`)
lines.push(`DTSTAMP:${formatICalDate(new Date())}`)
lines.push(`DTSTART:${formatICalDate(startDate)}`)
lines.push(`DTEND:${formatICalDate(endDate)}`)
lines.push(`DTSTAMP:${formatICalDateUTC(new Date())}`)
lines.push(`DTSTART;TZID=${TIMEZONE}:${formatICalDateLocal(startDate)}`)
lines.push(`DTEND;TZID=${TIMEZONE}:${formatICalDateLocal(endDate)}`)
lines.push(`SUMMARY:${escapeICalText(appt.title)}`)
if (appt.location) {
@@ -116,9 +129,9 @@ export function generateMedicationEvents(
lines.push('BEGIN:VEVENT')
lines.push(`UID:med-${med.id}-${dateStr}-${time}@nextstep`)
lines.push(`DTSTAMP:${formatICalDate(new Date())}`)
lines.push(`DTSTART:${formatICalDate(startDate)}`)
lines.push(`DTEND:${formatICalDate(endDate)}`)
lines.push(`DTSTAMP:${formatICalDateUTC(new Date())}`)
lines.push(`DTSTART;TZID=${TIMEZONE}:${formatICalDateLocal(startDate)}`)
lines.push(`DTEND;TZID=${TIMEZONE}:${formatICalDateLocal(endDate)}`)
lines.push(`SUMMARY:Take ${escapeICalText(med.name)}`)
lines.push('CATEGORIES:MEDICATION')
@@ -161,11 +174,16 @@ function getMedicationTimes(med: Medication): string[] {
}
}
function formatICalDate(date: Date): string {
// Format: YYYYMMDDTHHMMSSZ
function formatICalDateUTC(date: Date): string {
// Format: YYYYMMDDTHHMMSSZ (UTC)
return format(date, "yyyyMMdd'T'HHmmss'Z'")
}
function formatICalDateLocal(date: Date): string {
// Format: YYYYMMDDTHHMMSS (local time, no Z suffix)
return format(date, "yyyyMMdd'T'HHmmss")
}
function escapeICalText(text: string): string {
return text
.replace(/\\/g, '\\\\')

View File

@@ -0,0 +1,96 @@
import { describe, it, expect } from 'vitest'
import { checkInteractions } from './checker'
describe('checkInteractions', () => {
it('returns empty array for fewer than 2 medications', () => {
expect(checkInteractions([])).toEqual([])
expect(checkInteractions(['methotrexate'])).toEqual([])
})
it('finds a known CONTRAINDICATED interaction', () => {
const results = checkInteractions(['Methotrexate', 'Trimethoprim'])
expect(results.length).toBe(1)
expect(results[0].severity).toBe('CONTRAINDICATED')
expect(results[0].drug1Name).toBe('Methotrexate')
expect(results[0].drug2Name).toBe('Trimethoprim')
})
it('finds a known MAJOR interaction', () => {
const results = checkInteractions(['Fluorouracil', 'Warfarin'])
expect(results.length).toBe(1)
expect(results[0].severity).toBe('MAJOR')
})
it('finds a known MODERATE interaction', () => {
const results = checkInteractions(['Methotrexate', 'Aspirin'])
expect(results.length).toBe(1)
expect(results[0].severity).toBe('MODERATE')
})
it('finds a known MINOR interaction', () => {
const results = checkInteractions(['Ondansetron', 'Aprepitant'])
expect(results.length).toBe(1)
expect(results[0].severity).toBe('MINOR')
})
it('returns empty for medications with no known interactions', () => {
const results = checkInteractions(['Acetaminophen', 'Vitamin D'])
expect(results).toEqual([])
})
it('handles case-insensitive matching', () => {
const results = checkInteractions(['METHOTREXATE', 'trimethoprim'])
expect(results.length).toBe(1)
expect(results[0].severity).toBe('CONTRAINDICATED')
})
it('strips dosage suffixes when matching', () => {
const results = checkInteractions(['Methotrexate 500mg', 'Trimethoprim 200mg'])
expect(results.length).toBe(1)
expect(results[0].severity).toBe('CONTRAINDICATED')
})
it('strips dosage form suffixes when matching', () => {
const results = checkInteractions(['Methotrexate tablets', 'Ibuprofen capsules'])
expect(results.length).toBe(1)
expect(results[0].severity).toBe('MAJOR')
})
it('finds multiple interactions for a drug with many conflicts', () => {
const results = checkInteractions([
'Methotrexate',
'Ibuprofen',
'Trimethoprim',
'Omeprazole',
])
expect(results.length).toBeGreaterThanOrEqual(3)
// Should be sorted: CONTRAINDICATED first, then MAJOR, then MODERATE
expect(results[0].severity).toBe('CONTRAINDICATED')
})
it('sorts results by severity (most severe first)', () => {
const results = checkInteractions([
'Methotrexate',
'Ibuprofen',
'Trimethoprim',
'Aspirin',
])
const severities = results.map((r) => r.severity)
const order = { CONTRAINDICATED: 0, MAJOR: 1, MODERATE: 2, MINOR: 3 }
for (let i = 1; i < severities.length; i++) {
expect(order[severities[i]]).toBeGreaterThanOrEqual(order[severities[i - 1]])
}
})
it('does not produce duplicate interaction entries', () => {
const results = checkInteractions(['Warfarin', 'Fluorouracil'])
expect(results.length).toBe(1)
})
it('handles reversed drug order correctly', () => {
const r1 = checkInteractions(['Fluorouracil', 'Warfarin'])
const r2 = checkInteractions(['Warfarin', 'Fluorouracil'])
expect(r1.length).toBe(r2.length)
expect(r1[0].severity).toBe(r2[0].severity)
})
})

View File

@@ -0,0 +1,81 @@
import { INTERACTION_DATABASE, type DrugInteractionEntry } from './data'
export interface InteractionResult {
drug1Name: string
drug2Name: string
severity: 'MINOR' | 'MODERATE' | 'MAJOR' | 'CONTRAINDICATED'
description: string
recommendation: string
}
/**
* Normalize a drug name for matching against the interaction database.
* Strips common suffixes, lowercases, and trims.
*/
function normalizeDrugName(name: string): string {
return name
.toLowerCase()
.trim()
.replace(/\s+(tablets?|capsules?|injection|solution|cream|gel|patch|oral|iv|im|sc)\s*$/i, '')
.replace(/\s+\d+\s*m?g\s*$/i, '') // Remove dosage (e.g. "500mg")
.trim()
}
/**
* Check for known interactions between a list of medication names.
* Returns all found interactions sorted by severity.
*/
export function checkInteractions(medicationNames: string[]): InteractionResult[] {
if (medicationNames.length < 2) return []
const normalized = medicationNames.map((name) => ({
original: name,
normalized: normalizeDrugName(name),
}))
const results: InteractionResult[] = []
const seen = new Set<string>()
// Check each pair of medications
for (let i = 0; i < normalized.length; i++) {
for (let j = i + 1; j < normalized.length; j++) {
const nameA = normalized[i].normalized
const nameB = normalized[j].normalized
// Find matching interactions (both orderings)
const matches = INTERACTION_DATABASE.filter(
(entry) =>
(entry.drug1 === nameA && entry.drug2 === nameB) ||
(entry.drug1 === nameB && entry.drug2 === nameA) ||
(nameA.includes(entry.drug1) && nameB.includes(entry.drug2)) ||
(nameA.includes(entry.drug2) && nameB.includes(entry.drug1))
)
for (const match of matches) {
const key = [nameA, nameB].sort().join('|')
if (seen.has(key)) continue
seen.add(key)
results.push({
drug1Name: normalized[i].original,
drug2Name: normalized[j].original,
severity: match.severity,
description: match.description,
recommendation: match.recommendation,
})
}
}
}
// Sort by severity (most severe first)
const severityOrder: Record<string, number> = {
CONTRAINDICATED: 0,
MAJOR: 1,
MODERATE: 2,
MINOR: 3,
}
results.sort((a, b) => (severityOrder[a.severity] ?? 99) - (severityOrder[b.severity] ?? 99))
return results
}

View File

@@ -0,0 +1,76 @@
/**
* Curated drug interaction database for common chemo and supportive care medications.
* This is a local lookup table — no external API calls.
* Can be upgraded to OpenFDA/RxNorm in a future version.
*
* Drug names are stored in lowercase for case-insensitive matching.
* Each entry is a pair of drugs with their interaction details.
*/
export interface DrugInteractionEntry {
drug1: string
drug2: string
severity: 'MINOR' | 'MODERATE' | 'MAJOR' | 'CONTRAINDICATED'
description: string
recommendation: string
}
export const INTERACTION_DATABASE: DrugInteractionEntry[] = [
// Chemo + anticoagulant interactions
{ drug1: 'fluorouracil', drug2: 'warfarin', severity: 'MAJOR', description: 'Fluorouracil can significantly increase the anticoagulant effect of warfarin, increasing bleeding risk.', recommendation: 'Monitor INR closely. Dose adjustment of warfarin may be needed.' },
{ drug1: 'capecitabine', drug2: 'warfarin', severity: 'MAJOR', description: 'Capecitabine can markedly increase warfarin levels, leading to dangerous bleeding.', recommendation: 'Avoid combination if possible. Monitor INR very frequently.' },
{ drug1: 'methotrexate', drug2: 'warfarin', severity: 'MODERATE', description: 'Methotrexate may enhance anticoagulant effect of warfarin.', recommendation: 'Monitor INR regularly during concurrent use.' },
// Chemo + NSAID interactions
{ drug1: 'methotrexate', drug2: 'ibuprofen', severity: 'MAJOR', description: 'NSAIDs can reduce renal clearance of methotrexate, leading to toxic levels.', recommendation: 'Avoid NSAIDs during methotrexate treatment. Use acetaminophen instead.' },
{ drug1: 'methotrexate', drug2: 'naproxen', severity: 'MAJOR', description: 'Naproxen can reduce renal clearance of methotrexate, leading to toxic levels.', recommendation: 'Avoid NSAIDs during methotrexate treatment.' },
{ drug1: 'methotrexate', drug2: 'aspirin', severity: 'MODERATE', description: 'Aspirin can decrease methotrexate clearance and increase toxicity risk.', recommendation: 'Monitor for methotrexate toxicity. Low-dose aspirin may be acceptable.' },
// Chemo + antifungal interactions
{ drug1: 'cyclophosphamide', drug2: 'fluconazole', severity: 'MODERATE', description: 'Fluconazole may inhibit metabolism of cyclophosphamide, affecting efficacy.', recommendation: 'Monitor for increased cyclophosphamide toxicity.' },
{ drug1: 'vincristine', drug2: 'itraconazole', severity: 'MAJOR', description: 'Itraconazole inhibits CYP3A4, which can significantly increase vincristine levels and neurotoxicity.', recommendation: 'Avoid concurrent use. Use alternative antifungal.' },
{ drug1: 'docetaxel', drug2: 'ketoconazole', severity: 'MAJOR', description: 'Ketoconazole can dramatically increase docetaxel levels through CYP3A4 inhibition.', recommendation: 'Avoid concurrent use. Use alternative antifungal.' },
// Chemo + PPI interactions
{ drug1: 'methotrexate', drug2: 'omeprazole', severity: 'MODERATE', description: 'PPIs may reduce renal elimination of methotrexate, especially at high doses.', recommendation: 'Consider using H2 blockers instead during high-dose methotrexate.' },
{ drug1: 'methotrexate', drug2: 'pantoprazole', severity: 'MODERATE', description: 'PPIs may reduce renal elimination of methotrexate.', recommendation: 'Monitor methotrexate levels during concurrent use.' },
{ drug1: 'capecitabine', drug2: 'omeprazole', severity: 'MINOR', description: 'PPIs may slightly reduce absorption of capecitabine.', recommendation: 'Take capecitabine with food as directed. Usually not clinically significant.' },
// Chemo + antibiotic interactions
{ drug1: 'methotrexate', drug2: 'trimethoprim', severity: 'CONTRAINDICATED', description: 'Trimethoprim can cause severe, potentially fatal, pancytopenia when used with methotrexate.', recommendation: 'Do NOT use together. Use alternative antibiotic.' },
{ drug1: 'methotrexate', drug2: 'penicillin', severity: 'MODERATE', description: 'Penicillins can reduce renal clearance of methotrexate.', recommendation: 'Monitor methotrexate levels during concurrent use.' },
{ drug1: 'fluorouracil', drug2: 'metronidazole', severity: 'MODERATE', description: 'Metronidazole may increase fluorouracil toxicity.', recommendation: 'Monitor for increased GI toxicity and myelosuppression.' },
// Chemo + steroid interactions
{ drug1: 'dexamethasone', drug2: 'aprepitant', severity: 'MODERATE', description: 'Aprepitant inhibits CYP3A4, increasing dexamethasone exposure.', recommendation: 'Reduce dexamethasone dose by 50% when given with aprepitant.' },
// Supportive care interactions
{ drug1: 'ondansetron', drug2: 'aprepitant', severity: 'MINOR', description: 'The combination is commonly used but aprepitant can modestly increase ondansetron levels.', recommendation: 'Generally safe. No dose adjustment typically needed.' },
{ drug1: 'ondansetron', drug2: 'tramadol', severity: 'MODERATE', description: 'Both affect serotonin levels, increasing risk of serotonin syndrome.', recommendation: 'Monitor for serotonin syndrome symptoms (agitation, tremor, diarrhea).' },
{ drug1: 'ondansetron', drug2: 'methadone', severity: 'MODERATE', description: 'Both can prolong QT interval, increasing risk of cardiac arrhythmia.', recommendation: 'ECG monitoring recommended. Consider alternative antiemetic.' },
// Pain medication interactions
{ drug1: 'morphine', drug2: 'gabapentin', severity: 'MODERATE', description: 'Combined CNS depression can cause excessive sedation and respiratory depression.', recommendation: 'Start gabapentin at low dose. Monitor for excessive sedation.' },
{ drug1: 'oxycodone', drug2: 'diazepam', severity: 'MAJOR', description: 'Combined opioid and benzodiazepine use significantly increases risk of respiratory depression and death.', recommendation: 'Avoid combination if possible. Use lowest effective doses if necessary.' },
{ drug1: 'fentanyl', drug2: 'fluconazole', severity: 'MAJOR', description: 'Fluconazole inhibits CYP3A4, which can dramatically increase fentanyl levels.', recommendation: 'Reduce fentanyl dose or use alternative antifungal.' },
{ drug1: 'morphine', drug2: 'lorazepam', severity: 'MAJOR', description: 'Combined opioid and benzodiazepine use increases risk of severe sedation and respiratory depression.', recommendation: 'Avoid combination if possible. Monitor closely.' },
// Immunosuppressant interactions
{ drug1: 'tacrolimus', drug2: 'fluconazole', severity: 'MAJOR', description: 'Fluconazole inhibits tacrolimus metabolism, causing potentially toxic levels.', recommendation: 'Monitor tacrolimus levels closely. Dose reduction usually needed.' },
{ drug1: 'cyclosporine', drug2: 'methotrexate', severity: 'MODERATE', description: 'Both are immunosuppressive and nephrotoxic. Combined risk is additive.', recommendation: 'Monitor renal function and blood counts closely.' },
// Chemo + chemo interactions
{ drug1: 'cisplatin', drug2: 'methotrexate', severity: 'MAJOR', description: 'Cisplatin reduces renal clearance of methotrexate, increasing toxicity risk.', recommendation: 'Give methotrexate before cisplatin if used together. Monitor closely.' },
{ drug1: 'doxorubicin', drug2: 'trastuzumab', severity: 'MAJOR', description: 'Both cause cardiotoxicity. Combined use significantly increases heart failure risk.', recommendation: 'Avoid concurrent use. Sequential administration preferred.' },
{ drug1: 'paclitaxel', drug2: 'cisplatin', severity: 'MODERATE', description: 'Sequence matters: cisplatin before paclitaxel increases myelosuppression.', recommendation: 'Give paclitaxel before cisplatin to reduce toxicity.' },
// Targeted therapy interactions
{ drug1: 'imatinib', drug2: 'ketoconazole', severity: 'MAJOR', description: 'Ketoconazole increases imatinib exposure through CYP3A4 inhibition.', recommendation: 'Avoid concurrent use. Use alternative antifungal.' },
{ drug1: 'imatinib', drug2: 'warfarin', severity: 'MAJOR', description: 'Imatinib can affect warfarin metabolism unpredictably.', recommendation: 'Use low-molecular-weight heparin instead of warfarin.' },
{ drug1: 'erlotinib', drug2: 'omeprazole', severity: 'MODERATE', description: 'PPIs reduce erlotinib absorption due to pH-dependent solubility.', recommendation: 'Avoid PPIs. If needed, stagger doses (erlotinib 12h before PPI).' },
// Common supplement interactions
{ drug1: 'methotrexate', drug2: 'folic acid', severity: 'MINOR', description: 'Folic acid supplementation can reduce methotrexate efficacy as an antifolate.', recommendation: 'Use leucovorin rescue as prescribed. Discuss folic acid timing with oncologist.' },
{ drug1: 'cisplatin', drug2: 'magnesium', severity: 'MINOR', description: 'Cisplatin causes significant magnesium wasting.', recommendation: 'Magnesium supplementation is generally recommended with cisplatin.' },
{ drug1: 'doxorubicin', drug2: 'coenzyme q10', severity: 'MINOR', description: 'CoQ10 may provide some cardioprotection but could theoretically reduce doxorubicin efficacy.', recommendation: 'Discuss with oncologist before supplementing.' },
]

View File

@@ -0,0 +1,97 @@
import { describe, it, expect } from 'vitest'
import { computeFlag, LAB_PANELS } from './panels'
describe('computeFlag', () => {
it('returns null when value is within normal range', () => {
expect(computeFlag(7.0, 4.5, 11.0)).toBeNull()
expect(computeFlag(4.5, 4.5, 11.0)).toBeNull()
expect(computeFlag(11.0, 4.5, 11.0)).toBeNull()
})
it('returns LOW when value is below refMin but not critical', () => {
// refMin is 4.5, 80% of refMin is 3.6
// Value 4.0 is below 4.5 but above 3.6
expect(computeFlag(4.0, 4.5, 11.0)).toBe('LOW')
})
it('returns CRITICAL_LOW when value is > 20% below refMin', () => {
// refMin is 4.5, 80% of refMin is 3.6
// Value 3.0 is below 3.6
expect(computeFlag(3.0, 4.5, 11.0)).toBe('CRITICAL_LOW')
})
it('returns HIGH when value is above refMax but not critical', () => {
// refMax is 11.0, 120% of refMax is 13.2
// Value 12.0 is above 11.0 but below 13.2
expect(computeFlag(12.0, 4.5, 11.0)).toBe('HIGH')
})
it('returns CRITICAL_HIGH when value is > 20% above refMax', () => {
// refMax is 11.0, 120% of refMax is 13.2
// Value 14.0 is above 13.2
expect(computeFlag(14.0, 4.5, 11.0)).toBe('CRITICAL_HIGH')
})
it('returns null when both refMin and refMax are null', () => {
expect(computeFlag(50.0, null, null)).toBeNull()
})
it('handles refMin-only check (no refMax)', () => {
expect(computeFlag(3.0, 4.5, null)).toBe('CRITICAL_LOW')
expect(computeFlag(4.0, 4.5, null)).toBe('LOW')
expect(computeFlag(5.0, 4.5, null)).toBeNull()
})
it('handles refMax-only check (no refMin)', () => {
// Tumor markers: refMin is null, refMax is 3.0
expect(computeFlag(2.5, null, 3.0)).toBeNull()
expect(computeFlag(3.5, null, 3.0)).toBe('HIGH')
expect(computeFlag(4.0, null, 3.0)).toBe('CRITICAL_HIGH')
})
it('flags exact boundary as within range', () => {
expect(computeFlag(4.5, 4.5, 11.0)).toBeNull()
expect(computeFlag(11.0, 4.5, 11.0)).toBeNull()
})
it('flags value just below refMin as LOW', () => {
expect(computeFlag(4.49, 4.5, 11.0)).toBe('LOW')
})
it('flags value just above refMax as HIGH', () => {
expect(computeFlag(11.01, 4.5, 11.0)).toBe('HIGH')
})
})
describe('LAB_PANELS', () => {
it('contains at least 4 panel templates', () => {
expect(LAB_PANELS.length).toBeGreaterThanOrEqual(4)
})
it('each panel has a name and markers array', () => {
for (const panel of LAB_PANELS) {
expect(panel.name).toBeTruthy()
expect(Array.isArray(panel.markers)).toBe(true)
}
})
it('CBC panel has expected markers', () => {
const cbc = LAB_PANELS.find((p) => p.name.includes('CBC'))
expect(cbc).toBeDefined()
const markerNames = cbc!.markers.map((m) => m.marker)
expect(markerNames).toContain('WBC')
expect(markerNames).toContain('RBC')
expect(markerNames).toContain('Hemoglobin')
expect(markerNames).toContain('Platelets')
})
it('each marker in standard panels has unit and at least one reference bound', () => {
for (const panel of LAB_PANELS) {
if (panel.name === 'Custom Panel') continue
for (const marker of panel.markers) {
expect(marker.unit).toBeTruthy()
expect(marker.refMin !== null || marker.refMax !== null).toBe(true)
}
}
})
})

103
src/lib/labs/panels.ts Normal file
View File

@@ -0,0 +1,103 @@
/**
* Lab panel templates for common blood work panels.
* Each template defines the expected markers with their units and reference ranges.
*/
export interface PanelMarker {
marker: string
unit: string
refMin: number | null
refMax: number | null
}
export interface PanelTemplate {
name: string
description: string
markers: PanelMarker[]
}
export const LAB_PANELS: PanelTemplate[] = [
{
name: 'Complete Blood Count (CBC)',
description: 'White cells, red cells, hemoglobin, platelets',
markers: [
{ marker: 'WBC', unit: 'K/uL', refMin: 4.5, refMax: 11.0 },
{ marker: 'RBC', unit: 'M/uL', refMin: 4.0, refMax: 5.5 },
{ marker: 'Hemoglobin', unit: 'g/dL', refMin: 12.0, refMax: 17.5 },
{ marker: 'Hematocrit', unit: '%', refMin: 36.0, refMax: 50.0 },
{ marker: 'Platelets', unit: 'K/uL', refMin: 150, refMax: 400 },
{ marker: 'MCV', unit: 'fL', refMin: 80, refMax: 100 },
{ marker: 'Neutrophils', unit: 'K/uL', refMin: 1.8, refMax: 7.7 },
{ marker: 'Lymphocytes', unit: 'K/uL', refMin: 1.0, refMax: 4.8 },
],
},
{
name: 'Comprehensive Metabolic Panel (CMP)',
description: 'Glucose, electrolytes, kidney & liver function',
markers: [
{ marker: 'Glucose', unit: 'mg/dL', refMin: 70, refMax: 100 },
{ marker: 'BUN', unit: 'mg/dL', refMin: 7, refMax: 20 },
{ marker: 'Creatinine', unit: 'mg/dL', refMin: 0.6, refMax: 1.2 },
{ marker: 'Sodium', unit: 'mEq/L', refMin: 136, refMax: 145 },
{ marker: 'Potassium', unit: 'mEq/L', refMin: 3.5, refMax: 5.1 },
{ marker: 'Chloride', unit: 'mEq/L', refMin: 98, refMax: 106 },
{ marker: 'CO2', unit: 'mEq/L', refMin: 23, refMax: 29 },
{ marker: 'Calcium', unit: 'mg/dL', refMin: 8.5, refMax: 10.5 },
{ marker: 'Total Protein', unit: 'g/dL', refMin: 6.0, refMax: 8.3 },
{ marker: 'Albumin', unit: 'g/dL', refMin: 3.5, refMax: 5.5 },
],
},
{
name: 'Liver Function Panel',
description: 'AST, ALT, bilirubin, alkaline phosphatase',
markers: [
{ marker: 'AST', unit: 'U/L', refMin: 10, refMax: 40 },
{ marker: 'ALT', unit: 'U/L', refMin: 7, refMax: 56 },
{ marker: 'ALP', unit: 'U/L', refMin: 44, refMax: 147 },
{ marker: 'Total Bilirubin', unit: 'mg/dL', refMin: 0.1, refMax: 1.2 },
{ marker: 'Direct Bilirubin', unit: 'mg/dL', refMin: 0.0, refMax: 0.3 },
{ marker: 'GGT', unit: 'U/L', refMin: 9, refMax: 48 },
],
},
{
name: 'Tumor Markers',
description: 'Common cancer-related markers',
markers: [
{ marker: 'CEA', unit: 'ng/mL', refMin: null, refMax: 3.0 },
{ marker: 'CA 19-9', unit: 'U/mL', refMin: null, refMax: 37 },
{ marker: 'CA 125', unit: 'U/mL', refMin: null, refMax: 35 },
{ marker: 'AFP', unit: 'ng/mL', refMin: null, refMax: 10 },
{ marker: 'PSA', unit: 'ng/mL', refMin: null, refMax: 4.0 },
],
},
{
name: 'Custom Panel',
description: 'Add your own markers',
markers: [],
},
]
/**
* Determine flag status for a marker value given reference ranges.
*/
export function computeFlag(
value: number,
refMin: number | null,
refMax: number | null
): 'LOW' | 'HIGH' | 'CRITICAL_LOW' | 'CRITICAL_HIGH' | null {
if (refMin === null && refMax === null) return null
if (refMin !== null && value < refMin) {
// Critical if > 20% below refMin
const criticalThreshold = refMin * 0.8
return value < criticalThreshold ? 'CRITICAL_LOW' : 'LOW'
}
if (refMax !== null && value > refMax) {
// Critical if > 20% above refMax
const criticalThreshold = refMax * 1.2
return value > criticalThreshold ? 'CRITICAL_HIGH' : 'HIGH'
}
return null
}

View File

@@ -0,0 +1,9 @@
export function isDue(scheduledTime: string, now: Date): boolean {
const [hours, minutes] = scheduledTime.split(':').map(Number)
const nowMinutes = now.getHours() * 60 + now.getMinutes()
const schedMinutes = hours * 60 + minutes
// The sender is expected to run every minute. Matching the exact minute
// prevents the same reminder from being sent repeatedly across a tolerance window.
return nowMinutes === schedMinutes
}

View File

@@ -0,0 +1,10 @@
import { describe, expect, it } from 'vitest'
import { isDue } from './due'
describe('notification scheduler', () => {
it('matches only the exact scheduled minute', () => {
expect(isDue('09:00', new Date('2024-01-15T09:00:00+08:00'))).toBe(true)
expect(isDue('09:00', new Date('2024-01-15T08:59:00+08:00'))).toBe(false)
expect(isDue('09:00', new Date('2024-01-15T09:01:00+08:00'))).toBe(false)
})
})

View File

@@ -1,5 +1,6 @@
import { prisma } from '@/lib/db/prisma'
import { sendPushNotification } from './push'
import { isDue } from './due'
interface MedicationSchedule {
medicationId: string
@@ -34,18 +35,6 @@ function isQuietHours(
return currentMinutes >= startMinutes && currentMinutes < endMinutes
}
/**
* Check if a medication dose is due at the current time
*/
function isDue(scheduledTime: string, now: Date, toleranceMinutes = 5): boolean {
const [hours, minutes] = scheduledTime.split(':').map(Number)
const nowMinutes = now.getHours() * 60 + now.getMinutes()
const schedMinutes = hours * 60 + minutes
// Due if within tolerance window
return Math.abs(nowMinutes - schedMinutes) <= toleranceMinutes
}
/**
* Get all medication schedules that need notifications sent
* This should be called by a cron job or similar

Some files were not shown because too many files have changed in this diff Show More