Step 9: Build History and Medication Details
What we are building
A medication details view backed by its MedicationRequest, and an adherence history backed by the Patient's MedicationAdministration records.
What you should already have
- The Patient's medication plan and
MedicationAdministrationreports from steps 5 and 8. - A patient AccessPolicy that allows reading/searching those resources.
- CMS strings for the details and history screens.
The implementation
Medication details
Render fields that exist in the source request. For the tutorial plan, show the medication concept text or referenced Medication display, each dosage instruction, the explicit schedule, and boundsPeriod start/end when present. Show the request status and source information separately from adherence history.
import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
type MedicationRequestView = {
id?: string;
status?: string;
medicationCodeableConcept?: { text?: string };
medicationReference?: { display?: string };
dosageInstruction?: Array<{
doseAndRate?: Array<{ doseQuantity?: { value?: number; unit?: string } }>;
text?: string;
timing?: { repeat?: { boundsPeriod?: { start?: string; end?: string } } };
}>;
};
export function MedicationDetails({ request }: { request: MedicationRequestView }) {
const { t } = useTranslation();
const name = request.medicationCodeableConcept?.text ?? request.medicationReference?.display ?? t("medication.unknownName");
return (
<View>
<Text accessibilityRole="header">{name}</Text>
<Text>{t("medication.requestStatus", { status: request.status })}</Text>
{(request.dosageInstruction ?? []).map((dosage, index) => (
<View key={`${request.id}-${index}`}>
{dosage.doseAndRate?.[0]?.doseQuantity && (
<Text>{t("medication.dose", { value: dosage.doseAndRate[0].doseQuantity.value, unit: dosage.doseAndRate[0].doseQuantity.unit })}</Text>
)}
{dosage.text && <Text>{dosage.text}</Text>}
{dosage.timing?.repeat?.boundsPeriod?.start && (
<Text>{t("medication.startDate", { date: dosage.timing.repeat.boundsPeriod.start })}</Text>
)}
{dosage.timing?.repeat?.boundsPeriod?.end && (
<Text>{t("medication.endDate", { date: dosage.timing.repeat.boundsPeriod.end })}</Text>
)}
</View>
))}
</View>
);
}
MedicationRequestView is the subset of FHIR fields this view reads; the server resource remains the source of truth. Do not synthesize a dose or schedule when the corresponding elements are absent.
Create src/app/(patient)/medications/[requestId].tsx to load the selected request from the authenticated Patient's plan and render that component:
import { useLocalSearchParams } from "expo-router";
import { useTranslation } from "react-i18next";
import { ActivityIndicator, Text } from "react-native";
import { useMedicationPlan } from "../../../features/medication/use-medication-plan";
import { MedicationDetails } from "../../../features/medication/medication-details";
export default function MedicationDetailsScreen() {
const { requestId } = useLocalSearchParams<{ requestId: string }>();
const { plan, loading, error } = useMedicationPlan();
const { t } = useTranslation();
if (loading) return <ActivityIndicator accessibilityLabel={t("medication.loading")} />;
if (error) return <Text accessibilityRole="alert">{t("medication.loadError")}</Text>;
const request = plan.find((item) => item.id === requestId);
if (!request) return <Text>{t("medication.notInPlan")}</Text>;
return <MedicationDetails request={request} />;
}
In each Today card, link to the route using the request id returned by Ovok:
Import Pressable from react-native, useRouter from expo-router, and create const router = useRouter() in the Today screen. The route snippet below uses those values:
{item.request.id && (
<Pressable onPress={() => router.push({
pathname: "/(patient)/medications/[requestId]",
params: { requestId: item.request.id },
})}>
<Text>{t("medication.viewDetails")}</Text>
</Pressable>
)}
Load all history pages
The Native/Core FHIR client exposes searchResourcePages() as an async generator. It follows the server's paging links; append each returned page rather than assuming the first page is the whole history:
import { useClient } from "@ovok/core";
import * as React from "react";
export type MedicationAdministrationView = {
id?: string;
status?: string;
effectiveDateTime?: string;
request?: { reference?: string };
medicationCodeableConcept?: { text?: string };
medicationReference?: { display?: string };
identifier?: Array<{ system?: string; value?: string }>;
};
export function useMedicationHistory(patientId: string) {
const client = useClient();
const [events, setEvents] = React.useState<MedicationAdministrationView[]>([]);
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState<Error>();
const [revision, setRevision] = React.useState(0);
const reload = React.useCallback(() => setRevision((value) => value + 1), []);
React.useEffect(() => {
let current = true;
setEvents([]);
setError(undefined);
if (!patientId) {
setLoading(false);
return () => { current = false; };
}
setLoading(true);
void (async () => {
try {
const loaded: MedicationAdministrationView[] = [];
for await (const page of client.searchResourcePages("MedicationAdministration", {
subject: `Patient/${patientId}`,
_count: "50",
})) {
loaded.push(...page);
}
if (current) setEvents(loaded);
} catch (reason) {
if (current) setError(reason instanceof Error ? reason : new Error(String(reason)));
} finally {
if (current) setLoading(false);
}
})();
return () => { current = false; };
}, [client, patientId, revision]);
return { events, loading, error, reload };
}
Sort the loaded events by effectiveDateTime for display. Each record's request.reference links it back to the plan; its identifier links it to the scheduled occurrence. Use the FHIR effective-time search parameter for server-side date filtering when needed, after confirming it in the current project's CapabilityStatement.
Display a clear label such as Patient reported next to Taken and Skipped. For a date range, show a Missed row only when the app can match a supported scheduled occurrence to no adherence event after the product-approved cutoff. Do not write or present that derived state as a clinical judgment.
Create src/app/(patient)/history.tsx for a simple local 7-day / 30-day / all filter. This view shows only events linked to requests in the current plan and uses neutral labels for statuses the tutorial did not create:
import { useSession } from "@ovok/native";
import { useTranslation } from "react-i18next";
import { Pressable, ScrollView, Text, View } from "react-native";
import { useState } from "react";
import { occurrenceIdentifierSystem } from "../../features/medication/adherence";
import { useMedicationHistory } from "../../features/medication/history";
export default function HistoryScreen() {
const { status, profile } = useSession({ sessions: false });
const patientId = profile?.resourceType === "Patient" ? profile.id : "";
const { events, loading, error } = useMedicationHistory(patientId);
const { t, i18n } = useTranslation();
const [days, setDays] = useState<number | null>(30);
const cutoff = days === null ? 0 : Date.now() - days * 24 * 60 * 60 * 1000;
const visible = events
.filter((event) => event.identifier?.some((item) => item.system === occurrenceIdentifierSystem))
.filter((event) => days === null || Date.parse(event.effectiveDateTime ?? "") >= cutoff)
.slice()
.sort((a, b) => Date.parse(b.effectiveDateTime ?? "") - Date.parse(a.effectiveDateTime ?? ""));
if (status === "loading" || loading) return <Text>{t("medication.loading")}</Text>;
if (error) return <Text accessibilityRole="alert">{t("medication.loadError")}</Text>;
return (
<ScrollView contentContainerStyle={{ padding: 20, gap: 12 }}>
<Text accessibilityRole="header">{t("medication.historyTitle")}</Text>
<View style={{ flexDirection: "row", gap: 12 }}>
<Pressable onPress={() => setDays(7)}><Text>{t("medication.filter7Days")}</Text></Pressable>
<Pressable onPress={() => setDays(30)}><Text>{t("medication.filter30Days")}</Text></Pressable>
<Pressable onPress={() => setDays(null)}><Text>{t("medication.filterAll")}</Text></Pressable>
</View>
{visible.length === 0 && <Text>{t("medication.noHistory")}</Text>}
{visible.map((event, index) => {
const label = event.status === "completed" ? t("medication.taken")
: event.status === "not-done" ? t("medication.skipped")
: t("medication.recordedStatus", { status: event.status ?? "unknown" });
const medicationName = event.medicationCodeableConcept?.text ?? event.medicationReference?.display ?? t("medication.unknownName");
return (
<View key={event.id ?? `${event.effectiveDateTime}-${index}`}>
<Text>{medicationName}</Text>
<Text>{label} · {t("medication.patientReported")}</Text>
{!!event.effectiveDateTime && <Text>{new Intl.DateTimeFormat(i18n.language, { dateStyle: "medium", timeStyle: "short" }).format(new Date(event.effectiveDateTime))}</Text>}
</View>
);
})}
</ScrollView>
);
}
Connect History to Today
Export the occurrence identifier system from step 8. In the Today screen, read history for the authenticated Patient, index reports by that identifier, pass the matching event to doseStatus(), and give the action component a reload callback:
import { AdherenceActions } from "../../features/medication/adherence-actions";
import { occurrenceIdentifierSystem } from "../../features/medication/adherence";
import { useMedicationHistory } from "../../features/medication/history";
const { patient } = useMedicationPlan();
const { events, reload: reloadHistory } = useMedicationHistory(patient?.id ?? "");
const eventsByOccurrence = new Map(
events.flatMap((event) => {
const occurrence = event.identifier?.find((item) => item.system === occurrenceIdentifierSystem)?.value;
return occurrence ? [[occurrence, event] as const] : [];
}),
);
// Inside each Today card:
const status = doseStatus(item.scheduledAt, eventsByOccurrence.get(item.occurrenceKey));
<AdherenceActions
occurrence={item}
existingEvent={eventsByOccurrence.get(item.occurrenceKey)}
onSaved={reloadHistory}
/>
Add the hook and AdherenceActions imports to the Today route. The cached local outbox in step 10 adds its pending state separately; do not mix unsynced reports with server-confirmed History entries.
Important Ovok decisions
- Details come from
MedicationRequest; adherence events come fromMedicationAdministration. Do not overwrite the plan with a patient's action. status: completedmeans the patient reported taking that occurrence in this tutorial; it does not mean the platform verified it.status: not-doneis the patient's skipped report. A missed row is derived from the schedule and absence of a record.- Show only fields that are present. The app must not infer a medicine's identity or dose from a free-text string.
Expected result
The details screen presents the underlying plan without rewriting it, and History loads every matching FHIR page and labels adherence as patient-reported.
Common errors and troubleshooting
- History stops after one page: use
searchResourcePages()and iterate until it completes;searchResources()is a single result set and may not include older records. - History includes another Patient's data: verify the Patient search filter and AccessPolicy. The server-side policy must enforce the boundary even if a client omits its filter.
- An event is detached from its medication. ensure every event has the source
MedicationRequestreference and the same occurrence identifier used by the Today screen. - The UI treats status as clinical truth: label events as patient reports and do not translate them into care advice.
Previous / Next
Previous: step 8: record medication adherence · Continue to step 10: offline behavior, Settings, and localisation.