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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-22 14:50:00 +02:00
1 parent 1619b2406a
commit 764794496c
72 files changed
+425 -159

No files matched your search

+4 -3
View File
@@ -1,16 +1,16 @@
name: Translation feedback
description: Report a wording, grammar, or register problem in HealthLog's AI-initial locales (FR / ES / IT / PL / KO / NL).
description: Report a wording, grammar, or register problem in HealthLog's community-maintained locales (FR / ES / IT / PL / KO / NL / PT-BR).
title: "[Translation] "
labels: ["i18n", "translation-feedback"]
body:
- type: markdown
attributes:
value: |
HealthLog ships German and English as maintainer-curated translations. French, Spanish, Italian, Polish, Korean, and Dutch ship as **AI-initial translations** — the prose passes the locale-integrity test (full key parity, no empty values) but has not been hand-reviewed for register or regional vocabulary.
HealthLog ships German and English as maintainer-curated translations. French, Spanish, Italian, Polish, Korean, Dutch, and Brazilian Portuguese ship as **AI-initial, community-maintained translations** — the prose passes the locale-integrity test (full key parity, no empty values) but has not been hand-reviewed for register or regional vocabulary.
This template captures concrete fixes you would like to see. Phrasing nuance, regional vocabulary (e.g. "Tú" vs "Usted"), gendered forms, and domain-specific health terms are all in scope. The maintainer applies fixes during the regular release cycle.
For wholly new locale requests (e.g. "please add Portuguese"), open a [Discussion](https://github.com/MBombeck/HealthLog/discussions) instead.
For wholly new locale requests (e.g. "please add Swedish"), open a [Discussion](https://github.com/MBombeck/HealthLog/discussions) instead.
- type: dropdown
id: locale
attributes:
@@ -22,6 +22,7 @@ body:
- Polish (pl)
- Korean (ko)
- Dutch (nl)
- Brazilian Portuguese (pt-BR)
- Other (specify under "Surface")
validations:
required: true
+2 -2
View File
@@ -121,7 +121,7 @@ Contributors: branch off `main`; PRs target `main`.
## Translations
HealthLog ships with English (`en`) and German (`de`) maintained by the project, plus French (`fr`), Spanish (`es`), Italian (`it`), Polish (`pl`), Korean (`ko`), and Dutch (`nl`) that are **AI-initial, community-maintained**. The maintained locales are reviewed line-by-line; the community-maintained ones surface a small banner inviting users to improve them.
HealthLog ships with English (`en`) and German (`de`) maintained by the project, plus French (`fr`), Spanish (`es`), Italian (`it`), Polish (`pl`), Korean (`ko`), Dutch (`nl`), and Brazilian Portuguese (`pt-BR`) that are **AI-initial, community-maintained**. The maintained locales are reviewed line-by-line; the community-maintained ones surface a small banner inviting users to improve them.
### Improving an existing locale
@@ -136,7 +136,7 @@ To suggest a fix or improvement:
### Adding a new locale
If you want to introduce a locale that isn't shipped (e.g. `pt-BR`, `cs`):
If you want to introduce a locale that isn't shipped (e.g. `sv`, `cs`):
1. Open an issue first using the **Translation request** template. We'll confirm the locale is in scope and decide whether it joins the AI-initial-community-maintained tier or the maintained tier.
2. Copy `messages/en.json` to `messages/<locale>.json`.
+1 -1
View File
@@ -56,7 +56,7 @@ Try the [live demo](https://demo.healthlog.dev) to see a working install — sig
- **AI insights you own.** Daily briefing, health scores, correlations, and a conversational coach grounded in your own measurements and interpreted against cited guideline reference bands — where a value sits and what that means in plain words, never a diagnosis. The coach proposes concrete if-then plans you accept or decline, and suppresses proactive check-ins when there's nothing genuine to say. Bring your own OpenAI or Anthropic key, or point at a local endpoint (Ollama, LM Studio, vLLM) so AI requests stay on your network.
- **Clinician-ready output.** A doctor-report PDF rendered on your server, a read-only HL7 FHIR R4 API, and scoped, time-limited share links you revoke after the visit.
- **Private by construction.** AES-256-GCM encryption at rest for notes and other free text, documents, questionnaire answers, AI-written text and credentials, with zero-downtime key rotation. Measurement values, lab values, medication names and mood scores are stored unencrypted in the database, so the database volume and its backups need the same protection as the host. Passkey login, server-side sessions, strict CSP, and no third-party tracking anywhere.
- **Built to be lived in.** Installable PWA with offline support, medication reminders over APNs, Telegram, ntfy, and Web Push, a sub-second dashboard on years of imported history — in English, German, Spanish, French, Italian, Polish, Korean, and Dutch end to end.
- **Built to be lived in.** Installable PWA with offline support, medication reminders over APNs, Telegram, ntfy, and Web Push, a sub-second dashboard on years of imported history — in English, German, Spanish, French, Italian, Polish, Korean, Dutch, and Brazilian Portuguese end to end.
The full feature tour, integration guides, and API reference live at [docs.healthlog.dev](https://docs.healthlog.dev).
+14 -6
View File
@@ -27541,6 +27541,7 @@ components:
- pl
- ko
- nl
- pt-BR
practiceName:
type: string
maxLength: 120
@@ -30039,14 +30040,15 @@ components:
description: Optional UI locale hint for the model.
type: string
enum:
- en
- de
- es
- en
- fr
- es
- it
- pl
- ko
- nl
- pt-BR
today:
description: "Optional override of the reference date used to resolve relative phrases (\"tomorrow\", \"next Monday\").
Format: `YYYY-MM-DD`. Defaults to the account's own day in its time zone."
@@ -31015,6 +31017,7 @@ components:
- pl
- ko
- nl
- pt-BR
description: One of the interface locales this build ships.
required:
- locale
@@ -36349,12 +36352,13 @@ components:
enum:
- de
- en
- es
- fr
- es
- it
- pl
- ko
- nl
- pt-BR
timezone:
type: string
minLength: 1
@@ -36404,12 +36408,13 @@ components:
enum:
- de
- en
- es
- fr
- es
- it
- pl
- ko
- nl
- pt-BR
timezone:
type: string
minLength: 1
@@ -36622,12 +36627,13 @@ components:
enum:
- de
- en
- es
- fr
- es
- it
- pl
- ko
- nl
- pt-BR
- type: "null"
timezone:
type: string
@@ -59173,6 +59179,7 @@ components:
- pl
- ko
- nl
- pt-BR
required:
- locale
additionalProperties: false
@@ -87488,12 +87495,13 @@ components:
enum:
- de
- en
- es
- fr
- es
- it
- pl
- ko
- nl
- pt-BR
- type: "null"
timezone:
type: string
+11 -1
View File
@@ -33,7 +33,17 @@ import {
type SetupScreen,
} from "./setup-flow-helpers";
const LOCALES = ["en", "de", "es", "fr", "it", "pl", "ko", "nl"] as const;
const LOCALES = [
"en",
"de",
"es",
"fr",
"it",
"pl",
"ko",
"nl",
"pt-BR",
] as const;
const VIEWPORTS = [
{ label: "desktop 1280", width: 1280, height: 800 },
+1 -1
View File
@@ -28,7 +28,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(__dirname, "..");
const outDir = join(repoRoot, "public", "i18n");
const LOCALES = ["de", "en", "fr", "es", "it", "pl", "ko", "nl"];
const LOCALES = ["de", "en", "fr", "es", "it", "pl", "ko", "nl", "pt-BR"];
mkdirSync(outDir, { recursive: true });
+1 -1
View File
@@ -11,7 +11,7 @@
*/
import { readFileSync, writeFileSync } from "node:fs";
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko", "nl"];
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko", "nl", "pt-BR"];
const file = (loc) => `messages/${loc}.json`;
function read(loc) {
+1 -1
View File
@@ -18,7 +18,7 @@
*/
import { readFileSync, writeFileSync } from "node:fs";
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko", "nl"];
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko", "nl", "pt-BR"];
const BOUNDARY = /(?<=[.!?])\s+(?=[A-ZÄÖÜÀÂÉÈÊÎÔÙÛÇŁŚŹŻĄĆĘŃÓ0-9„"„«])/u;
/**
+11 -1
View File
@@ -25,7 +25,17 @@ import { gradeVoice } from "@/lib/ai/coach/eval/grade-voice";
import { DEFAULT_COACH_PREFS } from "@/lib/validations/coach-prefs";
import type { Locale } from "@/lib/i18n/config";
const LOCALES: Locale[] = ["de", "en", "fr", "es", "it", "pl", "ko", "nl"];
const LOCALES: Locale[] = [
"de",
"en",
"fr",
"es",
"it",
"pl",
"ko",
"nl",
"pt-BR",
];
/** The rules a voice section must state; `[]` when it states them all. */
function missingVoiceRules(prompt: string, lang: "de" | "en"): string[] {
@@ -42,7 +42,17 @@ import { describe, expect, it } from "vitest";
import { consentNeededForProviders } from "@/lib/ai/capabilities/resolve";
const MESSAGES = join(__dirname, "../../messages");
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko", "nl"] as const;
const LOCALES = [
"de",
"en",
"es",
"fr",
"it",
"pl",
"ko",
"nl",
"pt-BR",
] as const;
function bundle(locale: string): Record<string, unknown> {
return JSON.parse(readFileSync(join(MESSAGES, `${locale}.json`), "utf8"));
@@ -166,7 +166,7 @@ describe("dynamic-key exhaustiveness (enum-derived)", () => {
expect(LOCALES.length).toBeGreaterThanOrEqual(6);
});
it("discovers all eight shipped locales", () => {
it("discovers all nine shipped locales", () => {
expect(LOCALES.map((l) => l.locale).sort()).toEqual([
"de",
"en",
@@ -176,6 +176,7 @@ describe("dynamic-key exhaustiveness (enum-derived)", () => {
"ko",
"nl",
"pl",
"pt-BR",
]);
});
@@ -33,7 +33,7 @@ const EN_PATH = join(MESSAGES_DIR, "en.json");
// intentionally excluded — it carries accepted anglicisms (e.g. "Health
// Score") that would be noise here, and is covered by its own pinned checks in
// `i18n-locale-integrity`.
const LEAK_LOCALES = ["es", "fr", "it", "pl", "ko", "nl"] as const;
const LEAK_LOCALES = ["es", "fr", "it", "pl", "ko", "nl", "pt-BR"] as const;
function flattenValues(
obj: unknown,
@@ -20,7 +20,17 @@ import { join } from "node:path";
* the key is a prop, not a call argument. Both halves are asserted here.
*/
const LOCALES = ["en", "de", "es", "fr", "it", "pl", "ko", "nl"] as const;
const LOCALES = [
"en",
"de",
"es",
"fr",
"it",
"pl",
"ko",
"nl",
"pt-BR",
] as const;
const INSIGHTS_PAGES_DIR = join(process.cwd(), "src/app/insights");
/**
@@ -93,7 +93,17 @@ const NOT_IN_THE_FEED = [
/** What an action with no sentence of its own renders as. */
const FALLBACK_KEY = "recordSharing.activity.acted";
/** The six bundles the product ships. */
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko", "nl"] as const;
const LOCALES = [
"de",
"en",
"es",
"fr",
"it",
"pl",
"ko",
"nl",
"pt-BR",
] as const;
/** Every `route.ts` under `src/app/api`, walked rather than globbed: a glob's
* `*` skips a leading dot and this tree has dot-directories. */
+11 -1
View File
@@ -26,7 +26,17 @@ import { readSource } from "@/__tests__/helpers/source-files";
const ROOT = path.resolve(__dirname, "../..");
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko", "nl"] as const;
const LOCALES = [
"de",
"en",
"es",
"fr",
"it",
"pl",
"ko",
"nl",
"pt-BR",
] as const;
/** Every path the retired bridge occupied. */
const REMOVED_PATHS = [
+2 -2
View File
@@ -50,8 +50,8 @@ describe("managed record settings UI", () => {
fromRoot("src/components/settings/record-settings-section-gate.tsx"),
"utf8",
),
...["de", "en", "es", "fr", "it", "pl", "ko", "nl"].map((locale) =>
readFile(fromRoot(`messages/${locale}.json`), "utf8"),
...["de", "en", "es", "fr", "it", "pl", "ko", "nl", "pt-BR"].map(
(locale) => readFile(fromRoot(`messages/${locale}.json`), "utf8"),
),
]);
+11 -1
View File
@@ -38,7 +38,17 @@ import {
* it — which is the part that rots silently.
*/
const ROOT = process.cwd();
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko", "nl"] as const;
const LOCALES = [
"de",
"en",
"es",
"fr",
"it",
"pl",
"ko",
"nl",
"pt-BR",
] as const;
/** The published description of the level, off the Zod schema itself. */
function accessDescription(): string {
+11 -1
View File
@@ -4,7 +4,17 @@ import { join } from "node:path";
import { describe, expect, it } from "vitest";
const ROOT = join(__dirname, "../..");
const LOCALES = ["en", "de", "es", "fr", "it", "pl", "ko", "nl"] as const;
const LOCALES = [
"en",
"de",
"es",
"fr",
"it",
"pl",
"ko",
"nl",
"pt-BR",
] as const;
const REQUIRED_KEYS = [
// Security: adding a passkey now requires an existing sign-in factor.
+11 -1
View File
@@ -10,7 +10,17 @@ import { readFileSync } from "node:fs";
import { join } from "node:path";
const ROOT = join(__dirname, "../../..");
const LOCALES = ["en", "de", "fr", "es", "it", "pl", "ko", "nl"] as const;
const LOCALES = [
"en",
"de",
"fr",
"es",
"it",
"pl",
"ko",
"nl",
"pt-BR",
] as const;
interface Messages {
notFound: {
+1 -14
View File
@@ -23,7 +23,7 @@ import type {
ChannelType,
NotificationPayload,
} from "@/lib/notifications/types";
import { defaultLocale, type Locale } from "@/lib/i18n/config";
import { defaultLocale, isLocale, type Locale } from "@/lib/i18n/config";
import { getServerTranslator } from "@/lib/i18n/server-translator";
export const dynamic = "force-dynamic";
@@ -111,19 +111,6 @@ function failedSend(
};
}
function isLocale(value: string | null | undefined): value is Locale {
return (
value === "de" ||
value === "en" ||
value === "fr" ||
value === "es" ||
value === "it" ||
value === "pl" ||
value === "ko" ||
value === "nl"
);
}
export const POST = apiHandler(async () => {
const { user } = await requireAdmin();
annotate({ action: { name: "admin.notifications.test" } });
+2 -1
View File
@@ -91,6 +91,7 @@ import {
medicationExtractionSchema,
type MedicationExtractionResult,
} from "@/lib/ai/coach/medication-extract-prompt";
import { locales } from "@/lib/i18n/config";
/**
* How the extractor refuses with no provider. 503 predates the capability
@@ -118,7 +119,7 @@ const requestSchema = z
.object({
text: z.string().min(1).max(MAX_TEXT_LENGTH),
/** Optional UI locale; informational hint for the model. */
locale: z.enum(["en", "de", "es", "fr", "it", "pl", "ko", "nl"]).optional(),
locale: z.enum(locales).optional(),
/**
* Optional override of the reference date used to resolve relative
* phrases ("tomorrow", "next Monday"). Defaults to the server's
+1 -14
View File
@@ -19,25 +19,12 @@ import { apiSuccess, apiError } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { sendViaApns } from "@/lib/notifications/senders/apns";
import { defaultLocale, type Locale } from "@/lib/i18n/config";
import { defaultLocale, isLocale, type Locale } from "@/lib/i18n/config";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { prisma } from "@/lib/db";
export const dynamic = "force-dynamic";
function isLocale(value: string | null | undefined): value is Locale {
return (
value === "de" ||
value === "en" ||
value === "fr" ||
value === "es" ||
value === "it" ||
value === "pl" ||
value === "ko" ||
value === "nl"
);
}
export const POST = apiHandler(async (request: NextRequest) => {
void request;
const { user } = await requireAuth();
+1 -14
View File
@@ -5,22 +5,9 @@ import { sendTelegramMessage } from "@/lib/telegram";
import { checkRateLimit } from "@/lib/rate-limit";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { defaultLocale, type Locale } from "@/lib/i18n/config";
import { defaultLocale, isLocale, type Locale } from "@/lib/i18n/config";
import { getServerTranslator } from "@/lib/i18n/server-translator";
function isLocale(value: string | null | undefined): value is Locale {
return (
value === "de" ||
value === "en" ||
value === "fr" ||
value === "es" ||
value === "it" ||
value === "pl" ||
value === "ko" ||
value === "nl"
);
}
/**
* Send a test Telegram message to verify the bot token and chat ID.
*/
@@ -218,7 +218,17 @@ describe("dashboard.*Short keys exist across all six locales", () => {
// Resolved at test time so the assertion runs against the current
// bundle, not a stale cached snapshot.
const locales = ["en", "de", "fr", "es", "it", "pl", "ko", "nl"] as const;
const locales = [
"en",
"de",
"fr",
"es",
"it",
"pl",
"ko",
"nl",
"pt-BR",
] as const;
it.each(locales)("%s carries every dashboard.*Short key", async (locale) => {
const messages = (await import(`../../../../messages/${locale}.json`))
@@ -31,7 +31,7 @@ vi.mock("@/components/ui/responsive-sheet", () => ({
import { KeyboardShortcutsDialog } from "../keyboard-shortcuts-dialog";
import { primeLocales } from "@/__tests__/helpers/prime-locales";
primeLocales("de", "es", "fr", "it", "pl", "ko", "nl");
primeLocales("de", "es", "fr", "it", "pl", "ko", "nl", "pt-BR");
const EVERYTHING = {
navHrefs: ["/", "/medications", "/labs", "/timeline", "/insights", "/coach"],
@@ -106,7 +106,7 @@ describe("<KeyboardShortcutsDialog>", () => {
expect(kbd).not.toMatch(/#[0-9a-f]{3,6}|dracula|\[/i);
});
it.each(["de", "es", "fr", "it", "pl", "ko", "nl"])(
it.each(["de", "es", "fr", "it", "pl", "ko", "nl", "pt-BR"])(
"is translated in %s",
(locale) => {
const html = render({}, locale);
@@ -75,6 +75,7 @@ describe("<OfflineBanner>", () => {
"pl",
"ko",
"nl",
"pt-BR",
] as const) {
const m = loadMessages(locale);
expect(m.offlineBanner.message.length, locale).toBeGreaterThan(0);
@@ -8,7 +8,17 @@ import { describe, expect, it } from "vitest";
import { formatPeriod, parsePeriodQuery } from "../period-query";
const NOW = new Date("2026-10-15T12:00:00.000Z");
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko", "nl"] as const;
const LOCALES = [
"de",
"en",
"es",
"fr",
"it",
"pl",
"ko",
"nl",
"pt-BR",
] as const;
function window(term: string, locale: string) {
const parsed = parsePeriodQuery(term, locale, NOW);
+1
View File
@@ -52,6 +52,7 @@ export interface PeriodQuery {
const CONNECTORS: Record<string, readonly string[]> = {
es: ["de", "del"],
it: ["del"],
pt: ["de", "do"],
};
const nameCache = new Map<string, ReadonlyArray<[string, number]>>();
@@ -41,7 +41,7 @@ const REQUIRED_KEYS = [
];
describe("measurement-reminder + bulk-share dynamic i18n keys", () => {
it("discovers all eight shipped locales", () => {
it("discovers all nine shipped locales", () => {
expect(LOCALES.map((l) => l.locale).sort()).toEqual([
"de",
"en",
@@ -51,6 +51,7 @@ describe("measurement-reminder + bulk-share dynamic i18n keys", () => {
"ko",
"nl",
"pl",
"pt-BR",
]);
});
@@ -108,7 +108,7 @@ describe("<ImportPanel> — SSR smoke", () => {
});
describe("<AppleHealthEstimateWarning>", () => {
it.each(["en", "de", "es", "fr", "it", "pl", "ko", "nl"] as const)(
it.each(["en", "de", "es", "fr", "it", "pl", "ko", "nl", "pt-BR"] as const)(
"renders localized estimate disclosure for %s",
(locale) => {
const html = render(<AppleHealthEstimateWarning days={3} />, locale);
@@ -10,7 +10,17 @@ import {
ManagedRecordSettingsForm,
} from "../managed-record-settings-section";
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko", "nl"] as const;
const LOCALES = [
"de",
"en",
"es",
"fr",
"it",
"pl",
"ko",
"nl",
"pt-BR",
] as const;
const render = (
family: Parameters<typeof ManagedRecordSettingsForm>[0]["family"],
@@ -215,7 +215,7 @@ describe("<AppleHealthCard>", () => {
}
});
it.each(["en", "de", "es", "fr", "it", "pl", "ko", "nl"] as const)(
it.each(["en", "de", "es", "fr", "it", "pl", "ko", "nl", "pt-BR"] as const)(
"renders localized Apple Health copy for %s without leaking keys",
(locale) => {
statusPayload = { lastSyncedAt: null, health: null };
@@ -15,6 +15,7 @@ import itMessages from "../../../../../messages/it.json";
import ko from "../../../../../messages/ko.json";
import nl from "../../../../../messages/nl.json";
import pl from "../../../../../messages/pl.json";
import ptBR from "../../../../../messages/pt-BR.json";
import {
diagnosticsText,
@@ -34,6 +35,7 @@ const CATALOGS: Record<Locale, unknown> = {
pl,
nl,
ko,
"pt-BR": ptBR,
};
function translator(locale: Locale) {
@@ -46,6 +46,7 @@ import { SettingsCardActions } from "./_card-actions";
import { SettingsCardHeader } from "./_card-header";
import { SettingsCard } from "./settings-card";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { locales } from "@/lib/i18n/config";
interface ManagedRecordSettingsResponse {
recordId: string;
@@ -411,7 +412,7 @@ function ProfileSettingsForm({
<option value="">
{t("settings.sharedRecord.managedSettings.profile.systemDefault")}
</option>
{["de", "en", "es", "fr", "it", "pl", "ko", "nl"].map((locale) => (
{locales.map((locale) => (
<option key={locale} value={locale}>
{locale}
</option>
@@ -34,7 +34,17 @@ import { TimelineLegend } from "../timeline-view";
import { useSeriesValueFormat } from "../use-series-value-format";
import { item, wordsIn } from "./timeline-fixture";
const LOCALES: Locale[] = ["de", "en", "es", "fr", "it", "pl", "ko", "nl"];
const LOCALES: Locale[] = [
"de",
"en",
"es",
"fr",
"it",
"pl",
"ko",
"nl",
"pt-BR",
];
function render(node: React.ReactElement, locale: Locale = "en") {
return renderToStaticMarkup(
+3 -1
View File
@@ -1,4 +1,4 @@
import { de, enUS, es, fr, it, ko, nl, pl } from "date-fns/locale";
import { de, enUS, es, fr, it, ko, nl, pl, ptBR } from "date-fns/locale";
import type { Locale as DateFnsLocale } from "date-fns";
import type { Locale } from "@/lib/i18n/config";
@@ -25,6 +25,8 @@ export function resolveDateFnsLocale(locale: Locale): DateFnsLocale {
return ko;
case "nl":
return nl;
case "pt-BR":
return ptBR;
default:
return enUS;
}
@@ -242,7 +242,17 @@ describe("offline-geo runtime detection", () => {
// issue #659 — the message used to tell a self-hoster to set
// MAXMIND_LICENSE_KEY in this repository's Actions secrets, which is
// not something they can do. Every locale must name the mount instead.
const locales = ["de", "en", "es", "fr", "it", "pl", "ko", "nl"] as const;
const locales = [
"de",
"en",
"es",
"fr",
"it",
"pl",
"ko",
"nl",
"pt-BR",
] as const;
for (const locale of locales) {
const bundle = JSON.parse(
readFileSync(
@@ -254,6 +254,7 @@ describe("normative-claim detection across the shipped locales", () => {
pl: "Ciśnienie skurczowe powinno pozostać poniżej celu 130 mmHg.",
nl: "Je bovendruk zou onder de streefwaarde van 130 mmHg moeten blijven.",
ko: "수축기 혈압은 목표인 130 mmHg 아래로 유지해야 해요.",
"pt-BR": "Sua sistólica deveria ficar abaixo da meta de 130 mmHg.",
};
/**
@@ -270,6 +271,8 @@ describe("normative-claim detection across the shipped locales", () => {
pl: "Twoja średnia z 7 dni przekracza medianę z 90 dni o 4 mmHg.",
nl: "Je 7-daagse gemiddelde ligt 4 mmHg hoger dan je 90-daagse mediaan.",
ko: "7일 평균이 90일 중앙값보다 4 mmHg 더 커요.",
"pt-BR":
"Sua média de 7 dias está 4 mmHg mais alta que a sua mediana de 90 dias.",
};
it.each(locales)("carries a non-empty keyword bank for %s", (locale) => {
+16
View File
@@ -162,6 +162,22 @@ const NORMATIVE_KEYWORDS: Record<Locale, readonly string[]> = {
"초과",
"미만",
],
// "meta" alone would also open "metabolismo" and "metade", so the goal noun
// is spelled with its preposition.
"pt-BR": [
"meta de",
"metas de",
"alvo",
"objetivo",
"deveria",
"deve ser",
"devem ser",
"faixa normal",
"acima d",
"abaixo d",
"superior a",
"inferior a",
],
};
/**
@@ -24,7 +24,17 @@ import type { Locale } from "@/lib/i18n/config";
* translated) and only ever appears inside the dose-safety contract.
*/
const ALL_LOCALES: Locale[] = ["de", "en", "fr", "es", "it", "pl", "ko", "nl"];
const ALL_LOCALES: Locale[] = [
"de",
"en",
"fr",
"es",
"it",
"pl",
"ko",
"nl",
"pt-BR",
];
describe("resolved Coach system prompt — grounding + GLP-1 per locale", () => {
for (const locale of ALL_LOCALES) {
+4 -2
View File
@@ -1258,6 +1258,8 @@ const LOCALE_REPLY_FOOTER_FALLBACK: Record<
pl: "\n\nREPLY LANGUAGE: respond in Polish. Mirror the user's register (formal Pan/Pani for medical-adjacent topics); use natural Polish health vocabulary.",
nl: "\n\nREPLY LANGUAGE: respond in Dutch. Use the informal je/jij register throughout (never formal u); use natural Dutch health vocabulary.",
ko: "\n\nREPLY LANGUAGE: respond in Korean. Use the 해요체 register throughout (never 합쇼체 or plain 한다체); use natural Korean health vocabulary.",
"pt-BR":
"\n\nREPLY LANGUAGE: respond in Brazilian Portuguese (pt-BR, never European Portuguese). Address the user as você throughout; use natural Brazilian health vocabulary.",
};
/**
@@ -1295,8 +1297,8 @@ export function getCoachSystemPrompt(
base = COACH_PROMPT_DE;
} else if (locale === "en") {
base = COACH_PROMPT_EN;
} else if (locale === "ko" || locale === "nl") {
// ko and nl ship no safety-contract matrix, so they are not a
} else if (locale === "ko" || locale === "nl" || locale === "pt-BR") {
// ko, nl and pt-BR ship no safety-contract matrix, so they are not a
// NativeCoachLocale.
base = COACH_PROMPT_EN + LOCALE_REPLY_FOOTER_FALLBACK[locale];
} else {
@@ -57,6 +57,7 @@ const DRUG_LEVEL_CONCEPT_TOKENS: Record<Locale, readonly string[]> = {
it: ["livello", "concentrazione", "picco", "valle", "Cmax", "terapeutica"],
pl: ["poziom", "stężeni", "szczyt", "minimum", "Cmax", "terapeutyczn"],
nl: ["spiegel", "concentratie", "piek", "dal", "Cmax", "therapeutisch"],
"pt-BR": ["nível", "concentração", "pico", "vale", "Cmax", "terapêutic"],
ko: ["농도", "혈중", "최고", "최저", "Cmax", "치료"],
};
@@ -41,6 +41,7 @@ const EXPECTED_LANGUAGE_NAME: Record<Locale, string> = {
pl: "Polish",
nl: "Dutch",
ko: "Korean",
"pt-BR": "Brazilian Portuguese",
};
/** Which body each locale is expected to compose. */
@@ -53,6 +54,7 @@ const EXPECTED_BODY: Record<Locale, "de" | "en"> = {
pl: "en",
nl: "en",
ko: "en",
"pt-BR": "en",
};
describe("output-language helper", () => {
+4 -2
View File
@@ -994,6 +994,8 @@ const INSIGHTS_LOCALE_REPLY_FOOTER_FALLBACK: Record<
pl: "\n\nREPLY LANGUAGE: render all user-facing strings in Polish. Use natural Polish health vocabulary with formal Pan/Pani register for medical-adjacent topics. The severity / sourceWindow / sourceMetric / topic enum values stay in lowercase English exactly as listed in OUTPUT FORMAT — those are contract keys, NOT translations.",
nl: "\n\nREPLY LANGUAGE: render all user-facing strings in Dutch, in the informal je/jij register throughout (never formal u). Use natural Dutch health vocabulary. The severity / sourceWindow / sourceMetric / topic enum values stay in lowercase English exactly as listed in OUTPUT FORMAT — those are contract keys, NOT translations.",
ko: "\n\nREPLY LANGUAGE: render all user-facing strings in Korean, in the 해요체 register throughout (never 합쇼체 or plain 한다체). Use natural Korean health vocabulary. The severity / sourceWindow / sourceMetric / topic enum values stay in lowercase English exactly as listed in OUTPUT FORMAT — those are contract keys, NOT translations.",
"pt-BR":
"\n\nREPLY LANGUAGE: render all user-facing strings in Brazilian Portuguese (pt-BR, never European Portuguese), addressing the user as você throughout. Use natural Brazilian health vocabulary. The severity / sourceWindow / sourceMetric / topic enum values stay in lowercase English exactly as listed in OUTPUT FORMAT — those are contract keys, NOT translations.",
};
/**
@@ -1003,9 +1005,9 @@ const INSIGHTS_LOCALE_REPLY_FOOTER_FALLBACK: Record<
export function getStrictInsightsSystemPrompt(locale: Locale): string {
if (locale === "de") return SYSTEM_PROMPT_DE;
if (locale === "en") return SYSTEM_PROMPT_EN;
// ko and nl ship no safety-contract matrix, so they are not a
// ko, nl and pt-BR ship no safety-contract matrix, so they are not a
// NativeCoachLocale.
if (locale === "ko" || locale === "nl") {
if (locale === "ko" || locale === "nl" || locale === "pt-BR") {
return SYSTEM_PROMPT_EN + INSIGHTS_LOCALE_REPLY_FOOTER_FALLBACK[locale];
}
try {
+2
View File
@@ -58,6 +58,8 @@ export function targetLanguageName(locale: Locale): string {
return "Korean";
case "nl":
return "Dutch";
case "pt-BR":
return "Brazilian Portuguese";
default:
return "English";
}
+1 -1
View File
@@ -510,7 +510,7 @@ describe("the scale the popover quotes", () => {
).toBeLessThan(100);
});
it.each(["en", "de", "fr", "es", "it", "pl", "ko", "nl"])(
it.each(["en", "de", "fr", "es", "it", "pl", "ko", "nl", "pt-BR"])(
"quotes those same two numbers in %s",
(locale) => {
const bundle = JSON.parse(
+1 -14
View File
@@ -22,23 +22,10 @@ import {
} from "@/lib/auth/device-fingerprint";
import { dispatchNotification } from "@/lib/notifications/dispatcher";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { defaultLocale, type Locale } from "@/lib/i18n/config";
import { defaultLocale, isLocale, type Locale } from "@/lib/i18n/config";
import { getEvent } from "@/lib/logging/context";
import { isP2002 } from "@/lib/prisma-errors";
function isLocale(value: string | null | undefined): value is Locale {
return (
value === "de" ||
value === "en" ||
value === "fr" ||
value === "es" ||
value === "it" ||
value === "pl" ||
value === "ko" ||
value === "nl"
);
}
export interface RecordSignInResult {
known: boolean;
alerted: boolean;
@@ -630,7 +630,7 @@ describe("appointments", () => {
});
describe("locales", () => {
it.each(["de", "es", "fr", "it", "pl", "ko", "nl"] as Locale[])(
it.each(["de", "es", "fr", "it", "pl", "ko", "nl", "pt-BR"] as Locale[])(
"resolves every %s string from its own bundle",
(locale) => {
const tl = getServerTranslator(locale).t;
@@ -49,7 +49,17 @@ import {
} from "@/lib/ai/capabilities/types";
const ROOT = path.resolve(__dirname, "../../../..");
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko", "nl"] as const;
const LOCALES = [
"de",
"en",
"es",
"fr",
"it",
"pl",
"ko",
"nl",
"pt-BR",
] as const;
interface RetiredSwitch {
/** The wire name, also the admin copy key. */
+4 -10
View File
@@ -14,7 +14,7 @@
* UTC is always preserved in the database.
*/
import type { Locale } from "./i18n/config";
import { localeFromLanguageTag, type Locale } from "./i18n/config";
import { getDateTimeFormat, getNumberFormat } from "./intl/formatter-cache";
import { DEFAULT_TIMEZONE, isValidTimezone } from "./tz/format";
@@ -32,6 +32,7 @@ const INTL_LOCALE_MAP: Record<Locale, string> = {
pl: "pl-PL",
ko: "ko-KR",
nl: "nl-NL",
"pt-BR": "pt-BR",
};
export function resolveIntlLocale(locale: Locale): string {
@@ -361,13 +362,6 @@ export function makeFormatters(
*/
export function parseLocaleFromAcceptLanguage(header: string | null): Locale {
if (!header) return "en";
const primary = header.split(",")[0]?.trim().toLowerCase() ?? "";
if (primary.startsWith("de")) return "de";
if (primary.startsWith("fr")) return "fr";
if (primary.startsWith("es")) return "es";
if (primary.startsWith("it")) return "it";
if (primary.startsWith("pl")) return "pl";
if (primary.startsWith("ko")) return "ko";
if (primary.startsWith("nl")) return "nl";
return "en";
const first = header.split(",")[0]?.split(";")[0];
return localeFromLanguageTag(first) ?? "en";
}
@@ -18,7 +18,15 @@ import { locales, localeLanguageNames, coerceLocale } from "../config";
import { getPhaseMessage } from "@/lib/jobs/reminder-phases";
import { detectRefusal } from "@/lib/ai/coach/refusal";
const NON_GERMAN_TRANSLATED = ["es", "fr", "it", "pl", "ko", "nl"] as const;
const NON_GERMAN_TRANSLATED = [
"es",
"fr",
"it",
"pl",
"ko",
"nl",
"pt-BR",
] as const;
describe("plural tiers", () => {
it("gives Polish its third tier for 2-4 and keeps many for 5+", () => {
+26
View File
@@ -7,6 +7,7 @@ export const locales = [
"pl",
"ko",
"nl",
"pt-BR",
] as const;
export type Locale = (typeof locales)[number];
@@ -22,6 +23,29 @@ export const defaultLocale: Locale = "en";
* bundles were complete. Route every such coercion through here so adding a
* locale to `locales` is the only edit a new language needs.
*/
export function isLocale(value: unknown): value is Locale {
return (
typeof value === "string" && (locales as readonly string[]).includes(value)
);
}
/**
* Map a BCP-47 language tag (`navigator.language`, one Accept-Language entry)
* to a shipped locale: an exact match first, case-insensitive so `pt-br`
* finds `pt-BR`, then the primary subtag, so `de-AT` reads German and `pt-PT`
* reads the one Portuguese bundle rather than English.
*/
export function localeFromLanguageTag(
tag: string | null | undefined,
): Locale | null {
const lower = tag?.trim().toLowerCase();
if (!lower) return null;
const exact = locales.find((l) => l.toLowerCase() === lower);
if (exact) return exact;
const primary = lower.split("-")[0];
return locales.find((l) => l.toLowerCase().split("-")[0] === primary) ?? null;
}
export function coerceLocale(locale: string | null | undefined): Locale {
return locales.includes(locale as Locale)
? (locale as Locale)
@@ -43,6 +67,7 @@ export const localeLanguageNames: Record<Locale, string> = {
pl: "Polish",
ko: "Korean",
nl: "Dutch",
"pt-BR": "Brazilian Portuguese",
};
export const localeLabels: Record<Locale, string> = {
@@ -54,6 +79,7 @@ export const localeLabels: Record<Locale, string> = {
pl: "Polski",
ko: "한국어",
nl: "Nederlands",
"pt-BR": "Português (Brasil)",
};
/**
+7 -5
View File
@@ -12,7 +12,12 @@ import {
type ReactNode,
} from "react";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import { locales, defaultLocale, type Locale } from "./config";
import {
locales,
defaultLocale,
localeFromLanguageTag,
type Locale,
} from "./config";
import {
makeFormatters,
type Formatters,
@@ -66,10 +71,7 @@ const I18nContext = createContext<I18nContextValue | null>(null);
function detectSystemLocale(): Locale {
if (typeof window === "undefined") return defaultLocale;
const browserLang = navigator.language.split("-")[0];
return (locales as readonly string[]).includes(browserLang)
? (browserLang as Locale)
: defaultLocale;
return localeFromLanguageTag(navigator.language) ?? defaultLocale;
}
function readLocaleCookie(): Locale | null {
+1
View File
@@ -55,6 +55,7 @@ const loaders: Record<Locale, () => Promise<{ default: MessageBundle }>> = {
pl: () => import("../../../messages/pl.json"),
ko: () => import("../../../messages/ko.json"),
nl: () => import("../../../messages/nl.json"),
"pt-BR": () => import("../../../messages/pt-BR.json"),
};
function initialCache(): Partial<Record<Locale, MessageBundle>> {
+8 -3
View File
@@ -1,6 +1,11 @@
import { cookies } from "next/headers";
import type { NextRequest } from "next/server";
import { defaultLocale, locales, type Locale } from "./config";
import {
defaultLocale,
locales,
localeFromLanguageTag,
type Locale,
} from "./config";
const LOCALE_COOKIE_NAMES = ["healthlog-locale", "userLocale"] as const;
@@ -24,8 +29,8 @@ function pickLocaleFromAcceptLanguage(header: string | null): Locale | null {
.sort((a, b) => b.q - a.q);
for (const { tag } of candidates) {
const primary = tag.split("-")[0];
if (isLocale(primary)) return primary;
const locale = localeFromLanguageTag(tag);
if (locale) return locale;
}
return null;
}
@@ -117,4 +117,10 @@ describe("parseYesNo", () => {
expect(parseYesNo("")).toBeNull();
expect(parseYesNo("maybe")).toBeNull();
});
it("reads Portuguese, with or without the accent", () => {
expect(parseYesNo("Sim")).toBe(true);
expect(parseYesNo("Não")).toBe(false);
expect(parseYesNo("nao")).toBe(false);
});
});
+3
View File
@@ -203,6 +203,7 @@ export function parseYesNo(raw: string): boolean | null {
"sí",
"si",
"oui",
"sim",
"tak",
"예",
"wahr",
@@ -220,6 +221,8 @@ export function parseYesNo(raw: string): boolean | null {
"non",
"nie",
"nee",
"não",
"nao",
"아니오",
"falsch",
].includes(v)
+50
View File
@@ -103,6 +103,7 @@ const VERDICT_STEADY: Localized<string> = {
it: "Stabile, come al solito",
pl: "Stabilnie, tak jak zwykle",
nl: "Stabiel en zoals gewoonlijk",
"pt-BR": "Estável e como de costume",
};
const VERDICT_FAVOURABLE: Localized<string> = {
@@ -113,6 +114,7 @@ const VERDICT_FAVOURABLE: Localized<string> = {
it: "Sta andando nella direzione giusta",
pl: "To zmierza w dobrym kierunku",
nl: "Dit gaat de goede kant op",
"pt-BR": "Isso está indo numa boa direção",
};
const VERDICT_OFF_USUAL: Localized<string> = {
@@ -123,6 +125,7 @@ const VERDICT_OFF_USUAL: Localized<string> = {
it: "Ultimamente un po’ fuori dal tuo intervallo abituale",
pl: "Ostatnio nieco poza twoim zwykłym zakresem",
nl: "De laatste tijd iets buiten je gebruikelijke bereik",
"pt-BR": "Ultimamente um pouco fora do seu padrão habitual",
};
function verdictLead(
@@ -196,6 +199,8 @@ const VALUE_VS_BASELINE: Localized<
`${l} wynosi obecnie ${c} — ${higher ? "powyżej" : "poniżej"} twojej zwykłej średniej ${b}.`,
nl: (l, c, b, higher) =>
`${l} staat nu op ${c}, ${higher ? "hoger" : "lager"} dan je gebruikelijke gemiddelde van ${b}.`,
"pt-BR": (l, c, b, higher) =>
`${l} está em ${c} agora, ${higher ? "acima" : "abaixo"} da sua média habitual de ${b}.`,
};
const VALUE_AT_BASELINE: Localized<(label: string, current: string) => string> =
@@ -210,6 +215,7 @@ const VALUE_AT_BASELINE: Localized<(label: string, current: string) => string> =
`${l} wynosi obecnie ${c}, czyli mniej więcej tyle co twoja zwykła średnia.`,
nl: (l, c) =>
`${l} staat op ${c}, ongeveer op je gebruikelijke gemiddelde.`,
"pt-BR": (l, c) => `${l} está em ${c}, bem perto da sua média habitual.`,
};
const VALUE_ALONE: Localized<(label: string, current: string) => string> = {
@@ -220,6 +226,7 @@ const VALUE_ALONE: Localized<(label: string, current: string) => string> = {
it: (l, c) => `${l} è a ${c} in questo momento.`,
pl: (l, c) => `${l} wynosi obecnie ${c}.`,
nl: (l, c) => `${l} staat nu op ${c}.`,
"pt-BR": (l, c) => `${l} está em ${c} agora.`,
};
const CLOSER_IN_RANGE: Localized<string> = {
@@ -230,6 +237,8 @@ const CLOSER_IN_RANGE: Localized<string> = {
it: "Rientra nel tuo intervallo abituale — niente su cui intervenire adesso.",
pl: "Mieści się to w twoim zwykłym zakresie — nic, na co trzeba teraz reagować.",
nl: "Dat valt binnen je gebruikelijke bereik, dus niets waar je nu iets mee hoeft te doen.",
"pt-BR":
"Isso está dentro da sua faixa habitual, nada que peça uma ação agora.",
};
const CLOSER_UNKNOWN_SWING: Localized<string> = {
@@ -240,6 +249,8 @@ const CLOSER_UNKNOWN_SWING: Localized<string> = {
it: "Qualche misurazione in più renderà la tendenza affidabile — tienila d’occhio nei prossimi giorni.",
pl: "Kilka kolejnych pomiarów sprawi, że tendencja stanie się wiarygodna — obserwuj ją przez najbliższe dni.",
nl: "Met nog een paar metingen wordt de trend betrouwbaar. Houd hem de komende dagen in de gaten.",
"pt-BR":
"Mais algumas medições vão tornar a tendência confiável. Acompanhe nos próximos dias.",
};
/**
@@ -314,6 +325,7 @@ const BLOOD_PRESSURE_COPY: FallbackCopy = {
it: "la tua pressione sanguigna",
pl: "twoje ciśnienie krwi",
nl: "je bloeddruk",
"pt-BR": "sua pressão arterial",
},
unit: "",
digits: 0,
@@ -325,6 +337,8 @@ const BLOOD_PRESSURE_COPY: FallbackCopy = {
it: "Qualche misurazione a riposo nelle stesse condizioni mostrerà se è la tendenza o un valore isolato.",
pl: "Kilka spokojnych pomiarów w tych samych warunkach pokaże, czy to tendencja, czy pojedynczy wyskok.",
nl: "Een paar rustige metingen onder dezelfde omstandigheden laten zien of dit de trend is of een losse uitschieter.",
"pt-BR":
"Algumas medições tranquilas nas mesmas condições vão mostrar se isso é a tendência ou um valor isolado.",
},
};
@@ -337,6 +351,7 @@ const WEIGHT_COPY: FallbackCopy = {
it: "il tuo peso",
pl: "twoja waga",
nl: "je gewicht",
"pt-BR": "seu peso",
},
unit: "",
digits: 1,
@@ -348,6 +363,8 @@ const WEIGHT_COPY: FallbackCopy = {
it: "Pesati alla stessa ora per qualche giorno e la normale oscillazione quotidiana si compensa da sé.",
pl: "Waż się przez kilka dni o tej samej porze, a normalne dobowe wahania same się wyrównają.",
nl: "Weeg je een paar dagen op hetzelfde tijdstip, dan middelt de normale schommeling van dag tot dag zich vanzelf uit.",
"pt-BR":
"Pese-se no mesmo horário por alguns dias, e a oscilação normal do dia a dia se acerta sozinha.",
},
};
@@ -360,6 +377,7 @@ const PULSE_COPY: FallbackCopy = {
it: "il tuo battito a riposo",
pl: "twoje tętno spoczynkowe",
nl: "je rusthartslag",
"pt-BR": "seu pulso em repouso",
},
unit: " bpm",
digits: 0,
@@ -371,6 +389,8 @@ const PULSE_COPY: FallbackCopy = {
it: "Misuralo da rilassato e alla stessa ora del giorno per vedere se la direzione tiene.",
pl: "Mierz je na spokojnie i o tej samej porze dnia, żeby zobaczyć, czy kierunek się utrzyma.",
nl: "Meet hem ontspannen en op hetzelfde moment van de dag, dan zie je of de richting aanhoudt.",
"pt-BR":
"Meça com calma e no mesmo horário do dia para ver se a direção se mantém.",
},
};
@@ -383,6 +403,7 @@ const BMI_COPY: FallbackCopy = {
it: "il tuo IMC",
pl: "twoje BMI",
nl: "je BMI",
"pt-BR": "seu IMC",
},
unit: "",
digits: 1,
@@ -394,6 +415,8 @@ const BMI_COPY: FallbackCopy = {
it: "Leggilo insieme all’andamento del tuo peso e alla massa grassa nell’arco di qualche settimana, non giorno per giorno.",
pl: "Czytaj je razem z tendencją wagi i poziomem tkanki tłuszczowej w skali kilku tygodni, a nie dzień po dniu.",
nl: "Bekijk hem samen met je gewichtstrend en vetpercentage over een paar weken, niet dag voor dag.",
"pt-BR":
"Leia junto com a tendência do seu peso e o percentual de gordura ao longo de algumas semanas, não dia a dia.",
},
};
@@ -406,6 +429,7 @@ const MOOD_COPY: FallbackCopy = {
it: "il tuo umore",
pl: "twój nastrój",
nl: "je stemming",
"pt-BR": "seu humor",
},
unit: "",
digits: 1,
@@ -417,6 +441,8 @@ const MOOD_COPY: FallbackCopy = {
it: "Registralo per qualche giorno — uno schema che si ripete dice più di un singolo giorno.",
pl: "Zapisuj go przez kilka dni — powtarzający się wzorzec mówi więcej niż pojedynczy dzień.",
nl: "Houd hem een paar dagen bij. Een terugkerend patroon zegt meer dan één losse dag.",
"pt-BR":
"Registre por alguns dias. Um padrão que se repete diz mais do que um dia isolado.",
},
};
@@ -429,6 +455,7 @@ const ADHERENCE_COPY: FallbackCopy = {
it: "la tua aderenza alla terapia",
pl: "twoja regularność przyjmowania leków",
nl: "je medicijntrouw",
"pt-BR": "sua adesão à medicação",
},
unit: "%",
digits: 0,
@@ -440,6 +467,8 @@ const ADHERENCE_COPY: FallbackCopy = {
it: "Un orario fisso o un promemoria breve è ciò che intercetta meglio le dimenticanze ripetute.",
pl: "Stała pora albo krótkie przypomnienie najlepiej wyłapuje powtarzające się pominięcia.",
nl: "Een vast tijdstip of een korte herinnering vangt herhaalde vergeten innames het best op.",
"pt-BR":
"Um horário fixo ou um lembrete rápido é a forma mais confiável de evitar as tomadas esquecidas que se repetem.",
},
};
@@ -452,6 +481,8 @@ const GENERIC_METRIC_POINTER: Localized<string> = {
it: "Qualche misurazione costante mostrerà se è la direzione o un singolo giorno.",
pl: "Kilka konsekwentnych pomiarów pokaże, czy to kierunek, czy pojedynczy dzień.",
nl: "Een paar consistente metingen laten zien of dit de richting is of één enkele dag.",
"pt-BR":
"Algumas medições consistentes vão mostrar se essa é a direção ou só um dia isolado.",
};
// ── public fallbacks ────────────────────────────────────────────────────────
@@ -513,6 +544,7 @@ const NO_READ_LEAD: Localized<string> = {
pl: "Na razie nie ma tu żadnej oceny.",
nl: "Voor deze kaart is er nu geen beoordeling.",
ko: "지금은 이 카드에 대한 평가가 없어요.",
"pt-BR": "Não há avaliação disponível para este cartão no momento.",
};
function noReadLead(locale: InsightLocale): string {
@@ -534,6 +566,8 @@ export function getNoKeyGeneralStatusText(locale: InsightLocale): string {
it: "Questi numeri diventano leggibili nell’arco di più settimane anziché di singoli giorni, e rilevati a orari costanti per restare confrontabili. Il segnale che conta è più indicatori che scivolano insieme nella stessa direzione sfavorevole, non un valore in un giorno.",
pl: "Te liczby stają się czytelne w skali kilku tygodni, a nie pojedynczych dni, i tylko wtedy, gdy są zbierane o stałych porach, żeby pozostały porównywalne. Sygnałem, który się liczy, jest kilka wskaźników dryfujących jednocześnie w tę samą niekorzystną stronę, a nie jedna wartość jednego dnia.",
nl: "Deze cijfers worden leesbaar over meerdere weken, niet over losse dagen, en gemeten op vaste momenten zodat ze vergelijkbaar blijven. Het signaal dat telt is dat meerdere waarden tegelijk dezelfde ongunstige kant op schuiven, niet één waarde op één dag.",
"pt-BR":
"Estes números ficam legíveis ao longo de várias semanas, não em dias isolados, e medidos em horários consistentes para continuarem comparáveis. O sinal que conta são várias métricas se movendo ao mesmo tempo na mesma direção desfavorável, não um valor em um único dia.",
});
}
@@ -557,6 +591,7 @@ const BIOMARKER_NAMED_LEAD: Localized<(named: string) => string> = {
pl: (n) => `Na razie nie ma żadnej oceny${n}.`,
nl: (n) => `Er is nu geen beoordeling${n}.`,
ko: (n) => `지금은${n} 평가가 없어요.`,
"pt-BR": (n) => `Não há avaliação${n} no momento.`,
};
const BIOMARKER_NAMED_CLAUSE: Localized<(marker: string) => string> = {
@@ -568,6 +603,7 @@ const BIOMARKER_NAMED_CLAUSE: Localized<(marker: string) => string> = {
pl: (m) => ` dla „${m}”`,
nl: (m) => ` voor "${m}"`,
ko: (m) => ` '${m}'에 대한`,
"pt-BR": (m) => ` para "${m}"`,
};
export function getNoKeyBiomarkerStatusText(
@@ -586,6 +622,8 @@ export function getNoKeyBiomarkerStatusText(
it: "Un valore di laboratorio si legge prima di tutto rispetto ai tuoi prelievi precedenti — un intervallo di riferimento è un ancoraggio grossolano, non un verdetto. Uno che si è mosso appena nell’arco di più prelievi racconta una storia diversa da uno che ha appena fatto un salto. Il tuo prossimo controllo medico è il posto giusto per contestualizzarlo.",
pl: "Wynik laboratoryjny czyta się najpierw na tle twoich wcześniejszych oznaczeń — zakres referencyjny to zgrubna kotwica, a nie wyrok. Wynik, który przez kilka oznaczeń niemal się nie ruszył, mówi co innego niż taki, który właśnie podskoczył. Najbliższa kontrola lekarska to właściwe miejsce, żeby go osadzić w kontekście.",
nl: "Een labwaarde lees je eerst tegen je eigen eerdere metingen. Een referentiebereik is een ruw ankerpunt, geen oordeel. Een waarde die over meerdere metingen nauwelijks beweegt, vertelt iets anders dan een waarde die net een sprong heeft gemaakt. Je volgende controle bij de arts is de juiste plek om hem in context te plaatsen.",
"pt-BR":
"Um valor de exame se lê primeiro em relação às suas coletas anteriores. Uma faixa de referência é um ponto de apoio aproximado, não um veredito. Um valor que mal se moveu ao longo de várias coletas conta uma história diferente de um que acabou de dar um salto. Sua próxima consulta é o lugar certo para colocá-lo em contexto.",
})}`;
}
@@ -603,6 +641,8 @@ export function getNoKeyBloodPressureStatusText(
it: "La pressione si legge su una serie di misurazioni a riposo in condizioni confrontabili — la direzione nell’arco di più giorni dice molto più di un singolo valore isolato, e sistolica e diastolica si leggono insieme anziché una alla volta.",
pl: "Ciśnienie staje się czytelne dopiero w serii spokojnych pomiarów w porównywalnych warunkach — kierunek z kilku dni mówi znacznie więcej niż pojedynczy wyskok, a wartość skurczowa i rozkurczowa czyta się razem, a nie osobno.",
nl: "Bloeddruk wordt leesbaar over een reeks rustige metingen onder vergelijkbare omstandigheden. De richting over meerdere dagen zegt veel meer dan één uitschieter, en de boven- en onderdruk lees je samen in plaats van los van elkaar.",
"pt-BR":
"A pressão arterial fica legível ao longo de uma série de medições tranquilas em condições parecidas. A direção ao longo de vários dias diz muito mais do que um valor fora da curva, e a sistólica e a diastólica são lidas juntas, não separadamente.",
})
);
}
@@ -621,6 +661,8 @@ export function getNoKeyWeightStatusText(
it: "Il peso si legge come andamento e non come numero del giorno — pesato in condizioni costanti, la normale oscillazione quotidiana si compensa da sé. Quello che regge è la direzione nell’arco di settimane, letta insieme alla pressione e all’IMC.",
pl: "Wagę czyta się jako przebieg, a nie jako liczbę z jednego dnia — przy ważeniu w stałych warunkach normalne dobowe wahania same się wyrównają. Nośny jest kierunek z kilku tygodni, czytany razem z ciśnieniem i BMI.",
nl: "Gewicht lees je als trend, niet als getal van de dag. Weeg je onder vaste omstandigheden, dan middelt de normale schommeling van dag tot dag zich vanzelf uit. Wat telt is de richting over weken, samen gelezen met bloeddruk en BMI.",
"pt-BR":
"O peso se lê como tendência, não como número do dia. Pesando-se em condições consistentes, a oscilação normal do dia a dia se acerta sozinha. O que conta é a direção ao longo das semanas, lida junto com a pressão arterial e o IMC.",
})
);
}
@@ -639,6 +681,8 @@ export function getNoKeyPulseStatusText(
it: "Il battito a riposo si legge meglio se misurato da rilassato e alla stessa ora del giorno. I picchi brevi sono ordinari; quello che spicca è uno schema che tiene per più giorni, o uno scostamento ripetuto dal tuo intervallo abituale.",
pl: "Tętno spoczynkowe najlepiej czytać zmierzone na spokojnie i o tej samej porze dnia. Krótkie skoki są czymś zwyczajnym; uwagę zwraca wzorzec utrzymujący się przez kilka dni albo powtarzające się oddalanie od twojego zwykłego zakresu.",
nl: "De rusthartslag lees je het best als je hem ontspannen en op hetzelfde moment van de dag meet. Korte pieken zijn gewoon; wat opvalt is een patroon dat meerdere dagen aanhoudt, of steeds weer afwijken van je gebruikelijke bereik.",
"pt-BR":
"O pulso em repouso se lê melhor quando medido com calma e no mesmo horário do dia. Picos curtos são normais; o que chama atenção é um padrão que se mantém por vários dias, ou um afastamento repetido da sua faixa habitual.",
})
);
}
@@ -657,6 +701,8 @@ export function getNoKeyBmiStatusText(
it: "L’IMC è un orientamento grossolano più che un verdetto, e assume senso letto insieme all’andamento del tuo peso e alla composizione corporea. Un movimento sostenuto nell’arco di settimane regge dove un valore singolo non regge.",
pl: "BMI to zgrubna orientacja, a nie wyrok, i nabiera sensu dopiero czytane razem z tendencją wagi i składem ciała. Utrzymujący się ruch w skali tygodni jest nośny tam, gdzie pojedyncza wartość nie jest.",
nl: "BMI is een ruwe oriëntatie, geen oordeel, en zegt het meest samen met je gewichtstrend en lichaamssamenstelling. Een aanhoudende beweging over weken zegt iets waar één losse waarde dat niet doet.",
"pt-BR":
"O IMC é uma orientação aproximada, não um veredito, e diz mais quando lido junto com a tendência do seu peso e a sua composição corporal. Um movimento sustentado ao longo de semanas diz algo que um valor isolado não diz.",
})
);
}
@@ -675,6 +721,8 @@ export function getNoKeyMedicationComplianceStatusText(
it: "L’aderenza si legge come costanza nell’arco di settimane più che come una serie di giornate perfette — per singolo farmaco e nel quadro complessivo. Le dimenticanze ripetute alla stessa ora sono lo schema che conta, ed è una routine fissa a intercettarle di solito.",
pl: "Regularność przyjmowania leków czyta się jako stałość w skali tygodni, a nie jako serię idealnych dni — osobno dla każdego leku i w całym obrazie. Powtarzające się pominięcia o tej samej porze to wzorzec, który się liczy, a wyłapuje je zwykle stały rytm dnia.",
nl: "Medicijntrouw lees je als regelmaat over weken, niet als een reeks perfecte dagen, per medicijn en in het geheel. Herhaald vergeten op hetzelfde moment van de dag is het patroon dat telt, en een vaste routine vangt dat meestal op.",
"pt-BR":
"A adesão se lê como regularidade ao longo de semanas, não como uma sequência de dias perfeitos, por medicamento e no conjunto. Esquecimentos repetidos no mesmo horário do dia são o padrão que conta, e uma rotina fixa costuma resolvê-los.",
})
);
}
@@ -693,6 +741,8 @@ export function getNoKeyMoodStatusText(
it: "L’umore si legge nell’arco di settimane più che nei singoli giorni — quello che regge sono gli schemi ricorrenti e come si allineano con gli altri tuoi valori. È una fase bassa che si protrae quella che merita attenzione.",
pl: "Nastrój czyta się w skali tygodni, a nie pojedynczych dni — nośne są powtarzające się wzorce i to, jak układają się względem twoich pozostałych wartości. Uwagi wymaga przede wszystkim dłużej utrzymujący się spadek.",
nl: "Stemming lees je over weken, niet over losse dagen. Wat telt zijn terugkerende patronen en hoe die samenhangen met je andere waarden. Een aanhoudende periode van lage stemming is wat aandacht verdient.",
"pt-BR":
"O humor se lê ao longo de semanas, não em dias isolados. O que conta são os padrões que se repetem e como eles se relacionam com as suas outras métricas. Um período prolongado de humor baixo é o que merece atenção.",
})
);
}
@@ -241,7 +241,17 @@ describe("enqueueSummaryCatchUp", () => {
});
describe("the toggle copy discloses the catch-up", () => {
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko", "nl"] as const;
const LOCALES = [
"de",
"en",
"es",
"fr",
"it",
"pl",
"ko",
"nl",
"pt-BR",
] as const;
function autoReadCopy(locale: string): { subLabel: string; honesty: string } {
const bundle = JSON.parse(
@@ -236,6 +236,7 @@ describe("enqueueStatusGeneration", () => {
"pl",
"ko",
"nl",
"pt-BR",
] as const) {
await enqueueStatusGeneration({ userId: "u1", metric: "mood", locale });
}
+1 -1
View File
@@ -217,7 +217,7 @@ describe("buildMoodReminderPayload", () => {
expect(p.body).toBe("How are you feeling today?");
});
it.each(["es", "fr", "it", "pl", "ko", "nl"] as const)(
it.each(["es", "fr", "it", "pl", "ko", "nl", "pt-BR"] as const)(
"returns native (non-EN) strings for locale=%s",
(locale) => {
const p = buildMoodReminderPayload(locale);
@@ -9,6 +9,7 @@ import {
isValidatedAuditCLocale,
normaliseAuditCLocale,
} from "../audit-c";
import { locales } from "@/lib/i18n/config";
import { AUDIT_C_ITEMS, AUDIT_C_STANDARD_DRINK } from "../instruments";
describe("AUDIT-C cut-offs", () => {
@@ -75,12 +76,16 @@ describe("AUDIT-C instrument", () => {
it("knows which languages are validated", () => {
expect(["de", "en", "ko", "pl"].every(isValidatedAuditCLocale)).toBe(true);
expect(["es", "fr", "it"].some(isValidatedAuditCLocale)).toBe(false);
expect(normaliseAuditCLocale("pt-BR")).toBe("en");
expect(normaliseAuditCLocale("sv-SE")).toBe("en");
expect(normaliseAuditCLocale("de-AT")).toBe("de");
// A shipped region tag keeps its region instead of collapsing to "pt".
expect(normaliseAuditCLocale("pt-BR")).toBe("pt-BR");
expect(normaliseAuditCLocale("pt-PT")).toBe("pt-BR");
expect(isValidatedAuditCLocale("pt-BR")).toBe(false);
});
it("carries a standard-drink sentence for every app language", () => {
for (const locale of ["de", "en", "es", "fr", "it", "pl", "ko", "nl"]) {
for (const locale of locales) {
expect(AUDIT_C_STANDARD_DRINK[locale]?.length).toBeGreaterThan(20);
}
});
@@ -204,6 +204,10 @@ describe("mental-health instruments", () => {
expect(hasValidatedItems("PHQ9", "nl")).toBe(false);
expect(hasValidatedItems("GAD7", "nl")).toBe(false);
expect(hasValidatedItems("WHO5", "nl")).toBe(false);
// Nor has the Brazilian Portuguese one.
expect(hasValidatedItems("PHQ9", "pt-BR")).toBe(false);
expect(hasValidatedItems("GAD7", "pt-BR")).toBe(false);
expect(hasValidatedItems("WHO5", "pt-BR")).toBe(false);
expect(hasValidatedItems("SCI", "en")).toBe(true);
expect(hasValidatedItems("SCI", "en-GB")).toBe(true);
expect(hasValidatedItems("SCI", "de")).toBe(false);
+7 -6
View File
@@ -26,6 +26,7 @@ import {
AUDIT_C_ITEMS,
AUDIT_C_STANDARD_DRINK,
} from "@/lib/mental-health/instruments";
import { localeFromLanguageTag } from "@/lib/i18n/config";
export type AuditCCutoffSource =
"S3_ALCOHOL_DE" | "KNHANES_KR" | "BRADLEY_2007";
@@ -110,15 +111,15 @@ export function auditCTotal(items: readonly number[]): number {
return items.reduce((sum, item) => sum + item, 0);
}
/** The app languages; anything else is read as English. */
const APP_LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko", "nl"] as const;
/** Normalise a requested or stored locale onto the app languages. */
/**
* Normalise a requested or stored locale onto the app languages; anything
* else is read as English. A region tag keeps its region where the app ships
* one (`pt-BR`), so the lookups below do not cut it down to a bare `pt`.
*/
export function normaliseAuditCLocale(
locale: string | null | undefined,
): string {
const base = (locale ?? "en").slice(0, 2).toLowerCase();
return (APP_LOCALES as readonly string[]).includes(base) ? base : "en";
return localeFromLanguageTag(locale) ?? "en";
}
/** Whether `locale` has a validated translation of the three items. */
+6 -4
View File
@@ -101,10 +101,10 @@ export interface InstrumentDefinition {
/**
* The app languages whose PHQ-9 / GAD-7 / WHO-5 item wording is bundled from
* a published validated version. `nl` is absent on purpose: its bundle carries
* a Dutch wording that has not yet been read against the published Dutch
* PHQ-9, GAD-7 and WHO-5 forms, so Dutch readers get the English items plus
* the honest note until it has.
* a published validated version. `nl` and `pt-BR` are absent on purpose: their
* bundles carry a wording that has not yet been read against the published
* Dutch and Brazilian Portuguese PHQ-9, GAD-7 and WHO-5 forms, so those
* readers get the English items plus the honest note until it has.
*/
const ALL_APP_LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"] as const;
@@ -567,5 +567,7 @@ export const AUDIT_C_STANDARD_DRINK: Readonly<Record<string, string>> = {
it: "Una bevanda corrisponde a una bevanda standard di circa 10 g di alcol puro, la definizione dell'OMS su cui si basano queste domande.",
pl: "Standardowa porcja alkoholu to około 10 g czystego alkoholu, zgodnie z definicją WHO, na której opierają się te pytania.",
nl: "Eén drankje is een standaardglas met ongeveer 10 g pure alcohol, de WHO-definitie waarop deze vragen gebaseerd zijn.",
"pt-BR":
"Uma bebida corresponde a uma dose padrão de cerca de 10 g de álcool puro, a definição da OMS na qual estas perguntas se baseiam.",
ko: "표준 1잔: 양주잔으로 양주 1잔, 와인잔으로 와인 1잔, 막걸리 1사발이 각각 표준 1잔이다. 맥주는 캔맥주 1캔 또는 작은 병맥주 1병이며 생맥주는 500 mL가 표준 1잔이다. 소주는 1/4병이 표준 1잔에 해당한다.",
};
+1 -14
View File
@@ -38,7 +38,7 @@
*/
import { prisma } from "@/lib/db";
import { getEvent } from "@/lib/logging/context";
import { defaultLocale, type Locale } from "@/lib/i18n/config";
import { defaultLocale, isLocale, type Locale } from "@/lib/i18n/config";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { dispatchNotification } from "@/lib/notifications/dispatcher";
import type { EventType, NotificationPayload } from "@/lib/notifications/types";
@@ -72,19 +72,6 @@ interface DispatchLocalisedOptions {
urgent?: boolean;
}
function isLocale(value: string | null | undefined): value is Locale {
return (
value === "de" ||
value === "en" ||
value === "fr" ||
value === "es" ||
value === "it" ||
value === "pl" ||
value === "ko" ||
value === "nl"
);
}
/**
* Process-level locale cache.
*
@@ -24,6 +24,7 @@ import {
} from "@/lib/medication-list-layout";
import { MEDICATION_IMPORT_SKIP_REASONS } from "@/lib/jobs/medication-intake-import";
import { isoDateTime } from "@/lib/validations/datetime";
import { locales } from "@/lib/i18n/config";
// ── Medications (v1.5 scheduling) ────────────────────────────────────
//
@@ -1124,7 +1125,7 @@ export const medicationExtractRequest = z
"Free-text medication description (any locale). Up to 2 000 characters. The model never echoes the text back into another tenant — it is only used to produce the structured fields.",
),
locale: z
.enum(["en", "de", "es", "fr", "it", "pl", "ko", "nl"])
.enum(locales)
.optional()
.describe("Optional UI locale hint for the model."),
today: z
+2 -1
View File
@@ -34,6 +34,7 @@ import {
stdResponses,
} from "./shared";
import { isoDateTime } from "@/lib/validations/datetime";
import { locales } from "@/lib/i18n/config";
const thresholdMetricEnum = z
.enum(Object.keys(METRIC_BOUNDS) as [string, ...string[]])
@@ -127,7 +128,7 @@ const profileSettings = z
.nullable()
.describe("Calendar date (`YYYY-MM-DD`), not an instant."),
gender: z.enum(["MALE", "FEMALE", "OTHER"]).nullable(),
locale: z.enum(["de", "en", "es", "fr", "it", "pl", "ko", "nl"]).nullable(),
locale: z.enum(locales).nullable(),
timezone: z.string(),
unitPreference: z
.enum(["metric", "imperial"])
+1
View File
@@ -17,6 +17,7 @@ const NAMES: Readonly<Record<string, Readonly<Record<SourceCountry, string>>>> =
pl: { DE: "Niemcy", KR: "Korea Południowa", US: "Stany Zjednoczone" },
nl: { DE: "Duitsland", KR: "Zuid-Korea", US: "Verenigde Staten" },
ko: { DE: "독일", KR: "대한민국", US: "미국" },
"pt-BR": { DE: "Alemanha", KR: "Coreia do Sul", US: "Estados Unidos" },
};
export function countryLabel(
+2 -4
View File
@@ -11,6 +11,7 @@ import {
WRITABLE_MODULE_KEYS,
} from "@/lib/validations/modules";
import { thresholdsUpdateSchema } from "@/lib/validations/thresholds";
import { locales } from "@/lib/i18n/config";
/**
* The target-record configuration boundary. These literals are both the DTO
@@ -60,10 +61,7 @@ const profilePatchSchema = z
heightCm: z.number().finite().min(30).max(300).nullable().optional(),
dateOfBirth: z.string().date().nullable().optional(),
gender: z.enum(["MALE", "FEMALE", "OTHER"]).nullable().optional(),
locale: z
.enum(["de", "en", "es", "fr", "it", "pl", "ko", "nl"])
.nullable()
.optional(),
locale: z.enum(locales).nullable().optional(),
timezone: timezoneSchema.optional(),
unitPreference: z.enum(["metric", "imperial"]).optional(),
timeFormat: z.enum(["AUTO", "H12", "H24"]).optional(),
+4
View File
@@ -72,6 +72,7 @@ const HELP_WORDS: readonly string[] = [
"ayuda",
"aiuto",
"pomoc",
"ajuda",
];
/** Greetings the bot answers with a wave, in every language the app ships. */
@@ -90,6 +91,9 @@ const GREETING_WORDS: readonly string[] = [
"czesc",
"witaj",
"dzien dobry",
"oi",
"ola",
"bom dia",
];
/**
+2 -1
View File
@@ -15,6 +15,7 @@ import { z } from "zod/v4";
import { isValidTimezone } from "@/lib/tz/format";
import { isoDateTime } from "@/lib/validations/datetime";
import { locales } from "@/lib/i18n/config";
/**
* The record's sex, as `User.gender` already stores it.
@@ -33,7 +34,7 @@ const managedProfileGenderSchema = z.enum(["MALE", "FEMALE", "OTHER"]);
const managedProfileFields = {
displayName: z.string().trim().min(1).max(DISPLAY_NAME_MAX_LENGTH),
dateOfBirth: z.iso.date().nullable(),
locale: z.enum(["de", "en", "es", "fr", "it", "pl", "ko", "nl"]),
locale: z.enum(locales),
timezone: z
.string()
.min(1)