4 Commits

Author SHA1 Message Date
Gemini Agent
cae436a20d Add scheduler service for push notifications 2026-01-25 02:25:48 +00:00
Gemini Agent
8c9ae06360 Implement Edit Medication feature, refactor medication form, and fix build issues 2026-01-25 02:20:16 +00:00
Gemini Agent
7fa95c058e Fix medication scheduling bugs and add delete dose feature 2026-01-25 02:13:51 +00:00
Gemini Agent
f598f6138e Add test notification feature for push notification debugging
- Add POST /api/notifications/test endpoint to send test notifications
- Add "Send Test Notification" button to notifications settings page
- Shows success/failure feedback and removes expired subscriptions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 22:44:05 +00:00
9 changed files with 662 additions and 346 deletions

View File

@@ -41,7 +41,7 @@ RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs RUN adduser --system --uid 1001 nextjs
# Install OpenSSL and CA certificates for Prisma # Install OpenSSL and CA certificates for Prisma
RUN apt-get update && apt-get install -y openssl ca-certificates && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y openssl ca-certificates wget && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/public ./public COPY --from=builder /app/public ./public
COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/prisma ./prisma

View File

@@ -31,6 +31,24 @@ services:
retries: 3 retries: 3
start_period: 40s start_period: 40s
scheduler:
image: alpine
restart: unless-stopped
depends_on:
app:
condition: service_healthy
entrypoint: /bin/sh
command: >
-c "apk add --no-cache curl &&
while true; do
echo 'Triggering notification check...' &&
curl -s -X POST http://app:3000/api/notifications/send &&
echo '' &&
sleep 60;
done"
networks:
- nextstep-network
db: db:
image: postgres:16-alpine image: postgres:16-alpine
container_name: nextstep-db container_name: nextstep-db

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -133,6 +133,11 @@ function calculateIntervalDue(
// Calculate how many intervals have passed since start time today // Calculate how many intervals have passed since start time today
const minutesSinceStart = differenceInMinutes(now, startToday) const minutesSinceStart = differenceInMinutes(now, startToday)
const intervalMinutes = schedule.hours * 60 const intervalMinutes = schedule.hours * 60
if (intervalMinutes <= 0) {
return startToday
}
const intervalsPassed = Math.floor(minutesSinceStart / intervalMinutes) const intervalsPassed = Math.floor(minutesSinceStart / intervalMinutes)
const nextDue = addMinutes(startToday, (intervalsPassed + 1) * intervalMinutes) const nextDue = addMinutes(startToday, (intervalsPassed + 1) * intervalMinutes)