Files
HealthLog/src/lib/i18n/server-locale.ts
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

81 lines
2.3 KiB
TypeScript

import { cookies } from "next/headers";
import type { NextRequest } from "next/server";
import {
defaultLocale,
locales,
localeFromLanguageTag,
type Locale,
} from "./config";
const LOCALE_COOKIE_NAMES = ["healthlog-locale", "userLocale"] as const;
function isLocale(value: string | null | undefined): value is Locale {
return !!value && (locales as readonly string[]).includes(value);
}
function pickLocaleFromAcceptLanguage(header: string | null): Locale | null {
if (!header) return null;
// Accept-Language: "en-US,en;q=0.9,de;q=0.8"
const candidates = header
.split(",")
.map((part) => {
const [tag, qStr] = part.trim().split(";");
const q = qStr?.startsWith("q=") ? Number(qStr.slice(2)) : 1;
return {
tag: tag.trim().toLowerCase(),
q: Number.isFinite(q) ? q : 0,
};
})
.sort((a, b) => b.q - a.q);
for (const { tag } of candidates) {
const locale = localeFromLanguageTag(tag);
if (locale) return locale;
}
return null;
}
interface ResolveOptions {
/** If a NextRequest is available, the Accept-Language header is read from it. */
request?: NextRequest | null;
/** The user-record locale (e.g. User.locale). */
userLocale?: string | null;
/** Explicit override (e.g. ?locale= query param). */
override?: string | null;
}
/**
* Resolve the active locale for a server-side request.
*
* Priority:
* 1. Explicit override (e.g. `?locale=…` query param).
* 2. healthlog-locale / userLocale cookie (set by the client when the user
* switches language).
* 3. The User.locale column (persisted preference).
* 4. Accept-Language header.
* 5. defaultLocale (English).
*/
export async function resolveServerLocale(
options: ResolveOptions = {},
): Promise<Locale> {
if (isLocale(options.override)) return options.override;
try {
const cookieStore = await cookies();
for (const name of LOCALE_COOKIE_NAMES) {
const value = cookieStore.get(name)?.value;
if (isLocale(value)) return value;
}
} catch {
// cookies() throws outside a request context — ignore.
}
if (isLocale(options.userLocale)) return options.userLocale;
const accept = options.request?.headers.get("accept-language") ?? null;
const fromHeader = pickLocaleFromAcceptLanguage(accept);
if (fromHeader) return fromHeader;
return defaultLocale;
}