Add 11 major features for caregiver health management

Features added:
- Emergency Info Card: Full-screen emergency view with patient info
- Refill Tracker: Track pill counts with auto-decrement on dose
- Activity Feed: View caregiver activity with filtering
- Symptom Tracker: Log symptoms with severity and offline sync
- Print Views: Daily meds, appointments, doctor visit summaries
- iCal Export: Calendar subscription for appointments
- PDF Export: Medical summary for doctor visits
- Calendar View: Monthly calendar for appointments
- Appointment Preparation: Checklist for upcoming appointments
- Medication Reminders: PWA push notifications with quiet hours

Bug fixes:
- Fix invite workflow: Register/login now properly redirect back
- Add undo for doctor questions (can unmark "asked" questions)
- Fix API route type annotations for Next.js 14 compatibility
- Add Suspense boundary for useSearchParams in login/register

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Gemini Agent
2026-01-23 09:42:46 +00:00
parent 515376e126
commit dd4ef2c4cd
70 changed files with 7322 additions and 79 deletions

View File

@@ -0,0 +1,133 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { Loader2 } from 'lucide-react'
import { Card, LoadingState, Button } from '@/components/ui'
import { Header, PageContainer } from '@/components/layout/header'
import { ActivityItem } from '@/components/activity/ActivityItem'
import { ActivityFilter } from '@/components/activity/ActivityFilter'
import { useApp } from '../provider'
interface Activity {
id: string
action: string
entityType: string
entityId: string
details: Record<string, unknown> | null
createdAt: string
user: { id: string; name: string }
}
export default function ActivityPage() {
const { currentWorkspace } = useApp()
const [activities, setActivities] = useState<Activity[]>([])
const [loading, setLoading] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
const [hasMore, setHasMore] = useState(false)
const [entityType, setEntityType] = useState('')
const [offset, setOffset] = useState(0)
const fetchActivities = useCallback(async (reset = false) => {
const currentOffset = reset ? 0 : offset
if (reset) {
setLoading(true)
} else {
setLoadingMore(true)
}
try {
const params = new URLSearchParams({
limit: '50',
offset: String(currentOffset),
...(entityType && { entityType }),
})
const response = await fetch(
`/api/workspaces/${currentWorkspace.id}/activity?${params}`
)
if (!response.ok) throw new Error('Failed to fetch')
const data = await response.json()
if (reset) {
setActivities(data.activities)
setOffset(data.activities.length)
} else {
setActivities((prev) => [...prev, ...data.activities])
setOffset(currentOffset + data.activities.length)
}
setHasMore(data.hasMore)
} catch (err) {
console.error('Failed to fetch activities:', err)
} finally {
setLoading(false)
setLoadingMore(false)
}
}, [currentWorkspace.id, entityType, offset])
useEffect(() => {
fetchActivities(true)
}, [currentWorkspace.id, entityType])
const handleLoadMore = () => {
fetchActivities(false)
}
const handleEntityTypeChange = (type: string) => {
setEntityType(type)
setOffset(0)
}
if (loading) {
return (
<>
<Header title="Activity" showBack />
<PageContainer>
<LoadingState message="Loading activity..." />
</PageContainer>
</>
)
}
return (
<>
<Header title="Activity" showBack />
<PageContainer className="pt-4 space-y-4">
<ActivityFilter
entityType={entityType}
onEntityTypeChange={handleEntityTypeChange}
/>
{activities.length === 0 ? (
<Card variant="outline" className="text-center py-8">
<p className="text-secondary-500">No activity yet</p>
</Card>
) : (
<Card padding="none">
<div className="divide-y divide-border">
{activities.map((activity) => (
<div key={activity.id} className="px-4">
<ActivityItem activity={activity} />
</div>
))}
</div>
</Card>
)}
{hasMore && (
<div className="text-center pb-4">
<Button
variant="secondary"
onClick={handleLoadMore}
loading={loadingMore}
>
{loadingMore ? 'Loading...' : 'Load more'}
</Button>
</div>
)}
</PageContainer>
</>
)
}

View File

@@ -0,0 +1,262 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter, useParams } from 'next/navigation'
import { format, parseISO, isPast, isTomorrow, isToday } from 'date-fns'
import { toZonedTime } from 'date-fns-tz'
import {
Calendar,
MapPin,
Clock,
Edit,
Trash2,
ExternalLink,
ClipboardCheck,
ChevronRight,
} from 'lucide-react'
import { Card, Button, LoadingState, Modal, 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 AppointmentDetailPage() {
const router = useRouter()
const params = useParams()
const appointmentId = params.id as string
const { currentWorkspace, refreshData } = useApp()
const [appointment, setAppointment] = useState<Appointment | null>(null)
const [loading, setLoading] = useState(true)
const [showDelete, setShowDelete] = useState(false)
const [deleting, setDeleting] = useState(false)
useEffect(() => {
async function fetchAppointment() {
try {
const response = await fetch(
`/api/workspaces/${currentWorkspace.id}/appointments/${appointmentId}`
)
if (response.ok) {
const data = await response.json()
setAppointment(data.appointment)
}
} catch (err) {
console.error('Failed to fetch appointment:', err)
} finally {
setLoading(false)
}
}
fetchAppointment()
}, [currentWorkspace.id, appointmentId])
const handleDelete = async () => {
setDeleting(true)
try {
const response = await fetch(
`/api/workspaces/${currentWorkspace.id}/appointments/${appointmentId}`,
{ method: 'DELETE' }
)
if (!response.ok) throw new Error('Failed to delete')
showToast('Appointment deleted', 'success')
refreshData()
router.push('/appointments')
} catch {
showToast('Failed to delete', 'error')
} finally {
setDeleting(false)
setShowDelete(false)
}
}
if (loading) {
return (
<>
<Header title="Appointment" showBack />
<PageContainer>
<LoadingState message="Loading..." />
</PageContainer>
</>
)
}
if (!appointment) {
return (
<>
<Header title="Appointment" showBack />
<PageContainer className="pt-4">
<Card className="text-center py-8">
<p className="text-secondary-500">Appointment not found</p>
</Card>
</PageContainer>
</>
)
}
const apptDate = toZonedTime(parseISO(appointment.datetime), TIMEZONE)
const isInPast = isPast(apptDate)
const showPrepButton = !isInPast && (isToday(apptDate) || isTomorrow(apptDate) || !isPast(apptDate))
return (
<>
<Header
title="Appointment"
showBack
rightAction={{
icon: <Edit className="w-6 h-6 text-secondary-700" />,
label: 'Edit',
onClick: () => router.push(`/appointments/${appointmentId}/edit`),
}}
/>
<PageContainer className="pt-4 space-y-4">
{/* Main details */}
<Card>
<div className="flex items-start gap-4">
<div
className={`w-14 h-14 rounded-full flex items-center justify-center flex-shrink-0 ${
isInPast ? 'bg-secondary-100' : 'bg-primary-100'
}`}
>
<Calendar
className={`w-7 h-7 ${
isInPast ? 'text-secondary-400' : 'text-primary-600'
}`}
/>
</div>
<div className="flex-1">
<h1 className="text-xl font-bold text-secondary-900">
{appointment.title}
</h1>
<div className="mt-3 space-y-2">
<p
className={`flex items-center gap-2 ${
isInPast ? 'text-secondary-400' : 'text-secondary-700'
}`}
>
<Clock className="w-5 h-5" />
<span>
{format(apptDate, 'EEEE, MMMM d, yyyy')} at{' '}
{format(apptDate, 'h:mm a')}
</span>
</p>
{appointment.location && (
<div className="flex items-start gap-2 text-secondary-600">
<MapPin className="w-5 h-5 mt-0.5" />
<span>{appointment.location}</span>
</div>
)}
</div>
</div>
</div>
</Card>
{/* Map link */}
{appointment.mapUrl && (
<a
href={appointment.mapUrl}
target="_blank"
rel="noopener noreferrer"
className="block"
>
<Card className="hover:bg-secondary-50">
<div className="flex items-center gap-3">
<ExternalLink className="w-5 h-5 text-primary-600" />
<span className="font-medium text-primary-600">Open in Maps</span>
</div>
</Card>
</a>
)}
{/* Preparation checklist */}
{showPrepButton && (
<Card
onClick={() => router.push(`/appointments/${appointmentId}/prep`)}
className="hover:bg-secondary-50 cursor-pointer"
>
<div className="flex items-center gap-3">
<div className="p-2 bg-green-100 rounded-lg">
<ClipboardCheck className="w-5 h-5 text-green-600" />
</div>
<div className="flex-1">
<p className="font-medium text-secondary-900">
Prepare for this appointment
</p>
<p className="text-sm text-secondary-500">
Checklist to help you get ready
</p>
</div>
<ChevronRight className="w-5 h-5 text-secondary-300" />
</div>
</Card>
)}
{/* Notes */}
{appointment.notes && (
<Card>
<h2 className="font-semibold text-secondary-700 mb-2">Notes</h2>
<p className="text-secondary-600 whitespace-pre-wrap">
{appointment.notes}
</p>
</Card>
)}
{/* Delete button */}
<div className="pt-4">
<Button
variant="ghost"
className="text-red-600 hover:bg-red-50 w-full"
onClick={() => setShowDelete(true)}
>
<Trash2 className="w-4 h-4 mr-2" />
Delete Appointment
</Button>
</div>
</PageContainer>
{/* Delete confirmation modal */}
<Modal
isOpen={showDelete}
onClose={() => setShowDelete(false)}
title="Delete Appointment"
>
<div className="space-y-4">
<p className="text-secondary-600">
Are you sure you want to delete "{appointment.title}"? This action
cannot be undone.
</p>
<div className="flex gap-3">
<Button
variant="secondary"
onClick={() => setShowDelete(false)}
className="flex-1"
>
Cancel
</Button>
<Button
variant="danger"
onClick={handleDelete}
loading={deleting}
className="flex-1"
>
Delete
</Button>
</div>
</div>
</Modal>
</>
)
}

View File

@@ -0,0 +1,142 @@
'use client'
import { useState, useEffect } from 'react'
import { useParams } from 'next/navigation'
import { format, parseISO } from 'date-fns'
import { Calendar, MapPin, Clock, Printer } from 'lucide-react'
import { Card, LoadingState, Button } from '@/components/ui'
import { Header, PageContainer } from '@/components/layout/header'
import { PrepChecklist } from '@/components/appointments/PrepChecklist'
import { useApp } from '../../../provider'
interface Appointment {
id: string
title: string
datetime: string
location: string | null
notes: string | null
}
export default function AppointmentPrepPage() {
const params = useParams()
const appointmentId = params.id as string
const { currentWorkspace } = useApp()
const [appointment, setAppointment] = useState<Appointment | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
async function fetchAppointment() {
try {
const response = await fetch(
`/api/workspaces/${currentWorkspace.id}/appointments/${appointmentId}`
)
if (response.ok) {
const data = await response.json()
setAppointment(data.appointment)
}
} catch (err) {
console.error('Failed to fetch appointment:', err)
} finally {
setLoading(false)
}
}
fetchAppointment()
}, [currentWorkspace.id, appointmentId])
if (loading) {
return (
<>
<Header title="Prepare" showBack />
<PageContainer>
<LoadingState message="Loading..." />
</PageContainer>
</>
)
}
if (!appointment) {
return (
<>
<Header title="Prepare" showBack />
<PageContainer className="pt-4">
<Card className="text-center py-8">
<p className="text-secondary-500">Appointment not found</p>
</Card>
</PageContainer>
</>
)
}
const apptDate = parseISO(appointment.datetime)
return (
<>
<Header
title="Prepare for Appointment"
showBack
rightAction={{
icon: <Printer className="w-6 h-6 text-secondary-700" />,
label: 'Print',
onClick: () => window.print(),
}}
/>
<PageContainer className="pt-4 space-y-6">
{/* Appointment summary */}
<Card className="bg-primary-50 border border-primary-200">
<div className="flex items-start gap-3">
<div className="w-12 h-12 rounded-full bg-primary-100 flex items-center justify-center flex-shrink-0">
<Calendar className="w-6 h-6 text-primary-600" />
</div>
<div className="flex-1">
<h2 className="font-semibold text-lg text-secondary-900">
{appointment.title}
</h2>
<p className="text-primary-700 flex items-center gap-1 mt-1">
<Clock className="w-4 h-4" />
{format(apptDate, 'EEEE, MMMM d')} at {format(apptDate, 'h:mm a')}
</p>
{appointment.location && (
<p className="text-secondary-600 flex items-center gap-1 mt-1">
<MapPin className="w-4 h-4" />
{appointment.location}
</p>
)}
</div>
</div>
</Card>
{/* Tips */}
<div className="bg-blue-50 rounded-lg p-4">
<h3 className="font-medium text-blue-900 mb-2">Preparation Tips</h3>
<ul className="text-sm text-blue-800 list-disc list-inside space-y-1">
<li>Arrive 15 minutes early</li>
<li>Bring all items on your checklist</li>
<li>Have someone drive you if needed</li>
<li>Eat a light meal beforehand unless fasting</li>
</ul>
</div>
{/* Checklist */}
<div>
<h3 className="text-lg font-semibold text-secondary-900 mb-4">
Preparation Checklist
</h3>
<PrepChecklist
workspaceId={currentWorkspace.id}
appointmentId={appointmentId}
/>
</div>
{/* Notes */}
{appointment.notes && (
<Card>
<h3 className="font-medium text-secondary-700 mb-2">Notes</h3>
<p className="text-secondary-600 whitespace-pre-wrap">{appointment.notes}</p>
</Card>
)}
</PageContainer>
</>
)
}

View File

@@ -0,0 +1,115 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { List, Plus } from 'lucide-react'
import { useLiveQuery } from 'dexie-react-hooks'
import { db } from '@/lib/sync'
import { Card, LoadingState, Button } from '@/components/ui'
import { Header, PageContainer } from '@/components/layout/header'
import { CalendarMonth } from '@/components/calendar/CalendarMonth'
import { useApp } from '../../provider'
export default function CalendarPage() {
const router = useRouter()
const { currentWorkspace } = useApp()
const [currentMonth, setCurrentMonth] = useState(new Date())
const [selectedDate, setSelectedDate] = useState(new Date())
const [loading, setLoading] = useState(true)
const [serverAppointments, setServerAppointments] = useState<any[]>([])
// Fetch from IndexedDB for offline support
const localAppointments = useLiveQuery(
() =>
db.appointments
.where('workspaceId')
.equals(currentWorkspace.id)
.and((a) => !a.deletedAt)
.toArray(),
[currentWorkspace.id]
)
// Also fetch from server for latest data
useEffect(() => {
async function fetchAppointments() {
try {
const response = await fetch(
`/api/workspaces/${currentWorkspace.id}/appointments`
)
if (response.ok) {
const data = await response.json()
setServerAppointments(data.appointments)
}
} catch (err) {
console.error('Failed to fetch appointments:', err)
} finally {
setLoading(false)
}
}
fetchAppointments()
}, [currentWorkspace.id])
// Prefer server data if available
const appointments = serverAppointments.length > 0 ? serverAppointments : localAppointments || []
if (loading && !localAppointments) {
return (
<>
<Header
title="Calendar"
showBack
rightAction={{
icon: <List className="w-6 h-6 text-secondary-700" />,
label: 'List view',
onClick: () => router.push('/appointments'),
}}
/>
<PageContainer>
<LoadingState message="Loading appointments..." />
</PageContainer>
</>
)
}
return (
<>
<Header
title="Calendar"
showBack
rightAction={{
icon: <List className="w-6 h-6 text-secondary-700" />,
label: 'List view',
onClick: () => router.push('/appointments'),
}}
/>
<PageContainer className="pt-4 space-y-4">
<Card>
<CalendarMonth
appointments={appointments.map((a: any) => ({
id: a.id,
title: a.title,
datetime: a.datetime,
location: a.location,
}))}
selectedDate={selectedDate}
onDateSelect={setSelectedDate}
onMonthChange={setCurrentMonth}
currentMonth={currentMonth}
/>
</Card>
{/* Add appointment FAB */}
<div className="fixed bottom-20 right-4 z-30">
<Button
onClick={() => router.push('/appointments/new')}
className="w-14 h-14 rounded-full shadow-lg flex items-center justify-center"
>
<Plus className="w-6 h-6" />
</Button>
</div>
</PageContainer>
</>
)
}

View File

@@ -3,11 +3,11 @@
import { useRouter } from 'next/navigation'
import { format, isToday, isTomorrow, parseISO, startOfDay } from 'date-fns'
import { toZonedTime } from 'date-fns-tz'
import { Plus, Calendar, MapPin, Clock, ChevronRight } from 'lucide-react'
import { Plus, Calendar, MapPin, Clock, ChevronRight, CalendarDays } from 'lucide-react'
import { useLiveQuery } from 'dexie-react-hooks'
import { db } from '@/lib/sync'
import { Card, LoadingState, EmptyState } from '@/components/ui'
import { Card, LoadingState, EmptyState, Button } from '@/components/ui'
import { Header, PageContainer } from '@/components/layout/header'
import { useApp } from '../provider'
@@ -59,9 +59,9 @@ export default function AppointmentsPage() {
<Header
title="Appointments"
rightAction={{
icon: <Plus className="w-6 h-6 text-secondary-700" />,
label: 'Add appointment',
onClick: () => router.push('/appointments/new'),
icon: <CalendarDays className="w-6 h-6 text-secondary-700" />,
label: 'Calendar view',
onClick: () => router.push('/appointments/calendar'),
}}
/>
<PageContainer>
@@ -76,9 +76,9 @@ export default function AppointmentsPage() {
<Header
title="Appointments"
rightAction={{
icon: <Plus className="w-6 h-6 text-secondary-700" />,
label: 'Add appointment',
onClick: () => router.push('/appointments/new'),
icon: <CalendarDays className="w-6 h-6 text-secondary-700" />,
label: 'Calendar view',
onClick: () => router.push('/appointments/calendar'),
}}
/>
<PageContainer className="pt-4">
@@ -158,6 +158,16 @@ export default function AppointmentsPage() {
})}
</div>
)}
{/* Add appointment FAB */}
<div className="fixed bottom-20 right-4 z-30">
<Button
onClick={() => router.push('/appointments/new')}
className="w-14 h-14 rounded-full shadow-lg flex items-center justify-center"
>
<Plus className="w-6 h-6" />
</Button>
</div>
</PageContainer>
</>
)

View File

