Skip to main content

Step 6: Build the Today screen

What we are building​

A patient-facing schedule grouped into Morning, Afternoon, Evening, and Night, with a clear status for each supported occurrence.

What you should already have​

  • The authenticated Patient and MedicationRequest[] from step 5.
  • The local time and localisation rules from step 4.

The implementation​

Create src/features/medication/today-schedule.ts. Only turn explicit daily Timing.repeat.timeOfDay values into reminder rows. This parser accepts a one-day period, matching frequency and timeOfDay count, and optional FHIR day-of-week/bounds fields. Other repeat patterns are skipped instead of guessed.

import { atDeviceLocalTime } from "../../localization/schedule-time";
import { loadMedicationPlan } from "./medication-plan";

export function buildOccurrencesForDay(plan: Awaited<ReturnType<typeof loadMedicationPlan>>, day = new Date()) {
const date = [day.getFullYear(), String(day.getMonth() + 1).padStart(2, "0"), String(day.getDate()).padStart(2, "0")].join("-");
const weekday = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"][day.getDay()];

return plan.flatMap((request) =>
(request.dosageInstruction ?? []).flatMap((dosage, dosageIndex) => {
const repeat = dosage.timing?.repeat;
if (!repeat?.timeOfDay?.length) return [];
if (repeat.periodUnit !== "d" || repeat.period !== 1) return [];
if (repeat.frequency !== undefined && repeat.frequency !== repeat.timeOfDay.length) return [];
if (
repeat.frequencyMax !== undefined || repeat.periodMax !== undefined ||
repeat.duration !== undefined || repeat.durationMax !== undefined ||
repeat.count !== undefined || repeat.countMax !== undefined ||
repeat.boundsDuration !== undefined || repeat.boundsRange !== undefined ||
repeat.offset !== undefined || repeat.when?.length
) return [];
if (repeat.dayOfWeek?.length && !repeat.dayOfWeek.includes(weekday)) return [];
if (repeat.boundsPeriod?.start && date < repeat.boundsPeriod.start) return [];
if (repeat.boundsPeriod?.end && date > repeat.boundsPeriod.end) return [];

return repeat.timeOfDay.flatMap((time) => {
const scheduledAt = atDeviceLocalTime(day, time);
if (!scheduledAt || !request.id) return [];

const medicationName =
request.medicationCodeableConcept?.text ??
request.medicationReference?.display;
const doseText = dosage.doseAndRate?.[0]?.doseQuantity
? `${dosage.doseAndRate[0].doseQuantity.value ?? ""} ${dosage.doseAndRate[0].doseQuantity.unit ?? ""}`.trim()
: undefined;
const occurrenceKey = `${request.id}:${dosageIndex}:${date}:${time}`;

return [{
occurrenceKey,
medicationName,
doseText,
instruction: dosage.text,
request,
scheduledAt,
}];
});
}),
);
}

loadMedicationPlan is the shared loader from step 5. For multiple days, call the same function with each local calendar date. If your plan uses a more complex FHIR Timing pattern, render its text and mark the schedule as unsupported until its interpretation has been reviewed.

For each occurrence, derive state from the most recent matching MedicationAdministration identifier:

type DoseStatus = "upcoming" | "due" | "taken" | "skipped" | "missed";

export function doseStatus(
scheduledAt: Date,
event: { status?: string } | undefined,
now = new Date(),
missedAfterMs?: number,
): DoseStatus {
if (event?.status === "completed") return "taken";
if (event?.status === "not-done") return "skipped";
if (now < scheduledAt) return "upcoming";
if (missedAfterMs !== undefined && now.getTime() > scheduledAt.getTime() + missedAfterMs) {
return "missed";
}
return "due";
}

missedAfterMs is an application rule, not an Ovok or FHIR value. Choose its value with the product and clinical owners. If no approved cutoff exists, leave the occurrence as “Due” or “No response recorded”; do not present a guessed grace period as a clinical fact. The stable occurrence key uses the local calendar date and source timeOfDay, so rescheduling after a timezone change does not create a second report identifier for the same scheduled row.

