Skip to main content

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.
  • i18next and react-i18next installed.

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 slugKeys used in this tutorial
medicationtodayTitle, 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
settingstitle, profile, language, english, german, reminders, privacyAndConsent, signOut, appInformation, notificationsDenied
sign-inKeep the full key set listed in the Native SDK sign-in reference; this slug replaces the shared group.
registerKeep 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​

KeyEnglish value
todayTitleToday's medications
loadingLoading…
loadErrorWe couldn't load this information. Try again.
emptyPlanNo active medication plan is available for this account.
noSupportedScheduleNo supported schedule details are available yet.
morningMorning
afternoonAfternoon
eveningEvening
nightNight
upcomingUpcoming
dueDue
takenTaken
skippedSkipped
missedNot recorded
missedMessageThis dose was not recorded as taken.
unknownNameMedication name not provided
requestStatusPlan status: {{status}}
dose{{value}} {{unit}}
startDateStarts {{date}}
endDateEnds {{date}}
notificationChannelReminders
reminderTitleReminder
reminderBodyYou have a scheduled reminder. Open the app to view details.
reminderScheduleErrorWe couldn't update reminders. Open Settings and try again.
savingSaving your response…
markTakenMark as taken
markSkippedMark as skipped
patientReportedPatient reported
reportErrorWe couldn't save this response. Try again.
retryReportRetry sending response
historyTitleMedication history
filterAllAll time
filter7DaysLast 7 days
filter30DaysLast 30 days
noHistoryNo medication reports in this period.
recordedStatusRecorded status: {{status}}
viewDetailsView details
notInPlanThis medication is not in the current plan.
lastSyncedLast synced {{date}}
offlineCacheErrorThis information could not be saved for offline use. It may not be available without a connection next time.
pendingSyncYour response is saved on this device and is waiting to sync.
syncErrorA saved response needs attention before it can sync.

settings English values​

KeyEnglish value
titleSettings
profilePatient ID
languageLanguage
englishEnglish
germanDeutsch
remindersReminders
privacyAndConsentPrivacy and consent
signOutSign out
appInformationApp information
notificationsDeniedNotifications 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 CmsTranslations uses the same i18next instance as React screens.
  • A schedule appears one hour off: confirm that code treats timeOfDay as 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.