Skip to main content

Step 8: Record medication adherence

What we are building​

Two explicit actions for each scheduled occurrence: Taken and Skipped. The app stores each report as a FHIR MedicationAdministration, linked to the Patient and the source MedicationRequest.

What you should already have​

  • An authenticated Patient, active plan, and occurrence key from steps 5–6.
  • A patient AccessPolicy that allows creating MedicationAdministration for that same Patient.
  • Conditional create available for MedicationAdministration in the project's CapabilityStatement.

The implementation​

FHIR R4 MedicationAdministration.status supports completed and not-done. Use completed for a patient-reported taken dose and not-done for a patient-reported skipped occurrence. A report from the app is not independent verification that a dose was administered.

Create src/features/medication/adherence.ts:

import type { OvokClient } from "@ovok/core";

export const occurrenceIdentifierSystem = "https://example.org/fhir/identifier/medication-occurrence";

export type PlanRequest = {
id?: string;
medicationCodeableConcept?: { text?: string };
medicationReference?: { reference?: string; display?: string };
};

export type DoseOccurrence = {
occurrenceKey: string;
scheduledAt: Date;
request: PlanRequest;
};

export type PatientReport = {
action: "taken" | "skipped";
reportedAt: Date;
};

export function buildAdherenceBundle(
patientId: string,
occurrence: DoseOccurrence,
report: PatientReport,
) {
const request = occurrence.request;
const medication = request.medicationCodeableConcept
? { medicationCodeableConcept: request.medicationCodeableConcept }
: request.medicationReference?.reference
? { medicationReference: request.medicationReference }
: undefined;

if (!request.id || !medication) {
throw new Error("The plan request must have an id and a medication[x] value.");
}

const status = report.action === "taken" ? "completed" : "not-done";
const event = {
resourceType: "MedicationAdministration",
identifier: [{ system: occurrenceIdentifierSystem, value: occurrence.occurrenceKey }],
status,
...medication,
subject: { reference: `Patient/${patientId}` },
request: { reference: `MedicationRequest/${request.id}` },
effectiveDateTime: (report.action === "taken" ? report.reportedAt : occurrence.scheduledAt).toISOString(),
...(report.action === "taken" ? { performer: [{ actor: { reference: `Patient/${patientId}` } }] } : {}),
note: [{ text: `Patient reported ${report.action} in the app at ${report.reportedAt.toISOString()}.` }],
};

const ifNoneExist = new URLSearchParams({
identifier: `${occurrenceIdentifierSystem}|${occurrence.occurrenceKey}`,
}).toString();

return {
resourceType: "Bundle",
type: "transaction",
entry: [{
resource: event,
request: {
method: "POST",
url: "MedicationAdministration",
ifNoneExist,
},
}],
};
}

export function submitAdherenceReport(
client: OvokClient,
patientId: string,
occurrence: DoseOccurrence,
report: PatientReport,
) {
return client.executeBatch(buildAdherenceBundle(patientId, occurrence, report));
}

Replace the example.org identifier system with a stable URL on a domain your team controls before using real data. The identifier value comes from step 6 and identifies one plan request, dosage instruction, and scheduled occurrence. Conditional create makes a retry for the same occurrence return the existing record instead of adding a duplicate. Keep the Taken/Skipped action disabled after a report succeeds; correcting a report needs an explicitly designed, auditable workflow.

effectiveDateTime records the reported time for Taken and the scheduled occurrence time for Skipped. The note preserves when the patient submitted the report, including when the report was queued offline. A server's meta.lastUpdated is the time Ovok received or changed the resource; it is not a substitute for the patient's report time.

The app does not write a MedicationAdministration for Missed. Step 6 derives that display state when an occurrence passes a product-approved cutoff without a report. It must not tell the patient what to do about a missed dose.

Add Taken and Skipped actions to each card​

Add this component to the same file or src/features/medication/adherence-actions.tsx. It takes the DoseOccurrence from the Today screen, uses the signed-in Patient id, and only switches to a saved state after the transaction entry succeeds:

import { useClient } from "@ovok/core";
import { useSession } from "@ovok/native";
import { useTranslation } from "react-i18next";
import { ActivityIndicator, Pressable, Text, View } from "react-native";
import * as React from "react";
import { useState } from "react";