@@ -0,0 +1,115 @@
'use client'
import { useEffect, useState } from 'react'
import { ArrowLeft, Edit2 } 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 { useApp } from '../provider'
export default function EmergencyPage() {
const router = useRouter()
const { currentWorkspace } = useApp()
// Fetch workspace from IndexedDB for offline access
const workspace = useLiveQuery(
() => db.workspaces.get(currentWorkspace.id),
[currentWorkspace.id]
)
// Fetch active medications
const medications = useLiveQuery(
() =>
db.medications
.where('workspaceId')
.equals(currentWorkspace.id)
.and((m) => m.active && !m.deletedAt)
.toArray(),
[currentWorkspace.id]
)
if (!workspace) {
return <LoadingState message="Loading emergency info..." />
}
const emergencyInfo = {
patientName: workspace.patientName,
patientDOB: workspace.patientDOB,
bloodType: workspace.bloodType,
allergies: workspace.allergies,
medicalConditions: workspace.medicalConditions,
primaryPhysician: workspace.primaryPhysician,
physicianPhone: workspace.physicianPhone,
clinicPhone: workspace.clinicPhone,
emergencyPhone: workspace.emergencyPhone,
}
const hasInfo = emergencyInfo.patientName || emergencyInfo.bloodType ||
emergencyInfo.allergies || emergencyInfo.medicalConditions
const medsList = medications?.map(m => ({
name: m.name,
instructions: m.instructions,
})) || []
return (
<div className="min-h-screen bg-red-50">
{/* Header */}
<div className="bg-red-600 text-white safe-top">
<div className="flex items-center justify-between px-4 py-3">
<button
onClick={() => router.back()}
className="flex items-center gap-2 text-white/90 hover:text-white"
>
<ArrowLeft className="w-5 h-5" />
<span>Back</span>
</button>
{currentWorkspace.role !== 'VIEWER' && (
<button
onClick={() => router.push('/settings/emergency')}
className="flex items-center gap-2 text-white/90 hover:text-white"
>
<Edit2 className="w-4 h-4" />
<span>Edit</span>
</button>
)}
</div>
</div>
<div className="p-4">
{hasInfo ? (
<EmergencyCard info={emergencyInfo} medications={medsList} />
) : (
<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>
<h2 className="text-lg font-semibold text-secondary-900 mb-2">
No Emergency Info Set
</h2>
<p className="text-secondary-600 mb-4">
Add important medical information for emergencies.
</p>
{currentWorkspace.role !== 'VIEWER' && (
<Button onClick={() => router.push('/settings/emergency')}>
Add Emergency Info
</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>
</div>
</div>
)
}

View File

@@ -2,6 +2,7 @@ import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { prisma } from '@/lib/db/prisma'
import { BottomNav } from '@/components/layout/bottom-nav'
import { ServiceWorkerRegistrar } from '@/components/notifications/ServiceWorkerRegistrar'
import { AppProvider } from './provider'
export default async function AppLayout({
@@ -36,6 +37,8 @@ export default async function AppLayout({
clinicPhone: m.workspace.clinicPhone,
emergencyPhone: m.workspace.emergencyPhone,
largeTextMode: m.workspace.largeTextMode,
quietHoursStart: m.workspace.quietHoursStart,
quietHoursEnd: m.workspace.quietHoursEnd,
}))
return (
@@ -44,6 +47,7 @@ export default async function AppLayout({
workspaces={workspaces}
initialWorkspaceId={workspaces[0].id}
>
<ServiceWorkerRegistrar />
<div className={workspaces[0].largeTextMode ? 'large-text' : ''}>
{children}
<BottomNav />

View File

@@ -0,0 +1,247 @@
'use client'
import { use, 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 { useLiveQuery } from 'dexie-react-hooks'
import { db, logDose, undoDose } 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)
const router = useRouter()
const { currentWorkspace, refreshData } = useApp()
const [showDeleteModal, setShowDeleteModal] = useState(false)
const [deleting, setDeleting] = useState(false)
// Fetch medication from IndexedDB
const medication = useLiveQuery(
() => db.medications.get(medicationId),
[medicationId]
)
// Fetch recent dose logs
const doseLogs = useLiveQuery(
() =>
db.doseLogs
.where('medicationId')
.equals(medicationId)
.reverse()
.limit(10)
.toArray(),
[medicationId]
)
const handleRefresh = useCallback(async () => {
await refreshData()
}, [refreshData])
const handleTakeDose = useCallback(async () => {
if (!medication) return
try {
const doseLog = await logDose(
currentWorkspace.id,
medication.id,
{ id: medication.id, name: medication.name }
)
showUndoToast(`Took ${medication.name}`, async () => {
await undoDose(doseLog)
showToast('Dose undone', 'info')
})
} catch {
showToast('Failed to log dose', 'error')
}
}, [medication, currentWorkspace.id])
const handleDelete = async () => {
if (!medication) return
setDeleting(true)
try {
const response = await fetch(
`/api/workspaces/${currentWorkspace.id}/medications/${medication.id}`,
{ method: 'DELETE' }
)
if (!response.ok) throw new Error('Failed to delete')
await refreshData()
showToast('Medication deleted', 'success')
router.push('/meds')
} catch {
showToast('Failed to delete medication', 'error')
} finally {
setDeleting(false)
setShowDeleteModal(false)
}
}
const formatSchedule = () => {
if (!medication) return ''
const data = medication.scheduleData as Record<string, unknown>
switch (medication.scheduleType) {
case 'FIXED_TIMES':
return `Daily at ${(data.times as string[]).join(', ')}`
case 'INTERVAL':
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}`
case 'PRN':
return `As needed (min ${data.minHoursBetween}h between doses)`
default:
return medication.scheduleType
}
}
if (!medication) {
return (
<>
<Header title="Medication" showBack />
<PageContainer>
<LoadingState message="Loading medication..." />
</PageContainer>
</>
)
}
const recentDoses = doseLogs?.filter(d => !d.undoneAt) || []
return (
<>
<Header
title={medication.name}
showBack
rightAction={
currentWorkspace.role !== 'VIEWER'
? {
icon: <Trash2 className="w-6 h-6 text-red-600" />,
label: 'Delete',
onClick: () => setShowDeleteModal(true),
}
: undefined
}
/>
<PageContainer className="pt-4 space-y-6">
{/* Status Card */}
<Card className={medication.active ? '' : 'opacity-60'}>
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-full bg-primary-100 flex items-center justify-center">
<Pill className="w-6 h-6 text-primary-600" />
</div>
<div className="flex-1">
<h2 className="text-lg font-semibold text-secondary-900">
{medication.name}
</h2>
<p className="text-sm text-secondary-600 flex items-center gap-1 mt-1">
<Clock className="w-4 h-4" />
{formatSchedule()}
</p>
{medication.instructions && (
<p className="text-sm text-secondary-500 mt-2">
{medication.instructions}
</p>
)}
{!medication.active && (
<p className="text-sm text-orange-600 font-medium mt-2">
Inactive
</p>
)}
</div>
</div>
{currentWorkspace.role !== 'VIEWER' && medication.active && (
<Button
onClick={handleTakeDose}
variant="success"
fullWidth
className="mt-4"
>
Mark as Taken
</Button>
)}
</Card>
{/* Refill Tracker */}
{medication.pillCount !== null && (
<RefillTracker
medicationId={medication.id}
workspaceId={currentWorkspace.id}
medicationName={medication.name}
pillCount={medication.pillCount}
pillsPerDose={medication.pillsPerDose}
refillThreshold={medication.refillThreshold}
onRefill={handleRefresh}
/>
)}
{/* Recent Doses */}
<section>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-secondary-600">Recent Doses</h3>
<button
onClick={() => router.push('/meds/history')}
className="text-sm text-primary-600 font-medium flex items-center gap-1"
>
<History className="w-4 h-4" />
Full history
</button>
</div>
{recentDoses.length > 0 ? (
<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">
<div>
<p className="text-sm font-medium text-secondary-900">
{format(new Date(dose.takenAt), 'EEEE, MMM d')}
</p>
<p className="text-xs text-secondary-500">
{format(new Date(dose.takenAt), 'h:mm a')}
{dose.loggedBy && ` by ${dose.loggedBy.name}`}
</p>
</div>
</li>
))}
</ul>
</Card>
) : (
<Card variant="outline" className="text-center py-6">
<p className="text-secondary-500">No doses logged yet</p>
</Card>
)}
</section>
</PageContainer>
{/* Delete Confirmation Modal */}
<Modal
isOpen={showDeleteModal}
onClose={() => setShowDeleteModal(false)}
title="Delete Medication"
>
<p className="text-secondary-600 mb-4">
Are you sure you want to delete "{medication.name}"? This action cannot be undone.
</p>
<div className="flex gap-3">
<Button
variant="secondary"
fullWidth
onClick={() => setShowDeleteModal(false)}
>
Cancel
</Button>
<Button
variant="danger"
fullWidth
onClick={handleDelete}
loading={deleting}
>
Delete
</Button>
</div>
</Modal>
</>
)
}

View File

@@ -47,6 +47,12 @@ export default function NewMedicationPage() {
// 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('')
@@ -102,6 +108,12 @@ export default function NewMedicationPage() {
scheduleType,
scheduleData: buildScheduleData(),
active: true,
// Refill tracking (optional)
...(trackRefills && pillCount !== '' && {
pillCount: Number(pillCount),
pillsPerDose,
refillThreshold,
}),
}),
}
)
@@ -248,6 +260,53 @@ export default function NewMedicationPage() {
/>
)}
{/* 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}

View File

@@ -2,7 +2,7 @@
import { useEffect, useState, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { Plus, Pill, Clock, ChevronRight, History } from 'lucide-react'
import { Plus, Pill, Clock, ChevronRight, History, AlertTriangle } from 'lucide-react'
import { useLiveQuery } from 'dexie-react-hooks'
import { db, logDose, undoDose } from '@/lib/sync'
@@ -10,6 +10,7 @@ import { calculateAllMedicationsDue, formatTimeUntil } from '@/lib/schedule'
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 { useApp } from '../provider'
export default function MedsPage() {
@@ -119,16 +120,26 @@ export default function MedsPage() {
onClick: () => router.push('/meds/new'),
}}
/>
<PageContainer className="pt-4">
<PageContainer className="pt-4 space-y-4">
{/* History link */}
<button
onClick={() => router.push('/meds/history')}
className="flex items-center gap-2 text-primary-600 font-medium mb-4"
className="flex items-center gap-2 text-primary-600 font-medium"
>
<History className="w-4 h-4" />
View dose history
</button>
{/* Refill Alerts */}
<RefillAlert
medications={medications.map(m => ({
id: m.id,
name: m.name,
pillCount: m.pillCount,
refillThreshold: m.refillThreshold,
}))}
/>
{medications.length === 0 ? (
<EmptyState
type="medications"
@@ -206,6 +217,12 @@ interface MedicationCardProps {
function MedicationCard({ status, now, onTake, onClick }: MedicationCardProps) {
const { medication, isOverdue, isPRN, prnAvailable, prnAvailableAt, nextDueAt } = status
// Check for low pill count
const med = medication as unknown as { pillCount?: number | null; refillThreshold?: number | null }
const isLowOnPills = med.pillCount !== undefined && med.pillCount !== null &&
med.refillThreshold !== undefined && med.refillThreshold !== null &&
med.pillCount <= med.refillThreshold
const getTimeLabel = () => {
if (isOverdue && nextDueAt) {
return formatTimeUntil(nextDueAt, now)
@@ -230,11 +247,18 @@ function MedicationCard({ status, now, onTake, onClick }: MedicationCardProps) {
return (
<Card className={isOverdue ? 'overdue' : ''} onClick={onClick}>
<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={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${isLowOnPills ? 'bg-orange-100' : 'bg-primary-100'}`}>
<Pill className={`w-5 h-5 ${isLowOnPills ? 'text-orange-600' : 'text-primary-600'}`} />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-secondary-900">{medication.name}</h3>
<div className="flex items-center gap-2">
<h3 className="font-semibold text-secondary-900">{medication.name}</h3>
{isLowOnPills && (
<span className="px-1.5 py-0.5 text-xs font-medium bg-orange-100 text-orange-700 rounded">
{med.pillCount} left
</span>
)}
</div>
<p className={`text-sm flex items-center gap-1 ${isOverdue ? 'text-red-600 font-medium' : 'text-secondary-500'}`}>
<Clock className="w-3.5 h-3.5" />
{getTimeLabel()}

View File

@@ -6,7 +6,7 @@ import { toZonedTime } from 'date-fns-tz'
import { HelpCircle, CheckCircle, Copy } from 'lucide-react'
import { useLiveQuery } from 'dexie-react-hooks'
import { db, markQuestionAsked } from '@/lib/sync'
import { db, markQuestionAsked, unmarkQuestionAsked } from '@/lib/sync'
import { Card, Button, LoadingState, EmptyState, showToast } from '@/components/ui'
import { Header, PageContainer } from '@/components/layout/header'
import { useApp } from '../../provider'
@@ -46,6 +46,22 @@ export default function QuestionsPage() {
[questions, refreshData]
)
const handleUnmarkAsked = useCallback(
async (noteId: string) => {
const note = questions?.find((n) => n.id === noteId)
if (!note) return
try {
await unmarkQuestionAsked(note)
await refreshData()
showToast('Moved back to "To Ask"', 'success')
} catch {
showToast('Failed to update', 'error')
}
},
[questions, refreshData]
)
const copyAllQuestions = () => {
const text = unanswered.map((q) => `${q.content}`).join('\n')
navigator.clipboard.writeText(text)
@@ -129,7 +145,7 @@ export default function QuestionsPage() {
</h2>
<div className="space-y-2">
{answered.map((note) => (
<Card key={note.id} className="opacity-60">
<Card key={note.id} className="opacity-75">
<div className="flex items-start gap-3">
<div className="w-8 h-8 rounded-full bg-green-100 flex items-center justify-center flex-shrink-0">
<CheckCircle className="w-5 h-5 text-green-600" />
@@ -144,6 +160,13 @@ export default function QuestionsPage() {
)}
</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => handleUnmarkAsked(note.id)}
>
Undo
</Button>
</div>
</Card>
))}

View File

@@ -0,0 +1,157 @@
'use client'
import { useState, useEffect } from 'react'
import { format, isAfter, parseISO, addMonths } from 'date-fns'
import { Printer, MapPin } from 'lucide-react'
import { Button, LoadingState } from '@/components/ui'
import { Header, PageContainer } from '@/components/layout/header'
import { useApp } from '../../provider'
import '@/styles/print.css'
interface Appointment {
id: string
title: string
datetime: string
location: string | null
mapUrl: string | null
notes: string | null
}
export default function AppointmentsPrintPage() {
const { currentWorkspace } = useApp()
const [appointments, setAppointments] = useState<Appointment[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
async function fetchAppointments() {
try {
const response = await fetch(`/api/workspaces/${currentWorkspace.id}/appointments`)
if (response.ok) {
const data = await response.json()
// Filter to upcoming appointments within next 3 months
const now = new Date()
const threeMonthsFromNow = addMonths(now, 3)
const upcoming = data.appointments
.filter((a: Appointment) => {
const apptDate = parseISO(a.datetime)
return isAfter(apptDate, now) && isAfter(threeMonthsFromNow, apptDate)
})
.sort(
(a: Appointment, b: Appointment) =>
parseISO(a.datetime).getTime() - parseISO(b.datetime).getTime()
)
setAppointments(upcoming)
}
} catch (err) {
console.error('Failed to fetch appointments:', err)
} finally {
setLoading(false)
}
}
fetchAppointments()
}, [currentWorkspace.id])
const handlePrint = () => {
window.print()
}
if (loading) {
return (
<>
<Header title="Upcoming Appointments" showBack />
<PageContainer>
<LoadingState message="Loading appointments..." />
</PageContainer>
</>
)
}
const today = format(new Date(), 'MMMM d, yyyy')
return (
<>
<div className="screen-only">
<Header title="Upcoming Appointments" showBack />
<PageContainer className="pt-4">
<div className="flex justify-between items-center mb-4">
<p className="text-secondary-600">Preview your printable appointments list</p>
<Button onClick={handlePrint} className="flex items-center gap-2">
<Printer className="w-4 h-4" />
Print
</Button>
</div>
</PageContainer>
</div>
<div className="print-preview p-8">
<div className="print-title text-2xl font-bold mb-2">Upcoming Appointments</div>
<div className="print-date text-gray-600 mb-6">Generated: {today}</div>
{currentWorkspace.name && (
<div className="print-subtitle text-lg font-semibold mb-4">
Patient: {currentWorkspace.name}
</div>
)}
{appointments.length === 0 ? (
<p className="text-gray-500">No upcoming appointments scheduled</p>
) : (
<table className="print-table w-full border-collapse">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-300 p-3 text-left font-semibold">Date & Time</th>
<th className="border border-gray-300 p-3 text-left font-semibold">Appointment</th>
<th className="border border-gray-300 p-3 text-left font-semibold">Location</th>
<th className="border border-gray-300 p-3 text-left font-semibold">Notes</th>
</tr>
</thead>
<tbody>
{appointments.map((appt) => (
<tr key={appt.id} className="print-no-break">
<td className="border border-gray-300 p-3 whitespace-nowrap">
<div className="font-semibold">
{format(parseISO(appt.datetime), 'EEE, MMM d')}
</div>
<div className="text-gray-600">
{format(parseISO(appt.datetime), 'h:mm a')}
</div>
</td>
<td className="border border-gray-300 p-3">
<div className="font-medium">{appt.title}</div>
</td>
<td className="border border-gray-300 p-3">
{appt.location && (
<div className="flex items-start gap-1">
<MapPin className="w-4 h-4 mt-0.5 text-gray-500 flex-shrink-0" />
<span>{appt.location}</span>
</div>
)}
</td>
<td className="border border-gray-300 p-3 text-gray-600 text-sm">
{appt.notes || '-'}
</td>
</tr>
))}
</tbody>
</table>
)}
<div className="mt-8 pt-4 border-t border-gray-300">
<div className="print-subtitle font-semibold mb-2">Reminders:</div>
<ul className="list-disc list-inside text-sm text-gray-600 space-y-1">
<li>Bring insurance card and photo ID</li>
<li>Arrive 15 minutes early for new appointments</li>
<li>Bring a list of current medications</li>
<li>Prepare questions for your doctor</li>
</ul>
</div>
<div className="print-footer mt-8 text-center text-sm text-gray-500">
Generated by NextStep - {today}
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,208 @@
'use client'
import { useState, useEffect } from 'react'
import { format } from 'date-fns'
import { Printer } from 'lucide-react'
import { Button, LoadingState } from '@/components/ui'
import { Header, PageContainer } from '@/components/layout/header'
import { useApp } from '../../provider'
import '@/styles/print.css'
interface Medication {
id: string
name: string
instructions: string | null
scheduleType: string
scheduleData: {
type: string
times?: string[]
hours?: number
startTime?: string
time?: string
days?: number[]
}
active: boolean
}
interface MedTime {
time: string
medications: { id: string; name: string; instructions: string | null }[]
}
function parseScheduleTimes(med: Medication): string[] {
const { scheduleType, scheduleData } = med
switch (scheduleType) {
case 'FIXED_TIMES':
return scheduleData.times || []
case 'INTERVAL':
// Generate times based on interval
const times: string[] = []
const startHour = parseInt(scheduleData.startTime?.split(':')[0] || '8')
const hours = scheduleData.hours || 4
for (let h = startHour; h < 24; h += hours) {
const hourStr = h.toString().padStart(2, '0')
times.push(`${hourStr}:00`)
}
return times
case 'WEEKDAYS':
return scheduleData.time ? [scheduleData.time] : []
case 'PRN':
return ['As needed']
default:
return []
}
}
function groupMedicationsByTime(medications: Medication[]): MedTime[] {
const timeMap = new Map<string, { id: string; name: string; instructions: string | null }[]>()
for (const med of medications) {
if (!med.active) continue
const times = parseScheduleTimes(med)
for (const time of times) {
if (!timeMap.has(time)) {
timeMap.set(time, [])
}
timeMap.get(time)!.push({
id: med.id,
name: med.name,
instructions: med.instructions,
})
}
}
// Sort by time
const sorted = Array.from(timeMap.entries())
.filter(([time]) => time !== 'As needed')
.sort(([a], [b]) => a.localeCompare(b))
.map(([time, medications]) => ({ time, medications }))
// Add PRN meds at the end
const prnMeds = timeMap.get('As needed')
if (prnMeds && prnMeds.length > 0) {
sorted.push({ time: 'As needed', medications: prnMeds })
}
return sorted
}
function formatTime(time: string): string {
if (time === 'As needed') return time
const [hours, minutes] = time.split(':').map(Number)
const ampm = hours >= 12 ? 'PM' : 'AM'
const displayHours = hours % 12 || 12
return `${displayHours}:${minutes.toString().padStart(2, '0')} ${ampm}`
}
export default function DailyMedsPrintPage() {
const { currentWorkspace } = useApp()
const [medications, setMedications] = useState<Medication[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
async function fetchMedications() {
try {
const response = await fetch(`/api/workspaces/${currentWorkspace.id}/medications`)
if (response.ok) {
const data = await response.json()
setMedications(data.medications.filter((m: Medication) => m.active))
}
} catch (err) {
console.error('Failed to fetch medications:', err)
} finally {
setLoading(false)
}
}
fetchMedications()
}, [currentWorkspace.id])
const handlePrint = () => {
window.print()
}
if (loading) {
return (
<>
<Header title="Daily Medications" showBack />
<PageContainer>
<LoadingState message="Loading medications..." />
</PageContainer>
</>
)
}
const medTimes = groupMedicationsByTime(medications)
const today = format(new Date(), 'EEEE, MMMM d, yyyy')
return (
<>
<div className="screen-only">
<Header title="Daily Medications" showBack />
<PageContainer className="pt-4">
<div className="flex justify-between items-center mb-4">
<p className="text-secondary-600">Preview your printable medication schedule</p>
<Button onClick={handlePrint} className="flex items-center gap-2">
<Printer className="w-4 h-4" />
Print
</Button>
</div>
</PageContainer>
</div>
<div className="print-preview p-8">
<div className="print-title text-2xl font-bold mb-2">Daily Medication Schedule</div>
<div className="print-date text-gray-600 mb-6">{today}</div>
{currentWorkspace.name && (
<div className="print-subtitle text-lg font-semibold mb-4">
Patient: {currentWorkspace.name}
</div>
)}
{medTimes.length === 0 ? (
<p className="text-gray-500">No medications scheduled</p>
) : (
<div className="space-y-6">
{medTimes.map((medTime, idx) => (
<div key={idx} className="print-section print-no-break">
<div className="flex items-center gap-3 mb-3 pb-2 border-b-2 border-gray-300">
<span className="print-subtitle text-xl font-bold">
{formatTime(medTime.time)}
</span>
</div>
<div className="space-y-3">
{medTime.medications.map((med) => (
<div key={med.id} className="print-med-item flex items-start gap-3 py-2">
<div className="print-checkbox w-6 h-6 border-2 border-black flex-shrink-0 mt-1" />
<div className="flex-1">
<div className="print-med-name text-lg font-semibold">{med.name}</div>
{med.instructions && (
<div className="print-text text-gray-600 text-sm mt-1">
{med.instructions}
</div>
)}
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
<div className="mt-8 pt-4 border-t border-gray-300">
<div className="print-subtitle font-semibold mb-2">Notes:</div>
<div className="print-notes border border-gray-300 rounded p-3 min-h-[100px] bg-gray-50" />
</div>
<div className="print-footer mt-8 text-center text-sm text-gray-500">
Generated by NextStep - {today}
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,323 @@
'use client'
import { useState, useEffect } from 'react'
import { format, subDays } from 'date-fns'
import { Printer } from 'lucide-react'
import { Button, LoadingState } from '@/components/ui'
import { Header, PageContainer } from '@/components/layout/header'
import { useApp } from '../../provider'
import '@/styles/print.css'
interface Medication {
id: string
name: string
instructions: string | null
scheduleType: string
active: boolean
}
interface Symptom {
id: string
type: string
customName: string | null
severity: number
notes: string | null
recordedAt: string
}
interface Note {
id: string
type: string
content: string
askedAt: string | null
}
const SYMPTOM_LABELS: Record<string, string> = {
FATIGUE: 'Fatigue',
NAUSEA: 'Nausea',
PAIN: 'Pain',
APPETITE: 'Appetite Changes',
SLEEP: 'Sleep Issues',
MOOD: 'Mood Changes',
CUSTOM: 'Other',
}
const SEVERITY_LABELS = ['Minimal', 'Mild', 'Moderate', 'Severe', 'Extreme']
export default function DoctorVisitPrintPage() {
const { currentWorkspace } = useApp()
const [medications, setMedications] = useState<Medication[]>([])
const [symptoms, setSymptoms] = useState<Symptom[]>([])
const [questions, setQuestions] = useState<Note[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
async function fetchData() {
try {
// Fetch medications
const medsResponse = await fetch(
`/api/workspaces/${currentWorkspace.id}/medications`
)
if (medsResponse.ok) {
const data = await medsResponse.json()
setMedications(data.medications.filter((m: Medication) => m.active))
}
// Fetch symptoms from last 30 days
const symptomsResponse = await fetch(
`/api/workspaces/${currentWorkspace.id}/symptoms?limit=50`
)
if (symptomsResponse.ok) {
const data = await symptomsResponse.json()
const thirtyDaysAgo = subDays(new Date(), 30)
setSymptoms(
data.symptoms.filter(
(s: Symptom) => new Date(s.recordedAt) >= thirtyDaysAgo
)
)
}
// Fetch questions (unasked notes)
const notesResponse = await fetch(
`/api/workspaces/${currentWorkspace.id}/notes`
)
if (notesResponse.ok) {
const data = await notesResponse.json()
setQuestions(
data.notes.filter(
(n: Note) => n.type === 'QUESTION' && !n.askedAt
)
)
}
} catch (err) {
console.error('Failed to fetch data:', err)
} finally {
setLoading(false)
}
}
fetchData()
}, [currentWorkspace.id])
const handlePrint = () => {
window.print()
}
if (loading) {
return (
<>
<Header title="Doctor's Visit Summary" showBack />
<PageContainer>
<LoadingState message="Loading summary..." />
</PageContainer>
</>
)
}
const today = format(new Date(), 'MMMM d, yyyy')
// Group symptoms by type for summary
const symptomSummary = symptoms.reduce(
(acc, s) => {
const key = s.type
if (!acc[key]) {
acc[key] = { count: 0, totalSeverity: 0, maxSeverity: 0 }
}
acc[key].count++
acc[key].totalSeverity += s.severity
acc[key].maxSeverity = Math.max(acc[key].maxSeverity, s.severity)
return acc
},
{} as Record<string, { count: number; totalSeverity: number; maxSeverity: number }>
)
return (
<>
<div className="screen-only">
<Header title="Doctor's Visit Summary" showBack />
<PageContainer className="pt-4">
<div className="flex justify-between items-center mb-4">
<p className="text-secondary-600">
Complete summary for your doctor appointment
</p>
<Button onClick={handlePrint} className="flex items-center gap-2">
<Printer className="w-4 h-4" />
Print
</Button>
</div>
</PageContainer>
</div>
<div className="print-preview p-8">
<div className="print-title text-2xl font-bold mb-2">
Doctor's Visit Summary
</div>
<div className="print-date text-gray-600 mb-6">Prepared: {today}</div>
{/* Patient Info */}
<div className="print-section print-no-break mb-6 p-4 bg-gray-50 rounded-lg">
<div className="print-subtitle font-semibold mb-3">Patient Information</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-600">Name:</span>{' '}
<span className="font-medium">
{(currentWorkspace as any).patientName || currentWorkspace.name || '_______________'}
</span>
</div>
<div>
<span className="text-gray-600">DOB:</span>{' '}
<span className="font-medium">
{(currentWorkspace as any).patientDOB
? format(new Date((currentWorkspace as any).patientDOB), 'MM/dd/yyyy')
: '_______________'}
</span>
</div>
<div>
<span className="text-gray-600">Blood Type:</span>{' '}
<span className="font-medium">
{(currentWorkspace as any).bloodType || '_______________'}
</span>
</div>
</div>
{(currentWorkspace as any).allergies && (
<div className="mt-3">
<span className="text-gray-600">Allergies:</span>{' '}
<span className="font-medium text-red-600">
{(currentWorkspace as any).allergies}
</span>
</div>
)}
{(currentWorkspace as any).medicalConditions && (
<div className="mt-2">
<span className="text-gray-600">Medical Conditions:</span>{' '}
<span className="font-medium">
{(currentWorkspace as any).medicalConditions}
</span>
</div>
)}
</div>
{/* Current Medications */}
<div className="print-section print-no-break mb-6">
<div className="print-subtitle font-semibold mb-3">
Current Medications ({medications.length})
</div>
{medications.length === 0 ? (
<p className="text-gray-500 text-sm">No active medications</p>
) : (
<table className="print-table w-full border-collapse text-sm">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-300 p-2 text-left">Medication</th>
<th className="border border-gray-300 p-2 text-left">Instructions</th>
<th className="border border-gray-300 p-2 text-left">Schedule</th>
</tr>
</thead>
<tbody>
{medications.map((med) => (
<tr key={med.id}>
<td className="border border-gray-300 p-2 font-medium">
{med.name}
</td>
<td className="border border-gray-300 p-2">
{med.instructions || '-'}
</td>
<td className="border border-gray-300 p-2">
{med.scheduleType.replace('_', ' ')}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* Symptom Summary */}
<div className="print-section print-no-break mb-6">
<div className="print-subtitle font-semibold mb-3">
Symptoms (Last 30 Days)
</div>
{Object.keys(symptomSummary).length === 0 ? (
<p className="text-gray-500 text-sm">No symptoms recorded</p>
) : (
<table className="print-table w-full border-collapse text-sm">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-300 p-2 text-left">Symptom</th>
<th className="border border-gray-300 p-2 text-left">Occurrences</th>
<th className="border border-gray-300 p-2 text-left">Avg Severity</th>
<th className="border border-gray-300 p-2 text-left">Max Severity</th>
</tr>
</thead>
<tbody>
{Object.entries(symptomSummary)
.sort((a, b) => b[1].count - a[1].count)
.map(([type, data]) => (
<tr key={type}>
<td className="border border-gray-300 p-2 font-medium">
{SYMPTOM_LABELS[type] || type}
</td>
<td className="border border-gray-300 p-2">{data.count}x</td>
<td className="border border-gray-300 p-2">
{SEVERITY_LABELS[Math.round(data.totalSeverity / data.count) - 1]}
</td>
<td className="border border-gray-300 p-2">
{SEVERITY_LABELS[data.maxSeverity - 1]}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* Questions to Ask */}
<div className="print-section mb-6">
<div className="print-subtitle font-semibold mb-3">
Questions to Ask ({questions.length})
</div>
{questions.length === 0 ? (
<p className="text-gray-500 text-sm">No questions prepared</p>
) : (
<div className="space-y-2">
{questions.map((q, idx) => (
<div key={q.id} className="print-question flex items-start gap-2 py-2">
<div className="print-question-checkbox w-4 h-4 border-2 border-black flex-shrink-0 mt-0.5" />
<span>
{idx + 1}. {q.content}
</span>
</div>
))}
</div>
)}
</div>
{/* Doctor's Notes Section */}
<div className="print-section print-page-break">
<div className="print-subtitle font-semibold mb-3">Doctor's Notes</div>
<div className="print-notes border border-gray-300 rounded p-3 min-h-[200px] bg-gray-50" />
</div>
{/* Follow-up */}
<div className="print-section mt-6">
<div className="print-subtitle font-semibold mb-3">Follow-up</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-600">Next Appointment:</span>{' '}
<span>________________________</span>
</div>
<div>
<span className="text-gray-600">Labs Ordered:</span>{' '}
<span>________________________</span>
</div>
</div>
</div>
<div className="print-footer mt-8 text-center text-sm text-gray-500">
Generated by NextStep - {today}
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,74 @@
'use client'
import { Printer, Pill, Calendar, Stethoscope } from 'lucide-react'
import Link from 'next/link'
import { Card } from '@/components/ui'
import { Header, PageContainer } from '@/components/layout/header'
const printOptions = [
{
href: '/print/daily-meds',
icon: Pill,
title: 'Daily Medication Schedule',
description: 'Large checkboxes for tracking daily doses. Great for posting on the fridge.',
},
{
href: '/print/appointments',
icon: Calendar,
title: 'Upcoming Appointments',
description: 'List of upcoming appointments with dates, times, and locations.',
},
{
href: '/print/doctor-visit',
icon: Stethoscope,
title: "Doctor's Visit Summary",
description: 'Complete summary with medications, symptoms, and questions to ask.',
},
]
export default function PrintPage() {
return (
<>
<Header title="Print" showBack />
<PageContainer className="pt-4">
<div className="mb-6">
<div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-primary-100 rounded-lg">
<Printer className="w-6 h-6 text-primary-600" />
</div>
<h2 className="text-lg font-semibold text-secondary-900">Print Documents</h2>
</div>
<p className="text-secondary-600 text-sm">
Generate printable documents for caregiving, appointments, and medication tracking.
</p>
</div>
<div className="space-y-3">
{printOptions.map((option) => (
<Link key={option.href} href={option.href}>
<Card className="hover:bg-secondary-50 transition-colors">
<div className="flex items-start gap-4">
<div className="p-2 bg-secondary-100 rounded-lg">
<option.icon className="w-5 h-5 text-secondary-600" />
</div>
<div className="flex-1">
<h3 className="font-medium text-secondary-900">{option.title}</h3>
<p className="text-sm text-secondary-500 mt-0.5">{option.description}</p>
</div>
</div>
</Card>
</Link>
))}
</div>
<div className="mt-6 p-4 bg-blue-50 rounded-lg">
<p className="text-sm text-blue-800">
<strong>Tip:</strong> After opening a print page, use your browser's print function
(Ctrl/Cmd + P) to print or save as PDF.
</p>
</div>
</PageContainer>
</>
)
}

View File

@@ -16,6 +16,8 @@ interface Workspace {
clinicPhone: string | null
emergencyPhone: string | null
largeTextMode: boolean
quietHoursStart: string | null
quietHoursEnd: string | null
}
interface AppContextType {

View File

@@ -0,0 +1,219 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { format } from 'date-fns'
import { Input, Textarea, Select, Button, showToast } from '@/components/ui'
import { Header, PageContainer } from '@/components/layout/header'
import { useApp } from '../../provider'
const BLOOD_TYPES = [
{ value: '', label: 'Select blood type' },
{ value: 'A+', label: 'A+' },
{ value: 'A-', label: 'A-' },
{ value: 'B+', label: 'B+' },
{ value: 'B-', label: 'B-' },
{ value: 'AB+', label: 'AB+' },
{ value: 'AB-', label: 'AB-' },
{ value: 'O+', label: 'O+' },
{ value: 'O-', label: 'O-' },
{ value: 'Unknown', label: 'Unknown' },
]
export default function EmergencySettingsPage() {
const router = useRouter()
const { currentWorkspace, refreshData } = useApp()
const [saving, setSaving] = useState(false)
const [loading, setLoading] = useState(true)
const [patientName, setPatientName] = useState('')
const [patientDOB, setPatientDOB] = useState('')
const [bloodType, setBloodType] = useState('')
const [allergies, setAllergies] = useState('')
const [medicalConditions, setMedicalConditions] = useState('')
const [primaryPhysician, setPrimaryPhysician] = useState('')
const [physicianPhone, setPhysicianPhone] = useState('')
const [clinicPhone, setClinicPhone] = useState('')
const [emergencyPhone, setEmergencyPhone] = useState('')
useEffect(() => {
const loadData = async () => {
try {
const response = await fetch(`/api/workspaces/${currentWorkspace.id}/emergency-info`)
if (response.ok) {
const data = await response.json()
setPatientName(data.patientName || '')
setPatientDOB(data.patientDOB ? format(new Date(data.patientDOB), 'yyyy-MM-dd') : '')
setBloodType(data.bloodType || '')
setAllergies(data.allergies || '')
setMedicalConditions(data.medicalConditions || '')
setPrimaryPhysician(data.primaryPhysician || '')
setPhysicianPhone(data.physicianPhone || '')
setClinicPhone(data.clinicPhone || '')
setEmergencyPhone(data.emergencyPhone || '')
}
} catch (err) {
console.error('Failed to load emergency info:', err)
} finally {
setLoading(false)
}
}
loadData()
}, [currentWorkspace.id])
const handleSave = async () => {
setSaving(true)
try {
const response = await fetch(`/api/workspaces/${currentWorkspace.id}/emergency-info`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
patientName: patientName.trim() || null,
patientDOB: patientDOB ? new Date(patientDOB).toISOString() : null,
bloodType: bloodType || null,
allergies: allergies.trim() || null,
medicalConditions: medicalConditions.trim() || null,
primaryPhysician: primaryPhysician.trim() || null,
physicianPhone: physicianPhone.trim() || null,
clinicPhone: clinicPhone.trim() || null,
emergencyPhone: emergencyPhone.trim() || null,
}),
})
if (!response.ok) throw new Error('Failed to save')
await refreshData()
showToast('Emergency information saved', 'success')
router.back()
} catch (err) {
showToast('Failed to save', 'error')
} finally {
setSaving(false)
}
}
return (
<>
<Header title="Emergency Info" showBack />
<PageContainer className="pt-4 space-y-6 pb-24">
<p className="text-secondary-600">
This information will be available offline in emergencies. Fill in what you know.
</p>
{/* Patient Information */}
<section>
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
Patient Information
</h2>
<div className="space-y-4">
<Input
label="Patient Name"
value={patientName}
onChange={(e) => setPatientName(e.target.value)}
placeholder="Full name"
disabled={loading}
/>
<Input
label="Date of Birth"
type="date"
value={patientDOB}
onChange={(e) => setPatientDOB(e.target.value)}
disabled={loading}
/>
<Select
label="Blood Type"
value={bloodType}
onChange={(e) => setBloodType(e.target.value)}
options={BLOOD_TYPES}
disabled={loading}
/>
</div>
</section>
{/* Medical Information */}
<section>
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
Medical Information
</h2>
<div className="space-y-4">
<Textarea
label="Allergies"
value={allergies}
onChange={(e) => setAllergies(e.target.value)}
placeholder="List any allergies (medications, food, environmental)"
rows={3}
disabled={loading}
/>
<Textarea
label="Medical Conditions"
value={medicalConditions}
onChange={(e) => setMedicalConditions(e.target.value)}
placeholder="List ongoing conditions (e.g., Cancer - receiving chemotherapy)"
rows={3}
disabled={loading}
/>
</div>
</section>
{/* Healthcare Provider */}
<section>
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
Healthcare Provider
</h2>
<div className="space-y-4">
<Input
label="Primary Physician"
value={primaryPhysician}
onChange={(e) => setPrimaryPhysician(e.target.value)}
placeholder="Doctor's name"
disabled={loading}
/>
<Input
label="Physician Phone"
type="tel"
value={physicianPhone}
onChange={(e) => setPhysicianPhone(e.target.value)}
placeholder="e.g., 08 9400 1234"
disabled={loading}
/>
</div>
</section>
{/* Emergency Contacts */}
<section>
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
Emergency Contacts
</h2>
<div className="space-y-4">
<Input
label="Clinic Phone"
type="tel"
value={clinicPhone}
onChange={(e) => setClinicPhone(e.target.value)}
placeholder="e.g., 08 9400 1234"
disabled={loading}
/>
<Input
label="Emergency Contact (Family)"
type="tel"
value={emergencyPhone}
onChange={(e) => setEmergencyPhone(e.target.value)}
placeholder="e.g., 0412 345 678"
disabled={loading}
/>
</div>
</section>
</PageContainer>
{/* Fixed Save Button */}
<div className="fixed bottom-0 left-0 right-0 p-4 bg-surface border-t border-border safe-bottom">
<Button onClick={handleSave} fullWidth loading={saving} disabled={loading}>
Save Emergency Info
</Button>
</div>
</>
)
}

View File

@@ -0,0 +1,144 @@
'use client'
import { useState } from 'react'
import { Bell, Clock, Moon } from 'lucide-react'
import { Card, Button, Input, showToast } from '@/components/ui'
import { Header, PageContainer } from '@/components/layout/header'
import { NotificationPermission } from '@/components/notifications/NotificationPermission'
import { useApp } from '../../provider'
export default function NotificationsSettingsPage() {
const { currentWorkspace, refreshData } = useApp()
const [quietStart, setQuietStart] = useState(currentWorkspace.quietHoursStart || '22:00')
const [quietEnd, setQuietEnd] = useState(currentWorkspace.quietHoursEnd || '07:00')
const [saving, setSaving] = useState(false)
const handleSaveQuietHours = async () => {
setSaving(true)
try {
const response = await fetch(`/api/workspaces/${currentWorkspace.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
quietHoursStart: quietStart,
quietHoursEnd: quietEnd,
}),
})
if (!response.ok) throw new Error('Failed to save')
await refreshData()
showToast('Quiet hours updated', 'success')
} catch {
showToast('Failed to save', 'error')
} finally {
setSaving(false)
}
}
return (
<>
<Header title="Notifications" showBack />
<PageContainer className="pt-4 space-y-6">
{/* Push Notifications */}
<section>
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
Push Notifications
</h2>
<Card>
<NotificationPermission workspaceId={currentWorkspace.id} />
</Card>
</section>
{/* Quiet Hours */}
<section>
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
Quiet Hours
</h2>
<Card>
<div className="flex items-start gap-3 mb-4">
<div className="p-2 bg-indigo-100 rounded-lg">
<Moon className="w-5 h-5 text-indigo-600" />
</div>
<div>
<p className="font-medium text-secondary-900">
Do Not Disturb
</p>
<p className="text-sm text-secondary-500">
No reminders will be sent during quiet hours.
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
Start Time
</label>
<input
type="time"
value={quietStart}
onChange={(e) => setQuietStart(e.target.value)}
className="w-full px-3 py-2 border border-border rounded-button text-base focus:outline-none focus:ring-2 focus:ring-primary-200 focus:border-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
End Time
</label>
<input
type="time"
value={quietEnd}
onChange={(e) => setQuietEnd(e.target.value)}
className="w-full px-3 py-2 border border-border rounded-button text-base focus:outline-none focus:ring-2 focus:ring-primary-200 focus:border-primary-500"
/>
</div>
</div>
<Button
onClick={handleSaveQuietHours}
loading={saving}
fullWidth
variant="secondary"
>
Save Quiet Hours
</Button>
</Card>
</section>
{/* How it works */}
<section>
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
How It Works
</h2>
<Card variant="outline">
<div className="space-y-4 text-sm text-secondary-600">
<div className="flex items-start gap-3">
<Bell className="w-5 h-5 text-primary-500 flex-shrink-0 mt-0.5" />
<p>
When enabled, you'll receive push notifications when it's time
to take your medications.
</p>
</div>
<div className="flex items-start gap-3">
<Clock className="w-5 h-5 text-primary-500 flex-shrink-0 mt-0.5" />
<p>
Reminders are sent based on your medication schedules.
PRN (as-needed) medications don't trigger reminders.
</p>
</div>
<div className="flex items-start gap-3">
<Moon className="w-5 h-5 text-primary-500 flex-shrink-0 mt-0.5" />
<p>
Quiet hours prevent notifications from being sent during
sleep or rest times.
</p>
</div>
</div>
</Card>
</section>
</PageContainer>
</>
)
}

View File

@@ -12,6 +12,12 @@ import {
Shield,
ExternalLink,
Copy,
AlertTriangle,
Activity,
Printer,
Calendar,
FileText,
Bell,
} from 'lucide-react'
import { useLiveQuery } from 'dexie-react-hooks'
@@ -33,6 +39,7 @@ export default function SettingsPage() {
const [inviteLoading, setInviteLoading] = useState(false)
const [inviteUrl, setInviteUrl] = useState('')
const [inviteRole, setInviteRole] = useState<'EDITOR' | 'VIEWER'>('VIEWER')
const [showCalendarUrl, setShowCalendarUrl] = useState(false)
// Get workspace from IndexedDB for large text mode
const workspace = useLiveQuery(
@@ -153,6 +160,29 @@ export default function SettingsPage() {
}
}
const handleExportPDF = async () => {
try {
showToast('Generating PDF...', 'info')
const response = await fetch(`/api/workspaces/${currentWorkspace.id}/export/summary.pdf`)
if (!response.ok) {
throw new Error('Failed to generate PDF')
}
const blob = await response.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `medical-summary-${new Date().toISOString().split('T')[0]}.pdf`
a.click()
URL.revokeObjectURL(url)
showToast('PDF downloaded', 'success')
} catch {
showToast('PDF export failed', 'error')
}
}
return (
<>
<Header title="Settings" />
@@ -207,6 +237,50 @@ export default function SettingsPage() {
</Card>
</section>
{/* Emergency Info */}
<section>
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
Emergency Information
</h2>
<Card padding="none">
<button
onClick={() => router.push('/settings/emergency')}
className="w-full flex items-center gap-3 p-4 hover:bg-muted transition-colors"
>
<AlertTriangle className="w-5 h-5 text-red-500" />
<div className="flex-1 text-left">
<p className="font-medium text-secondary-900">Medical Emergency Info</p>
<p className="text-sm text-secondary-500">
Blood type, allergies, conditions
</p>
</div>
<ChevronRight className="w-5 h-5 text-secondary-300" />
</button>
</Card>
</section>
{/* Activity Feed */}
<section>
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
History
</h2>
<Card padding="none">
<button
onClick={() => router.push('/activity')}
className="w-full flex items-center gap-3 p-4 hover:bg-muted transition-colors"
>
<Activity className="w-5 h-5 text-secondary-500" />
<div className="flex-1 text-left">
<p className="font-medium text-secondary-900">Activity Log</p>
<p className="text-sm text-secondary-500">
View all changes and actions
</p>
</div>
<ChevronRight className="w-5 h-5 text-secondary-300" />
</button>
</Card>
</section>
{/* Family members */}
{currentWorkspace.role === 'OWNER' && (
<section>
@@ -261,21 +335,90 @@ export default function SettingsPage() {
</Card>
</section>
{/* Notifications */}
<section>
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
Notifications
</h2>
<Card padding="none">
<button
onClick={() => router.push('/settings/notifications')}
className="w-full flex items-center gap-3 p-4 hover:bg-muted transition-colors"
>
<Bell className="w-5 h-5 text-secondary-500" />
<div className="flex-1 text-left">
<p className="font-medium text-secondary-900">Medication Reminders</p>
<p className="text-sm text-secondary-500">Push notifications & quiet hours</p>
</div>
<ChevronRight className="w-5 h-5 text-secondary-300" />
</button>
</Card>
</section>
{/* Print */}
<section>
<h2 className="text-sm font-semibold text-secondary-600 mb-3">Print</h2>
<Card padding="none">
<button
onClick={() => router.push('/print')}
className="w-full flex items-center gap-3 p-4 hover:bg-muted transition-colors"
>
<Printer className="w-5 h-5 text-secondary-500" />
<div className="flex-1 text-left">
<p className="font-medium text-secondary-900">Print Documents</p>
<p className="text-sm text-secondary-500">Medication schedules, appointments</p>
</div>
<ChevronRight className="w-5 h-5 text-secondary-300" />
</button>
</Card>
</section>
{/* Calendar Sync */}
<section>
<h2 className="text-sm font-semibold text-secondary-600 mb-3">Calendar</h2>
<Card padding="none">
<button
onClick={() => setShowCalendarUrl(true)}
className="w-full flex items-center gap-3 p-4 hover:bg-muted transition-colors"
>
<Calendar className="w-5 h-5 text-secondary-500" />
<div className="flex-1 text-left">
<p className="font-medium text-secondary-900">Subscribe to Calendar</p>
<p className="text-sm text-secondary-500">Sync appointments to iPhone, Google</p>
</div>
<ChevronRight className="w-5 h-5 text-secondary-300" />
</button>
</Card>
</section>
{/* Data */}
<section>
<h2 className="text-sm font-semibold text-secondary-600 mb-3">Data</h2>
<Card padding="none">
<button
onClick={handleExportJSON}
onClick={handleExportPDF}
className="w-full flex items-center gap-3 p-4 hover:bg-muted transition-colors"
>
<Download className="w-5 h-5 text-secondary-500" />
<FileText className="w-5 h-5 text-secondary-500" />
<div className="flex-1 text-left">
<p className="font-medium text-secondary-900">Export Data</p>
<p className="text-sm text-secondary-500">Download as JSON</p>
<p className="font-medium text-secondary-900">Medical Summary PDF</p>
<p className="text-sm text-secondary-500">For doctor appointments</p>
</div>
<ChevronRight className="w-5 h-5 text-secondary-300" />
</button>
<div className="border-t border-border">
<button
onClick={handleExportJSON}
className="w-full flex items-center gap-3 p-4 hover:bg-muted transition-colors"
>
<Download className="w-5 h-5 text-secondary-500" />
<div className="flex-1 text-left">
<p className="font-medium text-secondary-900">Export Data</p>
<p className="text-sm text-secondary-500">Download as JSON</p>
</div>
<ChevronRight className="w-5 h-5 text-secondary-300" />
</button>
</div>
</Card>
</section>
@@ -412,6 +555,54 @@ export default function SettingsPage() {
</div>
)}
</Modal>
{/* Calendar URL modal */}
<Modal
isOpen={showCalendarUrl}
onClose={() => setShowCalendarUrl(false)}
title="Subscribe to Calendar"
>
<div className="space-y-4">
<p className="text-secondary-600">
Add your appointments to your phone's calendar app. This creates a subscription
that stays up to date automatically.
</p>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-secondary-700 mb-2">
Calendar URL
</label>
<div className="bg-muted p-3 rounded-button break-all text-sm text-secondary-700 font-mono">
{typeof window !== 'undefined' &&
`${window.location.origin}/api/workspaces/${currentWorkspace.id}/calendar.ics?token=${user.id}`}
</div>
</div>
<Button
onClick={() => {
const url = `${window.location.origin}/api/workspaces/${currentWorkspace.id}/calendar.ics?token=${user.id}`
navigator.clipboard.writeText(url)
showToast('URL copied!', 'success')
}}
fullWidth
variant="secondary"
>
<Copy className="w-4 h-4 mr-2" />
Copy URL
</Button>
</div>
<div className="bg-blue-50 p-3 rounded-lg">
<p className="text-sm text-blue-800 font-medium mb-2">How to subscribe:</p>
<ul className="text-sm text-blue-700 list-disc list-inside space-y-1">
<li><strong>iPhone:</strong> Settings Calendar Accounts Add Other Add Subscribed Calendar</li>
<li><strong>Google Calendar:</strong> Settings Add calendar From URL</li>
<li><strong>Outlook:</strong> Add calendar Subscribe from web</li>
</ul>
</div>
</div>
</Modal>
</>
)
}

View File

@@ -0,0 +1,159 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { subDays, format } from 'date-fns'
import { Card, LoadingState, Button } from '@/components/ui'
import { Header, PageContainer } from '@/components/layout/header'
import { SymptomCard } from '@/components/symptoms/SymptomCard'
import { SymptomChart } from '@/components/symptoms/SymptomChart'
import { useApp } from '../../provider'
const SYMPTOM_TYPES = [
{ value: '', label: 'All' },
{ value: 'FATIGUE', label: 'Fatigue' },
{ value: 'NAUSEA', label: 'Nausea' },
{ value: 'PAIN', label: 'Pain' },
{ value: 'APPETITE', label: 'Appetite' },
{ value: 'SLEEP', label: 'Sleep' },
{ value: 'MOOD', label: 'Mood' },
]
export default function SymptomsHistoryPage() {
const { currentWorkspace } = useApp()
const [symptoms, setSymptoms] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
const [hasMore, setHasMore] = useState(false)
const [filterType, setFilterType] = useState('')
const [offset, setOffset] = useState(0)
const fetchSymptoms = useCallback(async (reset = false) => {
const currentOffset = reset ? 0 : offset
if (reset) {
setLoading(true)
} else {
setLoadingMore(true)
}
try {
const params = new URLSearchParams({
limit: '50',
offset: String(currentOffset),
...(filterType && { type: filterType }),
})
const response = await fetch(
`/api/workspaces/${currentWorkspace.id}/symptoms?${params}`
)
if (!response.ok) throw new Error('Failed to fetch')
const data = await response.json()
if (reset) {
setSymptoms(data.symptoms)
setOffset(data.symptoms.length)
} else {
setSymptoms((prev) => [...prev, ...data.symptoms])
setOffset(currentOffset + data.symptoms.length)
}
setHasMore(data.symptoms.length === 50)
} catch (err) {
console.error('Failed to fetch symptoms:', err)
} finally {
setLoading(false)
setLoadingMore(false)
}
}, [currentWorkspace.id, filterType, offset])
useEffect(() => {
fetchSymptoms(true)
}, [currentWorkspace.id, filterType])
const handleFilterChange = (type: string) => {
setFilterType(type)
setOffset(0)
}
if (loading) {
return (
<>
<Header title="Symptom History" showBack />
<PageContainer>
<LoadingState message="Loading history..." />
</PageContainer>
</>
)
}
return (
<>
<Header title="Symptom History" showBack />
<PageContainer className="pt-4 space-y-6">
{/* Filter */}
<div className="flex gap-2 overflow-x-auto pb-2 scrollbar-hide">
{SYMPTOM_TYPES.map((type) => (
<button
key={type.value}
onClick={() => handleFilterChange(type.value)}
className={`px-3 py-1.5 rounded-full text-sm font-medium whitespace-nowrap transition-colors ${
filterType === type.value
? 'bg-primary-500 text-white'
: 'bg-secondary-100 text-secondary-600 hover:bg-secondary-200'
}`}
>
{type.label}
</button>
))}
</div>
{/* 30-Day Chart */}
{symptoms.length > 0 && (
<section>
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
Last 30 Days
</h2>
<Card>
<SymptomChart
symptoms={symptoms}
days={30}
type={filterType || undefined}
/>
</Card>
</section>
)}
{/* Symptoms List */}
<section>
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
All Entries
</h2>
{symptoms.length === 0 ? (
<Card variant="outline" className="text-center py-8">
<p className="text-secondary-500">No symptoms found</p>
</Card>
) : (
<div className="space-y-3">
{symptoms.map((symptom) => (
<SymptomCard key={symptom.id} symptom={symptom} />
))}
</div>
)}
</section>
{hasMore && (
<div className="text-center pb-4">
<Button
variant="secondary"
onClick={() => fetchSymptoms(false)}
loading={loadingMore}
>
Load more
</Button>
</div>
)}
</PageContainer>
</>
)
}

View File

@@ -0,0 +1,150 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { BarChart2, History } from 'lucide-react'
import { useLiveQuery } from 'dexie-react-hooks'
import { db } from '@/lib/sync'
import { Card, LoadingState, EmptyState } from '@/components/ui'
import { Header, PageContainer } from '@/components/layout/header'
import { SymptomQuickLog } from '@/components/symptoms/SymptomQuickLog'
import { SymptomCard } from '@/components/symptoms/SymptomCard'
import { SymptomChart } from '@/components/symptoms/SymptomChart'
import { useApp } from '../provider'
export default function SymptomsPage() {
const router = useRouter()
const { currentWorkspace, refreshData } = useApp()
const [recentSymptoms, setRecentSymptoms] = useState<any[]>([])
const [loading, setLoading] = useState(true)
// Fetch symptoms from IndexedDB
const localSymptoms = useLiveQuery(
() =>
db.symptoms
.where('workspaceId')
.equals(currentWorkspace.id)
.and((s) => !s.deletedAt)
.reverse()
.limit(50)
.toArray(),
[currentWorkspace.id]
)
// Also fetch from server to get the latest
const fetchSymptoms = useCallback(async () => {
try {
const response = await fetch(
`/api/workspaces/${currentWorkspace.id}/symptoms?limit=20`
)
if (response.ok) {
const data = await response.json()
setRecentSymptoms(data.symptoms)
}
} catch (err) {
console.error('Failed to fetch symptoms:', err)
} finally {
setLoading(false)
}
}, [currentWorkspace.id])
useEffect(() => {
fetchSymptoms()
}, [fetchSymptoms])
const handleLogged = () => {
fetchSymptoms()
refreshData()
}
// Combine local and server data, preferring server
const symptoms = recentSymptoms.length > 0 ? recentSymptoms : localSymptoms || []
if (loading && !localSymptoms) {
return (
<>
<Header title="Symptoms" />
<PageContainer>
<LoadingState message="Loading symptoms..." />
</PageContainer>
</>
)
}
return (
<>
<Header
title="Symptoms"
rightAction={{
icon: <History className="w-6 h-6 text-secondary-700" />,
label: 'History',
onClick: () => router.push('/symptoms/history'),
}}
/>
<PageContainer className="pt-4 space-y-6">
{/* Quick Log */}
<section>
<h2 className="text-lg font-semibold text-secondary-900 mb-3">Log a Symptom</h2>
<Card>
<SymptomQuickLog
workspaceId={currentWorkspace.id}
onLogged={handleLogged}
/>
</Card>
</section>
{/* 7-Day Chart */}
{symptoms.length > 0 && (
<section>
<h2 className="text-lg font-semibold text-secondary-900 mb-3">
Last 7 Days
</h2>
<Card>
<SymptomChart
symptoms={symptoms.map((s: any) => ({
id: s.id,
type: s.type,
severity: s.severity,
recordedAt: s.recordedAt,
}))}
days={7}
/>
</Card>
</section>
)}
{/* Recent Symptoms */}
<section>
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-secondary-900">Recent</h2>
{symptoms.length > 5 && (
<button
onClick={() => router.push('/symptoms/history')}
className="text-sm text-primary-600 font-medium"
>
View all
</button>
)}
</div>
{symptoms.length === 0 ? (
<Card variant="outline" className="text-center py-8">
<BarChart2 className="w-10 h-10 text-secondary-300 mx-auto mb-3" />
<p className="text-secondary-500">No symptoms logged yet</p>
<p className="text-sm text-secondary-400 mt-1">
Use the form above to track how you're feeling
</p>
</Card>
) : (
<div className="space-y-3">
{symptoms.slice(0, 5).map((symptom: any) => (
<SymptomCard key={symptom.id} symptom={symptom} />
))}
</div>
)}
</section>
</PageContainer>
</>
)
}

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 } from 'lucide-react'
import { Phone, MapPin, Clock, ChevronRight, Pill, Calendar, Plus, AlertTriangle, ClipboardCheck } from 'lucide-react'
import { useLiveQuery } from 'dexie-react-hooks'
import { db, logDose, undoDose } from '@/lib/sync'
@@ -12,6 +12,7 @@ import { calculateAllMedicationsDue, formatTimeUntil } from '@/lib/schedule'
import type { Medication, DoseLog, MedicationDueStatus } from '@/lib/schedule'
import { Card, CardTitle, Button, LoadingState, EmptyState, showUndoToast, showToast } from '@/components/ui'
import { Header, PageContainer } from '@/components/layout/header'
import { RefillAlert } from '@/components/medications/RefillAlert'
import { useApp } from '../provider'
const TIMEZONE = 'Australia/Perth'
@@ -173,21 +174,38 @@ export default function TodayPage() {
</p>
</div>
{/* Call Clinic Button */}
{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"
{/* Emergency & Call Clinic Buttons */}
<div className="flex gap-3">
{/* 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"
>
<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 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>
<p className="font-medium text-primary-800">Call Clinic</p>
<p className="text-sm text-primary-600">{currentWorkspace.clinicPhone}</p>
<div className="text-left">
<p className="font-medium text-red-800">Emergency</p>
<p className="text-sm text-red-600">Medical info</p>
</div>
</a>
)}
</button>
{/* Call Clinic Button */}
{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"
>
<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>
</a>
)}
</div>
{/* Next Appointment */}
<section>
@@ -258,6 +276,51 @@ export default function TodayPage() {
)}
</section>
{/* Prep Reminder for Tomorrow's Appointment */}
{appointments && appointments.length > 0 && (() => {
const tomorrowAppt = appointments.find((appt) =>
isTomorrow(toZonedTime(new Date(appt.datetime), TIMEZONE))
)
if (tomorrowAppt) {
return (
<section>
<Card
className="bg-green-50 border border-green-200 cursor-pointer hover:bg-green-100 transition-colors"
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>
<div className="flex-1">
<p className="font-medium text-green-800">
Prepare for tomorrow
</p>
<p className="text-sm text-green-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" />
</div>
</Card>
</section>
)
}
return null
})()}
{/* Refill Alerts */}
{medications && medications.length > 0 && (
<RefillAlert
medications={medications.map(m => ({
id: m.id,
name: m.name,
pillCount: m.pillCount,
refillThreshold: m.refillThreshold,
}))}
/>
)}
{/* Meds Due */}
<section>
<div className="flex items-center justify-between mb-3">

View File

@@ -0,0 +1,48 @@
import { NextResponse } from 'next/server'
import { sendDueNotifications } from '@/lib/notifications/scheduler'
// This endpoint should be called by a cron job every minute
// You can set up a cron service like:
// - Vercel Cron Jobs
// - AWS EventBridge
// - A simple setInterval in a long-running process
// POST /api/notifications/send - Trigger sending due notifications
export async function POST(req: Request) {
try {
// Verify cron secret to prevent unauthorized access
const authHeader = req.headers.get('authorization')
const cronSecret = process.env.CRON_SECRET
// If CRON_SECRET is set, verify it
if (cronSecret && authHeader !== `Bearer ${cronSecret}`) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
)
}
const { sent, failed } = await sendDueNotifications()
return NextResponse.json({
success: true,
sent,
failed,
timestamp: new Date().toISOString(),
})
} catch (error) {
console.error('Send notifications error:', error)
return NextResponse.json(
{ error: 'Failed to send notifications' },
{ status: 500 }
)
}
}
// GET endpoint for health checks
export async function GET() {
return NextResponse.json({
status: 'ok',
message: 'Notification sender is ready',
})
}

View File

@@ -0,0 +1,106 @@
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 { getPublicVAPIDKey } from '@/lib/notifications/push'
// GET /api/notifications/subscribe - Get VAPID public key
export const GET = withAuth(async () => {
const publicKey = getPublicVAPIDKey()
if (!publicKey) {
return NextResponse.json(
{ error: 'Push notifications not configured' },
{ status: 503 }
)
}
return NextResponse.json({ publicKey })
})
// POST /api/notifications/subscribe - Subscribe to push notifications
export const POST = withAuth(async (req: AuthenticatedRequest) => {
try {
const body = await req.json()
const { subscription, workspaceId } = body
if (!subscription || !subscription.endpoint || !subscription.keys) {
return NextResponse.json(
{ error: 'Invalid subscription data' },
{ status: 400 }
)
}
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
// Upsert the subscription (update if exists, create if not)
const existing = await prisma.pushSubscription.findFirst({
where: {
endpoint: subscription.endpoint,
userId: req.session.user.id,
},
})
if (existing) {
await prisma.pushSubscription.update({
where: { id: existing.id },
data: {
p256dh: subscription.keys.p256dh,
auth: subscription.keys.auth,
workspaceId,
},
})
} else {
await prisma.pushSubscription.create({
data: {
userId: req.session.user.id,
workspaceId,
endpoint: subscription.endpoint,
p256dh: subscription.keys.p256dh,
auth: subscription.keys.auth,
},
})
}
return NextResponse.json({ success: true })
} catch (error) {
console.error('Subscribe error:', error)
return NextResponse.json(
{ error: 'Failed to subscribe' },
{ status: 500 }
)
}
})
// DELETE /api/notifications/subscribe - Unsubscribe from push notifications
export const DELETE = withAuth(async (req: AuthenticatedRequest) => {
try {
const { searchParams } = new URL(req.url)
const endpoint = searchParams.get('endpoint')
if (!endpoint) {
return NextResponse.json(
{ error: 'Endpoint required' },
{ status: 400 }
)
}
await prisma.pushSubscription.deleteMany({
where: {
endpoint,
userId: req.session.user.id,
},
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Unsubscribe error:', error)
return NextResponse.json(
{ error: 'Failed to unsubscribe' },
{ status: 500 }
)
}
})

View File

@@ -33,7 +33,7 @@ export const GET = withAuth(async (req: AuthenticatedRequest) => {
const sinceDate = new Date(since)
// Fetch all changed entities
const [appointments, medications, notes, doseLogs, workspace] = await Promise.all([
const [appointments, medications, notes, doseLogs, symptoms, workspace] = await Promise.all([
prisma.appointment.findMany({
where: { workspaceId, syncedAt: { gt: sinceDate } },
include: {
@@ -63,6 +63,12 @@ export const GET = withAuth(async (req: AuthenticatedRequest) => {
undoneBy: { select: { id: true, name: true } },
},
}),
prisma.symptom.findMany({
where: { workspaceId, syncedAt: { gt: sinceDate } },
include: {
createdBy: { select: { id: true, name: true } },
},
}),
prisma.workspace.findUnique({
where: { id: workspaceId },
select: {
@@ -74,13 +80,21 @@ export const GET = withAuth(async (req: AuthenticatedRequest) => {
quietHoursEnd: true,
largeTextMode: true,
updatedAt: true,
// Emergency info fields
patientName: true,
patientDOB: true,
bloodType: true,
allergies: true,
medicalConditions: true,
primaryPhysician: true,
physicianPhone: true,
},
}),
])
// Calculate new cursor (latest syncedAt timestamp)
let cursor = since
const allItems = [...appointments, ...medications, ...notes, ...doseLogs]
const allItems = [...appointments, ...medications, ...notes, ...doseLogs, ...symptoms]
for (const item of allItems) {
const itemTime = (item as { syncedAt: Date }).syncedAt.getTime()
if (itemTime > cursor) {
@@ -94,6 +108,7 @@ export const GET = withAuth(async (req: AuthenticatedRequest) => {
medications,
notes,
doseLogs,
symptoms,
cursor,
hasConflicts: false, // For now, always false - client handles conflicts
})
@@ -296,6 +311,64 @@ export const POST = withAuth(async (req: AuthenticatedRequest) => {
break
}
case 'UNMARK_ASKED': {
if (!op.entityId) {
results.push({ opId: op.id, success: false, error: 'Missing entityId' })
break
}
await prisma.note.update({
where: { id: op.entityId },
data: {
askedAt: null,
updatedById: req.session.user.id,
version: { increment: 1 },
syncedAt: new Date(),
},
})
results.push({ opId: op.id, success: true })
break
}
case 'LOG_SYMPTOM': {
if (!op.data) {
results.push({ opId: op.id, success: false, error: 'Missing symptom data' })
break
}
const symptom = await prisma.symptom.create({
data: {
workspaceId,
type: op.data.type as 'FATIGUE' | 'NAUSEA' | 'PAIN' | 'APPETITE' | 'SLEEP' | 'MOOD' | 'CUSTOM',
customName: (op.data.customName as string) || null,
severity: op.data.severity as number,
notes: (op.data.notes as string) || null,
recordedAt: op.data.recordedAt ? new Date(op.data.recordedAt as string) : new Date(),
createdById: req.session.user.id,
},
})
results.push({ opId: op.id, success: true, entityId: symptom.id })
break
}
case 'DELETE_SYMPTOM': {
if (!op.entityId) {
results.push({ opId: op.id, success: false, error: 'Missing entityId' })
break
}
await prisma.symptom.update({
where: { id: op.entityId },
data: {
deletedAt: new Date(),
version: { increment: 1 },
syncedAt: new Date(),
},
})
results.push({ opId: op.id, success: true })
break
}
default:
results.push({ opId: op.id, success: false, error: 'Unknown operation type' })
}

View File

@@ -0,0 +1,55 @@
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'
// GET /api/workspaces/[id]/activity
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 limit = Math.min(parseInt(searchParams.get('limit') || '50'), 100)
const offset = parseInt(searchParams.get('offset') || '0')
const entityType = searchParams.get('entityType')
const action = searchParams.get('action')
const where: Record<string, unknown> = {
workspaceId,
...(entityType ? { entityType } : {}),
...(action ? { action } : {}),
}
const [activities, total] = await Promise.all([
prisma.auditLog.findMany({
where,
orderBy: { createdAt: 'desc' },
take: limit,
skip: offset,
include: {
user: {
select: { id: true, name: true },
},
},
}),
prisma.auditLog.count({ where }),
])
return NextResponse.json({
activities,
total,
hasMore: offset + activities.length < total,
})
} catch (error) {
console.error('Get activity error:', error)
return NextResponse.json({ error: 'Failed to get activity' }, { status: 500 })
}
})

View File

@@ -0,0 +1,107 @@
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'
// GET /api/workspaces/[id]/appointments/[appointmentId]/checklist
export const GET = withAuth(
async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId, appointmentId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
// Verify appointment exists
const appointment = await prisma.appointment.findFirst({
where: { id: appointmentId, workspaceId, deletedAt: null },
})
if (!appointment) {
return NextResponse.json({ error: 'Appointment not found' }, { status: 404 })
}
// Fetch checklist items
const checklists = await prisma.appointmentChecklist.findMany({
where: { workspaceId, appointmentId },
})
// Convert to a map of { itemId: isReady }
const checkedItems: Record<string, boolean> = {}
for (const item of checklists) {
checkedItems[item.item] = item.isReady
}
return NextResponse.json({
checkedItems,
customItems: [], // Future: support custom items
})
} catch (error) {
console.error('Checklist get error:', error)
return NextResponse.json({ error: 'Failed to get checklist' }, { status: 500 })
}
}
)
// POST /api/workspaces/[id]/appointments/[appointmentId]/checklist
export const POST = withAuth(
async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId, appointmentId } = await params
const body = await req.json()
const { checkedItems } = body as { checkedItems: Record<string, boolean> }
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id, [
'OWNER',
'EDITOR',
])
if (!access) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
// Verify appointment exists
const appointment = await prisma.appointment.findFirst({
where: { id: appointmentId, workspaceId, deletedAt: null },
})
if (!appointment) {
return NextResponse.json({ error: 'Appointment not found' }, { status: 404 })
}
// Upsert each checklist item
for (const [itemId, isReady] of Object.entries(checkedItems)) {
await prisma.appointmentChecklist.upsert({
where: {
workspaceId_appointmentId_item: {
workspaceId,
appointmentId,
item: itemId,
},
},
create: {
workspaceId,
appointmentId,
item: itemId,
isReady: isReady as boolean,
},
update: {
isReady: isReady as boolean,
},
})
}
return NextResponse.json({ success: true })
} catch (error) {
console.error('Checklist save error:', error)
return NextResponse.json({ error: 'Failed to save checklist' }, { status: 500 })
}
}
)

View File

@@ -0,0 +1,71 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { generateICalendar } from '@/lib/calendar/ical-generator'
// GET /api/workspaces/[id]/calendar.ics - Get iCal feed
// This endpoint uses a token-based auth for calendar subscription
export async function GET(
req: Request,
{ params }: { params: Promise<Record<string, string>> }
) {
try {
const { id: workspaceId } = await params
const { searchParams } = new URL(req.url)
const token = searchParams.get('token')
if (!token) {
return new NextResponse('Unauthorized - token required', { status: 401 })
}
// Verify the token is a valid workspace membership
// Token format: userId (simplified for now - could be JWT in production)
const membership = await prisma.workspaceMember.findFirst({
where: {
workspaceId,
userId: token,
},
include: {
workspace: true,
},
})
if (!membership) {
return new NextResponse('Unauthorized - invalid token', { status: 401 })
}
// Fetch appointments (non-deleted, future and recent)
const appointments = await prisma.appointment.findMany({
where: {
workspaceId,
deletedAt: null,
},
orderBy: {
datetime: 'asc',
},
})
// Generate iCal
const icalContent = generateICalendar(
appointments.map((a) => ({
id: a.id,
title: a.title,
datetime: a.datetime,
location: a.location,
notes: a.notes,
})),
membership.workspace.name
)
return new NextResponse(icalContent, {
status: 200,
headers: {
'Content-Type': 'text/calendar; charset=utf-8',
'Content-Disposition': `attachment; filename="${membership.workspace.name}-appointments.ics"`,
'Cache-Control': 'no-cache, no-store, must-revalidate',
},
})
} catch (error) {
console.error('iCal generation error:', error)
return new NextResponse('Internal Server Error', { status: 500 })
}
}

View File

@@ -113,6 +113,20 @@ export const POST = withAuth(async (req: AuthenticatedRequest, { params }) => {
},
})
// Decrement pill count if tracking is enabled
if (medication.pillCount !== null && medication.pillsPerDose !== null) {
const newCount = Math.max(0, medication.pillCount - (medication.pillsPerDose || 1))
await prisma.medication.update({
where: { id: medication.id },
data: {
pillCount: newCount,
version: { increment: 1 },
syncedAt: new Date(),
updatedById: req.session.user.id,
},
})
}
// Audit log
await prisma.auditLog.create({
data: {
@@ -165,7 +179,7 @@ export const PATCH = withAuth(async (req: AuthenticatedRequest, { params }) => {
undoneAt: null,
},
include: {
medication: { select: { name: true } },
medication: { select: { id: true, name: true, pillCount: true, pillsPerDose: true } },
},
})
@@ -198,6 +212,20 @@ export const PATCH = withAuth(async (req: AuthenticatedRequest, { params }) => {
},
})
// Restore pill count if tracking is enabled
if (doseLog.medication.pillCount !== null && doseLog.medication.pillsPerDose !== null) {
const newCount = doseLog.medication.pillCount + (doseLog.medication.pillsPerDose || 1)
await prisma.medication.update({
where: { id: doseLog.medication.id },
data: {
pillCount: newCount,
version: { increment: 1 },
syncedAt: new Date(),
updatedById: req.session.user.id,
},
})
}
// Audit log
await prisma.auditLog.create({
data: {

View File

@@ -0,0 +1,132 @@
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 { emergencyInfoSchema } from '@/lib/validation'
// GET /api/workspaces/[id]/emergency-info
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 workspace = await prisma.workspace.findUnique({
where: { id: workspaceId },
select: {
id: true,
patientName: true,
patientDOB: true,
bloodType: true,
allergies: true,
medicalConditions: true,
primaryPhysician: true,
physicianPhone: true,
clinicPhone: true,
emergencyPhone: true,
},
})
if (!workspace) {
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
}
// Also fetch active medications for the emergency view
const medications = await prisma.medication.findMany({
where: {
workspaceId,
active: true,
deletedAt: null,
},
select: {
id: true,
name: true,
instructions: true,
},
orderBy: { name: 'asc' },
})
return NextResponse.json({
...workspace,
medications,
})
} catch (error) {
console.error('Get emergency info error:', error)
return NextResponse.json({ error: 'Failed to get emergency info' }, { status: 500 })
}
})
// PATCH /api/workspaces/[id]/emergency-info
export const PATCH = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId } = await params
const body = await req.json()
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id, ['OWNER', 'EDITOR'])
if (!access) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
const result = emergencyInfoSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{ error: 'Invalid input', details: result.error.flatten() },
{ status: 400 }
)
}
const data = result.data
const workspace = await prisma.workspace.update({
where: { id: workspaceId },
data: {
patientName: data.patientName,
patientDOB: data.patientDOB ? new Date(data.patientDOB) : null,
bloodType: data.bloodType,
allergies: data.allergies,
medicalConditions: data.medicalConditions,
primaryPhysician: data.primaryPhysician,
physicianPhone: data.physicianPhone,
clinicPhone: data.clinicPhone,
emergencyPhone: data.emergencyPhone,
},
select: {
id: true,
patientName: true,
patientDOB: true,
bloodType: true,
allergies: true,
medicalConditions: true,
primaryPhysician: true,
physicianPhone: true,
clinicPhone: true,
emergencyPhone: true,
},
})
// Create audit log
await prisma.auditLog.create({
data: {
workspaceId,
userId: req.session.user.id,
action: 'UPDATE',
entityType: 'WORKSPACE',
entityId: workspaceId,
details: { updated: 'emergency_info' },
},
})
return NextResponse.json(workspace)
} catch (error) {
console.error('Update emergency info error:', error)
return NextResponse.json({ error: 'Failed to update emergency info' }, { status: 500 })
}
})

View File

@@ -0,0 +1,107 @@
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 { generateMedicalSummaryPDF } from '@/lib/export/pdf-generator'
// GET /api/workspaces/[id]/export/summary.pdf - Generate PDF summary
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 })
}
// Fetch all data
const [workspace, medications, appointments, symptoms] = await Promise.all([
prisma.workspace.findUnique({
where: { id: workspaceId },
select: {
name: true,
patientName: true,
patientDOB: true,
bloodType: true,
allergies: true,
medicalConditions: true,
primaryPhysician: true,
physicianPhone: true,
clinicPhone: true,
emergencyPhone: true,
},
}),
prisma.medication.findMany({
where: { workspaceId, deletedAt: null },
orderBy: { name: 'asc' },
}),
prisma.appointment.findMany({
where: { workspaceId, deletedAt: null },
orderBy: { datetime: 'asc' },
}),
prisma.symptom.findMany({
where: { workspaceId, deletedAt: null },
orderBy: { recordedAt: 'desc' },
take: 100, // Last 100 symptoms
}),
])
if (!workspace) {
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
}
// Generate PDF
const doc = generateMedicalSummaryPDF({
patient: workspace,
medications: medications.map((m) => ({
id: m.id,
name: m.name,
instructions: m.instructions,
scheduleType: m.scheduleType,
active: m.active,
})),
appointments: appointments.map((a) => ({
id: a.id,
title: a.title,
datetime: a.datetime,
location: a.location,
notes: a.notes,
})),
symptoms: symptoms.map((s) => ({
id: s.id,
type: s.type,
customName: s.customName,
severity: s.severity,
notes: s.notes,
recordedAt: s.recordedAt,
})),
generatedAt: new Date(),
})
// Convert PDF to buffer
const chunks: Buffer[] = []
doc.on('data', (chunk) => chunks.push(chunk))
await new Promise<void>((resolve, reject) => {
doc.on('end', () => resolve())
doc.on('error', (err) => reject(err))
doc.end()
})
const pdfBuffer = Buffer.concat(chunks)
const filename = `${workspace.patientName || workspace.name || 'medical'}-summary-${new Date().toISOString().split('T')[0]}.pdf`
return new NextResponse(pdfBuffer, {
status: 200,
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${filename}"`,
'Content-Length': String(pdfBuffer.length),
},
})
} catch (error) {
console.error('PDF generation error:', error)
return NextResponse.json({ error: 'Failed to generate PDF' }, { status: 500 })
}
})

View File

@@ -0,0 +1,86 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { prisma } from '@/lib/db/prisma'
import { checkWorkspaceAccess } from '@/lib/db/workspace-access'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
const refillAmountSchema = z.object({
amount: z.number().min(1, 'Amount must be at least 1'),
})
// POST /api/workspaces/[id]/medications/[medicationId]/refill
export const POST = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId, medicationId } = await params
const body = await req.json()
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id, ['OWNER', 'EDITOR'])
if (!access) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
const result = refillAmountSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{ error: 'Invalid input', details: result.error.flatten() },
{ status: 400 }
)
}
const { amount } = result.data
// Get current medication
const medication = await prisma.medication.findFirst({
where: {
id: medicationId,
workspaceId,
deletedAt: null,
},
})
if (!medication) {
return NextResponse.json({ error: 'Medication not found' }, { status: 404 })
}
// Update pill count
const newPillCount = (medication.pillCount ?? 0) + amount
const updated = await prisma.medication.update({
where: { id: medicationId },
data: {
pillCount: newPillCount,
lastRefillDate: new Date(),
version: { increment: 1 },
syncedAt: new Date(),
updatedById: req.session.user.id,
},
})
// Create audit log
await prisma.auditLog.create({
data: {
workspaceId,
userId: req.session.user.id,
action: 'REFILL',
entityType: 'MEDICATION',
entityId: medicationId,
details: {
amount,
previousCount: medication.pillCount,
newCount: newPillCount,
},
},
})
return NextResponse.json({
id: updated.id,
pillCount: updated.pillCount,
lastRefillDate: updated.lastRefillDate,
})
} catch (error) {
console.error('Refill error:', error)
return NextResponse.json({ error: 'Failed to record refill' }, { status: 500 })
}
})

View File

@@ -2,7 +2,7 @@ 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 { medicationSchema } from '@/lib/validation'
import { medicationWithRefillSchema } from '@/lib/validation'
// GET /api/workspaces/[id]/medications/[medicationId]
export const GET = withAuth(async (req: AuthenticatedRequest, { params }) => {
@@ -75,7 +75,7 @@ export const PATCH = withAuth(async (req: AuthenticatedRequest, { params }) => {
}
const body = await req.json()
const result = medicationSchema.partial().safeParse(body)
const result = medicationWithRefillSchema.partial().safeParse(body)
if (!result.success) {
return NextResponse.json(
@@ -97,6 +97,9 @@ export const PATCH = withAuth(async (req: AuthenticatedRequest, { params }) => {
if (result.data.endDate !== undefined) {
updateData.endDate = result.data.endDate ? new Date(result.data.endDate) : null
}
if (result.data.lastRefillDate !== undefined) {
updateData.lastRefillDate = result.data.lastRefillDate ? new Date(result.data.lastRefillDate) : null
}
const medication = await prisma.medication.update({
where: { id: medicationId },

View File

@@ -2,7 +2,7 @@ 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 { medicationSchema } from '@/lib/validation'
import { medicationWithRefillSchema } from '@/lib/validation'
// GET /api/workspaces/[id]/medications - List medications
export const GET = withAuth(async (req: AuthenticatedRequest, { params }) => {
@@ -60,7 +60,7 @@ export const POST = withAuth(async (req: AuthenticatedRequest, { params }) => {
}
const body = await req.json()
const result = medicationSchema.safeParse(body)
const result = medicationWithRefillSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
@@ -79,6 +79,11 @@ export const POST = withAuth(async (req: AuthenticatedRequest, { params }) => {
startDate: result.data.startDate ? new Date(result.data.startDate) : null,
endDate: result.data.endDate ? new Date(result.data.endDate) : null,
active: result.data.active ?? true,
// Refill tracking fields
pillCount: result.data.pillCount ?? null,
pillsPerDose: result.data.pillsPerDose ?? 1,
refillThreshold: result.data.refillThreshold ?? 7,
lastRefillDate: result.data.lastRefillDate ? new Date(result.data.lastRefillDate) : null,
createdById: req.session.user.id,
updatedById: req.session.user.id,
},

View File

@@ -0,0 +1,85 @@
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 { symptomSchema } from '@/lib/validation'
// GET /api/workspaces/[id]/symptoms/[symptomId]
export const GET = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId, symptomId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
const symptom = await prisma.symptom.findFirst({
where: { id: symptomId, workspaceId },
include: {
createdBy: { select: { id: true, name: true } },
},
})
if (!symptom) {
return NextResponse.json({ error: 'Symptom not found' }, { status: 404 })
}
return NextResponse.json({ symptom })
} catch (error) {
console.error('Get symptom error:', error)
return NextResponse.json({ error: 'Failed to get symptom' }, { status: 500 })
}
})
// DELETE /api/workspaces/[id]/symptoms/[symptomId] (soft delete)
export const DELETE = withAuth(async (
req: AuthenticatedRequest,
{ params }: { params: Promise<Record<string, string>> }
) => {
try {
const { id: workspaceId, symptomId } = 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.symptom.findFirst({
where: { id: symptomId, workspaceId, deletedAt: null },
})
if (!existing) {
return NextResponse.json({ error: 'Symptom not found' }, { status: 404 })
}
await prisma.symptom.update({
where: { id: symptomId },
data: {
deletedAt: new Date(),
version: { increment: 1 },
syncedAt: new Date(),
},
})
// Audit log
await prisma.auditLog.create({
data: {
workspaceId,
userId: req.session.user.id,
action: 'DELETE',
entityType: 'SYMPTOM',
entityId: symptomId,
details: { type: existing.type },
},
})
return NextResponse.json({ message: 'Symptom deleted' })
} catch (error) {
console.error('Delete symptom error:', error)
return NextResponse.json({ error: 'Failed to delete symptom' }, { status: 500 })
}
})

View File

@@ -0,0 +1,110 @@
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 { symptomSchema } from '@/lib/validation'
// GET /api/workspaces/[id]/symptoms
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 includeDeleted = searchParams.get('includeDeleted') === 'true'
const type = searchParams.get('type')
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,
...(includeDeleted ? {} : { deletedAt: null }),
...(type ? { type } : {}),
}
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 symptoms = await prisma.symptom.findMany({
where,
orderBy: { recordedAt: 'desc' },
take: limit,
include: {
createdBy: { select: { id: true, name: true } },
},
})
return NextResponse.json({ symptoms })
} catch (error) {
console.error('List symptoms error:', error)
return NextResponse.json({ error: 'Failed to list symptoms' }, { status: 500 })
}
})
// POST /api/workspaces/[id]/symptoms
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 = symptomSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{ error: 'Invalid input', details: result.error.flatten() },
{ status: 400 }
)
}
const symptom = await prisma.symptom.create({
data: {
workspaceId,
type: result.data.type,
customName: result.data.customName || null,
severity: result.data.severity,
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 } },
},
})
// Audit log
await prisma.auditLog.create({
data: {
workspaceId,
userId: req.session.user.id,
action: 'CREATE',
entityType: 'SYMPTOM',
entityId: symptom.id,
details: { type: symptom.type, severity: symptom.severity },
},
})
return NextResponse.json({ symptom }, { status: 201 })
} catch (error) {
console.error('Create symptom error:', error)
return NextResponse.json({ error: 'Failed to create symptom' }, { status: 500 })
}
})

View File

@@ -1,3 +1,5 @@
@import '../styles/print.css';
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -1,13 +1,15 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Suspense, useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import Link from 'next/link'
import { Heart } from 'lucide-react'
import { Button, Input, Card, showToast } from '@/components/ui'
export default function LoginPage() {
function LoginForm() {
const router = useRouter()
const searchParams = useSearchParams()
const redirectTo = searchParams.get('redirect')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
@@ -33,7 +35,8 @@ export default function LoginPage() {
}
showToast('Welcome back!', 'success')
router.push('/today')
// If there's a redirect param (e.g., from invite link), go there
router.push(redirectTo || '/today')
router.refresh()
} catch {
setError('Something went wrong. Please try again.')
@@ -97,3 +100,15 @@ export default function LoginPage() {
</div>
)
}
export default function LoginPage() {
return (
<Suspense fallback={
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="w-8 h-8 border-2 border-primary-500 border-t-transparent rounded-full animate-spin" />
</div>
}>
<LoginForm />
</Suspense>
)
}

View File

@@ -1,13 +1,15 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Suspense, useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import Link from 'next/link'
import { Heart } from 'lucide-react'
import { Button, Input, Card, showToast } from '@/components/ui'
export default function RegisterPage() {
function RegisterForm() {
const router = useRouter()
const searchParams = useSearchParams()
const redirectTo = searchParams.get('redirect')
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
@@ -46,7 +48,8 @@ export default function RegisterPage() {
}
showToast('Account created! Let\'s get started.', 'success')
router.push('/onboarding')
// If there's a redirect param (e.g., from invite link), go there instead of onboarding
router.push(redirectTo || '/onboarding')
router.refresh()
} catch {
setError('Something went wrong. Please try again.')
@@ -128,3 +131,15 @@ export default function RegisterPage() {
</div>
)
}
export default function RegisterPage() {
return (
<Suspense fallback={
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="w-8 h-8 border-2 border-primary-500 border-t-transparent rounded-full animate-spin" />
</div>
}>
<RegisterForm />
</Suspense>
)
}

View File

@@ -0,0 +1,34 @@
'use client'
interface ActivityFilterProps {
entityType: string
onEntityTypeChange: (type: string) => void
}
const ENTITY_TYPES = [
{ value: '', label: 'All' },
{ value: 'MEDICATION', label: 'Medications' },
{ value: 'APPOINTMENT', label: 'Appointments' },
{ value: 'NOTE', label: 'Notes' },
{ value: 'DOSE_LOG', label: 'Doses' },
]
export function ActivityFilter({ entityType, onEntityTypeChange }: ActivityFilterProps) {
return (
<div className="flex gap-2 overflow-x-auto pb-2 scrollbar-hide">
{ENTITY_TYPES.map((type) => (
<button
key={type.value}
onClick={() => onEntityTypeChange(type.value)}
className={`px-3 py-1.5 rounded-full text-sm font-medium whitespace-nowrap transition-colors ${
entityType === type.value
? 'bg-primary-500 text-white'
: 'bg-secondary-100 text-secondary-600 hover:bg-secondary-200'
}`}
>
{type.label}
</button>
))}
</div>
)
}

View File

@@ -0,0 +1,96 @@
'use client'
import { format, formatDistanceToNow } from 'date-fns'
import { Pill, Calendar, FileText, CheckCircle, XCircle, Plus, Edit2, Trash2, RefreshCw, HelpCircle } from 'lucide-react'
interface Activity {
id: string
action: string
entityType: string
entityId: string
details: Record<string, unknown> | null
createdAt: string
user: { id: string; name: string }
}
interface ActivityItemProps {
activity: Activity
}
const ACTION_ICONS: Record<string, React.ReactNode> = {
CREATE: <Plus className="w-4 h-4" />,
UPDATE: <Edit2 className="w-4 h-4" />,
DELETE: <Trash2 className="w-4 h-4" />,
TAKE_DOSE: <CheckCircle className="w-4 h-4" />,
UNDO_DOSE: <XCircle className="w-4 h-4" />,
MARK_ASKED: <HelpCircle className="w-4 h-4" />,
REFILL: <RefreshCw className="w-4 h-4" />,
}
const ENTITY_ICONS: Record<string, React.ReactNode> = {
MEDICATION: <Pill className="w-5 h-5" />,
APPOINTMENT: <Calendar className="w-5 h-5" />,
NOTE: <FileText className="w-5 h-5" />,
DOSE_LOG: <Pill className="w-5 h-5" />,
WORKSPACE: <Edit2 className="w-5 h-5" />,
}
const ACTION_COLORS: Record<string, string> = {
CREATE: 'bg-green-100 text-green-600',
UPDATE: 'bg-blue-100 text-blue-600',
DELETE: 'bg-red-100 text-red-600',
TAKE_DOSE: 'bg-primary-100 text-primary-600',
UNDO_DOSE: 'bg-orange-100 text-orange-600',
MARK_ASKED: 'bg-purple-100 text-purple-600',
REFILL: 'bg-teal-100 text-teal-600',
}
function getActivityDescription(activity: Activity): string {
const details = activity.details || {}
const name = details.name || details.medicationName || details.title || 'item'
switch (activity.action) {
case 'CREATE':
return `Added ${activity.entityType.toLowerCase()} "${name}"`
case 'UPDATE':
if (details.updated === 'emergency_info') {
return 'Updated emergency information'
}
return `Updated ${activity.entityType.toLowerCase()} "${name}"`
case 'DELETE':
return `Deleted ${activity.entityType.toLowerCase()} "${name}"`
case 'TAKE_DOSE':
return `Took ${name}`
case 'UNDO_DOSE':
return `Undid dose of ${name}`
case 'MARK_ASKED':
return 'Marked question as asked'
case 'REFILL':
return `Refilled ${name} (+${details.amount} pills)`
default:
return `${activity.action} ${activity.entityType.toLowerCase()}`
}
}
export function ActivityItem({ activity }: ActivityItemProps) {
const icon = ACTION_ICONS[activity.action] || <Edit2 className="w-4 h-4" />
const entityIcon = ENTITY_ICONS[activity.entityType] || <FileText className="w-5 h-5" />
const colorClass = ACTION_COLORS[activity.action] || 'bg-secondary-100 text-secondary-600'
return (
<div className="flex items-start gap-3 py-3">
<div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${colorClass}`}>
{entityIcon}
</div>
<div className="flex-1 min-w-0">
<p className="text-secondary-900">{getActivityDescription(activity)}</p>
<p className="text-sm text-secondary-500 mt-0.5">
{activity.user.name} {formatDistanceToNow(new Date(activity.createdAt), { addSuffix: true })}
</p>
</div>
<div className={`w-6 h-6 rounded-full flex items-center justify-center flex-shrink-0 ${colorClass}`}>
{icon}
</div>
</div>
)
}

View File

@@ -0,0 +1,164 @@
'use client'
import { useState, useEffect } from 'react'
import { Check, Square } from 'lucide-react'
import { clsx } from 'clsx'
import { Card, showToast } from '@/components/ui'
import {
DEFAULT_PREP_ITEMS,
CATEGORY_LABELS,
groupItemsByCategory,
type PrepItem,
} from '@/lib/appointments/prep-generator'
interface ChecklistState {
[itemId: string]: boolean
}
interface PrepChecklistProps {
workspaceId: string
appointmentId: string
}
export function PrepChecklist({ workspaceId, appointmentId }: PrepChecklistProps) {
const [checkedItems, setCheckedItems] = useState<ChecklistState>({})
const [loading, setLoading] = useState(true)
const [customItems, setCustomItems] = useState<PrepItem[]>([])
// Load checklist state from server
useEffect(() => {
async function loadChecklist() {
try {
const response = await fetch(
`/api/workspaces/${workspaceId}/appointments/${appointmentId}/checklist`
)
if (response.ok) {
const data = await response.json()
setCheckedItems(data.checkedItems || {})
setCustomItems(data.customItems || [])
}
} catch (err) {
console.error('Failed to load checklist:', err)
} finally {
setLoading(false)
}
}
loadChecklist()
}, [workspaceId, appointmentId])
const handleToggle = async (itemId: string) => {
const newChecked = !checkedItems[itemId]
const newState = { ...checkedItems, [itemId]: newChecked }
setCheckedItems(newState)
// Save to server
try {
await fetch(
`/api/workspaces/${workspaceId}/appointments/${appointmentId}/checklist`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ checkedItems: newState }),
}
)
} catch (err) {
console.error('Failed to save checklist:', err)
showToast('Failed to save', 'error')
}
}
const allItems = [...DEFAULT_PREP_ITEMS, ...customItems]
const groupedItems = groupItemsByCategory(allItems)
const categories = ['documents', 'health', 'comfort', 'questions']
// Calculate progress
const totalItems = allItems.length
const checkedCount = Object.values(checkedItems).filter(Boolean).length
const progressPercent = totalItems > 0 ? Math.round((checkedCount / totalItems) * 100) : 0
if (loading) {
return (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="h-20 bg-secondary-100 rounded-lg animate-pulse" />
))}
</div>
)
}
return (
<div className="space-y-6">
{/* Progress bar */}
<div className="bg-secondary-100 rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-secondary-700">Progress</span>
<span className="text-sm font-semibold text-primary-600">
{checkedCount} / {totalItems} items
</span>
</div>
<div className="w-full bg-secondary-200 rounded-full h-3">
<div
className="bg-primary-500 h-3 rounded-full transition-all duration-300"
style={{ width: `${progressPercent}%` }}
/>
</div>
{progressPercent === 100 && (
<p className="text-sm text-green-600 font-medium mt-2 text-center">
All set for your appointment!
</p>
)}
</div>
{/* Checklist items grouped by category */}
{categories.map((category) => {
const items = groupedItems[category] || []
if (items.length === 0) return null
return (
<Card key={category}>
<h3 className="text-sm font-semibold text-secondary-600 mb-3">
{CATEGORY_LABELS[category]}
</h3>
<div className="space-y-2">
{items.map((item) => (
<button
key={item.id}
onClick={() => handleToggle(item.id)}
className={clsx(
'w-full flex items-center gap-3 p-3 rounded-lg transition-colors text-left',
checkedItems[item.id]
? 'bg-green-50 hover:bg-green-100'
: 'bg-secondary-50 hover:bg-secondary-100'
)}
>
<div
className={clsx(
'w-6 h-6 rounded flex items-center justify-center flex-shrink-0',
checkedItems[item.id]
? 'bg-green-500 text-white'
: 'border-2 border-secondary-300'
)}
>
{checkedItems[item.id] && <Check className="w-4 h-4" />}
</div>
<span
className={clsx(
'flex-1',
checkedItems[item.id]
? 'text-secondary-500 line-through'
: 'text-secondary-900'
)}
>
{item.label}
</span>
</button>
))}
</div>
</Card>
)
})}
</div>
)
}

View File

@@ -0,0 +1,71 @@
'use client'
import { format, isToday, isSameMonth, isSameDay } from 'date-fns'
import { clsx } from 'clsx'
interface Appointment {
id: string
title: string
datetime: string
}
interface CalendarDayCellProps {
date: Date
currentMonth: Date
appointments: Appointment[]
selectedDate?: Date
onSelect: (date: Date) => void
}
export function CalendarDayCell({
date,
currentMonth,
appointments,
selectedDate,
onSelect,
}: CalendarDayCellProps) {
const isCurrentMonth = isSameMonth(date, currentMonth)
const isSelected = selectedDate && isSameDay(date, selectedDate)
const dayAppointments = appointments.filter((a) =>
isSameDay(new Date(a.datetime), date)
)
const hasAppointments = dayAppointments.length > 0
return (
<button
onClick={() => onSelect(date)}
className={clsx(
'flex flex-col items-center justify-start p-1 min-h-[60px] rounded-lg transition-colors',
isCurrentMonth ? 'text-secondary-900' : 'text-secondary-300',
isToday(date) && 'bg-primary-50 ring-1 ring-primary-200',
isSelected && 'bg-primary-100 ring-2 ring-primary-500',
!isSelected && isCurrentMonth && 'hover:bg-secondary-50'
)}
>
<span
className={clsx(
'text-sm font-medium w-7 h-7 flex items-center justify-center rounded-full',
isToday(date) && 'bg-primary-500 text-white'
)}
>
{format(date, 'd')}
</span>
{hasAppointments && (
<div className="flex flex-wrap gap-0.5 mt-1 justify-center">
{dayAppointments.slice(0, 3).map((_, idx) => (
<div
key={idx}
className="w-1.5 h-1.5 rounded-full bg-primary-500"
/>
))}
{dayAppointments.length > 3 && (
<span className="text-[10px] text-primary-600 font-medium">
+{dayAppointments.length - 3}
</span>
)}
</div>
)}
</button>
)
}

View File

@@ -0,0 +1,170 @@
'use client'
import {
format,
startOfMonth,
endOfMonth,
startOfWeek,
endOfWeek,
addDays,
addMonths,
subMonths,
isSameDay,
} from 'date-fns'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { CalendarDayCell } from './CalendarDayCell'
interface Appointment {
id: string
title: string
datetime: string
location: string | null
}
interface CalendarMonthProps {
appointments: Appointment[]
selectedDate: Date
onDateSelect: (date: Date) => void
onMonthChange: (date: Date) => void
currentMonth: Date
}
export function CalendarMonth({
appointments,
selectedDate,
onDateSelect,
onMonthChange,
currentMonth,
}: CalendarMonthProps) {
const monthStart = startOfMonth(currentMonth)
const monthEnd = endOfMonth(currentMonth)
const calendarStart = startOfWeek(monthStart)
const calendarEnd = endOfWeek(monthEnd)
// Generate all days in the calendar view
const days: Date[] = []
let day = calendarStart
while (day <= calendarEnd) {
days.push(day)
day = addDays(day, 1)
}
// Group into weeks
const weeks: Date[][] = []
for (let i = 0; i < days.length; i += 7) {
weeks.push(days.slice(i, i + 7))
}
const handlePrevMonth = () => {
onMonthChange(subMonths(currentMonth, 1))
}
const handleNextMonth = () => {
onMonthChange(addMonths(currentMonth, 1))
}
const handleToday = () => {
const today = new Date()
onMonthChange(today)
onDateSelect(today)
}
// Get appointments for selected date
const selectedDateAppointments = appointments
.filter((a) => isSameDay(new Date(a.datetime), selectedDate))
.sort((a, b) => new Date(a.datetime).getTime() - new Date(b.datetime).getTime())
return (
<div className="space-y-4">
{/* Month navigation */}
<div className="flex items-center justify-between">
<button
onClick={handlePrevMonth}
className="p-2 hover:bg-secondary-100 rounded-lg transition-colors"
aria-label="Previous month"
>
<ChevronLeft className="w-5 h-5 text-secondary-600" />
</button>
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold text-secondary-900">
{format(currentMonth, 'MMMM yyyy')}
</h2>
<button
onClick={handleToday}
className="text-sm text-primary-600 font-medium hover:underline"
>
Today
</button>
</div>
<button
onClick={handleNextMonth}
className="p-2 hover:bg-secondary-100 rounded-lg transition-colors"
aria-label="Next month"
>
<ChevronRight className="w-5 h-5 text-secondary-600" />
</button>
</div>
{/* Weekday headers */}
<div className="grid grid-cols-7 gap-1">
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((dayName) => (
<div
key={dayName}
className="text-center text-xs font-medium text-secondary-500 py-2"
>
{dayName}
</div>
))}
</div>
{/* Calendar grid */}
<div className="grid grid-cols-7 gap-1">
{weeks.map((week, weekIdx) =>
week.map((date, dayIdx) => (
<CalendarDayCell
key={`${weekIdx}-${dayIdx}`}
date={date}
currentMonth={currentMonth}
appointments={appointments}
selectedDate={selectedDate}
onSelect={onDateSelect}
/>
))
)}
</div>
{/* Selected date appointments */}
<div className="pt-4 border-t border-border">
<h3 className="text-sm font-semibold text-secondary-600 mb-3">
{format(selectedDate, 'EEEE, MMMM d')}
</h3>
{selectedDateAppointments.length === 0 ? (
<p className="text-secondary-500 text-sm">No appointments</p>
) : (
<div className="space-y-2">
{selectedDateAppointments.map((appt) => (
<div
key={appt.id}
className="flex items-start gap-3 p-3 bg-secondary-50 rounded-lg"
>
<div className="text-sm font-medium text-primary-600 whitespace-nowrap">
{format(new Date(appt.datetime), 'h:mm a')}
</div>
<div className="flex-1">
<p className="font-medium text-secondary-900">{appt.title}</p>
{appt.location && (
<p className="text-sm text-secondary-500">{appt.location}</p>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,178 @@
'use client'
import { Phone, User, Droplets, AlertTriangle, Activity, Stethoscope } from 'lucide-react'
import { format } from 'date-fns'
interface EmergencyInfo {
patientName: string | null
patientDOB: string | null
bloodType: string | null
allergies: string | null
medicalConditions: string | null
primaryPhysician: string | null
physicianPhone: string | null
clinicPhone: string | null
emergencyPhone: string | null
}
interface EmergencyCardProps {
info: EmergencyInfo
medications?: { name: string; instructions: string | null }[]
variant?: 'full' | 'compact'
}
export function EmergencyCard({ info, medications, variant = 'full' }: EmergencyCardProps) {
const formatDate = (dateStr: string | null) => {
if (!dateStr) return null
try {
return format(new Date(dateStr), 'MMMM d, yyyy')
} catch {
return dateStr
}
}
const hasEmergencyInfo = info.patientName || info.bloodType || info.allergies ||
info.medicalConditions || info.primaryPhysician
if (!hasEmergencyInfo && variant === 'compact') {
return null
}
return (
<div className="bg-red-50 border-2 border-red-200 rounded-xl overflow-hidden">
{/* 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>
</div>
<div className="p-4 space-y-4">
{/* 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>
{info.patientDOB && (
<p className="text-sm text-secondary-600">DOB: {formatDate(info.patientDOB)}</p>
)}
</div>
</div>
)}
{/* Blood Type */}
{info.bloodType && (
<div className="flex items-start gap-3">
<Droplets className="w-5 h-5 text-red-600 mt-0.5" />
<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>
</div>
</div>
)}
{/* Allergies - High visibility */}
{info.allergies && (
<div className="bg-red-100 border border-red-300 rounded-lg p-3">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-red-600 mt-0.5 flex-shrink-0" />
<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>
</div>
</div>
</div>
)}
{/* 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>
<p className="text-secondary-900">{info.medicalConditions}</p>
</div>
</div>
)}
{/* 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>
)}
{/* 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>
{info.physicianPhone && (
<a
href={`tel:${info.physicianPhone}`}
className="text-primary-600 hover:underline"
>
{info.physicianPhone}
</a>
)}
</div>
</div>
</div>
)}
{/* 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>
{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>
)}
</div>
)}
</div>
</div>
)
}

View File

@@ -3,13 +3,13 @@
import { usePathname } from 'next/navigation'
import Link from 'next/link'
import { clsx } from 'clsx'
import { Home, Calendar, Pill, FileText, MoreHorizontal } from 'lucide-react'
import { Home, Calendar, Pill, Activity, MoreHorizontal } from 'lucide-react'
const navItems = [
{ href: '/today', label: 'Today', icon: Home },
{ href: '/appointments', label: 'Appointments', icon: Calendar },
{ href: '/appointments', label: 'Appts', icon: Calendar },
{ href: '/meds', label: 'Meds', icon: Pill },
{ href: '/notes', label: 'Notes', icon: FileText },
{ href: '/symptoms', label: 'Symptoms', icon: Activity },
{ href: '/settings', label: 'More', icon: MoreHorizontal },
]

View File

@@ -0,0 +1,50 @@
'use client'
import { AlertTriangle } from 'lucide-react'
import { useRouter } from 'next/navigation'
interface RefillAlertProps {
medications: {
id: string
name: string
pillCount: number | null
refillThreshold: number | null
}[]
}
export function RefillAlert({ medications }: RefillAlertProps) {
const router = useRouter()
const lowMeds = medications.filter(
m => m.pillCount !== null && m.refillThreshold !== null && m.pillCount <= m.refillThreshold
)
if (lowMeds.length === 0) {
return null
}
return (
<div className="bg-orange-50 border border-orange-200 rounded-lg p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-orange-600 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<p className="font-semibold text-orange-800">
{lowMeds.length === 1 ? 'Medication running low' : `${lowMeds.length} medications running low`}
</p>
<ul className="mt-1 space-y-1">
{lowMeds.map(med => (
<li key={med.id}>
<button
onClick={() => router.push(`/meds/${med.id}`)}
className="text-sm text-orange-700 hover:text-orange-900 hover:underline"
>
{med.name} - {med.pillCount} pills left
</button>
</li>
))}
</ul>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,147 @@
'use client'
import { useState } from 'react'
import { Pill, Plus, Minus, RefreshCw } from 'lucide-react'
import { Button, Input, Modal, showToast } from '@/components/ui'
interface RefillTrackerProps {
medicationId: string
workspaceId: string
medicationName: string
pillCount: number | null
pillsPerDose: number | null
refillThreshold: number | null
onRefill?: () => void
}
export function RefillTracker({
medicationId,
workspaceId,
medicationName,
pillCount,
pillsPerDose = 1,
refillThreshold = 7,
onRefill,
}: RefillTrackerProps) {
const [showRefillModal, setShowRefillModal] = useState(false)
const [refillAmount, setRefillAmount] = useState('')
const [saving, setSaving] = useState(false)
const isLow = pillCount !== null && refillThreshold !== null && pillCount <= refillThreshold
const dosesRemaining = pillCount !== null && pillsPerDose !== null
? Math.floor(pillCount / pillsPerDose)
: null
const handleRefill = async () => {
const amount = parseInt(refillAmount, 10)
if (isNaN(amount) || amount <= 0) {
showToast('Please enter a valid amount', 'error')
return
}
setSaving(true)
try {
const response = await fetch(
`/api/workspaces/${workspaceId}/medications/${medicationId}/refill`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount }),
}
)
if (!response.ok) throw new Error('Failed to refill')
showToast(`Added ${amount} pills to ${medicationName}`, 'success')
setShowRefillModal(false)
setRefillAmount('')
onRefill?.()
} catch {
showToast('Failed to record refill', 'error')
} finally {
setSaving(false)
}
}
if (pillCount === null) {
return null
}
return (
<>
<div className={`rounded-lg p-4 ${isLow ? 'bg-orange-50 border border-orange-200' : 'bg-secondary-50'}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${isLow ? 'bg-orange-100' : 'bg-secondary-100'}`}>
<Pill className={`w-5 h-5 ${isLow ? 'text-orange-600' : 'text-secondary-600'}`} />
</div>
<div>
<p className="font-semibold text-secondary-900">
{pillCount} pills remaining
</p>
{dosesRemaining !== null && (
<p className={`text-sm ${isLow ? 'text-orange-600 font-medium' : 'text-secondary-500'}`}>
{dosesRemaining} doses left
{isLow && ' • Refill soon'}
</p>
)}
</div>
</div>
<Button
variant={isLow ? 'primary' : 'secondary'}
size="sm"
onClick={() => setShowRefillModal(true)}
>
<RefreshCw className="w-4 h-4 mr-1" />
Refill
</Button>
</div>
</div>
<Modal
isOpen={showRefillModal}
onClose={() => setShowRefillModal(false)}
title={`Refill ${medicationName}`}
>
<div className="space-y-4">
<p className="text-secondary-600">
Enter the number of pills you're adding.
</p>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => setRefillAmount(String(Math.max(0, (parseInt(refillAmount) || 0) - 10)))}
className="w-12 h-12 rounded-full bg-secondary-100 flex items-center justify-center hover:bg-secondary-200 transition-colors"
>
<Minus className="w-5 h-5" />
</button>
<Input
type="number"
value={refillAmount}
onChange={(e) => setRefillAmount(e.target.value)}
placeholder="0"
className="text-center text-2xl font-bold"
min={0}
/>
<button
type="button"
onClick={() => setRefillAmount(String((parseInt(refillAmount) || 0) + 10))}
className="w-12 h-12 rounded-full bg-secondary-100 flex items-center justify-center hover:bg-secondary-200 transition-colors"
>
<Plus className="w-5 h-5" />
</button>
</div>
<p className="text-sm text-secondary-500 text-center">
Current: {pillCount} pills
{refillAmount && parseInt(refillAmount) > 0 && (
<> After refill: {pillCount + parseInt(refillAmount)} pills</>
)}
</p>
<Button onClick={handleRefill} fullWidth loading={saving}>
Record Refill
</Button>
</div>
</Modal>
</>
)
}

View File

@@ -0,0 +1,227 @@
'use client'
import { useState, useEffect } from 'react'
import { Bell, BellOff, CheckCircle, AlertCircle, Loader } from 'lucide-react'
import { Button, showToast } from '@/components/ui'
interface NotificationPermissionProps {
workspaceId: string
}
type PermissionState = 'unsupported' | 'denied' | 'granted' | 'default' | 'loading'
export function NotificationPermission({ workspaceId }: NotificationPermissionProps) {
const [permission, setPermission] = useState<PermissionState>('loading')
const [isSubscribed, setIsSubscribed] = useState(false)
const [loading, setLoading] = useState(false)
useEffect(() => {
checkPermission()
}, [])
async function checkPermission() {
if (!('Notification' in window)) {
setPermission('unsupported')
return
}
if (!('serviceWorker' in navigator)) {
setPermission('unsupported')
return
}
const perm = Notification.permission as PermissionState
setPermission(perm)
if (perm === 'granted') {
// Check if already subscribed
try {
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.getSubscription()
setIsSubscribed(!!subscription)
} catch (err) {
console.error('Failed to check subscription:', err)
}
}
}
async function handleEnable() {
setLoading(true)
try {
// Request notification permission
const perm = await Notification.requestPermission()
setPermission(perm as PermissionState)
if (perm !== 'granted') {
showToast('Permission denied', 'error')
return
}
// Get VAPID public key
const keyResponse = await fetch('/api/notifications/subscribe')
if (!keyResponse.ok) {
throw new Error('Push notifications not available')
}
const { publicKey } = await keyResponse.json()
// Register service worker if not already registered
const registration = await navigator.serviceWorker.ready
// Subscribe to push
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey),
})
// Send subscription to server
const response = await fetch('/api/notifications/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
subscription: subscription.toJSON(),
workspaceId,
}),
})
if (!response.ok) {
throw new Error('Failed to save subscription')
}
setIsSubscribed(true)
showToast('Notifications enabled!', 'success')
} catch (err: any) {
console.error('Enable notifications error:', err)
showToast(err.message || 'Failed to enable notifications', 'error')
} finally {
setLoading(false)
}
}
async function handleDisable() {
setLoading(true)
try {
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.getSubscription()
if (subscription) {
await subscription.unsubscribe()
// Remove from server
await fetch(
`/api/notifications/subscribe?endpoint=${encodeURIComponent(subscription.endpoint)}`,
{ method: 'DELETE' }
)
}
setIsSubscribed(false)
showToast('Notifications disabled', 'info')
} catch (err) {
console.error('Disable notifications error:', err)
showToast('Failed to disable notifications', 'error')
} finally {
setLoading(false)
}
}
if (permission === 'loading') {
return (
<div className="flex items-center gap-3 p-4 bg-secondary-50 rounded-lg">
<Loader className="w-5 h-5 animate-spin text-secondary-500" />
<span className="text-secondary-600">Checking notification status...</span>
</div>
)
}
if (permission === 'unsupported') {
return (
<div className="flex items-center gap-3 p-4 bg-yellow-50 rounded-lg">
<AlertCircle className="w-5 h-5 text-yellow-600" />
<div>
<p className="font-medium text-yellow-800">Not supported</p>
<p className="text-sm text-yellow-700">
Push notifications are not supported in this browser.
</p>
</div>
</div>
)
}
if (permission === 'denied') {
return (
<div className="flex items-center gap-3 p-4 bg-red-50 rounded-lg">
<BellOff className="w-5 h-5 text-red-600" />
<div>
<p className="font-medium text-red-800">Blocked</p>
<p className="text-sm text-red-700">
Notifications are blocked. Please enable them in your browser settings.
</p>
</div>
</div>
)
}
if (isSubscribed) {
return (
<div className="space-y-3">
<div className="flex items-center gap-3 p-4 bg-green-50 rounded-lg">
<CheckCircle className="w-5 h-5 text-green-600" />
<div className="flex-1">
<p className="font-medium text-green-800">Notifications enabled</p>
<p className="text-sm text-green-700">
You'll receive medication reminders on this device.
</p>
</div>
</div>
<Button
variant="secondary"
onClick={handleDisable}
loading={loading}
className="w-full"
>
<BellOff className="w-4 h-4 mr-2" />
Disable Notifications
</Button>
</div>
)
}
return (
<div className="space-y-3">
<div className="flex items-center gap-3 p-4 bg-secondary-50 rounded-lg">
<Bell className="w-5 h-5 text-secondary-600" />
<div>
<p className="font-medium text-secondary-800">Enable notifications</p>
<p className="text-sm text-secondary-600">
Get reminders when it's time to take your medications.
</p>
</div>
</div>
<Button
onClick={handleEnable}
loading={loading}
className="w-full"
>
<Bell className="w-4 h-4 mr-2" />
Enable Notifications
</Button>
</div>
)
}
// Helper function to convert VAPID key
function urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer> {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
const rawData = window.atob(base64)
const buffer = new ArrayBuffer(rawData.length)
const outputArray = new Uint8Array(buffer)
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i)
}
return outputArray
}

View File

@@ -0,0 +1,26 @@
'use client'
import { useEffect } from 'react'
export function ServiceWorkerRegistrar() {
useEffect(() => {
if ('serviceWorker' in navigator) {
// Register service worker
navigator.serviceWorker
.register('/sw.js')
.then((registration) => {
console.log('Service Worker registered with scope:', registration.scope)
// Check for updates periodically
setInterval(() => {
registration.update()
}, 60 * 60 * 1000) // Check every hour
})
.catch((error) => {
console.error('Service Worker registration failed:', error)
})
}
}, [])
return null
}

View File

@@ -0,0 +1,88 @@
'use client'
import { format, formatDistanceToNow } from 'date-fns'
const SYMPTOM_INFO: Record<string, { emoji: string; label: string }> = {
FATIGUE: { emoji: '😴', label: 'Fatigue' },
NAUSEA: { emoji: '🤢', label: 'Nausea' },
PAIN: { emoji: '😣', label: 'Pain' },
APPETITE: { emoji: '🍽️', label: 'Appetite' },
SLEEP: { emoji: '😴', label: 'Sleep' },
MOOD: { emoji: '😔', label: 'Mood' },
CUSTOM: { emoji: '📝', label: 'Custom' },
}
const SEVERITY_COLORS: Record<number, string> = {
1: 'bg-green-100 text-green-800 border-green-200',
2: 'bg-lime-100 text-lime-800 border-lime-200',
3: 'bg-yellow-100 text-yellow-800 border-yellow-200',
4: 'bg-orange-100 text-orange-800 border-orange-200',
5: 'bg-red-100 text-red-800 border-red-200',
}
const SEVERITY_LABELS: Record<number, string> = {
1: 'Minimal',
2: 'Mild',
3: 'Moderate',
4: 'Severe',
5: 'Extreme',
}
interface Symptom {
id: string
type: string
customName: string | null
severity: number
notes: string | null
recordedAt: string
createdBy?: { id: string; name: string }
}
interface SymptomCardProps {
symptom: Symptom
compact?: boolean
}
export function SymptomCard({ symptom, compact = false }: SymptomCardProps) {
const info = SYMPTOM_INFO[symptom.type] || SYMPTOM_INFO.CUSTOM
const displayName = symptom.type === 'CUSTOM' && symptom.customName
? symptom.customName
: info.label
if (compact) {
return (
<div className={`flex items-center gap-2 px-3 py-2 rounded-lg border ${SEVERITY_COLORS[symptom.severity]}`}>
<span className="text-lg">{info.emoji}</span>
<span className="font-medium">{displayName}</span>
<span className="text-xs opacity-75">
{formatDistanceToNow(new Date(symptom.recordedAt), { addSuffix: true })}
</span>
</div>
)
}
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 ${SEVERITY_COLORS[symptom.severity]}`}>
<span className="text-2xl">{info.emoji}</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-secondary-900">{displayName}</h3>
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${SEVERITY_COLORS[symptom.severity]}`}>
{SEVERITY_LABELS[symptom.severity]}
</span>
</div>
<p className="text-sm text-secondary-500 mt-0.5">
{format(new Date(symptom.recordedAt), 'EEEE, MMM d \'at\' h:mm a')}
{symptom.createdBy && `${symptom.createdBy.name}`}
</p>
{symptom.notes && (
<p className="text-sm text-secondary-600 mt-2">{symptom.notes}</p>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,112 @@
'use client'
import { format, subDays, startOfDay, isSameDay } from 'date-fns'
interface Symptom {
id: string
type: string
severity: number
recordedAt: string
}
interface SymptomChartProps {
symptoms: Symptom[]
days?: number
type?: string
}
const SEVERITY_COLORS = [
'#22c55e', // 1 - green
'#84cc16', // 2 - lime
'#eab308', // 3 - yellow
'#f97316', // 4 - orange
'#ef4444', // 5 - red
]
export function SymptomChart({ symptoms, days = 7, type }: SymptomChartProps) {
// Generate date range
const today = startOfDay(new Date())
const dates = Array.from({ length: days }, (_, i) => subDays(today, days - 1 - i))
// Filter by type if specified
const filtered = type
? symptoms.filter(s => s.type === type)
: symptoms
// Group symptoms by date and calculate average severity
const dataByDate = dates.map(date => {
const daySymptoms = filtered.filter(s =>
isSameDay(new Date(s.recordedAt), date)
)
const avgSeverity = daySymptoms.length > 0
? daySymptoms.reduce((sum, s) => sum + s.severity, 0) / daySymptoms.length
: 0
return {
date,
avgSeverity,
count: daySymptoms.length,
}
})
const maxHeight = 100
if (filtered.length === 0) {
return (
<div className="text-center py-8 text-secondary-500">
No symptom data to display
</div>
)
}
return (
<div className="space-y-2">
{/* Chart */}
<div className="flex items-end gap-1 h-32">
{dataByDate.map((day, i) => {
const height = day.avgSeverity > 0 ? (day.avgSeverity / 5) * maxHeight : 4
const colorIndex = day.avgSeverity > 0 ? Math.round(day.avgSeverity) - 1 : 0
return (
<div key={i} className="flex-1 flex flex-col items-center gap-1">
<div
className="w-full rounded-t transition-all"
style={{
height: `${height}%`,
backgroundColor: day.avgSeverity > 0 ? SEVERITY_COLORS[colorIndex] : '#e5e7eb',
minHeight: day.avgSeverity > 0 ? '8px' : '4px',
}}
title={day.avgSeverity > 0 ? `Avg: ${day.avgSeverity.toFixed(1)}` : 'No data'}
/>
</div>
)
})}
</div>
{/* Date labels */}
<div className="flex gap-1">
{dataByDate.map((day, i) => (
<div key={i} className="flex-1 text-center">
<span className="text-xs text-secondary-500">
{format(day.date, 'EEE')}
</span>
</div>
))}
</div>
{/* Legend */}
<div className="flex justify-center gap-3 text-xs text-secondary-500 pt-2">
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded" style={{ backgroundColor: SEVERITY_COLORS[0] }} />
<span>Minimal</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded" style={{ backgroundColor: SEVERITY_COLORS[2] }} />
<span>Moderate</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded" style={{ backgroundColor: SEVERITY_COLORS[4] }} />
<span>Extreme</span>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,145 @@
'use client'
import { useState } from 'react'
import { Button, showToast } from '@/components/ui'
const SYMPTOM_TYPES = [
{ type: 'FATIGUE', emoji: '😴', label: 'Fatigue' },
{ type: 'NAUSEA', emoji: '🤢', label: 'Nausea' },
{ type: 'PAIN', emoji: '😣', label: 'Pain' },
{ type: 'APPETITE', emoji: '🍽️', label: 'Appetite' },
{ type: 'SLEEP', emoji: '😴', label: 'Sleep' },
{ type: 'MOOD', emoji: '😔', label: 'Mood' },
]
const SEVERITY_LABELS = [
{ value: 1, label: 'Minimal', color: 'bg-green-500' },
{ value: 2, label: 'Mild', color: 'bg-lime-500' },
{ value: 3, label: 'Moderate', color: 'bg-yellow-500' },
{ value: 4, label: 'Severe', color: 'bg-orange-500' },
{ value: 5, label: 'Extreme', color: 'bg-red-500' },
]
interface SymptomQuickLogProps {
workspaceId: string
onLogged?: () => void
}
export function SymptomQuickLog({ workspaceId, onLogged }: SymptomQuickLogProps) {
const [selectedType, setSelectedType] = useState<string | null>(null)
const [severity, setSeverity] = useState(3)
const [notes, setNotes] = useState('')
const [saving, setSaving] = useState(false)
const handleSubmit = async () => {
if (!selectedType) {
showToast('Please select a symptom', 'error')
return
}
setSaving(true)
try {
const response = await fetch(`/api/workspaces/${workspaceId}/symptoms`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: selectedType,
severity,
notes: notes.trim() || null,
}),
})
if (!response.ok) throw new Error('Failed to log symptom')
showToast('Symptom logged', 'success')
setSelectedType(null)
setSeverity(3)
setNotes('')
onLogged?.()
} catch {
showToast('Failed to log symptom', 'error')
} finally {
setSaving(false)
}
}
return (
<div className="space-y-4">
{/* Symptom Type Selection */}
<div>
<label className="block text-sm font-medium text-secondary-700 mb-2">
How are you feeling?
</label>
<div className="grid grid-cols-3 gap-2">
{SYMPTOM_TYPES.map((symptom) => (
<button
key={symptom.type}
type="button"
onClick={() => setSelectedType(symptom.type)}
className={`flex flex-col items-center gap-1 p-3 rounded-lg border-2 transition-all ${
selectedType === symptom.type
? 'border-primary-500 bg-primary-50'
: 'border-border hover:border-secondary-300 hover:bg-muted'
}`}
>
<span className="text-2xl">{symptom.emoji}</span>
<span className="text-xs font-medium text-secondary-700">{symptom.label}</span>
</button>
))}
</div>
</div>
{/* Severity Slider */}
{selectedType && (
<div>
<label className="block text-sm font-medium text-secondary-700 mb-2">
Severity: <span className="font-bold">{SEVERITY_LABELS[severity - 1].label}</span>
</label>
<div className="flex items-center gap-2">
{SEVERITY_LABELS.map((level) => (
<button
key={level.value}
type="button"
onClick={() => setSeverity(level.value)}
className={`flex-1 h-10 rounded-lg transition-all ${level.color} ${
severity === level.value
? 'ring-2 ring-offset-2 ring-secondary-900 scale-110'
: 'opacity-40 hover:opacity-70'
}`}
>
<span className="sr-only">{level.label}</span>
</button>
))}
</div>
<div className="flex justify-between mt-1 text-xs text-secondary-500">
<span>Minimal</span>
<span>Extreme</span>
</div>
</div>
)}
{/* Notes */}
{selectedType && (
<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 additional details..."
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 */}
{selectedType && (
<Button onClick={handleSubmit} fullWidth loading={saving}>
Log Symptom
</Button>
)}
</div>
)
}

View File

@@ -0,0 +1,55 @@
// Default checklist items for appointment preparation
export interface PrepItem {
id: string
label: string
category: 'documents' | 'health' | 'comfort' | 'questions'
}
export const DEFAULT_PREP_ITEMS: PrepItem[] = [
// Documents
{ id: 'insurance-card', label: 'Insurance card', category: 'documents' },
{ id: 'photo-id', label: 'Photo ID', category: 'documents' },
{ id: 'medication-list', label: 'Current medication list', category: 'documents' },
{ id: 'test-results', label: 'Recent test results', category: 'documents' },
{ id: 'referral', label: 'Referral letter (if needed)', category: 'documents' },
// Health
{ id: 'take-meds', label: 'Take morning medications', category: 'health' },
{ id: 'symptoms-notes', label: 'Write down recent symptoms', category: 'health' },
{ id: 'blood-pressure', label: 'Record blood pressure', category: 'health' },
// Comfort
{ id: 'snacks', label: 'Pack snacks and water', category: 'comfort' },
{ id: 'phone-charger', label: 'Phone charger', category: 'comfort' },
{ id: 'book-entertainment', label: 'Book or entertainment', category: 'comfort' },
{ id: 'comfortable-clothes', label: 'Comfortable clothing', category: 'comfort' },
// Questions
{ id: 'questions-list', label: 'Prepare questions for doctor', category: 'questions' },
{ id: 'side-effects', label: 'Note medication side effects', category: 'questions' },
{ id: 'concerns', label: 'List any concerns', category: 'questions' },
]
export const CATEGORY_LABELS: Record<string, string> = {
documents: 'Documents to Bring',
health: 'Health Preparation',
comfort: 'Comfort Items',
questions: 'Questions & Notes',
}
export function getDefaultChecklistItems(): PrepItem[] {
return [...DEFAULT_PREP_ITEMS]
}
export function groupItemsByCategory(items: PrepItem[]): Record<string, PrepItem[]> {
return items.reduce(
(acc, item) => {
if (!acc[item.category]) {
acc[item.category] = []
}
acc[item.category].push(item)
return acc
},
{} as Record<string, PrepItem[]>
)
}

View File

@@ -0,0 +1,175 @@
import { format, addHours } from 'date-fns'
interface Appointment {
id: string
title: string
datetime: Date | string
location: string | null
notes: string | null
}
interface Medication {
id: string
name: string
scheduleType: string
scheduleData: {
type: string
times?: string[]
hours?: number
startTime?: string
time?: string
days?: number[]
}
active: boolean
}
/**
* Generate an iCalendar format string for appointments
*/
export function generateICalendar(
appointments: Appointment[],
workspaceName: string
): string {
const lines: string[] = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//NextStep//Health Management//EN',
`X-WR-CALNAME:${escapeICalText(workspaceName)}`,
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
]
for (const appt of appointments) {
const startDate = new Date(appt.datetime)
const endDate = addHours(startDate, 1) // Default 1 hour duration
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(`SUMMARY:${escapeICalText(appt.title)}`)
if (appt.location) {
lines.push(`LOCATION:${escapeICalText(appt.location)}`)
}
if (appt.notes) {
lines.push(`DESCRIPTION:${escapeICalText(appt.notes)}`)
}
// Add reminder 1 day before
lines.push('BEGIN:VALARM')
lines.push('TRIGGER:-P1D')
lines.push('ACTION:DISPLAY')
lines.push(`DESCRIPTION:Reminder: ${escapeICalText(appt.title)}`)
lines.push('END:VALARM')
// Add reminder 2 hours before
lines.push('BEGIN:VALARM')
lines.push('TRIGGER:-PT2H')
lines.push('ACTION:DISPLAY')
lines.push(`DESCRIPTION:${escapeICalText(appt.title)} in 2 hours`)
lines.push('END:VALARM')
lines.push('END:VEVENT')
}
lines.push('END:VCALENDAR')
return lines.join('\r\n')
}
/**
* Generate medication schedule events for the current day
* This is useful for daily reminders but not recommended for calendar sync
*/
export function generateMedicationEvents(
medications: Medication[],
workspaceName: string,
targetDate: Date = new Date()
): string {
const lines: string[] = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//NextStep//Medications//EN',
`X-WR-CALNAME:${escapeICalText(workspaceName)} - Medications`,
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
]
const dateStr = format(targetDate, 'yyyy-MM-dd')
for (const med of medications) {
if (!med.active) continue
const times = getMedicationTimes(med)
for (const time of times) {
if (time === 'As needed') continue
const [hours, minutes] = time.split(':').map(Number)
const startDate = new Date(targetDate)
startDate.setHours(hours, minutes, 0, 0)
const endDate = new Date(startDate)
endDate.setMinutes(endDate.getMinutes() + 15)
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(`SUMMARY:Take ${escapeICalText(med.name)}`)
lines.push('CATEGORIES:MEDICATION')
lines.push('BEGIN:VALARM')
lines.push('TRIGGER:PT0M')
lines.push('ACTION:DISPLAY')
lines.push(`DESCRIPTION:Time to take ${escapeICalText(med.name)}`)
lines.push('END:VALARM')
lines.push('END:VEVENT')
}
}
lines.push('END:VCALENDAR')
return lines.join('\r\n')
}
function getMedicationTimes(med: Medication): string[] {
const { scheduleType, scheduleData } = med
switch (scheduleType) {
case 'FIXED_TIMES':
return scheduleData.times || []
case 'INTERVAL':
const times: string[] = []
const startHour = parseInt(scheduleData.startTime?.split(':')[0] || '8')
const hours = scheduleData.hours || 4
for (let h = startHour; h < 24; h += hours) {
const hourStr = h.toString().padStart(2, '0')
times.push(`${hourStr}:00`)
}
return times
case 'WEEKDAYS':
return scheduleData.time ? [scheduleData.time] : []
case 'PRN':
return ['As needed']
default:
return []
}
}
function formatICalDate(date: Date): string {
// Format: YYYYMMDDTHHMMSSZ
return format(date, "yyyyMMdd'T'HHmmss'Z'")
}
function escapeICalText(text: string): string {
return text
.replace(/\\/g, '\\\\')
.replace(/;/g, '\\;')
.replace(/,/g, '\\,')
.replace(/\n/g, '\\n')
}

View File

@@ -0,0 +1,266 @@
import PDFDocument from 'pdfkit'
import { format, subDays } from 'date-fns'
interface PatientInfo {
name: string
patientName?: string | null
patientDOB?: Date | string | null
bloodType?: string | null
allergies?: string | null
medicalConditions?: string | null
primaryPhysician?: string | null
physicianPhone?: string | null
clinicPhone?: string | null
emergencyPhone?: string | null
}
interface Medication {
id: string
name: string
instructions: string | null
scheduleType: string
active: boolean
}
interface Appointment {
id: string
title: string
datetime: Date | string
location: string | null
notes: string | null
}
interface Symptom {
id: string
type: string
customName: string | null
severity: number
notes: string | null
recordedAt: Date | string
}
interface SummaryData {
patient: PatientInfo
medications: Medication[]
appointments: Appointment[]
symptoms: Symptom[]
generatedAt: Date
}
const SYMPTOM_LABELS: Record<string, string> = {
FATIGUE: 'Fatigue',
NAUSEA: 'Nausea',
PAIN: 'Pain',
APPETITE: 'Appetite Changes',
SLEEP: 'Sleep Issues',
MOOD: 'Mood Changes',
CUSTOM: 'Other',
}
const SEVERITY_LABELS = ['Minimal', 'Mild', 'Moderate', 'Severe', 'Extreme']
export function generateMedicalSummaryPDF(data: SummaryData): PDFKit.PDFDocument {
const doc = new PDFDocument({
size: 'A4',
margins: { top: 50, bottom: 50, left: 50, right: 50 },
info: {
Title: `Medical Summary - ${data.patient.patientName || data.patient.name}`,
Author: 'NextStep Health Management',
Subject: 'Medical Summary',
CreationDate: data.generatedAt,
},
})
const pageWidth = doc.page.width - 100 // Minus margins
// Header
doc
.fontSize(24)
.font('Helvetica-Bold')
.text('Medical Summary', { align: 'center' })
.moveDown(0.5)
doc
.fontSize(10)
.font('Helvetica')
.fillColor('#666666')
.text(`Generated: ${format(data.generatedAt, 'MMMM d, yyyy h:mm a')}`, { align: 'center' })
.moveDown(1.5)
.fillColor('#000000')
// Patient Information Section
addSectionHeader(doc, 'Patient Information')
const patientInfo = [
['Name', data.patient.patientName || data.patient.name || 'Not specified'],
['Date of Birth', data.patient.patientDOB ? format(new Date(data.patient.patientDOB), 'MMMM d, yyyy') : 'Not specified'],
['Blood Type', data.patient.bloodType || 'Not specified'],
['Primary Physician', data.patient.primaryPhysician || 'Not specified'],
['Physician Phone', data.patient.physicianPhone || 'Not specified'],
['Clinic Phone', data.patient.clinicPhone || 'Not specified'],
['Emergency Contact', data.patient.emergencyPhone || 'Not specified'],
]
for (const [label, value] of patientInfo) {
doc
.fontSize(10)
.font('Helvetica-Bold')
.text(`${label}: `, { continued: true })
.font('Helvetica')
.text(value)
}
doc.moveDown(0.5)
// Allergies (highlighted)
if (data.patient.allergies) {
doc
.fontSize(10)
.font('Helvetica-Bold')
.fillColor('#DC2626')
.text('ALLERGIES: ', { continued: true })
.font('Helvetica')
.text(data.patient.allergies)
.fillColor('#000000')
}
// Medical Conditions
if (data.patient.medicalConditions) {
doc
.fontSize(10)
.font('Helvetica-Bold')
.text('Medical Conditions: ', { continued: true })
.font('Helvetica')
.text(data.patient.medicalConditions)
}
doc.moveDown(1)
// Current Medications Section
addSectionHeader(doc, `Current Medications (${data.medications.filter(m => m.active).length})`)
const activeMeds = data.medications.filter((m) => m.active)
if (activeMeds.length === 0) {
doc.fontSize(10).font('Helvetica-Oblique').text('No active medications')
} else {
for (const med of activeMeds) {
doc
.fontSize(10)
.font('Helvetica-Bold')
.text(`${med.name}`, { continued: med.instructions ? true : false })
if (med.instructions) {
doc.font('Helvetica').text(` - ${med.instructions}`)
} else {
doc.text('')
}
doc.fontSize(9).fillColor('#666666').text(` Schedule: ${formatScheduleType(med.scheduleType)}`).fillColor('#000000')
}
}
doc.moveDown(1)
// Upcoming Appointments Section
const upcomingAppts = data.appointments
.filter((a) => new Date(a.datetime) >= new Date())
.sort((a, b) => new Date(a.datetime).getTime() - new Date(b.datetime).getTime())
.slice(0, 5)
addSectionHeader(doc, `Upcoming Appointments (${upcomingAppts.length})`)
if (upcomingAppts.length === 0) {
doc.fontSize(10).font('Helvetica-Oblique').text('No upcoming appointments')
} else {
for (const appt of upcomingAppts) {
const apptDate = new Date(appt.datetime)
doc
.fontSize(10)
.font('Helvetica-Bold')
.text(`${format(apptDate, 'EEE, MMM d')} at ${format(apptDate, 'h:mm a')}`)
.font('Helvetica')
.text(` ${appt.title}${appt.location ? ` - ${appt.location}` : ''}`)
}
}
doc.moveDown(1)
// Recent Symptoms Section (last 30 days)
const thirtyDaysAgo = subDays(new Date(), 30)
const recentSymptoms = data.symptoms
.filter((s) => new Date(s.recordedAt) >= thirtyDaysAgo)
.sort((a, b) => new Date(b.recordedAt).getTime() - new Date(a.recordedAt).getTime())
// Group by type
const symptomSummary = recentSymptoms.reduce(
(acc, s) => {
const key = s.type
if (!acc[key]) {
acc[key] = { count: 0, totalSeverity: 0, maxSeverity: 0 }
}
acc[key].count++
acc[key].totalSeverity += s.severity
acc[key].maxSeverity = Math.max(acc[key].maxSeverity, s.severity)
return acc
},
{} as Record<string, { count: number; totalSeverity: number; maxSeverity: number }>
)
addSectionHeader(doc, 'Symptoms (Last 30 Days)')
if (Object.keys(symptomSummary).length === 0) {
doc.fontSize(10).font('Helvetica-Oblique').text('No symptoms recorded in the last 30 days')
} else {
for (const [type, stats] of Object.entries(symptomSummary).sort((a, b) => b[1].count - a[1].count)) {
const avgSeverity = Math.round(stats.totalSeverity / stats.count)
doc
.fontSize(10)
.font('Helvetica-Bold')
.text(`${SYMPTOM_LABELS[type] || type}: `, { continued: true })
.font('Helvetica')
.text(`${stats.count} occurrence(s), Avg: ${SEVERITY_LABELS[avgSeverity - 1]}, Max: ${SEVERITY_LABELS[stats.maxSeverity - 1]}`)
}
}
doc.moveDown(1.5)
// Footer
doc
.fontSize(8)
.fillColor('#999999')
.text('This document is for informational purposes only and does not constitute medical advice.', { align: 'center' })
.text('Generated by NextStep Health Management', { align: 'center' })
return doc
}
function addSectionHeader(doc: PDFKit.PDFDocument, title: string) {
doc
.fontSize(14)
.font('Helvetica-Bold')
.fillColor('#1E3A8A')
.text(title)
.moveDown(0.3)
.strokeColor('#1E3A8A')
.lineWidth(1)
.moveTo(50, doc.y)
.lineTo(545, doc.y)
.stroke()
.moveDown(0.5)
.fillColor('#000000')
}
function formatScheduleType(type: string): string {
switch (type) {
case 'FIXED_TIMES':
return 'Fixed times daily'
case 'INTERVAL':
return 'At regular intervals'
case 'WEEKDAYS':
return 'Specific days of the week'
case 'PRN':
return 'As needed'
default:
return type
}
}

View File

@@ -0,0 +1,81 @@
import webpush from 'web-push'
// These should be set in environment variables
const VAPID_PUBLIC_KEY = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY || ''
const VAPID_PRIVATE_KEY = process.env.VAPID_PRIVATE_KEY || ''
const VAPID_EMAIL = process.env.VAPID_EMAIL || 'mailto:admin@nextstep.local'
if (VAPID_PUBLIC_KEY && VAPID_PRIVATE_KEY) {
webpush.setVapidDetails(VAPID_EMAIL, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY)
}
interface PushSubscriptionData {
endpoint: string
p256dh: string
auth: string
}
interface NotificationPayload {
title: string
body: string
icon?: string
badge?: string
tag?: string
data?: {
url?: string
medicationId?: string
action?: string
}
actions?: Array<{
action: string
title: string
}>
}
export async function sendPushNotification(
subscription: PushSubscriptionData,
payload: NotificationPayload
): Promise<boolean> {
if (!VAPID_PUBLIC_KEY || !VAPID_PRIVATE_KEY) {
console.warn('VAPID keys not configured, skipping push notification')
return false
}
try {
const pushSubscription = {
endpoint: subscription.endpoint,
keys: {
p256dh: subscription.p256dh,
auth: subscription.auth,
},
}
await webpush.sendNotification(
pushSubscription,
JSON.stringify(payload)
)
return true
} catch (error: any) {
if (error.statusCode === 410 || error.statusCode === 404) {
// Subscription has expired or is no longer valid
console.log('Push subscription expired:', subscription.endpoint)
return false
}
console.error('Push notification error:', error)
throw error
}
}
export function generateVAPIDKeys(): { publicKey: string; privateKey: string } {
const keys = webpush.generateVAPIDKeys()
return {
publicKey: keys.publicKey,
privateKey: keys.privateKey,
}
}
export function getPublicVAPIDKey(): string {
return VAPID_PUBLIC_KEY
}

View File

@@ -0,0 +1,188 @@
import { prisma } from '@/lib/db/prisma'
import { sendPushNotification } from './push'
interface MedicationSchedule {
medicationId: string
medicationName: string
workspaceId: string
times: string[] // HH:MM format
quietHoursStart: string | null
quietHoursEnd: string | null
}
/**
* Check if current time is within quiet hours
*/
function isQuietHours(
now: Date,
quietStart: string | null,
quietEnd: string | null
): boolean {
if (!quietStart || !quietEnd) return false
const currentMinutes = now.getHours() * 60 + now.getMinutes()
const [startH, startM] = quietStart.split(':').map(Number)
const [endH, endM] = quietEnd.split(':').map(Number)
const startMinutes = startH * 60 + startM
const endMinutes = endH * 60 + endM
// Handle overnight quiet hours (e.g., 22:00 to 07:00)
if (startMinutes > endMinutes) {
return currentMinutes >= startMinutes || currentMinutes < endMinutes
}
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
*/
export async function getMedicationsDue(
now: Date = new Date()
): Promise<MedicationSchedule[]> {
const medications = await prisma.medication.findMany({
where: {
active: true,
deletedAt: null,
},
include: {
workspace: {
select: {
id: true,
quietHoursStart: true,
quietHoursEnd: true,
},
},
},
})
const due: MedicationSchedule[] = []
for (const med of medications) {
const scheduleData = med.scheduleData as any
// Skip if in quiet hours
if (
isQuietHours(
now,
med.workspace.quietHoursStart,
med.workspace.quietHoursEnd
)
) {
continue
}
let times: string[] = []
switch (med.scheduleType) {
case 'FIXED_TIMES':
times = scheduleData.times || []
break
case 'INTERVAL':
// Generate times based on interval
const startHour = parseInt(scheduleData.startTime?.split(':')[0] || '8')
const hours = scheduleData.hours || 4
for (let h = startHour; h < 24; h += hours) {
times.push(`${h.toString().padStart(2, '0')}:00`)
}
break
case 'WEEKDAYS':
// Check if today is a scheduled day
const todayDow = now.getDay()
if (scheduleData.days?.includes(todayDow)) {
times = scheduleData.time ? [scheduleData.time] : []
}
break
case 'PRN':
// PRN medications don't have scheduled times
break
}
// Check if any times are due now
const dueNow = times.some((time) => isDue(time, now))
if (dueNow) {
due.push({
medicationId: med.id,
medicationName: med.name,
workspaceId: med.workspaceId,
times,
quietHoursStart: med.workspace.quietHoursStart,
quietHoursEnd: med.workspace.quietHoursEnd,
})
}
}
return due
}
/**
* Send notifications for all due medications
*/
export async function sendDueNotifications(
now: Date = new Date()
): Promise<{ sent: number; failed: number }> {
const due = await getMedicationsDue(now)
let sent = 0
let failed = 0
for (const med of due) {
// Get all push subscriptions for this workspace
const subscriptions = await prisma.pushSubscription.findMany({
where: { workspaceId: med.workspaceId },
})
for (const sub of subscriptions) {
try {
const success = await sendPushNotification(
{
endpoint: sub.endpoint,
p256dh: sub.p256dh,
auth: sub.auth,
},
{
title: 'Medication Reminder',
body: `Time to take ${med.medicationName}`,
icon: '/icon-192.png',
badge: '/badge-72.png',
tag: `med-${med.medicationId}`,
data: {
url: '/meds',
medicationId: med.medicationId,
action: 'take_dose',
},
actions: [
{ action: 'take', title: 'Mark as Taken' },
{ action: 'snooze', title: 'Snooze 15 min' },
],
}
)
if (success) {
sent++
} else {
// Subscription expired, remove it
await prisma.pushSubscription.delete({ where: { id: sub.id } })
failed++
}
} catch (error) {
console.error('Failed to send notification:', error)
failed++
}
}
}
return { sent, failed }
}

View File

@@ -30,6 +30,11 @@ export interface LocalMedication {
syncedAt: string
createdBy?: { id: string; name: string }
updatedBy?: { id: string; name: string }
// Refill tracking fields
pillCount: number | null
pillsPerDose: number | null
refillThreshold: number | null
lastRefillDate: string | null
}
export interface LocalNote {
@@ -68,13 +73,37 @@ export interface LocalWorkspace {
largeTextMode: boolean
role?: string
updatedAt: string
// Emergency info fields
patientName: string | null
patientDOB: string | null
bloodType: string | null
allergies: string | null
medicalConditions: string | null
primaryPhysician: string | null
physicianPhone: string | null
}
export type SymptomType = 'FATIGUE' | 'NAUSEA' | 'PAIN' | 'APPETITE' | 'SLEEP' | 'MOOD' | 'CUSTOM'
export interface LocalSymptom {
id: string
workspaceId: string
type: SymptomType
customName: string | null
severity: number
notes: string | null
recordedAt: string
deletedAt: string | null
version: number
syncedAt: string
createdBy?: { id: string; name: string }
}
export interface SyncOp {
id: string
workspaceId: string
type: 'CREATE' | 'UPDATE' | 'DELETE' | 'TAKE_DOSE' | 'UNDO_DOSE' | 'MARK_ASKED'
entityType: 'APPOINTMENT' | 'MEDICATION' | 'NOTE' | 'DOSE_LOG'
type: 'CREATE' | 'UPDATE' | 'DELETE' | 'TAKE_DOSE' | 'UNDO_DOSE' | 'MARK_ASKED' | 'UNMARK_ASKED' | 'REFILL' | 'LOG_SYMPTOM' | 'DELETE_SYMPTOM'
entityType: 'APPOINTMENT' | 'MEDICATION' | 'NOTE' | 'DOSE_LOG' | 'SYMPTOM'
entityId?: string
data?: Record<string, unknown>
timestamp: number
@@ -94,12 +123,14 @@ class NextStepDB extends Dexie {
notes!: Table<LocalNote, string>
doseLogs!: Table<LocalDoseLog, string>
workspaces!: Table<LocalWorkspace, string>
symptoms!: Table<LocalSymptom, string>
outbox!: Table<SyncOp, string>
syncMeta!: Table<SyncMeta, string>
constructor() {
super('NextStepDB')
// Version 1: Original schema
this.version(1).stores({
appointments: 'id, workspaceId, datetime, deletedAt',
medications: 'id, workspaceId, active, deletedAt',
@@ -109,6 +140,18 @@ class NextStepDB extends Dexie {
outbox: 'id, workspaceId, timestamp',
syncMeta: 'id, workspaceId',
})
// Version 2: Add symptoms table, extend workspace & medication fields
this.version(2).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',
outbox: 'id, workspaceId, timestamp',
syncMeta: 'id, workspaceId',
})
}
}

View File

@@ -25,4 +25,5 @@ export {
logDose,
undoDose,
markQuestionAsked,
unmarkQuestionAsked,
} from './manager'

View File

@@ -1,5 +1,5 @@
import { db, generateTempId, type SyncOp } from './db'
import type { LocalAppointment, LocalMedication, LocalNote, LocalDoseLog } from './db'
import type { LocalAppointment, LocalMedication, LocalNote, LocalDoseLog, LocalSymptom } from './db'
const SYNC_INTERVAL = 30000 // 30 seconds
const MAX_RETRIES = 3
@@ -70,12 +70,20 @@ export async function pullChanges(workspaceId: string): Promise<boolean> {
const data = await response.json()
// Update local database
await db.transaction('rw', [db.appointments, db.medications, db.notes, db.doseLogs, db.workspaces, db.syncMeta], async () => {
// Update workspace
await db.transaction('rw', [db.appointments, db.medications, db.notes, db.doseLogs, db.symptoms, db.workspaces, db.syncMeta], async () => {
// Update workspace (including emergency info fields)
if (data.workspace) {
await db.workspaces.put({
...data.workspace,
updatedAt: data.workspace.updatedAt || new Date().toISOString(),
// Ensure emergency fields are properly set (even if null)
patientName: data.workspace.patientName || null,
patientDOB: data.workspace.patientDOB || null,
bloodType: data.workspace.bloodType || null,
allergies: data.workspace.allergies || null,
medicalConditions: data.workspace.medicalConditions || null,
primaryPhysician: data.workspace.primaryPhysician || null,
physicianPhone: data.workspace.physicianPhone || null,
})
}
@@ -132,6 +140,18 @@ export async function pullChanges(workspaceId: string): Promise<boolean> {
}
}
// Update symptoms
for (const symptom of data.symptoms || []) {
const existing = await db.symptoms.get(symptom.id)
if (!existing || new Date(symptom.syncedAt) > new Date(existing.syncedAt)) {
await db.symptoms.put({
...symptom,
recordedAt: symptom.recordedAt,
syncedAt: symptom.syncedAt,
})
}
}
// Update sync cursor
await db.syncMeta.put({
id: workspaceId,
@@ -179,7 +199,7 @@ export async function pushChanges(workspaceId: string): Promise<boolean> {
const data = await response.json()
// Process results and remove successful ops from outbox
await db.transaction('rw', [db.outbox, db.appointments, db.notes], async () => {
await db.transaction('rw', [db.outbox, db.appointments, db.notes, db.symptoms], async () => {
for (const result of data.results) {
if (result.success) {
// Find the op
@@ -198,6 +218,12 @@ export async function pushChanges(workspaceId: string): Promise<boolean> {
await db.notes.delete(op.entityId)
await db.notes.put({ ...local, id: result.entityId })
}
} else if (op.entityType === 'SYMPTOM') {
const local = await db.symptoms.get(op.entityId)
if (local) {
await db.symptoms.delete(op.entityId)
await db.symptoms.put({ ...local, id: result.entityId })
}
}
}
@@ -408,6 +434,14 @@ export async function logDose(
}
await db.doseLogs.add(doseLog)
// Decrement pill count if tracking is enabled
const localMed = await db.medications.get(medicationId)
if (localMed && localMed.pillCount !== null && localMed.pillsPerDose !== null) {
const newCount = Math.max(0, localMed.pillCount - (localMed.pillsPerDose || 1))
await db.medications.update(medicationId, { pillCount: newCount })
}
await addToOutbox({
workspaceId,
type: 'TAKE_DOSE',
@@ -424,6 +458,13 @@ export async function undoDose(doseLog: LocalDoseLog): Promise<void> {
const now = new Date().toISOString()
await db.doseLogs.update(doseLog.id, { undoneAt: now })
// Restore pill count if tracking is enabled
const localMed = await db.medications.get(doseLog.medicationId)
if (localMed && localMed.pillCount !== null && localMed.pillsPerDose !== null) {
const newCount = localMed.pillCount + (localMed.pillsPerDose || 1)
await db.medications.update(doseLog.medicationId, { pillCount: newCount })
}
await addToOutbox({
workspaceId: doseLog.workspaceId,
type: 'UNDO_DOSE',
@@ -445,3 +486,75 @@ export async function markQuestionAsked(note: LocalNote): Promise<void> {
timestamp: Date.now(),
})
}
export async function unmarkQuestionAsked(note: LocalNote): Promise<void> {
await db.notes.update(note.id, { askedAt: null })
await addToOutbox({
workspaceId: note.workspaceId,
type: 'UNMARK_ASKED',
entityType: 'NOTE',
entityId: note.id,
timestamp: Date.now(),
})
}
export async function logSymptom(
workspaceId: string,
data: {
type: LocalSymptom['type']
customName?: string
severity: number
notes?: string
}
): Promise<LocalSymptom> {
const id = generateTempId()
const now = new Date().toISOString()
const symptom: LocalSymptom = {
id,
workspaceId,
type: data.type,
customName: data.customName || null,
severity: data.severity,
notes: data.notes || null,
recordedAt: now,
deletedAt: null,
version: 1,
syncedAt: now,
}
await db.symptoms.add(symptom)
await addToOutbox({
workspaceId,
type: 'LOG_SYMPTOM',
entityType: 'SYMPTOM',
entityId: id,
data: {
type: data.type,
customName: data.customName,
severity: data.severity,
notes: data.notes,
recordedAt: now,
},
timestamp: Date.now(),
})
return symptom
}
export async function deleteSymptom(symptom: LocalSymptom): Promise<void> {
const now = new Date().toISOString()
await db.symptoms.update(symptom.id, {
deletedAt: now,
version: symptom.version + 1,
syncedAt: now,
})
await addToOutbox({
workspaceId: symptom.workspaceId,
type: 'DELETE_SYMPTOM',
entityType: 'SYMPTOM',
entityId: symptom.id,
timestamp: Date.now(),
})
}

View File

@@ -24,6 +24,26 @@ export const updateWorkspaceSchema = z.object({
quietHoursStart: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).nullable().optional(),
quietHoursEnd: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).nullable().optional(),
largeTextMode: z.boolean().optional(),
// Emergency info fields
patientName: z.string().max(100).nullable().optional(),
patientDOB: z.string().datetime().nullable().optional(),
bloodType: z.string().max(10).nullable().optional(),
allergies: z.string().max(1000).nullable().optional(),
medicalConditions: z.string().max(2000).nullable().optional(),
primaryPhysician: z.string().max(100).nullable().optional(),
physicianPhone: z.string().max(50).nullable().optional(),
})
export const emergencyInfoSchema = z.object({
patientName: z.string().max(100).nullable().optional(),
patientDOB: z.string().datetime().nullable().optional(),
bloodType: z.string().max(10).nullable().optional(),
allergies: z.string().max(1000).nullable().optional(),
medicalConditions: z.string().max(2000).nullable().optional(),
primaryPhysician: z.string().max(100).nullable().optional(),
physicianPhone: z.string().max(50).nullable().optional(),
clinicPhone: z.string().max(50).nullable().optional(),
emergencyPhone: z.string().max(50).nullable().optional(),
})
export const inviteSchema = z.object({
@@ -108,8 +128,8 @@ export const syncQuerySchema = z.object({
export const syncOpSchema = z.object({
id: z.string(),
type: z.enum(['CREATE', 'UPDATE', 'DELETE', 'TAKE_DOSE', 'UNDO_DOSE', 'MARK_ASKED']),
entityType: z.enum(['APPOINTMENT', 'MEDICATION', 'NOTE', 'DOSE_LOG']),
type: z.enum(['CREATE', 'UPDATE', 'DELETE', 'TAKE_DOSE', 'UNDO_DOSE', 'MARK_ASKED', 'UNMARK_ASKED', 'REFILL', 'LOG_SYMPTOM', 'DELETE_SYMPTOM']),
entityType: z.enum(['APPOINTMENT', 'MEDICATION', 'NOTE', 'DOSE_LOG', 'SYMPTOM']),
entityId: z.string().optional(),
data: z.record(z.unknown()).optional(),
timestamp: z.number(),
@@ -120,15 +140,45 @@ export const syncOpsSchema = z.object({
ops: z.array(syncOpSchema),
})
// Symptom schemas
export const symptomTypeEnum = z.enum(['FATIGUE', 'NAUSEA', 'PAIN', 'APPETITE', 'SLEEP', 'MOOD', 'CUSTOM'])
export const symptomSchema = z.object({
type: symptomTypeEnum,
customName: z.string().max(100).nullable().optional(),
severity: z.number().min(1).max(5),
notes: z.string().max(2000).nullable().optional(),
recordedAt: z.string().datetime().optional(),
})
// Medication refill schema
export const refillSchema = z.object({
pillCount: z.number().min(0).optional(),
pillsPerDose: z.number().min(1).default(1).optional(),
refillThreshold: z.number().min(0).default(7).optional(),
lastRefillDate: z.string().datetime().nullable().optional(),
})
export const medicationWithRefillSchema = medicationSchema.extend({
pillCount: z.number().min(0).nullable().optional(),
pillsPerDose: z.number().min(1).nullable().optional(),
refillThreshold: z.number().min(0).nullable().optional(),
lastRefillDate: z.string().datetime().nullable().optional(),
})
// Type exports
export type LoginInput = z.infer<typeof loginSchema>
export type RegisterInput = z.infer<typeof registerSchema>
export type CreateWorkspaceInput = z.infer<typeof createWorkspaceSchema>
export type UpdateWorkspaceInput = z.infer<typeof updateWorkspaceSchema>
export type EmergencyInfoInput = z.infer<typeof emergencyInfoSchema>
export type InviteInput = z.infer<typeof inviteSchema>
export type AppointmentInput = z.infer<typeof appointmentSchema>
export type MedicationInput = z.infer<typeof medicationSchema>
export type MedicationWithRefillInput = z.infer<typeof medicationWithRefillSchema>
export type ScheduleDataInput = z.infer<typeof scheduleDataSchema>
export type DoseLogInput = z.infer<typeof doseLogSchema>
export type NoteInput = z.infer<typeof noteSchema>
export type SymptomInput = z.infer<typeof symptomSchema>
export type SymptomType = z.infer<typeof symptomTypeEnum>
export type SyncOp = z.infer<typeof syncOpSchema>

215
src/styles/print.css Normal file
View File

@@ -0,0 +1,215 @@
/* Print-specific styles */
@media print {
/* Hide navigation and non-essential elements */
nav,
.no-print,
button:not(.print-button),
.bottom-nav {
display: none !important;
}
/* Reset background colors for printing */
body,
.bg-surface,
.bg-background {
background: white !important;
color: black !important;
}
/* Ensure text is readable */
* {
color-adjust: exact !important;
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
/* Page settings */
@page {
margin: 0.75in;
size: letter;
}
/* Prevent page breaks inside elements */
.print-no-break {
page-break-inside: avoid;
break-inside: avoid;
}
/* Force page breaks */
.print-page-break {
page-break-before: always;
break-before: page;
}
/* Print-specific sizes */
.print-title {
font-size: 24pt !important;
font-weight: bold !important;
margin-bottom: 12pt !important;
}
.print-subtitle {
font-size: 14pt !important;
font-weight: 600 !important;
margin-bottom: 8pt !important;
}
.print-text {
font-size: 12pt !important;
line-height: 1.4 !important;
}
.print-text-large {
font-size: 14pt !important;
line-height: 1.5 !important;
}
/* Large checkboxes for daily meds */
.print-checkbox {
width: 24pt !important;
height: 24pt !important;
border: 2pt solid black !important;
display: inline-block !important;
margin-right: 8pt !important;
vertical-align: middle !important;
}
/* Table styles */
.print-table {
width: 100% !important;
border-collapse: collapse !important;
margin-bottom: 12pt !important;
}
.print-table th,
.print-table td {
border: 1pt solid #333 !important;
padding: 6pt 8pt !important;
text-align: left !important;
font-size: 11pt !important;
}
.print-table th {
background-color: #f0f0f0 !important;
font-weight: bold !important;
}
/* Section spacing */
.print-section {
margin-bottom: 24pt !important;
}
/* Date header */
.print-date {
font-size: 12pt !important;
color: #666 !important;
margin-bottom: 16pt !important;
}
/* Emergency info box */
.print-emergency-box {
border: 2pt solid #dc2626 !important;
padding: 12pt !important;
margin-bottom: 16pt !important;
}
/* Medication list item */
.print-med-item {
display: flex !important;
align-items: center !important;
padding: 8pt 0 !important;
border-bottom: 1pt solid #ccc !important;
}
.print-med-name {
font-weight: bold !important;
font-size: 14pt !important;
flex: 1 !important;
}
.print-med-time {
font-size: 12pt !important;
min-width: 80pt !important;
text-align: right !important;
}
/* Notes section */
.print-notes {
border: 1pt solid #ccc !important;
padding: 8pt !important;
min-height: 48pt !important;
background: #fafafa !important;
}
/* Question list */
.print-question {
padding: 8pt 0 !important;
border-bottom: 1pt solid #eee !important;
}
.print-question-checkbox {
width: 16pt !important;
height: 16pt !important;
border: 1.5pt solid black !important;
display: inline-block !important;
margin-right: 8pt !important;
vertical-align: middle !important;
}
/* Footer */
.print-footer {
position: fixed !important;
bottom: 0 !important;
left: 0 !important;
right: 0 !important;
text-align: center !important;
font-size: 9pt !important;
color: #666 !important;
padding: 8pt !important;
}
/* Hide interactive elements */
input,
select,
textarea {
border: 1pt solid #ccc !important;
background: white !important;
}
/* Link styling */
a {
text-decoration: none !important;
color: black !important;
}
a[href]::after {
content: none !important;
}
}
/* Screen styles for print preview */
@media screen {
.print-preview {
max-width: 8.5in;
margin: 0 auto;
padding: 0.75in;
background: white;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
min-height: 11in;
}
.print-only {
display: none;
}
}
@media print {
.screen-only {
display: none !important;
}
.print-only {
display: block !important;
}
}