Files
HealthLog/src/lib/i18n/context.tsx
T
Marc-André Bombeck 764794496c feat(i18n): register Brazilian Portuguese as a locale
Add pt-BR to the locale list and everything that hangs off it: the
client loader, Intl and date-fns mapping, the reply-language footers of
the Coach and insights prompts (English body, pt-BR footer, the same
path Korean and Dutch take), the citation-coverage normative words, the
no-key fallback copy, the AUDIT-C standard-drink line, country labels,
period search, CSV yes/no cells and the Telegram bare-word aliases.

The locale enums in the extract, managed-profile and record-settings
schemas now derive from the locale list instead of repeating it, and
the five notification and login-alert routes share one isLocale guard
instead of hand-rolled chains, so a later locale is one edit there.
A language tag is matched exactly and case-insensitively before its
primary subtag, so pt-BR and pt-PT browsers land on the Portuguese
bundle rather than English, and AUDIT-C no longer cuts a region tag to
two letters.

PHQ-9, GAD-7 and WHO-5 are not marked validated for pt-BR: the wording
has not been read against the published Brazilian forms, so readers get
the English items plus the existing note.
2026-10-22 14:50:00 +02:00

417 lines
16 KiB
TypeScript

"use client";
import {
createContext,
useContext,
useEffect,
useMemo,
useRef,
useState,
useCallback,
useSyncExternalStore,
type ReactNode,
} from "react";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import {
locales,
defaultLocale,
localeFromLanguageTag,
type Locale,
} from "./config";
import {
makeFormatters,
type Formatters,
type TimeFormatPreference,
type DateFormatPreference,
} from "../format-locale";
import { readStoredTimeFormat, subscribeTimeFormat } from "../time-format";
import { readStoredDateFormat, subscribeDateFormat } from "../date-format";
import { readStoredTimezone, subscribeTimezone } from "../timezone-mirror";
import { resolveKey } from "./resolve-key";
import { pluralKey } from "./plural";
import {
getCachedMessages,
getFallbackMessages,
loadMessages,
primeMessages,
type MessageBundle,
} from "./load-locale";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
interface I18nContextValue {
locale: Locale;
setLocale: (locale: Locale) => void;
/**
* Non-null while a locale switch is waiting on its message bundle
* (dynamic import). The switcher uses it for a short pending state;
* `locale` / `t` only flip once the bundle is in so the UI never
* renders the new locale with the old (or fallback) strings.
*/
pendingLocale: Locale | null;
t: (key: string, params?: Record<string, string | number>) => string;
/**
* Plural-aware `t`. Resolves `<baseKey><One|Few|Other>` under the active
* locale's CLDR rules and passes `count` through as a parameter.
*
* Lives on the context rather than as a standalone helper each component
* imports: `pluralTier` is tiny, but a new module edge from a
* widely-instantiated component (the dashboard trend tile) gets duplicated
* into every chunk that contains it, which measured ~78 KB gz across the
* route chunks. The provider is already in every chunk, so routing through
* it is free.
*/
tCount: (
baseKey: string,
count: number,
params?: Record<string, string | number>,
) => string;
}
const I18nContext = createContext<I18nContextValue | null>(null);
function detectSystemLocale(): Locale {
if (typeof window === "undefined") return defaultLocale;
return localeFromLanguageTag(navigator.language) ?? defaultLocale;
}
function readLocaleCookie(): Locale | null {
if (typeof document === "undefined") return null;
const match = document.cookie.match(/(?:^|;\s*)healthlog-locale=([^;]+)/);
const value = match?.[1];
if (value && (locales as readonly string[]).includes(value)) {
return value as Locale;
}
return null;
}
function getSavedLocale(): Locale | null {
if (typeof window === "undefined") return null;
// Cookie wins over localStorage: the root layout already rendered the
// server HTML using the cookie, so matching the cookie on first client
// render avoids a hydration flash of the wrong language.
const fromCookie = readLocaleCookie();
if (fromCookie) return fromCookie;
const saved = localStorage.getItem("healthlog-locale");
if (saved && (locales as readonly string[]).includes(saved)) {
return saved as Locale;
}
return null;
}
// Persist a locale choice everywhere the next render / request reads
// it: localStorage (client preference), cookie (SSR handoff — layout +
// metadata render in the user's language), <html lang>. Called ONLY
// alongside the `setActive` flip so a failed bundle load never leaves
// the persisted locale pointing at strings the UI isn't showing.
function persistLocale(newLocale: Locale) {
localStorage.setItem("healthlog-locale", newLocale);
// 1-year expiry, Lax samesite, not HttpOnly so the client continues
// to own it.
document.cookie = `healthlog-locale=${newLocale}; path=/; max-age=${60 * 60 * 24 * 365}; SameSite=Lax`;
document.documentElement.lang = newLocale;
}
// Mirror the active locale onto `User.locale` so server-side background work
// (the proactive Coach nudge, the Telegram test message) renders in the
// user's language. The cookie/localStorage choice is invisible to a cron that
// has no request context.
//
// This is no longer what the column depends on. Being fire-and-forget, it
// loses the write on a 401, an offline blip or a tab closed mid-flight, and
// nothing retried — the column then held a language the user had left behind
// for good. `getSession` reconciles the column from the locale cookie on the
// next request instead, which is ordered by the request pipeline and needs no
// cooperation from the client. What this call still earns its place for is
// the server-set cookie the route emits in reply: Safari's ITP caps the
// script-written copy at 7 days, and a Set-Cookie is exempt.
function persistLocaleToServer(newLocale: Locale) {
void apiFetchRaw("/api/auth/me/locale", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ locale: newLocale }),
credentials: "same-origin",
keepalive: true,
}).catch(() => {
// Best-effort: the cookie remains the first-paint source of truth, and
// the next mount retries the backfill.
});
}
export function I18nProvider({
children,
initialLocale,
initialMessages,
}: {
children: ReactNode;
initialLocale?: Locale;
/**
* Optional pre-resolved bundle for standalone mounts (tests, embedded
* providers). The APP no longer passes this: the active bundle reaches
* the provider through the `load-locale` cache — seeded server-side from
* the full catalog map during SSR and client-side by the layout's
* versioned `/i18n/<locale>` boot script — so the catalog is never
* serialized into the RSC flight payload (it used to be 392 KB of every
* dashboard document).
*/
initialMessages?: MessageBundle;
}) {
// Locale + messages live in ONE state cell so a locale switch flips
// both atomically — `locale` never points at a bundle that hasn't
// arrived yet.
const [active, setActive] = useState<{
locale: Locale;
messages: MessageBundle;
}>(() => {
// Prefer the server-resolved initial locale to eliminate the hydration
// flash where the server renders EN ("Loading…") and the client then
// flips to DE ("Laden…") once localStorage/cookie is read at mount.
const locale =
initialLocale && (locales as readonly string[]).includes(initialLocale)
? initialLocale
: (getSavedLocale() ?? detectSystemLocale());
if (initialMessages && locale === initialLocale) {
// Seed the switch cache too, so switching away and back is instant.
// Idempotent, so safe under StrictMode's double initializer call.
primeMessages(locale, initialMessages);
return { locale, messages: initialMessages };
}
// Cache miss on every ladder rung (boot script failed AND the locale
// isn't EN-cached): render with an empty bundle for one frame — the
// backfill effect below dynamic-imports the bundle and flips state.
// In the app this only happens when the boot script is unreachable
// (evicted HTTP cache offline); keys render raw briefly rather than
// in the wrong language.
return {
locale,
messages: getCachedMessages(locale) ?? getFallbackMessages() ?? {},
};
});
const { locale, messages } = active;
const [pendingLocale, setPendingLocale] = useState<Locale | null>(null);
// Bumped when the lazily-loaded EN fallback bundle lands so every t()
// consumer re-renders with the fallback floor in place. The EN catalog is
// no longer a static import (it cost ~106 KB gz on every route); it loads
// on demand the first time a key actually misses the active bundle.
const [, setFallbackTick] = useState(0);
const fallbackRequestedRef = useRef(false);
// Tracks the most recent switch request so a slow bundle load can't
// clobber a newer one (last click wins).
const requestedLocaleRef = useRef<Locale | null>(null);
// Degenerate mount (no server-passed bundle, non-EN locale from
// localStorage / navigator): backfill the bundle asynchronously. In
// the app this path never runs — the root layout always passes the
// active locale's messages — but standalone mounts stay correct.
useEffect(() => {
if (getCachedMessages(locale)) return;
let cancelled = false;
void loadMessages(locale).then((loaded) => {
if (cancelled) return;
setActive((prev) =>
prev.locale === locale ? { locale, messages: loaded } : prev,
);
});
return () => {
cancelled = true;
};
}, [locale]);
// v1.25.0 — keep `User.locale` in step with the committed UI locale. Runs on
// mount (backfilling the column for users who set their language before this
// landed) and on every switch, so the proactive Coach nudge and other
// cookie-blind background paths render in the language the user reads.
useEffect(() => {
persistLocaleToServer(locale);
}, [locale]);
const setLocale = useCallback((newLocale: Locale) => {
if (!(locales as readonly string[]).includes(newLocale)) return;
const cached = getCachedMessages(newLocale);
if (cached) {
requestedLocaleRef.current = null;
setPendingLocale(null);
persistLocale(newLocale);
setActive({ locale: newLocale, messages: cached });
return;
}
requestedLocaleRef.current = newLocale;
setPendingLocale(newLocale);
loadMessages(newLocale)
.then((loaded) => {
if (requestedLocaleRef.current !== newLocale) return;
requestedLocaleRef.current = null;
setPendingLocale(null);
// Persist atomically with the flip — never before the bundle is
// in hand, so cookie / <html lang> / localStorage can't drift
// onto a locale the UI failed to load.
persistLocale(newLocale);
setActive({ locale: newLocale, messages: loaded });
})
.catch(() => {
// Bundle fetch failed (offline mid-session, …) — stay on the
// current locale rather than rendering the new one in EN, and
// leave cookie / <html lang> / localStorage untouched.
if (requestedLocaleRef.current !== newLocale) return;
requestedLocaleRef.current = null;
setPendingLocale(null);
});
}, []);
// Keep the HTML lang and the cookie in sync with the active locale on
// mount. The cookie acts as the SSR handoff for the next request.
useEffect(() => {
document.documentElement.lang = locale;
document.cookie = `healthlog-locale=${locale}; path=/; max-age=${60 * 60 * 24 * 365}; SameSite=Lax`;
}, [locale]);
const t = useCallback(
(key: string, params?: Record<string, string | number>): string => {
let value = resolveKey(messages, key);
// Fallback to English if key missing in current locale
if (value === undefined && locale !== "en") {
const fallback = getFallbackMessages();
if (fallback) {
value = resolveKey(fallback, key);
} else if (!fallbackRequestedRef.current) {
// First miss without the EN floor in hand: fetch it once, then
// re-render so this key (and any sibling misses) resolve. The
// locale-integrity guard keeps every locale key-complete, so in
// practice this path only runs against a drifted cached bundle.
fallbackRequestedRef.current = true;
void loadMessages("en")
.then(() => setFallbackTick((n) => n + 1))
.catch(() => {
// Leave the ref set — retrying every render would hammer a
// dead network; the raw key remains the last resort.
});
}
}
// Fallback to key itself
if (value === undefined) {
return key;
}
// Replace {param} placeholders
if (params) {
for (const [k, v] of Object.entries(params)) {
// A REPLACER FUNCTION, not a replacement string. `String.replace`
// reads `$&`, `$\``, `$'`, `$1` and `$$` out of a replacement
// STRING, so a value carrying one of them rewrites the sentence
// around it: a display name of `$\'` splices everything after the
// placeholder back in, and `$\`` splices everything before it. Every
// interpolated value here is user-controlled — a person's display
// name is the common one, and it lands in sentences on the sharing
// panel that describe who did what. A function replacement is
// substituted verbatim and has no `$` grammar at all.
value = value.replace(new RegExp(`\\{${k}\\}`, "g"), () => String(v));
}
}
return value;
},
[locale, messages],
);
const tCount = useCallback(
(
baseKey: string,
count: number,
params?: Record<string, string | number>,
): string => t(pluralKey(baseKey, count, locale), { count, ...params }),
[t, locale],
);
const value = useMemo(
() => ({ locale, setLocale, pendingLocale, t, tCount }),
[locale, setLocale, pendingLocale, t, tCount],
);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}
export function useTranslations() {
const context = useContext(I18nContext);
if (!context) {
throw new Error("useTranslations must be used within I18nProvider");
}
return context;
}
/**
* The user's hour-cycle preference (AUTO / H12 / H24), reactive to changes.
* Backed by the localStorage mirror that `useAuth`'s `/api/auth/me` fetch
* and the profile time-format select keep in sync with the server value —
* no QueryClient required in the tree. SSR resolves AUTO.
*/
export function useTimeFormatPreference(): TimeFormatPreference {
return useSyncExternalStore(
subscribeTimeFormat,
readStoredTimeFormat,
() => "AUTO" as const,
);
}
/**
* The user's date-order preference (AUTO / DMY / MDY / YMD), reactive to
* changes. Backed by the same localStorage-mirror pattern as the hour-cycle
* preference — `useAuth`'s `/api/auth/me` fetch and the profile date-format
* select keep it in sync with the server value, no QueryClient required.
* SSR resolves AUTO.
*/
export function useDateFormatPreference(): DateFormatPreference {
return useSyncExternalStore(
subscribeDateFormat,
readStoredDateFormat,
() => "AUTO" as const,
);
}
/**
* The user's mirrored profile timezone, resolved to a renderable IANA zone:
* valid mirror value → `DEFAULT_TIMEZONE` (Europe/Berlin). Reactive to
* mirror changes (profile save, cross-tab, `/api/auth/me` re-fetch) via the
* same localStorage-mirror pattern as the hour-cycle preference. SSR and the
* pre-fetch state resolve Berlin — deliberately NO browser-timezone rung
* (issue #490): the chain must match the server's `resolveUserTimezone`
* fallback so on-screen times agree with the PDF / exports.
*
* Use this wherever a day-boundary or weekday must be derived in the SAME
* zone `useFormatters()` renders clocks in (e.g. `formatUpdatedLabel`).
*/
export function useDisplayTimezone(): string {
const stored = useSyncExternalStore(
subscribeTimezone,
readStoredTimezone,
() => "",
);
return stored !== "" ? stored : DEFAULT_TIMEZONE;
}
/**
* Locale-aware formatters tied to the active UI locale. Use for every number,
* date, and time rendered in the UI so regional conventions (70,5 vs 70.5,
* 19.02.2026 vs Feb 19, 2026) follow the user's language choice. Times honour
* the per-user hour-cycle preference (AUTO follows the locale, H12 / H24 pin
* the cycle) and render in the user's profile timezone (issue #490 — the
* localStorage mirror `fetchMe` keeps in sync; "" falls back to Berlin
* inside `makeFormatters`).
*/
export function useFormatters(): Formatters {
const { locale } = useTranslations();
const timeFormat = useTimeFormatPreference();
const dateFormat = useDateFormatPreference();
const timezone = useSyncExternalStore(
subscribeTimezone,
readStoredTimezone,
() => "",
);
return useMemo(
() => makeFormatters(locale, timezone, timeFormat, dateFormat),
[locale, timezone, timeFormat, dateFormat],
);
}