feat(medication): improve snooze timer handling
This commit is contained in:
73
App.tsx
73
App.tsx
@@ -65,6 +65,7 @@ import { databaseService } from './services/database';
|
|||||||
import { databaseSeeder } from './services/database.seeder';
|
import { databaseSeeder } from './services/database.seeder';
|
||||||
import { logger } from './services/logging';
|
import { logger } from './services/logging';
|
||||||
import { normalizeError } from './utils/error';
|
import { normalizeError } from './utils/error';
|
||||||
|
import { determineDoseStatus } from './utils/doseStatus';
|
||||||
|
|
||||||
const Header: React.FC<{
|
const Header: React.FC<{
|
||||||
onAdd: () => void;
|
onAdd: () => void;
|
||||||
@@ -377,16 +378,35 @@ const MedicationScheduleApp: React.FC<{ user: User }> = ({ user }) => {
|
|||||||
async (doseId: string) => {
|
async (doseId: string) => {
|
||||||
if (!takenDosesDoc) return;
|
if (!takenDosesDoc) return;
|
||||||
const newDoses = { ...takenDosesDoc.doses };
|
const newDoses = { ...takenDosesDoc.doses };
|
||||||
if (newDoses[doseId]) {
|
const wasTaken = Boolean(newDoses[doseId]);
|
||||||
|
|
||||||
|
if (wasTaken) {
|
||||||
delete newDoses[doseId];
|
delete newDoses[doseId];
|
||||||
} else {
|
} else {
|
||||||
newDoses[doseId] = new Date().toISOString();
|
newDoses[doseId] = new Date().toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedDoc = await databaseService.updateTakenDoses({
|
const updatedDoc = await databaseService.updateTakenDoses({
|
||||||
...takenDosesDoc,
|
...takenDosesDoc,
|
||||||
doses: newDoses,
|
doses: newDoses,
|
||||||
});
|
});
|
||||||
setTakenDosesDoc(updatedDoc);
|
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]
|
[takenDosesDoc]
|
||||||
);
|
);
|
||||||
@@ -404,13 +424,13 @@ const MedicationScheduleApp: React.FC<{ user: User }> = ({ user }) => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const getDoseStatus = useCallback(
|
const getDoseStatus = useCallback(
|
||||||
(dose: Dose, doseTime: Date, now: Date): DoseStatus => {
|
(dose: Dose, doseTime: Date, now: Date): DoseStatus =>
|
||||||
if (takenDoses[dose.id]) return DoseStatus.TAKEN;
|
determineDoseStatus({
|
||||||
if (snoozedDoses[dose.id] && new Date(snoozedDoses[dose.id]) > now)
|
takenAt: takenDoses[dose.id],
|
||||||
return DoseStatus.SNOOZED;
|
snoozedUntil: snoozedDoses[dose.id],
|
||||||
if (doseTime.getTime() < now.getTime()) return DoseStatus.MISSED;
|
scheduledTime: doseTime,
|
||||||
return DoseStatus.UPCOMING;
|
now,
|
||||||
},
|
}),
|
||||||
[takenDoses, snoozedDoses]
|
[takenDoses, snoozedDoses]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -422,15 +442,20 @@ const MedicationScheduleApp: React.FC<{ user: User }> = ({ user }) => {
|
|||||||
const medication = medications.find(m => m._id === item.medicationId);
|
const medication = medications.find(m => m._id === item.medicationId);
|
||||||
if (!medication) return null;
|
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 {
|
return {
|
||||||
...item,
|
...item,
|
||||||
type: 'dose' as const,
|
type: 'dose' as const,
|
||||||
medication,
|
medication,
|
||||||
status: getDoseStatus(item, item.scheduledTime, currentTime),
|
status: getDoseStatus(item, item.scheduledTime, currentTime),
|
||||||
takenAt: takenDoses[item.id],
|
takenAt: takenDoses[item.id],
|
||||||
snoozedUntil: snoozedDoses[item.id]
|
snoozedUntil: validSnooze,
|
||||||
? new Date(snoozedDoses[item.id])
|
|
||||||
: undefined,
|
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// It's a Custom Reminder
|
// It's a Custom Reminder
|
||||||
@@ -469,9 +494,10 @@ const MedicationScheduleApp: React.FC<{ user: User }> = ({ user }) => {
|
|||||||
let timeToNotification = -1;
|
let timeToNotification = -1;
|
||||||
let notificationBody = '';
|
let notificationBody = '';
|
||||||
let notificationTitle = '';
|
let notificationTitle = '';
|
||||||
|
let targetTime: Date | null = null;
|
||||||
|
|
||||||
if (item.type === 'dose' && item.status === DoseStatus.UPCOMING) {
|
if (item.type === 'dose' && item.status === DoseStatus.UPCOMING) {
|
||||||
timeToNotification = item.scheduledTime.getTime() - now.getTime();
|
targetTime = item.snoozedUntil ?? item.scheduledTime;
|
||||||
notificationTitle = 'Time for your medication!';
|
notificationTitle = 'Time for your medication!';
|
||||||
notificationBody = `${item.medication.name} (${item.medication.dosage})`;
|
notificationBody = `${item.medication.name} (${item.medication.dosage})`;
|
||||||
} else if (
|
} else if (
|
||||||
@@ -479,17 +505,21 @@ const MedicationScheduleApp: React.FC<{ user: User }> = ({ user }) => {
|
|||||||
item.status === DoseStatus.SNOOZED &&
|
item.status === DoseStatus.SNOOZED &&
|
||||||
item.snoozedUntil
|
item.snoozedUntil
|
||||||
) {
|
) {
|
||||||
timeToNotification = item.snoozedUntil.getTime() - now.getTime();
|
targetTime = item.snoozedUntil;
|
||||||
notificationTitle = 'Snoozed Medication Reminder';
|
notificationTitle = 'Snoozed Medication Reminder';
|
||||||
notificationBody = `${item.medication.name} (${item.medication.dosage})`;
|
notificationBody = `${item.medication.name} (${item.medication.dosage})`;
|
||||||
} else if (item.type === 'reminder' && item.scheduledTime > now) {
|
} else if (item.type === 'reminder' && item.scheduledTime > now) {
|
||||||
timeToNotification = item.scheduledTime.getTime() - now.getTime();
|
targetTime = item.scheduledTime;
|
||||||
notificationTitle = 'Reminder';
|
notificationTitle = 'Reminder';
|
||||||
notificationBody = item.title;
|
notificationBody = item.title;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (targetTime) {
|
||||||
|
timeToNotification = targetTime.getTime() - now.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
if (timeToNotification > 0) {
|
if (timeToNotification > 0) {
|
||||||
activeTimers[itemId] = setTimeout(() => {
|
const timerId = window.setTimeout(() => {
|
||||||
new Notification(notificationTitle, {
|
new Notification(notificationTitle, {
|
||||||
body: notificationBody,
|
body: notificationBody,
|
||||||
tag: itemId,
|
tag: itemId,
|
||||||
@@ -497,16 +527,23 @@ const MedicationScheduleApp: React.FC<{ user: User }> = ({ user }) => {
|
|||||||
if (item.type === 'dose' && item.status === DoseStatus.SNOOZED) {
|
if (item.type === 'dose' && item.status === DoseStatus.SNOOZED) {
|
||||||
setSnoozedDoses(prev => {
|
setSnoozedDoses(prev => {
|
||||||
const newSnoozed = { ...prev };
|
const newSnoozed = { ...prev };
|
||||||
delete newSnoozed[itemId];
|
newSnoozed[itemId] = new Date().toISOString();
|
||||||
return newSnoozed;
|
return newSnoozed;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
delete activeTimers[itemId];
|
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]);
|
}, [scheduleWithStatus, settings?.notificationsEnabled]);
|
||||||
|
|
||||||
const filteredSchedule = useMemo(
|
const filteredSchedule = useMemo(
|
||||||
|
|||||||
63
utils/__tests__/doseStatus.test.ts
Normal file
63
utils/__tests__/doseStatus.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
35
utils/doseStatus.ts
Normal file
35
utils/doseStatus.ts
Normal file
@@ -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;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user