Skip to main content

Step 7: Add medication reminders

What we are building​

Device-local notifications for supported occurrences. The phone schedules and presents them; Ovok supplies the FHIR plan but does not schedule these reminders.

What you should already have​

  • Future scheduledAt occurrences from step 6.
  • expo-notifications installed and configured in step 1.
  • A CMS translation group with generic notification title/body strings.

The implementation​

Create src/features/medication/reminders.ts; the examples in this step use the occurrence builder from step 6.

Install a foreground handler once near app startup. On Android, create a notification channel before requesting permission so the Android 13 prompt can be shown.

import * as Notifications from "expo-notifications";
import * as React from "react";
import * as SecureStore from "expo-secure-store";
import { useFocusEffect } from "expo-router";
import { buildOccurrencesForDay } from "./today-schedule";
import { loadMedicationPlan } from "./medication-plan";
import { useTranslation } from "react-i18next";

type MedicationPlan = Awaited<ReturnType<typeof loadMedicationPlan>>;
export const reminderPreferenceKey = "ovok-medication-reminders-enabled";

Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldPlaySound: false,
shouldSetBadge: false,
shouldShowBanner: true,
shouldShowList: true,
}),
});

export async function requestReminderPermission(channelName: string) {
await Notifications.setNotificationChannelAsync("medication-reminders", {
name: channelName,
importance: Notifications.AndroidImportance.DEFAULT,
});

const existing = await Notifications.getPermissionsAsync();
if (existing.granted || existing.ios?.status === Notifications.IosAuthorizationStatus.PROVISIONAL) return true;
const requested = await Notifications.requestPermissionsAsync();
return requested.granted || requested.ios?.status === Notifications.IosAuthorizationStatus.PROVISIONAL;
}

Ask at a moment when the patient understands the benefit, and leave reminders off when permission is denied. Keep the lock-screen content generic:

export async function scheduleOccurrence(occurrence: {
occurrenceKey: string;
scheduledAt: Date;
}, copy: { title: string; body: string }) {
return Notifications.scheduleNotificationAsync({
content: {
title: copy.title,
body: copy.body,
data: { kind: "medication-reminder", occurrenceKey: occurrence.occurrenceKey },
},
trigger: {
type: Notifications.SchedulableTriggerInputTypes.DATE,
date: occurrence.scheduledAt,
},
});
}

Do not put the medication name, dose, diagnosis, or instructions in the notification body. Store the returned notification identifier so the app can cancel it when the plan changes or the patient disables reminders.

Reconcile after a plan or timezone change​

When the plan is refreshed, the patient changes reminder preferences, or the app returns to the foreground, replace only this app's medication notifications. Build occurrences for the next seven device-local calendar days rather than scheduling an unbounded future:

export function occurrencesForNextSevenDays(plan: MedicationPlan) {
return Array.from({ length: 7 }, (_, offset) => {
const day = new Date();
day.setDate(day.getDate() + offset);
return buildOccurrencesForDay(plan, day);
}).flat();
}

Serialize reconciliations so a simultaneous plan refresh and app-resume event cannot race and schedule duplicates:

let reconcileQueue: Promise<void> = Promise.resolve();

export function reconcileMedicationReminders(
occurrences: Array<{ occurrenceKey: string; scheduledAt: Date }>,
copy: { title: string; body: string },
) {
reconcileQueue = reconcileQueue.catch(() => undefined).then(async () => {
const existing = await Notifications.getAllScheduledNotificationsAsync();
const ours = existing.filter((item) => item.content.data?.kind === "medication-reminder");

await Promise.all(
ours.map((item) => Notifications.cancelScheduledNotificationAsync(item.identifier)),
);

const now = Date.now();
for (const occurrence of occurrences) {
if (occurrence.scheduledAt.getTime() <= now) continue;
await scheduleOccurrence(occurrence, copy);
}
});
return reconcileQueue;
}

Ask for permission only after the patient turns reminders on in Settings. For plan changes and app resume, read permission without prompting and reconcile the next seven days. This hook runs from the authenticated Today route; enabled is the app preference added in step 10:

export function useMedicationReminderSchedule(plan: MedicationPlan) {
const { t } = useTranslation();
const [error, setError] = React.useState<Error>();

useFocusEffect(React.useCallback(() => {
let current = true;
setError(undefined);
void (async () => {
try {
const enabled = await SecureStore.getItemAsync(reminderPreferenceKey) === "true";
const permission = await Notifications.getPermissionsAsync();
if (!current) return;
const allowed = permission.granted || permission.ios?.status === Notifications.IosAuthorizationStatus.PROVISIONAL;
const occurrences = enabled && allowed ? occurrencesForNextSevenDays(plan) : [];
await reconcileMedicationReminders(occurrences, {
title: t("medication.reminderTitle"),
body: t("medication.reminderBody"),
});
} catch (reason) {
if (current) setError(reason instanceof Error ? reason : new Error(String(reason)));
}
})();
return () => { current = false; };
}, [plan, t]));

return { error };
}

Call requestReminderPermission(t("medication.notificationChannel")) from the explicit enable action in Settings. The hook reads the preference whenever Today gains focus and rebuilds future reminders when the plan or locale changes, so a timezone change is picked up when the patient returns to the app. Surface its error as t("medication.reminderScheduleError"). The notification's data marker lets reconciliation leave unrelated app notifications alone. If the process stops partway through, the next run removes the partial set and rebuilds it from the plan. This example assumes one active patient account; when switching accounts, remove the previous account's local reminders before scheduling the new one.

Connect the hook to the authenticated Today screen and show a recoverable state if reconciliation fails:

const { error: reminderError } = useMedicationReminderSchedule(plan);

{reminderError && (
<Text accessibilityRole="alert">{t("medication.reminderScheduleError")}</Text>
)}

The device's local clock and timezone determine how Date triggers are interpreted. Recompute future occurrences when the timezone changes. Delivery and restoration after a device restart depend on the operating system, its settings, permission state, and platform scheduling behavior; test both platforms on real devices. Do not use this mechanism for urgent or clinically critical alerts.

The Native SDK's showPushNotification() helper presents an immediate notification for a background measurement. It does not schedule medication reminders; use the local Expo Notifications API above.

Important Ovok decisions​

  • Local reminders can fire without a network connection after the OS has accepted them.
  • No reminder delivery or acknowledgement is written to FHIR. Only the explicit patient action in step 8 creates an adherence record.
  • A delivered notification does not prove that a dose was taken.

Expected result​

With permission granted, the device presents a generic reminder at a scheduled local time. Re-running reconciliation does not leave duplicate tutorial reminders, and plan changes cancel obsolete notifications.

Common errors and troubleshooting​

  • Android 13 permission prompt does not appear: create the notification channel before calling requestPermissionsAsync().
  • No alert appears in foreground: confirm the global notification handler returns a presentation behavior.
  • Reminder time shifts after travel: recalculate upcoming occurrences using the new device timezone when the app resumes.
  • Reminder says too much on the lock screen: remove health details from both title and body; keep them inside the authenticated app.

Previous / Next​

Previous: step 6: build the Today screen · Continue to step 8: record medication adherence.