diff --git a/.github/ISSUE_TEMPLATE/translation.yml b/.github/ISSUE_TEMPLATE/translation.yml index 4e481ce85..331815c1d 100644 --- a/.github/ISSUE_TEMPLATE/translation.yml +++ b/.github/ISSUE_TEMPLATE/translation.yml @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4424d2695..cb70d27cb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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/.json`. diff --git a/README.md b/README.md index 7dfc102f6..644499bfa 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index cf0c4a73e..e06585d8f 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 diff --git a/e2e/setup-flow-locale-overflow.spec.ts b/e2e/setup-flow-locale-overflow.spec.ts index 9f62f357f..4718340dd 100644 --- a/e2e/setup-flow-locale-overflow.spec.ts +++ b/e2e/setup-flow-locale-overflow.spec.ts @@ -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 }, diff --git a/scripts/generate-i18n-boot.mjs b/scripts/generate-i18n-boot.mjs index 37e8a17fd..0d7642022 100644 --- a/scripts/generate-i18n-boot.mjs +++ b/scripts/generate-i18n-boot.mjs @@ -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 }); diff --git a/scripts/i18n-key-tool.mjs b/scripts/i18n-key-tool.mjs index 0e027eda9..ca4eb0cfd 100644 --- a/scripts/i18n-key-tool.mjs +++ b/scripts/i18n-key-tool.mjs @@ -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) { diff --git a/scripts/split-description.mjs b/scripts/split-description.mjs index 0b5699a14..4c6e9374f 100644 --- a/scripts/split-description.mjs +++ b/scripts/split-description.mjs @@ -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; /** diff --git a/src/__tests__/coach-voice-guard.test.ts b/src/__tests__/coach-voice-guard.test.ts index a78f4f90c..6c586857b 100644 --- a/src/__tests__/coach-voice-guard.test.ts +++ b/src/__tests__/coach-voice-guard.test.ts @@ -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[] { diff --git a/src/__tests__/copy-matches-behaviour-guard.test.ts b/src/__tests__/copy-matches-behaviour-guard.test.ts index 70e5c2153..727b10d01 100644 --- a/src/__tests__/copy-matches-behaviour-guard.test.ts +++ b/src/__tests__/copy-matches-behaviour-guard.test.ts @@ -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 { return JSON.parse(readFileSync(join(MESSAGES, `${locale}.json`), "utf8")); diff --git a/src/__tests__/dynamic-key-exhaustiveness.test.ts b/src/__tests__/dynamic-key-exhaustiveness.test.ts index 52d886c26..42e78665b 100644 --- a/src/__tests__/dynamic-key-exhaustiveness.test.ts +++ b/src/__tests__/dynamic-key-exhaustiveness.test.ts @@ -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", ]); }); diff --git a/src/__tests__/i18n-english-leak-guard.test.ts b/src/__tests__/i18n-english-leak-guard.test.ts index 9bda44703..7fe8b68fd 100644 --- a/src/__tests__/i18n-english-leak-guard.test.ts +++ b/src/__tests__/i18n-english-leak-guard.test.ts @@ -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, diff --git a/src/__tests__/insights-unit-literal-guard.test.ts b/src/__tests__/insights-unit-literal-guard.test.ts index 6561f5577..cbadc5ab0 100644 --- a/src/__tests__/insights-unit-literal-guard.test.ts +++ b/src/__tests__/insights-unit-literal-guard.test.ts @@ -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"); /** diff --git a/src/__tests__/manage-activity-inventory.test.ts b/src/__tests__/manage-activity-inventory.test.ts index ea8e83590..83f051f91 100644 --- a/src/__tests__/manage-activity-inventory.test.ts +++ b/src/__tests__/manage-activity-inventory.test.ts @@ -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. */ diff --git a/src/__tests__/moodlog-removal-guard.test.ts b/src/__tests__/moodlog-removal-guard.test.ts index 6eccca904..b2b218b14 100644 --- a/src/__tests__/moodlog-removal-guard.test.ts +++ b/src/__tests__/moodlog-removal-guard.test.ts @@ -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 = [ diff --git a/src/__tests__/record-settings-ui.test.tsx b/src/__tests__/record-settings-ui.test.tsx index 780d1f2f6..abea892d3 100644 --- a/src/__tests__/record-settings-ui.test.tsx +++ b/src/__tests__/record-settings-ui.test.tsx @@ -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"), ), ]); diff --git a/src/__tests__/sharing-boundary-copy.test.ts b/src/__tests__/sharing-boundary-copy.test.ts index dfd7e2136..55c6d2f14 100644 --- a/src/__tests__/sharing-boundary-copy.test.ts +++ b/src/__tests__/sharing-boundary-copy.test.ts @@ -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 { diff --git a/src/__tests__/v1341-locale-parity.test.ts b/src/__tests__/v1341-locale-parity.test.ts index 85ce47df5..4feaed2a2 100644 --- a/src/__tests__/v1341-locale-parity.test.ts +++ b/src/__tests__/v1341-locale-parity.test.ts @@ -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. diff --git a/src/app/__tests__/not-found-copy.test.ts b/src/app/__tests__/not-found-copy.test.ts index 0980df9fd..fc7681ea1 100644 --- a/src/app/__tests__/not-found-copy.test.ts +++ b/src/app/__tests__/not-found-copy.test.ts @@ -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: { diff --git a/src/app/api/admin/notifications/test/route.ts b/src/app/api/admin/notifications/test/route.ts index 9e7963d56..c72f67d35 100644 --- a/src/app/api/admin/notifications/test/route.ts +++ b/src/app/api/admin/notifications/test/route.ts @@ -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" } }); diff --git a/src/app/api/medications/extract/route.ts b/src/app/api/medications/extract/route.ts index 50eed8970..3868a8ac3 100644 --- a/src/app/api/medications/extract/route.ts +++ b/src/app/api/medications/extract/route.ts @@ -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 diff --git a/src/app/api/notifications/apns/test/route.ts b/src/app/api/notifications/apns/test/route.ts index 63225121d..ec6d9dd50 100644 --- a/src/app/api/notifications/apns/test/route.ts +++ b/src/app/api/notifications/apns/test/route.ts @@ -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(); diff --git a/src/app/api/settings/telegram/test/route.ts b/src/app/api/settings/telegram/test/route.ts index b3d7e8dca..d9530aa1a 100644 --- a/src/app/api/settings/telegram/test/route.ts +++ b/src/app/api/settings/telegram/test/route.ts @@ -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. */ diff --git a/src/components/charts/__tests__/trend-card-baseline-alignment.test.tsx b/src/components/charts/__tests__/trend-card-baseline-alignment.test.tsx index e14a1b2af..384d59535 100644 --- a/src/components/charts/__tests__/trend-card-baseline-alignment.test.tsx +++ b/src/components/charts/__tests__/trend-card-baseline-alignment.test.tsx @@ -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`)) diff --git a/src/components/layout/__tests__/keyboard-shortcuts-dialog.test.tsx b/src/components/layout/__tests__/keyboard-shortcuts-dialog.test.tsx index ee621a503..f2b5e74b4 100644 --- a/src/components/layout/__tests__/keyboard-shortcuts-dialog.test.tsx +++ b/src/components/layout/__tests__/keyboard-shortcuts-dialog.test.tsx @@ -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("", () => { 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); diff --git a/src/components/layout/__tests__/offline-banner.test.tsx b/src/components/layout/__tests__/offline-banner.test.tsx index 34992fcb0..3bb7d0bd6 100644 --- a/src/components/layout/__tests__/offline-banner.test.tsx +++ b/src/components/layout/__tests__/offline-banner.test.tsx @@ -75,6 +75,7 @@ describe("", () => { "pl", "ko", "nl", + "pt-BR", ] as const) { const m = loadMessages(locale); expect(m.offlineBanner.message.length, locale).toBeGreaterThan(0); diff --git a/src/components/links/__tests__/period-query.test.ts b/src/components/links/__tests__/period-query.test.ts index b33875342..a991e7d8d 100644 --- a/src/components/links/__tests__/period-query.test.ts +++ b/src/components/links/__tests__/period-query.test.ts @@ -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); diff --git a/src/components/links/period-query.ts b/src/components/links/period-query.ts index 88358f744..8e28d8142 100644 --- a/src/components/links/period-query.ts +++ b/src/components/links/period-query.ts @@ -52,6 +52,7 @@ export interface PeriodQuery { const CONNECTORS: Record = { es: ["de", "del"], it: ["del"], + pt: ["de", "do"], }; const nameCache = new Map>(); diff --git a/src/components/measurement-reminders/__tests__/type-labels-i18n.test.ts b/src/components/measurement-reminders/__tests__/type-labels-i18n.test.ts index fa84f8871..93689f42a 100644 --- a/src/components/measurement-reminders/__tests__/type-labels-i18n.test.ts +++ b/src/components/measurement-reminders/__tests__/type-labels-i18n.test.ts @@ -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", ]); }); diff --git a/src/components/settings/__tests__/import-panel.test.tsx b/src/components/settings/__tests__/import-panel.test.tsx index e06be042f..0388efbb4 100644 --- a/src/components/settings/__tests__/import-panel.test.tsx +++ b/src/components/settings/__tests__/import-panel.test.tsx @@ -108,7 +108,7 @@ describe(" — SSR smoke", () => { }); describe("", () => { - 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(, locale); diff --git a/src/components/settings/__tests__/managed-record-settings-section.test.tsx b/src/components/settings/__tests__/managed-record-settings-section.test.tsx index 484e0402a..06d646cd3 100644 --- a/src/components/settings/__tests__/managed-record-settings-section.test.tsx +++ b/src/components/settings/__tests__/managed-record-settings-section.test.tsx @@ -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[0]["family"], diff --git a/src/components/settings/integrations/__tests__/apple-health-card.test.tsx b/src/components/settings/integrations/__tests__/apple-health-card.test.tsx index f80b62791..6e3107e65 100644 --- a/src/components/settings/integrations/__tests__/apple-health-card.test.tsx +++ b/src/components/settings/integrations/__tests__/apple-health-card.test.tsx @@ -215,7 +215,7 @@ describe("", () => { } }); - 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 }; diff --git a/src/components/settings/integrations/__tests__/integration-health-copy.test.ts b/src/components/settings/integrations/__tests__/integration-health-copy.test.ts index d46b0a462..65623f293 100644 --- a/src/components/settings/integrations/__tests__/integration-health-copy.test.ts +++ b/src/components/settings/integrations/__tests__/integration-health-copy.test.ts @@ -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 = { pl, nl, ko, + "pt-BR": ptBR, }; function translator(locale: Locale) { diff --git a/src/components/settings/managed-record-settings-section.tsx b/src/components/settings/managed-record-settings-section.tsx index f67c2790e..111139dff 100644 --- a/src/components/settings/managed-record-settings-section.tsx +++ b/src/components/settings/managed-record-settings-section.tsx @@ -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({ - {["de", "en", "es", "fr", "it", "pl", "ko", "nl"].map((locale) => ( + {locales.map((locale) => ( diff --git a/src/components/timeline/__tests__/timeline-rework.test.tsx b/src/components/timeline/__tests__/timeline-rework.test.tsx index 3d7eb631b..45bac0cae 100644 --- a/src/components/timeline/__tests__/timeline-rework.test.tsx +++ b/src/components/timeline/__tests__/timeline-rework.test.tsx @@ -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( diff --git a/src/components/ui/calendar-locale.ts b/src/components/ui/calendar-locale.ts index 1d02c6c3f..87f2f05a3 100644 --- a/src/components/ui/calendar-locale.ts +++ b/src/components/ui/calendar-locale.ts @@ -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; } diff --git a/src/lib/__tests__/geo-offline-detection.test.ts b/src/lib/__tests__/geo-offline-detection.test.ts index 7152df7ea..0205a83b0 100644 --- a/src/lib/__tests__/geo-offline-detection.test.ts +++ b/src/lib/__tests__/geo-offline-detection.test.ts @@ -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( diff --git a/src/lib/ai/__tests__/citation-coverage-logging.test.ts b/src/lib/ai/__tests__/citation-coverage-logging.test.ts index 9b01e421b..00d002f61 100644 --- a/src/lib/ai/__tests__/citation-coverage-logging.test.ts +++ b/src/lib/ai/__tests__/citation-coverage-logging.test.ts @@ -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) => { diff --git a/src/lib/ai/citation-coverage.ts b/src/lib/ai/citation-coverage.ts index fecb4af8d..b4d356719 100644 --- a/src/lib/ai/citation-coverage.ts +++ b/src/lib/ai/citation-coverage.ts @@ -162,6 +162,22 @@ const NORMATIVE_KEYWORDS: Record = { "초과", "미만", ], + // "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", + ], }; /** diff --git a/src/lib/ai/coach/__tests__/system-prompt-contract-parity.test.ts b/src/lib/ai/coach/__tests__/system-prompt-contract-parity.test.ts index bf7b2e96a..0b1f6ba18 100644 --- a/src/lib/ai/coach/__tests__/system-prompt-contract-parity.test.ts +++ b/src/lib/ai/coach/__tests__/system-prompt-contract-parity.test.ts @@ -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) { diff --git a/src/lib/ai/coach/system-prompt.ts b/src/lib/ai/coach/system-prompt.ts index 5815c5064..0159ef2a6 100644 --- a/src/lib/ai/coach/system-prompt.ts +++ b/src/lib/ai/coach/system-prompt.ts @@ -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 { diff --git a/src/lib/ai/prompts/__tests__/drug-level-refusal.probe.test.ts b/src/lib/ai/prompts/__tests__/drug-level-refusal.probe.test.ts index 5bc350d66..dcd45e07b 100644 --- a/src/lib/ai/prompts/__tests__/drug-level-refusal.probe.test.ts +++ b/src/lib/ai/prompts/__tests__/drug-level-refusal.probe.test.ts @@ -57,6 +57,7 @@ const DRUG_LEVEL_CONCEPT_TOKENS: Record = { 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", "치료"], }; diff --git a/src/lib/ai/prompts/__tests__/output-language-contract.test.ts b/src/lib/ai/prompts/__tests__/output-language-contract.test.ts index 4e925d3cc..57ed9fd0e 100644 --- a/src/lib/ai/prompts/__tests__/output-language-contract.test.ts +++ b/src/lib/ai/prompts/__tests__/output-language-contract.test.ts @@ -41,6 +41,7 @@ const EXPECTED_LANGUAGE_NAME: Record = { 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 = { pl: "en", nl: "en", ko: "en", + "pt-BR": "en", }; describe("output-language helper", () => { diff --git a/src/lib/ai/prompts/insight-generator.ts b/src/lib/ai/prompts/insight-generator.ts index 5ad480107..98fd20153 100644 --- a/src/lib/ai/prompts/insight-generator.ts +++ b/src/lib/ai/prompts/insight-generator.ts @@ -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 { diff --git a/src/lib/ai/prompts/output-language.ts b/src/lib/ai/prompts/output-language.ts index 4b43a4734..31e56358a 100644 --- a/src/lib/ai/prompts/output-language.ts +++ b/src/lib/ai/prompts/output-language.ts @@ -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"; } diff --git a/src/lib/analytics/__tests__/bp-grade.test.ts b/src/lib/analytics/__tests__/bp-grade.test.ts index d7c83e365..9d3ca5b71 100644 --- a/src/lib/analytics/__tests__/bp-grade.test.ts +++ b/src/lib/analytics/__tests__/bp-grade.test.ts @@ -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( diff --git a/src/lib/auth/login-alert.ts b/src/lib/auth/login-alert.ts index d345f76f3..896b78f0f 100644 --- a/src/lib/auth/login-alert.ts +++ b/src/lib/auth/login-alert.ts @@ -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; diff --git a/src/lib/daily/__tests__/today-overview.test.ts b/src/lib/daily/__tests__/today-overview.test.ts index 31420c0e2..017d39cc8 100644 --- a/src/lib/daily/__tests__/today-overview.test.ts +++ b/src/lib/daily/__tests__/today-overview.test.ts @@ -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; diff --git a/src/lib/feature-flags/__tests__/no-dead-assistant-flag.test.ts b/src/lib/feature-flags/__tests__/no-dead-assistant-flag.test.ts index 5fd00eb49..775e8b359 100644 --- a/src/lib/feature-flags/__tests__/no-dead-assistant-flag.test.ts +++ b/src/lib/feature-flags/__tests__/no-dead-assistant-flag.test.ts @@ -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. */ diff --git a/src/lib/format-locale.ts b/src/lib/format-locale.ts index 415f3e95c..91530af61 100644 --- a/src/lib/format-locale.ts +++ b/src/lib/format-locale.ts @@ -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 = { 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"; } diff --git a/src/lib/i18n/__tests__/plural-locale-completeness.test.ts b/src/lib/i18n/__tests__/plural-locale-completeness.test.ts index 33ae50bcb..b158844d9 100644 --- a/src/lib/i18n/__tests__/plural-locale-completeness.test.ts +++ b/src/lib/i18n/__tests__/plural-locale-completeness.test.ts @@ -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+", () => { diff --git a/src/lib/i18n/config.ts b/src/lib/i18n/config.ts index d0efb9683..b830d4aa6 100644 --- a/src/lib/i18n/config.ts +++ b/src/lib/i18n/config.ts @@ -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 = { pl: "Polish", ko: "Korean", nl: "Dutch", + "pt-BR": "Brazilian Portuguese", }; export const localeLabels: Record = { @@ -54,6 +79,7 @@ export const localeLabels: Record = { pl: "Polski", ko: "한국어", nl: "Nederlands", + "pt-BR": "Português (Brasil)", }; /** diff --git a/src/lib/i18n/context.tsx b/src/lib/i18n/context.tsx index 87f7f80b2..cf0f4d48c 100644 --- a/src/lib/i18n/context.tsx +++ b/src/lib/i18n/context.tsx @@ -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(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 { diff --git a/src/lib/i18n/load-locale.ts b/src/lib/i18n/load-locale.ts index df3da17d8..17dce7f89 100644 --- a/src/lib/i18n/load-locale.ts +++ b/src/lib/i18n/load-locale.ts @@ -55,6 +55,7 @@ const loaders: Record 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> { diff --git a/src/lib/i18n/server-locale.ts b/src/lib/i18n/server-locale.ts index 2e34c2469..440e2e12b 100644 --- a/src/lib/i18n/server-locale.ts +++ b/src/lib/i18n/server-locale.ts @@ -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; } diff --git a/src/lib/import/__tests__/csv-values.test.ts b/src/lib/import/__tests__/csv-values.test.ts index d3c2ad27c..33a00febc 100644 --- a/src/lib/import/__tests__/csv-values.test.ts +++ b/src/lib/import/__tests__/csv-values.test.ts @@ -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); + }); }); diff --git a/src/lib/import/csv-values.ts b/src/lib/import/csv-values.ts index d48ded1ec..e4413ca4f 100644 --- a/src/lib/import/csv-values.ts +++ b/src/lib/import/csv-values.ts @@ -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) diff --git a/src/lib/insights/no-key-fallbacks.ts b/src/lib/insights/no-key-fallbacks.ts index 259d71e25..871f6bea1 100644 --- a/src/lib/insights/no-key-fallbacks.ts +++ b/src/lib/insights/no-key-fallbacks.ts @@ -103,6 +103,7 @@ const VERDICT_STEADY: Localized = { 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 = { @@ -113,6 +114,7 @@ const VERDICT_FAVOURABLE: Localized = { 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 = { @@ -123,6 +125,7 @@ const VERDICT_OFF_USUAL: Localized = { 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 = { @@ -230,6 +237,8 @@ const CLOSER_IN_RANGE: Localized = { 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 = { @@ -240,6 +249,8 @@ const CLOSER_UNKNOWN_SWING: Localized = { 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 = { 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 = { 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.", }) ); } diff --git a/src/lib/jobs/__tests__/document-summary-catchup.test.ts b/src/lib/jobs/__tests__/document-summary-catchup.test.ts index d0d3f92eb..96c7dd4da 100644 --- a/src/lib/jobs/__tests__/document-summary-catchup.test.ts +++ b/src/lib/jobs/__tests__/document-summary-catchup.test.ts @@ -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( diff --git a/src/lib/jobs/__tests__/insight-status-generate.test.ts b/src/lib/jobs/__tests__/insight-status-generate.test.ts index 98dd57e16..da40217a7 100644 --- a/src/lib/jobs/__tests__/insight-status-generate.test.ts +++ b/src/lib/jobs/__tests__/insight-status-generate.test.ts @@ -236,6 +236,7 @@ describe("enqueueStatusGeneration", () => { "pl", "ko", "nl", + "pt-BR", ] as const) { await enqueueStatusGeneration({ userId: "u1", metric: "mood", locale }); } diff --git a/src/lib/jobs/__tests__/mood-reminder.test.ts b/src/lib/jobs/__tests__/mood-reminder.test.ts index 2963e0643..f80785e23 100644 --- a/src/lib/jobs/__tests__/mood-reminder.test.ts +++ b/src/lib/jobs/__tests__/mood-reminder.test.ts @@ -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); diff --git a/src/lib/mental-health/__tests__/audit-c.test.ts b/src/lib/mental-health/__tests__/audit-c.test.ts index 0c7dbe5f4..5b2a042c9 100644 --- a/src/lib/mental-health/__tests__/audit-c.test.ts +++ b/src/lib/mental-health/__tests__/audit-c.test.ts @@ -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); } }); diff --git a/src/lib/mental-health/__tests__/instruments.test.ts b/src/lib/mental-health/__tests__/instruments.test.ts index 3879450b1..7808e53f0 100644 --- a/src/lib/mental-health/__tests__/instruments.test.ts +++ b/src/lib/mental-health/__tests__/instruments.test.ts @@ -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); diff --git a/src/lib/mental-health/audit-c.ts b/src/lib/mental-health/audit-c.ts index 581dbc581..417eee38b 100644 --- a/src/lib/mental-health/audit-c.ts +++ b/src/lib/mental-health/audit-c.ts @@ -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. */ diff --git a/src/lib/mental-health/instruments.ts b/src/lib/mental-health/instruments.ts index 8b05babc5..193b29393 100644 --- a/src/lib/mental-health/instruments.ts +++ b/src/lib/mental-health/instruments.ts @@ -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> = { 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잔에 해당한다.", }; diff --git a/src/lib/notifications/dispatch-localised.ts b/src/lib/notifications/dispatch-localised.ts index dddc7ba37..ac3100ac2 100644 --- a/src/lib/notifications/dispatch-localised.ts +++ b/src/lib/notifications/dispatch-localised.ts @@ -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. * diff --git a/src/lib/openapi/routes/medications/schemas.ts b/src/lib/openapi/routes/medications/schemas.ts index e8537d55d..55e9fbe13 100644 --- a/src/lib/openapi/routes/medications/schemas.ts +++ b/src/lib/openapi/routes/medications/schemas.ts @@ -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 diff --git a/src/lib/openapi/routes/record-settings.ts b/src/lib/openapi/routes/record-settings.ts index 35fcc9c2f..7619fb8a7 100644 --- a/src/lib/openapi/routes/record-settings.ts +++ b/src/lib/openapi/routes/record-settings.ts @@ -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"]) diff --git a/src/lib/profile/country-label.ts b/src/lib/profile/country-label.ts index 91289aa9e..f4dd81039 100644 --- a/src/lib/profile/country-label.ts +++ b/src/lib/profile/country-label.ts @@ -17,6 +17,7 @@ const NAMES: Readonly>>> = 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( diff --git a/src/lib/record-settings/configuration.ts b/src/lib/record-settings/configuration.ts index 0ebc84a24..20a355517 100644 --- a/src/lib/record-settings/configuration.ts +++ b/src/lib/record-settings/configuration.ts @@ -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(), diff --git a/src/lib/telegram-webhook-handlers.ts b/src/lib/telegram-webhook-handlers.ts index 32e010557..faea70a20 100644 --- a/src/lib/telegram-webhook-handlers.ts +++ b/src/lib/telegram-webhook-handlers.ts @@ -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", ]; /** diff --git a/src/lib/validations/managed-profiles.ts b/src/lib/validations/managed-profiles.ts index b267c1ec9..5e4a293ee 100644 --- a/src/lib/validations/managed-profiles.ts +++ b/src/lib/validations/managed-profiles.ts @@ -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)