From 2cb56d5f5f53264310f6babf05ebee12f27e72f5 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Tue, 23 Sep 2025 10:45:18 -0700 Subject: [PATCH] feat(medication): improve snooze timer handling --- App.tsx | 73 ++++++++++++++++++++++-------- utils/__tests__/doseStatus.test.ts | 63 ++++++++++++++++++++++++++ utils/doseStatus.ts | 35 ++++++++++++++ 3 files changed, 153 insertions(+), 18 deletions(-) create mode 100644 utils/__tests__/doseStatus.test.ts create mode 100644 utils/doseStatus.ts diff --git a/App.tsx b/App.tsx index 7f7deed..c306cc7 100644 --- a/App.tsx +++ b/App.tsx @@ -65,6 +65,7 @@ import { databaseService } from './services/database'; import { databaseSeeder } from './services/database.seeder'; import { logger } from './services/logging'; import { normalizeError } from './utils/error'; +import { determineDoseStatus } from './utils/doseStatus'; const Header: React.FC<{ onAdd: () => void; @@ -377,16 +378,35 @@ const MedicationScheduleApp: React.FC<{ user: User }> = ({ user }) => { async (doseId: string) => { if (!takenDosesDoc) return; const newDoses = { ...takenDosesDoc.doses }; - if (newDoses[doseId]) { + const wasTaken = Boolean(newDoses[doseId]); + + if (wasTaken) { delete newDoses[doseId]; } else { newDoses[doseId] = new Date().toISOString(); } + const updatedDoc = await databaseService.updateTakenDoses({ ...takenDosesDoc, doses: newDoses, }); setTakenDosesDoc(updatedDoc); + + if (!wasTaken) { + setSnoozedDoses(prev => { + if (!prev[doseId]) { + return prev; + } + const updated = { ...prev }; + delete updated[doseId]; + return updated; + }); + + if (notificationTimers.current[doseId]) { + clearTimeout(notificationTimers.current[doseId]); + delete notificationTimers.current[doseId]; + } + } }, [takenDosesDoc] ); @@ -404,13 +424,13 @@ const MedicationScheduleApp: React.FC<{ user: User }> = ({ user }) => { }, []); const getDoseStatus = useCallback( - (dose: Dose, doseTime: Date, now: Date): DoseStatus => { - if (takenDoses[dose.id]) return DoseStatus.TAKEN; - if (snoozedDoses[dose.id] && new Date(snoozedDoses[dose.id]) > now) - return DoseStatus.SNOOZED; - if (doseTime.getTime() < now.getTime()) return DoseStatus.MISSED; - return DoseStatus.UPCOMING; - }, + (dose: Dose, doseTime: Date, now: Date): DoseStatus => + determineDoseStatus({ + takenAt: takenDoses[dose.id], + snoozedUntil: snoozedDoses[dose.id], + scheduledTime: doseTime, + now, + }), [takenDoses, snoozedDoses] ); @@ -422,15 +442,20 @@ const MedicationScheduleApp: React.FC<{ user: User }> = ({ user }) => { const medication = medications.find(m => m._id === item.medicationId); if (!medication) return null; + const snoozeString = snoozedDoses[item.id]; + const snoozeDate = snoozeString ? new Date(snoozeString) : undefined; + const validSnooze = + snoozeDate && !Number.isNaN(snoozeDate.getTime()) + ? snoozeDate + : undefined; + return { ...item, type: 'dose' as const, medication, status: getDoseStatus(item, item.scheduledTime, currentTime), takenAt: takenDoses[item.id], - snoozedUntil: snoozedDoses[item.id] - ? new Date(snoozedDoses[item.id]) - : undefined, + snoozedUntil: validSnooze, }; } else { // It's a Custom Reminder @@ -469,9 +494,10 @@ const MedicationScheduleApp: React.FC<{ user: User }> = ({ user }) => { let timeToNotification = -1; let notificationBody = ''; let notificationTitle = ''; + let targetTime: Date | null = null; if (item.type === 'dose' && item.status === DoseStatus.UPCOMING) { - timeToNotification = item.scheduledTime.getTime() - now.getTime(); + targetTime = item.snoozedUntil ?? item.scheduledTime; notificationTitle = 'Time for your medication!'; notificationBody = `${item.medication.name} (${item.medication.dosage})`; } else if ( @@ -479,17 +505,21 @@ const MedicationScheduleApp: React.FC<{ user: User }> = ({ user }) => { item.status === DoseStatus.SNOOZED && item.snoozedUntil ) { - timeToNotification = item.snoozedUntil.getTime() - now.getTime(); + targetTime = item.snoozedUntil; notificationTitle = 'Snoozed Medication Reminder'; notificationBody = `${item.medication.name} (${item.medication.dosage})`; } else if (item.type === 'reminder' && item.scheduledTime > now) { - timeToNotification = item.scheduledTime.getTime() - now.getTime(); + targetTime = item.scheduledTime; notificationTitle = 'Reminder'; notificationBody = item.title; } + if (targetTime) { + timeToNotification = targetTime.getTime() - now.getTime(); + } + if (timeToNotification > 0) { - activeTimers[itemId] = setTimeout(() => { + const timerId = window.setTimeout(() => { new Notification(notificationTitle, { body: notificationBody, tag: itemId, @@ -497,16 +527,23 @@ const MedicationScheduleApp: React.FC<{ user: User }> = ({ user }) => { if (item.type === 'dose' && item.status === DoseStatus.SNOOZED) { setSnoozedDoses(prev => { const newSnoozed = { ...prev }; - delete newSnoozed[itemId]; + newSnoozed[itemId] = new Date().toISOString(); return newSnoozed; }); } delete activeTimers[itemId]; - }, timeToNotification) as unknown as number; + }, timeToNotification); + + activeTimers[itemId] = timerId; } }); - return () => Object.values(activeTimers).forEach(clearTimeout); + return () => { + Object.entries(activeTimers).forEach(([id, timer]) => { + clearTimeout(timer); + delete activeTimers[id]; + }); + }; }, [scheduleWithStatus, settings?.notificationsEnabled]); const filteredSchedule = useMemo( diff --git a/utils/__tests__/doseStatus.test.ts b/utils/__tests__/doseStatus.test.ts new file mode 100644 index 0000000..89f2546 --- /dev/null +++ b/utils/__tests__/doseStatus.test.ts @@ -0,0 +1,63 @@ +import { determineDoseStatus } from '../doseStatus'; +import { DoseStatus } from '../../types'; + +describe('determineDoseStatus', () => { + const scheduledTime = new Date('2024-05-10T08:00:00.000Z'); + const now = new Date('2024-05-10T07:00:00.000Z'); + + it('returns TAKEN when dose has been recorded as taken', () => { + const status = determineDoseStatus({ + takenAt: new Date().toISOString(), + snoozedUntil: undefined, + scheduledTime, + now, + }); + + expect(status).toBe(DoseStatus.TAKEN); + }); + + it('returns SNOOZED when snooze time is in the future', () => { + const status = determineDoseStatus({ + takenAt: undefined, + snoozedUntil: new Date(now.getTime() + 5 * 60 * 1000).toISOString(), + scheduledTime, + now, + }); + + expect(status).toBe(DoseStatus.SNOOZED); + }); + + it('returns UPCOMING when snooze time has expired', () => { + const pastSnooze = new Date(now.getTime() - 5 * 60 * 1000).toISOString(); + const status = determineDoseStatus({ + takenAt: undefined, + snoozedUntil: pastSnooze, + scheduledTime: new Date(now.getTime() - 60 * 60 * 1000), + now, + }); + + expect(status).toBe(DoseStatus.UPCOMING); + }); + + it('returns MISSED when scheduled time is in the past without snooze', () => { + const status = determineDoseStatus({ + takenAt: undefined, + snoozedUntil: undefined, + scheduledTime: new Date(now.getTime() - 60 * 60 * 1000), + now, + }); + + expect(status).toBe(DoseStatus.MISSED); + }); + + it('returns UPCOMING when scheduled time is in the future without snooze', () => { + const status = determineDoseStatus({ + takenAt: undefined, + snoozedUntil: undefined, + scheduledTime: new Date(now.getTime() + 60 * 60 * 1000), + now, + }); + + expect(status).toBe(DoseStatus.UPCOMING); + }); +}); diff --git a/utils/doseStatus.ts b/utils/doseStatus.ts new file mode 100644 index 0000000..6710745 --- /dev/null +++ b/utils/doseStatus.ts @@ -0,0 +1,35 @@ +import { DoseStatus } from '../types'; + +export interface DoseStatusParams { + takenAt?: string; + snoozedUntil?: string; + scheduledTime: Date; + now: Date; +} + +export const determineDoseStatus = ({ + takenAt, + snoozedUntil, + scheduledTime, + now, +}: DoseStatusParams): DoseStatus => { + if (takenAt) { + return DoseStatus.TAKEN; + } + + if (snoozedUntil) { + const snoozeTime = new Date(snoozedUntil); + if (!Number.isNaN(snoozeTime.getTime())) { + if (snoozeTime.getTime() > now.getTime()) { + return DoseStatus.SNOOZED; + } + return DoseStatus.UPCOMING; + } + } + + if (scheduledTime.getTime() < now.getTime()) { + return DoseStatus.MISSED; + } + + return DoseStatus.UPCOMING; +};