mirror of
https://github.com/Tony0410/nextstep.git
synced 2026-05-24 21:31:43 +08:00
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>
461 lines
16 KiB
TypeScript
461 lines
16 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useState, useCallback } from 'react'
|
|
import { useRouter } from 'next/navigation'
|
|
import { format, isToday, isTomorrow } from 'date-fns'
|
|
import { toZonedTime } from 'date-fns-tz'
|
|
import { Phone, MapPin, Clock, ChevronRight, Pill, Calendar, Plus, AlertTriangle, ClipboardCheck } from 'lucide-react'
|
|
import { useLiveQuery } from 'dexie-react-hooks'
|
|
|
|
import { db, logDose, undoDose } from '@/lib/sync'
|
|
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'
|
|
|
|
export default function TodayPage() {
|
|
const router = useRouter()
|
|
const { currentWorkspace, refreshData } = useApp()
|
|
const [now, setNow] = useState(() => new Date())
|
|
const [quickNote, setQuickNote] = useState('')
|
|
const [isAddingNote, setIsAddingNote] = useState(false)
|
|
|
|
// Update time every minute
|
|
useEffect(() => {
|
|
const interval = setInterval(() => setNow(new Date()), 60000)
|
|
return () => clearInterval(interval)
|
|
}, [])
|
|
|
|
// Fetch data from IndexedDB
|
|
const appointments = useLiveQuery(
|
|
() =>
|
|
db.appointments
|
|
.where('workspaceId')
|
|
.equals(currentWorkspace.id)
|
|
.and((a) => !a.deletedAt && new Date(a.datetime) >= now)
|
|
.sortBy('datetime'),
|
|
[currentWorkspace.id, now]
|
|
)
|
|
|
|
const medications = useLiveQuery(
|
|
() =>
|
|
db.medications
|
|
.where('workspaceId')
|
|
.equals(currentWorkspace.id)
|
|
.and((m) => m.active && !m.deletedAt)
|
|
.toArray(),
|
|
[currentWorkspace.id]
|
|
)
|
|
|
|
const doseLogs = useLiveQuery(
|
|
() =>
|
|
db.doseLogs
|
|
.where('workspaceId')
|
|
.equals(currentWorkspace.id)
|
|
.toArray(),
|
|
[currentWorkspace.id]
|
|
)
|
|
|
|
// Calculate medication due statuses
|
|
const [medStatuses, setMedStatuses] = useState<MedicationDueStatus[]>([])
|
|
|
|
useEffect(() => {
|
|
if (medications && doseLogs) {
|
|
const meds = medications.map((m) => ({
|
|
...m,
|
|
scheduleData: m.scheduleData as unknown as Medication['scheduleData'],
|
|
startDate: m.startDate ? new Date(m.startDate) : null,
|
|
endDate: m.endDate ? new Date(m.endDate) : null,
|
|
})) as Medication[]
|
|
|
|
const logs = doseLogs.map((d) => ({
|
|
...d,
|
|
takenAt: new Date(d.takenAt),
|
|
undoneAt: d.undoneAt ? new Date(d.undoneAt) : null,
|
|
})) as DoseLog[]
|
|
|
|
const statuses = calculateAllMedicationsDue(meds, now, logs)
|
|
setMedStatuses(statuses)
|
|
}
|
|
}, [medications, doseLogs, now])
|
|
|
|
// Get next appointment
|
|
const nextAppointment = appointments?.[0]
|
|
|
|
// Get meds due soon (due within 2 hours or overdue)
|
|
const medsDueSoon = medStatuses
|
|
.filter((s) => {
|
|
if (s.isOverdue) return true
|
|
if (s.isPRN && s.prnAvailable) return true
|
|
if (s.nextDueAt) {
|
|
const minutesUntil = (s.nextDueAt.getTime() - now.getTime()) / 1000 / 60
|
|
return minutesUntil <= 120
|
|
}
|
|
return false
|
|
})
|
|
.slice(0, 5)
|
|
|
|
const handleTakeMed = useCallback(
|
|
async (status: MedicationDueStatus) => {
|
|
try {
|
|
const doseLog = await logDose(
|
|
currentWorkspace.id,
|
|
status.medication.id,
|
|
{ id: status.medication.id, name: status.medication.name }
|
|
)
|
|
|
|
showUndoToast(`Took ${status.medication.name}`, async () => {
|
|
await undoDose(doseLog)
|
|
showToast('Dose undone', 'info')
|
|
})
|
|
} catch {
|
|
showToast('Failed to log dose', 'error')
|
|
}
|
|
},
|
|
[currentWorkspace.id]
|
|
)
|
|
|
|
const handleAddQuickNote = async () => {
|
|
if (!quickNote.trim()) return
|
|
|
|
setIsAddingNote(true)
|
|
try {
|
|
const response = await fetch(`/api/workspaces/${currentWorkspace.id}/notes`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
type: 'GENERAL',
|
|
content: quickNote.trim(),
|
|
}),
|
|
})
|
|
|
|
if (!response.ok) throw new Error('Failed to add note')
|
|
|
|
setQuickNote('')
|
|
showToast('Note added', 'success')
|
|
await refreshData()
|
|
} catch {
|
|
showToast('Failed to add note', 'error')
|
|
} finally {
|
|
setIsAddingNote(false)
|
|
}
|
|
}
|
|
|
|
const formatAppointmentDate = (datetime: string) => {
|
|
const date = toZonedTime(new Date(datetime), TIMEZONE)
|
|
if (isToday(date)) return `Today at ${format(date, 'h:mm a')}`
|
|
if (isTomorrow(date)) return `Tomorrow at ${format(date, 'h:mm a')}`
|
|
return format(date, 'EEE, MMM d \'at\' h:mm a')
|
|
}
|
|
|
|
if (!appointments || !medications) {
|
|
return (
|
|
<>
|
|
<Header title="Today" />
|
|
<PageContainer>
|
|
<LoadingState message="Loading your day..." />
|
|
</PageContainer>
|
|
</>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Header title="Today" />
|
|
<PageContainer className="pt-4 space-y-6">
|
|
{/* Greeting */}
|
|
<div className="mb-2">
|
|
<p className="text-secondary-500 text-sm">
|
|
{format(toZonedTime(now, TIMEZONE), 'EEEE, MMMM d')}
|
|
</p>
|
|
</div>
|
|
|
|
{/* 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-red-600 flex items-center justify-center">
|
|
<AlertTriangle className="w-5 h-5 text-white" />
|
|
</div>
|
|
<div className="text-left">
|
|
<p className="font-medium text-red-800">Emergency</p>
|
|
<p className="text-sm text-red-600">Medical info</p>
|
|
</div>
|
|
</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>
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h2 className="text-lg font-semibold text-secondary-900">Next Appointment</h2>
|
|
<button
|
|
onClick={() => router.push('/appointments')}
|
|
className="text-sm text-primary-600 font-medium flex items-center"
|
|
>
|
|
View all
|
|
<ChevronRight className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{nextAppointment ? (
|
|
<Card
|
|
className="card-appointment"
|
|
onClick={() => router.push(`/appointments/${nextAppointment.id}`)}
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
<div className="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center flex-shrink-0">
|
|
<Calendar className="w-5 h-5 text-primary-600" />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<h3 className="font-semibold text-secondary-900 truncate">
|
|
{nextAppointment.title}
|
|
</h3>
|
|
<p className="text-sm text-secondary-600 flex items-center gap-1 mt-1">
|
|
<Clock className="w-4 h-4" />
|
|
{formatAppointmentDate(nextAppointment.datetime)}
|
|
</p>
|
|
{nextAppointment.location && (
|
|
<p className="text-sm text-secondary-500 flex items-center gap-1 mt-0.5">
|
|
<MapPin className="w-4 h-4" />
|
|
<span className="truncate">{nextAppointment.location}</span>
|
|
</p>
|
|
)}
|
|
</div>
|
|
<ChevronRight className="w-5 h-5 text-secondary-400" />
|
|
</div>
|
|
{nextAppointment.mapUrl && (
|
|
<a
|
|
href={nextAppointment.mapUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="inline-flex items-center gap-1.5 mt-3 text-sm text-primary-600 font-medium hover:text-primary-700"
|
|
>
|
|
<MapPin className="w-4 h-4" />
|
|
Open in Maps
|
|
</a>
|
|
)}
|
|
</Card>
|
|
) : (
|
|
<Card variant="outline" className="text-center py-6">
|
|
<Calendar className="w-8 h-8 text-secondary-300 mx-auto mb-2" />
|
|
<p className="text-secondary-500">No upcoming appointments</p>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="mt-2"
|
|
onClick={() => router.push('/appointments/new')}
|
|
>
|
|
<Plus className="w-4 h-4 mr-1" />
|
|
Add one
|
|
</Button>
|
|
</Card>
|
|
)}
|
|
</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">
|
|
<h2 className="text-lg font-semibold text-secondary-900">Medications</h2>
|
|
<button
|
|
onClick={() => router.push('/meds')}
|
|
className="text-sm text-primary-600 font-medium flex items-center"
|
|
>
|
|
View all
|
|
<ChevronRight className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{medsDueSoon.length > 0 ? (
|
|
<div className="space-y-3">
|
|
{medsDueSoon.map((status) => (
|
|
<MedicationCard
|
|
key={status.medication.id}
|
|
status={status}
|
|
now={now}
|
|
onTake={() => handleTakeMed(status)}
|
|
/>
|
|
))}
|
|
</div>
|
|
) : medications.length > 0 ? (
|
|
<Card variant="outline" className="text-center py-6">
|
|
<Pill className="w-8 h-8 text-secondary-300 mx-auto mb-2" />
|
|
<p className="text-secondary-500">All caught up! No meds due soon.</p>
|
|
</Card>
|
|
) : (
|
|
<EmptyState
|
|
type="medications"
|
|
title="No medications"
|
|
description="Add medications to track when to take them."
|
|
action={{
|
|
label: 'Add Medication',
|
|
onClick: () => router.push('/meds/new'),
|
|
}}
|
|
/>
|
|
)}
|
|
</section>
|
|
|
|
{/* Quick Note */}
|
|
<section>
|
|
<h2 className="text-lg font-semibold text-secondary-900 mb-3">Quick Note</h2>
|
|
<Card padding="sm">
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="text"
|
|
value={quickNote}
|
|
onChange={(e) => setQuickNote(e.target.value)}
|
|
placeholder="Jot down a thought..."
|
|
className="flex-1 px-3 py-2.5 border border-border rounded-button text-base focus:outline-none focus:ring-2 focus:ring-primary-200 focus:border-primary-500"
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' && quickNote.trim()) {
|
|
handleAddQuickNote()
|
|
}
|
|
}}
|
|
/>
|
|
<Button
|
|
onClick={handleAddQuickNote}
|
|
disabled={!quickNote.trim() || isAddingNote}
|
|
loading={isAddingNote}
|
|
>
|
|
Add
|
|
</Button>
|
|
</div>
|
|
</Card>
|
|
</section>
|
|
</PageContainer>
|
|
</>
|
|
)
|
|
}
|
|
|
|
interface MedicationCardProps {
|
|
status: MedicationDueStatus
|
|
now: Date
|
|
onTake: () => void
|
|
}
|
|
|
|
function MedicationCard({ status, now, onTake }: MedicationCardProps) {
|
|
const { medication, isOverdue, isPRN, prnAvailable, prnAvailableAt, nextDueAt } = status
|
|
|
|
const getTimeLabel = () => {
|
|
if (isOverdue && nextDueAt) {
|
|
return formatTimeUntil(nextDueAt, now)
|
|
}
|
|
if (isPRN) {
|
|
if (prnAvailable) {
|
|
return 'Available now'
|
|
}
|
|
if (prnAvailableAt) {
|
|
return `Available ${formatTimeUntil(prnAvailableAt, now)}`
|
|
}
|
|
return 'As needed'
|
|
}
|
|
if (nextDueAt) {
|
|
return formatTimeUntil(nextDueAt, now)
|
|
}
|
|
return ''
|
|
}
|
|
|
|
const canTake = !isPRN || prnAvailable
|
|
|
|
return (
|
|
<Card className={isOverdue ? 'overdue' : ''}>
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center flex-shrink-0">
|
|
<Pill className="w-5 h-5 text-primary-600" />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<h3 className="font-semibold text-secondary-900">{medication.name}</h3>
|
|
<p className={`text-sm ${isOverdue ? 'text-red-600 font-medium' : 'text-secondary-500'}`}>
|
|
{getTimeLabel()}
|
|
{isPRN && ' • As needed'}
|
|
</p>
|
|
</div>
|
|
<Button
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
onTake()
|
|
}}
|
|
variant="success"
|
|
size="md"
|
|
disabled={!canTake}
|
|
>
|
|
Taken
|
|
</Button>
|
|
</div>
|
|
{medication.instructions && (
|
|
<p className="text-sm text-secondary-500 mt-2 ml-13">
|
|
{medication.instructions}
|
|
</p>
|
|
)}
|
|
</Card>
|
|
)
|
|
}
|