Step 4: Set up localisation and time handling
What we are building
An i18next instance that reads published UI strings from Ovok CMS and a single, explicit rule for interpreting medication schedule times.
What you should already have
- The Expo app and sandbox tenant code from steps 1 and 2.
- CMS enabled in the sandbox project.
i18nextandreact-i18nextinstalled.
The implementation
Author the English strings in the Console
Create published documents in the translations collection for the groups below. Every key used by the tutorial UI needs an English value; add the German values in step 10. For the Native SDK auth forms, use the sign-in and register slugs so CMS values override the built-in English strings. Do not create local en.json files; the app loads these strings from Ovok CMS. The collection is public tenant content, so never place patient data, tokens, or project secrets in it.
| Document slug | Keys used in this tutorial |
|---|---|
medication | todayTitle, loading, loadError, emptyPlan, noSupportedSchedule, morning, afternoon, evening, night, upcoming, due, taken, skipped, missed, missedMessage, unknownName, requestStatus, dose, startDate, endDate, notificationChannel, reminderTitle, reminderBody, reminderScheduleError, saving, markTaken, markSkipped, patientReported, reportError, retryReport, historyTitle, filterAll, filter7Days, filter30Days, noHistory, recordedStatus, viewDetails, notInPlan, lastSynced, offlineCacheError, pendingSync, syncError |
settings | title, profile, language, english, german, reminders, privacyAndConsent, signOut, appInformation, notificationsDenied |
sign-in | Keep the full key set listed in the Native SDK sign-in reference; this slug replaces the shared group. |
register | Keep the full key set listed in the Native SDK registration reference, plus registrationFailed and tryAgain for the app-owned registration alert; this slug replaces the shared group. |
Publish the following English key → value pairs for medication and settings. Keep the {{...}} interpolation names intact. The Register form's remaining keys come from the Native SDK reference linked above.
medication English values
| Key | English value |
|---|---|
todayTitle | Today's medications |
loading | Loading… |
loadError | We couldn't load this information. Try again. |
emptyPlan | No active medication plan is available for this account. |
noSupportedSchedule | No supported schedule details are available yet. |
morning | Morning |
afternoon | Afternoon |
evening | Evening |
night | Night |
upcoming | Upcoming |
due | Due |
taken | Taken |
skipped | Skipped |
missed | Not recorded |
missedMessage | This dose was not recorded as taken. |
unknownName | Medication name not provided |
requestStatus | Plan status: {{status}} |
dose | {{value}} {{unit}} |
startDate | Starts {{date}} |
endDate | Ends {{date}} |
notificationChannel | Reminders |
reminderTitle | Reminder |
reminderBody | You have a scheduled reminder. Open the app to view details. |
reminderScheduleError | We couldn't update reminders. Open Settings and try again. |
saving | Saving your response… |
markTaken | Mark as taken |
markSkipped | Mark as skipped |
patientReported | Patient reported |
reportError | We couldn't save this response. Try again. |
retryReport | Retry sending response |
historyTitle | Medication history |
filterAll | All time |
filter7Days | Last 7 days |
filter30Days | Last 30 days |
noHistory | No medication reports in this period. |
recordedStatus | Recorded status: {{status}} |
viewDetails | View details |
notInPlan | This medication is not in the current plan. |
lastSynced | Last synced {{date}} |
offlineCacheError | This information could not be saved for offline use. It may not be available without a connection next time. |
pendingSync | Your response is saved on this device and is waiting to sync. |
syncError | A saved response needs attention before it can sync. |
settings English values
| Key | English value |
|---|---|
title | Settings |
profile | Patient ID |
language | Language |
english | English |
german | Deutsch |
reminders | Reminders |
privacyAndConsent | Privacy and consent |
signOut | Sign out |
appInformation | App information |
notificationsDenied | Notifications are turned off. You can change this in device settings. |
For register.registrationFailed, use We couldn't create your account.; for register.tryAgain, use Check your details and try again. These are tutorial-owned additions. Keep the Native SDK's built-in English authentication text as the fallback for other keys.
The hook merges the CMS response into i18next's single translation namespace. A slug and key become a dotted lookup, such as medication.todayTitle; use t("medication.todayTitle"), not an i18next namespace separator such as t("medication:todayTitle").
Follow Use Ovok CMS with i18n for the supported fields, language codes, publication, and fallback behavior.
Initialize i18next without bundled app dictionaries
Create src/localization/i18n.ts:
import "@ovok/native/auth";
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
export const localizationReady = i18n.use(initReactI18next).init({
lng: "en",
fallbackLng: "en",
supportedLngs: ["en", "de"],
interpolation: { escapeValue: false },
react: { useSuspense: false },
});
export default i18n;
Import the Native SDK's built-in auth strings and create src/localization/localization-gate.tsx. The gate waits for the initial CMS read before showing screens, so the first render does not expose raw translation keys:
import "@ovok/native/auth";
import * as React from "react";
import { ActivityIndicator, View } from "react-native";
import { Button, Text } from "react-native-paper";
import { useCmsTranslations } from "@ovok/native/cms";
import { ovokConfig } from "../config/ovok";
import { localizationReady } from "./i18n";
function CmsTranslations({ children }: React.PropsWithChildren) {
const { data, loading, error, reload } = useCmsTranslations({
tenantCode: ovokConfig.tenantCode,
environment: "staging",
});
if (error) {
return (
<View>
<Text>App language content could not be loaded.</Text>
<Button onPress={() => void reload()}>Retry</Button>
</View>
);
}
if (loading || !data) return <ActivityIndicator />;
return children;
}
export function LocalizationGate({ children }: React.PropsWithChildren) {
const [ready, setReady] = React.useState(false);
React.useEffect(() => {
void localizationReady.then(() => setReady(true));
}, []);
if (!ready) return <ActivityIndicator />;
return <CmsTranslations>{children}</CmsTranslations>;
}
useCmsTranslations is called once near the root, below the I18nextProvider added in step 5. It observes the active locale and refetches when the user changes languages. The retry text above is a bootstrap fallback; it is not a replacement for the app's CMS strings. The authentication module import registers the Native SDK's built-in auth strings so missing CMS keys still have the SDK's English fallback.
Define one schedule-time rule
FHIR Timing.repeat.timeOfDay is a local clock time without a timezone offset. For this tutorial, interpret it in the device's current timezone and preserve that wall-clock value when the user travels. Display times with Intl.DateTimeFormat; do not convert the plan's timeOfDay to UTC and write it back.
Create src/localization/schedule-time.ts for the device-local time rule:
export function atDeviceLocalTime(day: Date, fhirTime: string): Date | undefined {
const [hour, minute, seconds = "0"] = fhirTime.split(":");
const second = Number(seconds.split(".")[0]);
const result = new Date(
day.getFullYear(),
day.getMonth(),
day.getDate(),
Number(hour),
Number(minute),
second,
0,
);
// A wall-clock time inside the spring DST gap does not exist on this date.
if (
result.getHours() !== Number(hour) ||
result.getMinutes() !== Number(minute) ||
result.getSeconds() !== second
) {
return undefined;
}
return result;
}
export function formatScheduleTime(date: Date, locale: string): string {
return new Intl.DateTimeFormat(locale, {
hour: "numeric",
minute: "2-digit",
}).format(date);
}
This tutorial's three sample times avoid the DST spring gap. A repeated wall-clock time in the autumn overlap is resolved by the device runtime; if your care workflow needs a specific “first” or “second” occurrence, choose and test that rule explicitly. When the app returns to the foreground, compare Intl.DateTimeFormat().resolvedOptions().timeZone with the last scheduled zone and rebuild future local notifications if it changed. The app cannot promise to react to a timezone change while it is not running.
Only schedule explicit daily timeOfDay entries with the simple daily period used in the sample plan. dayOfWeek and boundsPeriod also affect whether an occurrence is due. If a request uses a more complex Timing pattern, do not silently approximate it; show the supplied instructions and have the product owner define the schedule interpretation.
Important Ovok decisions
- CMS translations hold public interface text only; patient-specific content remains in FHIR and is rendered at runtime.
- Localisation changes display formatting. It does not change the meaning or timezone interpretation of a stored schedule.
- A timezone change triggers a local reschedule; it does not alter
MedicationRequest.
Expected result
The app can render English labels from published CMS translations and has a helper that maps supported FHIR local times to device-local Date values without silently moving a non-existent DST time.
Common errors and troubleshooting
- The app shows translation keys: check CMS publication, tenant code, locale, and that
CmsTranslationsuses the same i18next instance as React screens. - A schedule appears one hour off: confirm that code treats
timeOfDayas local wall-clock time and does not parse it as UTC. - An occurrence disappears on a DST transition: the helper intentionally returns no instant for a nonexistent local time. Define a product-approved handling rule rather than shifting it silently.
Previous / Next
Previous: step 3: model the medication plan · Continue to step 5: initialise Ovok and authentication.