Build the screen in src/app/(patient)/index.tsx with loading, error, and empty states. Group rows by device-local time; all labels come from CMS translations:

import { useTranslation } from "react-i18next";
import { ActivityIndicator, ScrollView, StyleSheet, Text, View } from "react-native";

import { useMedicationPlan } from "../../features/medication/use-medication-plan";
import { formatScheduleTime } from "../../localization/schedule-time";
import { buildOccurrencesForDay, doseStatus } from "../../features/medication/today-schedule";

const periods = ["morning", "afternoon", "evening", "night"] as const;

function periodFor(date: Date) {
const hour = date.getHours();
if (hour < 12) return "morning";
if (hour < 17) return "afternoon";
if (hour < 21) return "evening";
return "night";
}

export default function TodayScreen() {
const { plan, loading, error } = useMedicationPlan();
const { t, i18n } = useTranslation();

if (loading) return <ActivityIndicator accessibilityLabel={t("medication.loading")} />;
if (error) return <Text accessibilityRole="alert">{t("medication.loadError")}</Text>;
if (plan.length === 0) return <Text>{t("medication.emptyPlan")}</Text>;

const occurrences = buildOccurrencesForDay(plan);
return (
<ScrollView contentContainerStyle={styles.screen}>
<Text accessibilityRole="header" style={styles.title}>{t("medication.todayTitle")}</Text>
{periods.map((period) => {
const group = occurrences.filter((item) => periodFor(item.scheduledAt) === period);
if (group.length === 0) return null;
return (
<View key={period} style={styles.group}>
<Text accessibilityRole="header" style={styles.groupTitle}>{t(`medication.${period}`)}</Text>
{group.map((item) => {
const status = doseStatus(item.scheduledAt, undefined);
return (
<View key={item.occurrenceKey} style={styles.card}>
<Text style={styles.name}>{item.medicationName ?? t("medication.unknownName")}</Text>
{item.doseText && <Text>{item.doseText}</Text>}
<Text>{formatScheduleTime(item.scheduledAt, i18n.language)}</Text>
{item.instruction && <Text>{item.instruction}</Text>}
<Text>{t(`medication.${status}`)}</Text>
{status === "missed" && <Text>{t("medication.missedMessage")}</Text>}
</View>
);
})}
</View>
);
})}
{occurrences.length === 0 && <Text>{t("medication.noSupportedSchedule")}</Text>}
</ScrollView>
);
}

const styles = StyleSheet.create({
screen: { padding: 20, gap: 20 },
title: { fontSize: 28, fontWeight: "700" },
group: { gap: 10 },
groupTitle: { fontSize: 18, fontWeight: "600" },
card: { borderWidth: 1, borderColor: "#d9d2e5", borderRadius: 14, padding: 16, gap: 8 },
name: { fontSize: 17, fontWeight: "600" },
});

Step 8 adds patient actions; step 9 connects matching MedicationAdministration records so saved Taken and Skipped events replace the no-event status. The code passes undefined while that history integration is not yet in place. If a production request uses medicationReference without a display, resolve the referenced Medication through the FHIR client under the same Patient policy before rendering its name; the sample plan uses medicationCodeableConcept.text.

Important Ovok decisions​

  • No data means “no record,” not “not taken.”
  • The card displays the original source text. It does not translate or rewrite medication instructions unless a reviewed localization workflow provides an approved value.
  • MedicationRequest.status and event status are separate: an active request can have a taken or skipped occurrence.

Expected result​

The Today screen shows the three synthetic medicines grouped by local time, omits requests outside their boundsPeriod, and distinguishes an unrecorded occurrence from a saved patient report.

Common errors and troubleshooting​

  • The card has no scheduled time: the request may not contain supported timeOfDay values. Do not parse Dosage.text to guess.
  • The wrong weekday appears: FHIR day codes are lowercase mon through sun; compare them with the device-local weekday.
  • “Missed” appears too early: verify the app's explicitly approved missedAfterMs policy. Ovok does not supply this cutoff.

Previous / Next​

Previous: step 5: initialise Ovok and authentication · Continue to step 7: add medication reminders.