import { submitAdherenceReport, type DoseOccurrence, type PatientReport } from "./adherence";

export function AdherenceActions({
occurrence,
existingEvent,
onSaved,
}: {
occurrence: DoseOccurrence;
existingEvent?: { status?: string };
onSaved?: () => void;
}) {
const client = useClient();
const { isAuthenticated, profile } = useSession({ sessions: false });
const { t } = useTranslation();
const [saving, setSaving] = useState(false);
const [savedAction, setSavedAction] = useState<PatientReport["action"]>();
const [attemptedAction, setAttemptedAction] = useState<PatientReport["action"]>();
const [error, setError] = useState(false);

async function submit(action: PatientReport["action"]) {
if (!isAuthenticated || profile?.resourceType !== "Patient" || !profile.id || saving || savedAction) return;
if (Date.now() < occurrence.scheduledAt.getTime()) return;
if (attemptedAction && attemptedAction !== action) return;
setAttemptedAction(action);
setSaving(true);
setError(false);
try {
const response = await submitAdherenceReport(client, profile.id, occurrence, {
action,
reportedAt: new Date(),
});
const status = response.entry?.[0]?.response?.status;
if (!status || !/^2\d\d/.test(status)) throw new Error("The adherence report was not accepted.");
setSavedAction(action);
onSaved?.();
} catch {
setError(true);
} finally {
setSaving(false);
}
}

const existingAction = existingEvent?.status === "completed" ? "taken"
: existingEvent?.status === "not-done" ? "skipped"
: undefined;
if (existingAction) return <Text>{t("medication.patientReported")}: {t(`medication.${existingAction}`)}</Text>;
if (Date.now() < occurrence.scheduledAt.getTime()) return <Text>{t("medication.upcoming")}</Text>;
if (savedAction) return <Text>{t("medication.patientReported")}: {t(`medication.${savedAction}`)}</Text>;
if (saving) return <ActivityIndicator accessibilityLabel={t("medication.saving")} />;

return (
<View>
<View style={{ flexDirection: "row", gap: 12 }}>
{attemptedAction ? (
<Pressable accessibilityRole="button" onPress={() => void submit(attemptedAction)}>
<Text>{t("medication.retryReport")}</Text>
</Pressable>
) : (
<>
<Pressable accessibilityRole="button" onPress={() => void submit("taken")}>
<Text>{t("medication.markTaken")}</Text>
</Pressable>
<Pressable accessibilityRole="button" onPress={() => void submit("skipped")}>
<Text>{t("medication.markSkipped")}</Text>
</Pressable>
</>
)}
</View>
{error && <Text accessibilityRole="alert">{t("medication.reportError")}</Text>}
</View>
);
}

Render <AdherenceActions occurrence={item} onSaved={reloadHistory} /> inside each card from step 6. In step 10, replace the online-only submitAdherenceReport() call with the durable recordPatientReport() function so an offline action is stored before sync. Do not present a pending local report as saved on the server.

Important Ovok decisions​

  • The patient app creates adherence reports only. It cannot create or modify MedicationRequest prescriptions.
  • subject, request, medication[x], and the stable occurrence identifier preserve the resource relationships needed to show history.
  • The Patient AccessPolicy must restrict create and search to the authenticated Patient. Client-side filtering does not provide authorization.
  • The operation stores a patient report, not a clinical recommendation or verified administration record.

Expected result​

Taking or skipping a synthetic scheduled occurrence submits one conditional FHIR transaction. A second submission for the same occurrence does not create a duplicate.

Common errors and troubleshooting​

  • The server rejects the Bundle: check the CapabilityStatement, conditional-create support, the active Patient AccessPolicy, and that the Bundle entry uses the exact MedicationAdministration path.
  • The result is 403: check the patient AccessPolicy's resource interaction and scope. Do not replace the patient session with an administrator credential.
  • A retry creates another event: confirm that identifier.system and identifier.value are stable across retries and that ifNoneExist uses that same pair.
  • A skipped event appears as a missed dose: the explicit patient report takes precedence. “Missed” is only for an occurrence with no event after the configured cutoff.

Previous / Next​

Previous: step 7: add medication reminders · Continue to step 9: build History and Medication Details.