Merge pull request #1246 from robdegraaf/feat/i18n-nl

This commit is contained in:
Marc-André Bombeck committed 2026-10-20 00:14:49 +02:00
commit 7be3714ac2
83 files changed
+13832 -74

No files matched your search

+4 -2
View File
@@ -1,12 +1,12 @@
name: Translation feedback
description: Report a wording, grammar, or register problem in HealthLog's AI-initial locales (FR / ES / IT / PL).
description: Report a wording, grammar, or register problem in HealthLog's AI-initial locales (FR / ES / IT / PL / KO / NL).
title: "[Translation] "
labels: ["i18n", "translation-feedback"]
body:
- type: markdown
attributes:
value: |
HealthLog ships German and English as maintainer-curated translations. French, Spanish, Italian, and Polish 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, 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.
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.
@@ -20,6 +20,8 @@ body:
- Spanish (es)
- Italian (it)
- Polish (pl)
- Korean (ko)
- Dutch (nl)
- 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`), and Korean (`ko`) 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`), 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.
### 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. `nl`, `pt-BR`, `cs`):
If you want to introduce a locale that isn't shipped (e.g. `pt-BR`, `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
@@ -60,7 +60,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, and Korean 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, and Dutch end to end.
The full feature tour, integration guides, and API reference live at [docs.healthlog.dev](https://docs.healthlog.dev).
+8
View File
@@ -25902,6 +25902,7 @@ components:
- it
- pl
- ko
- nl
practiceName:
type: string
maxLength: 120
@@ -28360,6 +28361,7 @@ components:
- it
- pl
- ko
- nl
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."
@@ -29310,6 +29312,7 @@ components:
- it
- pl
- ko
- nl
description: One of the interface locales this build ships.
required:
- locale
@@ -34599,6 +34602,7 @@ components:
- it
- pl
- ko
- nl
timezone:
type: string
minLength: 1
@@ -34653,6 +34657,7 @@ components:
- it
- pl
- ko
- nl
timezone:
type: string
minLength: 1
@@ -34870,6 +34875,7 @@ components:
- it
- pl
- ko
- nl
- type: "null"
timezone:
type: string
@@ -55782,6 +55788,7 @@ components:
- it
- pl
- ko
- nl
required:
- locale
additionalProperties: false
@@ -82785,6 +82792,7 @@ components:
- it
- pl
- ko
- nl
- type: "null"
timezone:
type: string
+1 -1
View File
@@ -33,7 +33,7 @@ import {
type SetupScreen,
} from "./setup-flow-helpers";
const LOCALES = ["en", "de", "es", "fr", "it", "pl", "ko"] as const;
const LOCALES = ["en", "de", "es", "fr", "it", "pl", "ko", "nl"] as const;
const VIEWPORTS = [
{ label: "desktop 1280", width: 1280, height: 800 },
+13356
View File
File diff suppressed because it is too large. Load diff
+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"];
const LOCALES = ["de", "en", "fr", "es", "it", "pl", "ko", "nl"];
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"];
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko", "nl"];
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"];
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko", "nl"];
const BOUNDARY = /(?<=[.!?])\s+(?=[A-ZÄÖÜÀÂÉÈÊÎÔÙÛÇŁŚŹŻĄĆĘŃÓ0-9„"„«])/u;
/**
+1 -1
View File
@@ -25,7 +25,7 @@ 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"];
const LOCALES: Locale[] = ["de", "en", "fr", "es", "it", "pl", "ko", "nl"];
/** The rules a voice section must state; `[]` when it states them all. */
function missingVoiceRules(prompt: string, lang: "de" | "en"): string[] {
@@ -42,7 +42,7 @@ 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"] as const;
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko", "nl"] as const;
function bundle(locale: string): Record<string, unknown> {
return JSON.parse(readFileSync(join(MESSAGES, `${locale}.json`), "utf8"));
@@ -81,6 +81,7 @@ const FINALITY_PHRASES: Record<string, readonly string[]> = {
],
pl: ["nie można cofnąć", "nie można tego cofnąć", "trwale", "nieodwracaln"],
ko: ["되돌릴 수 없", "복구할 수 없", "영구", "완전히 삭제"],
nl: ["niet ongedaan", "definitief", "permanent", "onherroepelijk"],
};
/** Per-locale phrase that promises the short undo window. */
@@ -92,6 +93,7 @@ const UNDO_PHRASES: Record<string, string> = {
it: "annullare",
pl: "cofnąć",
ko: "되돌릴 수 있",
nl: "ongedaan maken",
};
/** Confirmation bodies whose surface renders an Undo affordance. */
@@ -176,6 +178,7 @@ describe("sleep debt is labelled as a balance, not a summed deficit", () => {
it: ["cumulato", "cumulata", "accumulato", "deficit"],
pl: ["skumulowan", "niedobór", "niedoboru"],
ko: ["누적", "부족분", "적자", "쌓인"],
nl: ["cumulatief", "opgeteld", "tekort", "opgebouwd"],
};
for (const locale of LOCALES) {
@@ -302,6 +305,17 @@ describe("the mood prognosis never states a cause", () => {
],
pl: ["ponieważ", "powoduje", "z powodu", "wywołuje", "poprawia twój"],
ko: ["때문", "탓", "원인", "유발", "초래", "야기", "덕분", "인해"],
nl: [
"omdat",
"veroorzaakt",
"leidt tot",
"zorgt voor",
"vanwege",
"verbetert je",
"verslechtert je",
"verlaagt je",
"verhoogt je",
],
};
/** Every leaf string under `insights.mood.prognosis`, flattened. */
@@ -401,6 +415,11 @@ describe("the MCP token copy never claims the mint is read-only", () => {
it: ["token in sola lettura"],
pl: ["token tylko do odczytu"],
ko: ["읽기 전용 토큰", "읽기전용 토큰"],
nl: [
"alleen-lezen token",
"alleen-lezentoken",
"token met alleen leesrechten",
],
};
for (const locale of LOCALES) {
@@ -450,6 +469,7 @@ describe("AI consent withdrawal copy matches what withdrawing does", () => {
it: ["tua chiave", "modello locale"],
pl: ["własnym kluczu", "lokalnym modelu"],
ko: ["자신의 키", "로컬 모델"],
nl: ["eigen sleutel", "lokaal model"],
};
for (const locale of LOCALES) {
@@ -166,7 +166,7 @@ describe("dynamic-key exhaustiveness (enum-derived)", () => {
expect(LOCALES.length).toBeGreaterThanOrEqual(6);
});
it("discovers all seven shipped locales", () => {
it("discovers all eight shipped locales", () => {
expect(LOCALES.map((l) => l.locale).sort()).toEqual([
"de",
"en",
@@ -174,6 +174,7 @@ describe("dynamic-key exhaustiveness (enum-derived)", () => {
"fr",
"it",
"ko",
"nl",
"pl",
]);
});
+11 -1
View File
@@ -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"] as const;
const LEAK_LOCALES = ["es", "fr", "it", "pl", "ko", "nl"] as const;
function flattenValues(
obj: unknown,
@@ -209,6 +209,16 @@ const LEGIT_IDENTICAL = new Set<string>([
"settings.nightscoutUrl",
"admin.aiServerKey.baseUrlLabel",
"admin.webPushVapidSubject",
// Dutch spells these exactly as English does: "In bed" and "capsule(s)" are
// ordinary Dutch, and the clinical index names and the fatty-acid
// abbreviations are written in their international form.
"mood.dayContext.linkedInBed",
"insights.sleep.stages.inBed",
"medications.wizard.steps.step3.unit.capsules",
"doctorReport.glucoseClinical.gmi",
"insights.bloodGlucose.clinical.advanced.jIndex.label",
"nutrients.names.dha",
"nutrients.names.epa",
]);
describe("i18n English-leak guard", () => {
@@ -20,7 +20,7 @@ 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"] as const;
const LOCALES = ["en", "de", "es", "fr", "it", "pl", "ko", "nl"] as const;
const INSIGHTS_PAGES_DIR = join(process.cwd(), "src/app/insights");
/**
@@ -93,7 +93,7 @@ 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"] as const;
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko", "nl"] 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. */
+1 -1
View File
@@ -25,7 +25,7 @@ import { INTEGRATION_DISPLAY_NAMES } from "@/components/settings/integrations/in
const ROOT = path.resolve(__dirname, "../..");
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"] as const;
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko", "nl"] as const;
/** Every path the retired bridge occupied. */
const REMOVED_PATHS = [
+1 -1
View File
@@ -50,7 +50,7 @@ describe("managed record settings UI", () => {
fromRoot("src/components/settings/record-settings-section-gate.tsx"),
"utf8",
),
...["de", "en", "es", "fr", "it", "pl", "ko"].map((locale) =>
...["de", "en", "es", "fr", "it", "pl", "ko", "nl"].map((locale) =>
readFile(fromRoot(`messages/${locale}.json`), "utf8"),
),
]);
+1 -1
View File
@@ -38,7 +38,7 @@ import {
* it — which is the part that rots silently.
*/
const ROOT = process.cwd();
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"] as const;
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko", "nl"] as const;
/** The published description of the level, off the Zod schema itself. */
function accessDescription(): string {
+1 -1
View File
@@ -4,7 +4,7 @@ import { join } from "node:path";
import { describe, expect, it } from "vitest";
const ROOT = join(__dirname, "../..");
const LOCALES = ["en", "de", "es", "fr", "it", "pl", "ko"] as const;
const LOCALES = ["en", "de", "es", "fr", "it", "pl", "ko", "nl"] as const;
const REQUIRED_KEYS = [
// Security: adding a passkey now requires an existing sign-in factor.
+1 -1
View File
@@ -10,7 +10,7 @@ import { readFileSync } from "node:fs";
import { join } from "node:path";
const ROOT = join(__dirname, "../../..");
const LOCALES = ["en", "de", "fr", "es", "it", "pl", "ko"] as const;
const LOCALES = ["en", "de", "fr", "es", "it", "pl", "ko", "nl"] as const;
interface Messages {
notFound: {
@@ -119,7 +119,8 @@ function isLocale(value: string | null | undefined): value is Locale {
value === "es" ||
value === "it" ||
value === "pl" ||
value === "ko"
value === "ko" ||
value === "nl"
);
}
+1 -1
View File
@@ -118,7 +118,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"]).optional(),
locale: z.enum(["en", "de", "es", "fr", "it", "pl", "ko", "nl"]).optional(),
/**
* Optional override of the reference date used to resolve relative
* phrases ("tomorrow", "next Monday"). Defaults to the server's
+2 -1
View File
@@ -33,7 +33,8 @@ function isLocale(value: string | null | undefined): value is Locale {
value === "es" ||
value === "it" ||
value === "pl" ||
value === "ko"
value === "ko" ||
value === "nl"
);
}
+2 -1
View File
@@ -16,7 +16,8 @@ function isLocale(value: string | null | undefined): value is Locale {
value === "es" ||
value === "it" ||
value === "pl" ||
value === "ko"
value === "ko" ||
value === "nl"
);
}
@@ -202,7 +202,7 @@ 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"] as const;
const locales = ["en", "de", "fr", "es", "it", "pl", "ko", "nl"] as const;
it.each(locales)("%s carries every dashboard.*Short key", async (locale) => {
const messages = (await import(`../../../../messages/${locale}.json`))
@@ -223,9 +223,9 @@ const FULL_RECORD = {
const DOTTED_KEY = /\b[a-z][A-Za-z0-9]*(\.[A-Za-z0-9_]+){2,}\b/g;
describe("<ClinicianView> resolves every label in every locale", () => {
it("covers all seven shipped locales", () => {
it("covers all eight shipped locales", () => {
// A floor: an empty locale list would make the loop below assert nothing.
expect(locales.length).toBe(7);
expect(locales.length).toBe(8);
});
for (const locale of locales) {
@@ -89,6 +89,7 @@ describe("CoachReadStrip — the latest reading's day", () => {
es: "1 de octubre",
fr: "1 octobre",
it: "1 ottobre",
nl: "1 oktober",
ko: "10월 1일",
pl: "1 października",
};
@@ -103,7 +103,7 @@ describe("<KeyboardShortcutsDialog>", () => {
expect(kbd).not.toMatch(/#[0-9a-f]{3,6}|dracula|\[/i);
});
it.each(["de", "es", "fr", "it", "pl", "ko"])(
it.each(["de", "es", "fr", "it", "pl", "ko", "nl"])(
"is translated in %s",
(locale) => {
const html = render({}, locale);
@@ -66,7 +66,16 @@ describe("<OfflineBanner>", () => {
});
it("ships the message key in every supported locale", () => {
for (const locale of ["en", "de", "fr", "es", "it", "pl", "ko"] as const) {
for (const locale of [
"en",
"de",
"fr",
"es",
"it",
"pl",
"ko",
"nl",
] as const) {
const m = loadMessages(locale);
expect(m.offlineBanner.message.length, locale).toBeGreaterThan(0);
}
@@ -8,7 +8,7 @@ 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"] as const;
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko", "nl"] as const;
function window(term: string, locale: string) {
const parsed = parsePeriodQuery(term, locale, NOW);
@@ -41,7 +41,7 @@ const REQUIRED_KEYS = [
];
describe("measurement-reminder + bulk-share dynamic i18n keys", () => {
it("discovers all seven shipped locales", () => {
it("discovers all eight shipped locales", () => {
expect(LOCALES.map((l) => l.locale).sort()).toEqual([
"de",
"en",
@@ -49,6 +49,7 @@ describe("measurement-reminder + bulk-share dynamic i18n keys", () => {
"fr",
"it",
"ko",
"nl",
"pl",
]);
});
@@ -72,7 +72,7 @@ export interface NaturalLanguageExtractorProps {
* UI locale forwarded to the route as a hint. The route accepts an
* undefined value and falls back to the resolver chain.
*/
locale?: "en" | "de" | "es" | "fr" | "it" | "pl" | "ko";
locale?: "en" | "de" | "es" | "fr" | "it" | "pl" | "ko" | "nl";
/**
* Optional override of the API path so the wizard test harness can
* point at a mock server. Defaults to the production route.
@@ -108,7 +108,7 @@ describe("<ImportPanel> — SSR smoke", () => {
});
describe("<AppleHealthEstimateWarning>", () => {
it.each(["en", "de", "es", "fr", "it", "pl", "ko"] as const)(
it.each(["en", "de", "es", "fr", "it", "pl", "ko", "nl"] as const)(
"renders localized estimate disclosure for %s",
(locale) => {
const html = render(<AppleHealthEstimateWarning days={3} />, locale);
@@ -10,7 +10,7 @@ import {
ManagedRecordSettingsForm,
} from "../managed-record-settings-section";
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"] as const;
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko", "nl"] as const;
const render = (
family: Parameters<typeof ManagedRecordSettingsForm>[0]["family"],
@@ -285,12 +285,12 @@ describe("the managed-profile card, in the shared-access section", () => {
}
// The timezone control is the shared picker, which labels itself.
expect(html).toContain('id="managed-profile-timezone"');
// Six languages offered, which is the route's enum exactly.
// Eight languages offered, which is the route's enum exactly.
const options =
/<select[^>]*data-slot="managed-profile-locale"[\s\S]*?<\/select>/
.exec(html)?.[0]
.match(/<option/g) ?? [];
expect(options).toHaveLength(7);
expect(options).toHaveLength(8);
// Four sex options, and the first is the empty one: null is a real answer
// ("not recorded") rather than an absent field, and a form offering only
// the three enum members would make it unsayable.
@@ -215,7 +215,7 @@ describe("<AppleHealthCard>", () => {
}
});
it.each(["en", "de", "es", "fr", "it", "pl", "ko"] as const)(
it.each(["en", "de", "es", "fr", "it", "pl", "ko", "nl"] as const)(
"renders localized Apple Health copy for %s without leaking keys",
(locale) => {
statusPayload = { lastSyncedAt: null, health: null };
@@ -13,6 +13,7 @@ import es from "../../../../../messages/es.json";
import fr from "../../../../../messages/fr.json";
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 {
@@ -31,6 +32,7 @@ const CATALOGS: Record<Locale, unknown> = {
fr,
it: itMessages,
pl,
nl,
ko,
};
@@ -411,7 +411,7 @@ function ProfileSettingsForm({
<option value="">
{t("settings.sharedRecord.managedSettings.profile.systemDefault")}
</option>
{["de", "en", "es", "fr", "it", "pl", "ko"].map((locale) => (
{["de", "en", "es", "fr", "it", "pl", "ko", "nl"].map((locale) => (
<option key={locale} value={locale}>
{locale}
</option>
@@ -34,7 +34,7 @@ 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"];
const LOCALES: Locale[] = ["de", "en", "es", "fr", "it", "pl", "ko", "nl"];
function render(node: React.ReactElement, locale: Locale = "en") {
return renderToStaticMarkup(
+4 -2
View File
@@ -1,4 +1,4 @@
import { de, enUS, es, fr, it, ko, pl } from "date-fns/locale";
import { de, enUS, es, fr, it, ko, nl, pl } from "date-fns/locale";
import type { Locale as DateFnsLocale } from "date-fns";
import type { Locale } from "@/lib/i18n/config";
@@ -6,7 +6,7 @@ import type { Locale } from "@/lib/i18n/config";
/**
* Map the app locale → the date-fns locale so the calendar's month / weekday
* names match the UI language. Defaults to enUS for anything unmapped.
* Imported only by the calendars, which load on demand, so the seven
* Imported only by the calendars, which load on demand, so the eight
* locales never ride a page's first load.
*/
export function resolveDateFnsLocale(locale: Locale): DateFnsLocale {
@@ -23,6 +23,8 @@ export function resolveDateFnsLocale(locale: Locale): DateFnsLocale {
return pl;
case "ko":
return ko;
case "nl":
return nl;
default:
return enUS;
}
@@ -242,7 +242,7 @@ 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"] as const;
const locales = ["de", "en", "es", "fr", "it", "pl", "ko", "nl"] as const;
for (const locale of locales) {
const bundle = JSON.parse(
readFileSync(
@@ -252,6 +252,7 @@ describe("normative-claim detection across the shipped locales", () => {
es: "Su sistólica debería mantenerse por debajo del objetivo de 130 mmHg.",
it: "La sistolica dovrebbe restare al di sotto dell’obiettivo di 130 mmHg.",
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 아래로 유지해야 해요.",
};
@@ -267,6 +268,7 @@ describe("normative-claim detection across the shipped locales", () => {
es: "Su media de 7 días excede en 4 mmHg su mediana de 90 días.",
it: "La media a 7 giorni supera di 4 mmHg la mediana a 90 giorni.",
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 더 커요.",
};
+10
View File
@@ -141,6 +141,16 @@ const NORMATIVE_KEYWORDS: Record<Locale, readonly string[]> = {
"powyzej",
"ponizej",
],
nl: [
"doel",
"streefwaarde",
"zou moeten",
"hoort",
"normaalwaarde",
"normaal bereik",
"boven de",
"onder de",
],
ko: [
"목표",
"권장",
@@ -24,7 +24,7 @@ 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"];
const ALL_LOCALES: Locale[] = ["de", "en", "fr", "es", "it", "pl", "ko", "nl"];
describe("resolved Coach system prompt — grounding + GLP-1 per locale", () => {
for (const locale of ALL_LOCALES) {
+14 -1
View File
@@ -624,6 +624,8 @@ const MONTHS = [
"gennaio|febbraio|aprile|maggio|giugno|luglio|settembre|ottobre|dicembre",
// pl — genitive, as a date spells it
"stycznia|lutego|marca|kwietnia|maja|czerwca|lipca|sierpnia|września|wrzesnia|października|pazdziernika|listopada|grudnia",
// nl
"januari|februari|maart|mei|augustus",
].join("|");
/** Parse a raw number token honouring the reply language's separators. */
@@ -633,7 +635,8 @@ function parseLocaleNumber(raw: string, locale: Locale): number | null {
// EN: "," groups thousands, "." is the decimal point.
normalised = raw.replace(/,/g, "");
} else {
// DE / fr / es / it / pl: "." groups thousands, "," is the decimal point.
// DE / fr / es / it / pl / nl: "." groups thousands, "," is the decimal
// point.
normalised = raw.replace(/\./g, "").replace(/,/g, ".");
}
const value = Number.parseFloat(normalised);
@@ -867,6 +870,16 @@ const POPULATION_NORM_PATTERNS: Record<Locale, readonly RegExp[]> = {
/\bdoros(?:li|łych)\s+(?:zazwyczaj|zwykle|na\s+ogół)\s+(?:potrzebują|wymagają)\b/i,
/\b(?:zakres|przedział)\s+(?:referencyjny|normy|prawidłowy|zalecany)\b/i,
/\b(?:wynosi|mieści\s+się)\s+(?:zazwyczaj|zwykle|na\s+ogół)\s+(?:od|między|około)\b/i,
],
nl: [
/\bhet\s+normale\s+(?:bereik|niveau)\b/i,
/\b(?:normale?|gezonde)\s+(?:rust)?(?:hartslag|pols|waarde|waarden|bereik)\b/i,
/\b(?:gezonde|de\s+meeste|de\s+gemiddelde)\s+(?:volwassenen|mensen|volwassene)\b/i,
/\bde\s+algemene\s+bevolking\b/i,
/\b(?:richtlijnen|artsen|experts?|deskundigen)\s+(?:zeggen|adviseren|raden|bevelen)\b/i,
/\b(?:volwassenen|mensen)\s+(?:hebben\s+)?(?:meestal|doorgaans|gewoonlijk|normaal\s+gesproken|over\s+het\s+algemeen)\s+(?:nodig|hebben)\b/i,
/\b(?:referentie|normaal|aanbevolen)\w*\s*(?:bereik|waarden?)\b/i,
/\bligt\s+(?:normaal\s+gesproken|meestal|doorgaans|gewoonlijk)\s+(?:tussen|rond|op)\b/i,
], // No `\b` next to Hangul (it is an ASCII \w boundary), and the object sits
// between subject and verb in Korean, so each entry allows a gap where the
// Latin banks put \s+.
+5 -3
View File
@@ -1244,6 +1244,7 @@ const LOCALE_REPLY_FOOTER_FALLBACK: Record<
es: "\n\nREPLY LANGUAGE: respond in Spanish. Mirror the user's register; use natural Spanish health vocabulary.",
it: "\n\nREPLY LANGUAGE: respond in Italian. Mirror the user's register; use natural Italian health vocabulary.",
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.",
};
@@ -1282,9 +1283,10 @@ export function getCoachSystemPrompt(
base = COACH_PROMPT_DE;
} else if (locale === "en") {
base = COACH_PROMPT_EN;
} else if (locale === "ko") {
// ko ships no safety-contract matrix, so it is not a NativeCoachLocale.
base = COACH_PROMPT_EN + LOCALE_REPLY_FOOTER_FALLBACK.ko;
} else if (locale === "ko" || locale === "nl") {
// ko and nl ship no safety-contract matrix, so they are not a
// NativeCoachLocale.
base = COACH_PROMPT_EN + LOCALE_REPLY_FOOTER_FALLBACK[locale];
} else {
try {
base = buildNativeCoachPrompt(locale, PROMPT_VERSION);
@@ -56,6 +56,7 @@ const DRUG_LEVEL_CONCEPT_TOKENS: Record<Locale, readonly string[]> = {
es: ["nivel", "concentración", "pico", "valle", "Cmax", "terapéutica"],
it: ["livello", "concentrazione", "picco", "valle", "Cmax", "terapeutica"],
pl: ["poziom", "stężeni", "szczyt", "minimum", "Cmax", "terapeutyczn"],
nl: ["spiegel", "concentratie", "piek", "dal", "Cmax", "therapeutisch"],
ko: ["농도", "혈중", "최고", "최저", "Cmax", "치료"],
};
@@ -39,6 +39,7 @@ const EXPECTED_LANGUAGE_NAME: Record<Locale, string> = {
es: "Spanish",
it: "Italian",
pl: "Polish",
nl: "Dutch",
ko: "Korean",
};
@@ -50,6 +51,7 @@ const EXPECTED_BODY: Record<Locale, "de" | "en"> = {
es: "en",
it: "en",
pl: "en",
nl: "en",
ko: "en",
};
+5 -3
View File
@@ -992,6 +992,7 @@ const INSIGHTS_LOCALE_REPLY_FOOTER_FALLBACK: Record<
es: "\n\nREPLY LANGUAGE: render all user-facing strings in Spanish (peninsular preferred). Use natural Spanish 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.",
it: "\n\nREPLY LANGUAGE: render all user-facing strings in Italian. Use natural Italian 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.",
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.",
};
@@ -1002,9 +1003,10 @@ 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 ships no safety-contract matrix, so it is not a NativeCoachLocale.
if (locale === "ko") {
return SYSTEM_PROMPT_EN + INSIGHTS_LOCALE_REPLY_FOOTER_FALLBACK.ko;
// ko and nl ship no safety-contract matrix, so they are not a
// NativeCoachLocale.
if (locale === "ko" || locale === "nl") {
return SYSTEM_PROMPT_EN + INSIGHTS_LOCALE_REPLY_FOOTER_FALLBACK[locale];
}
try {
return buildNativeInsightsPrompt(locale, PROMPT_VERSION);
+2
View File
@@ -56,6 +56,8 @@ export function targetLanguageName(locale: Locale): string {
return "Polish";
case "ko":
return "Korean";
case "nl":
return "Dutch";
default:
return "English";
}
@@ -33,6 +33,7 @@ const NAMES: Record<Locale, readonly string[]> = {
es: ["Ramipril", "Amlodipino", "Eliquis"],
it: ["Ramipril", "Amlodipina", "Eliquis"],
pl: ["Ramipril", "Amlodypina", "Eliquis"],
nl: ["Ramipril 5 mg", "Amlodipine", "Eliquis"],
ko: ["라미프릴", "암로디핀", "엘리퀴스"],
};
@@ -93,6 +94,17 @@ const VIOLATIONS: Record<Locale, readonly string[]> = {
"Odstaw ramipril przed biegiem.",
"Weź jedną tabletkę więcej.",
],
nl: [
"Neem daarna een extra ramipril.",
"Sla de amlodipine morgen over.",
"Neem nog een tablet ramipril.",
"Stop je bloedverdunner voor het hardlopen.",
"Neem vanavond twee tabletten ramipril.",
"Laat de Eliquis morgen weg.",
"Pauzeer je Eliquis in het weekend.",
"Neem een halve tablet amlodipine.",
"Neem vanavond een tablet meer.",
],
ko: [
"라미프릴을 한 알 더 드세요.",
"암로디핀은 내일 건너뛰세요.",
@@ -171,6 +183,17 @@ const CLEAN: Record<Locale, readonly string[]> = {
"Nie bierz dodatkowej tabletki.",
"Zrób dodatkowy spacer po obiedzie.",
],
nl: [
"Je ramipril-logboek laat zien dat je elke dosis hebt genomen.",
"Sla je ramipril niet over.",
"Neem geen extra tablet als je er een vergeten bent.",
"Je hebt dinsdag een extra tablet genomen.",
"Neem een extra wandeling na het eten.",
"Neem je ramipril zoals voorgeschreven.",
"Ramipril en amlodipine verlagen allebei de bloeddruk.",
"Laat de suiker in je koffie weg.",
"Neem nog een meting vanavond.",
],
ko: [
"라미프릴 기록을 보면 모두 복용하셨어요.",
"암로디핀을 건너뛰지 마세요.",
@@ -23,6 +23,7 @@ const DOSE_VIOLATIONS: Record<Locale, string> = {
es: "Aumente su dosis a 2,4 mg la próxima semana.",
it: "Aumenti la sua dose a 2,4 mg la prossima settimana.",
pl: "Proszę zwiększyć dawkę do 2,4 mg w przyszłym tygodniu.",
nl: "Verhoog je dosis volgende week naar 2,4 mg.",
ko: "다음 주에 2.4mg으로 늘려 보세요.",
};
@@ -38,6 +39,7 @@ const DOSE_CLEAN: Record<Locale, string> = {
es: "Está en la semana 3 con 7,5 mg, y su peso está bajando.",
it: "È alla settimana 3 con 7,5 mg, e il suo peso sta calando.",
pl: "Jest Pan w 3. tygodniu na 7,5 mg, a waga spada.",
nl: "Je zit in week 3 op 7,5 mg, en je gewicht gaat omlaag.",
ko: "3주째 7.5mg을 복용 중이고, 체중은 내려가고 있어요.",
};
@@ -96,6 +98,7 @@ const RISK_VIOLATIONS: Record<Locale, string> = {
es: "Su riesgo es del 12%.",
it: "Il suo rischio è del 12%.",
pl: "Pana ryzyko wynosi 12%.",
nl: "Je risico ligt op ongeveer 12%.",
ko: "10년 심혈관 위험도는 약 12%예요.",
};
@@ -107,6 +110,7 @@ const RISK_CLEAN: Record<Locale, string> = {
es: "Su adherencia fue del 92% en los últimos 30 días.",
it: "La sua aderenza è stata del 92% negli ultimi 30 giorni.",
pl: "Przestrzeganie zaleceń wyniosło 92% w ostatnich 30 dniach.",
nl: "Je therapietrouw was 92% in de afgelopen 30 dagen.",
ko: "지난 30일 복약 순응도는 92%였어요.",
};
@@ -350,6 +354,7 @@ const CAUSAL_VIOLATIONS: Record<Locale, string> = {
es: "Su peso bajó porque durmió más.",
it: "Il suo peso è calato perché ha dormito di più.",
pl: "Waga spadła, ponieważ spał Pan więcej.",
nl: "Je gewicht daalde omdat je meer sliep.",
ko: "수면 때문에 체중이 줄었어요.",
};
@@ -361,6 +366,7 @@ const CAUSAL_CLEAN: Record<Locale, string> = {
es: "Su peso evolucionó junto con su sueño esta semana.",
it: "Il suo peso si è mosso insieme al suo sonno questa settimana.",
pl: "Waga zmieniała się razem ze snem w tym tygodniu.",
nl: "Je gewicht bewoog deze week mee met je slaap.",
ko: "이번 주 체중은 수면과 함께 움직였어요.",
};
@@ -514,6 +520,16 @@ const DOSE_CLASS_VIOLATIONS: Record<Locale, readonly string[]> = {
"Pomiń wieczorną dawkę.",
"Przestań brać metforminę.",
],
nl: [
"Verhoog je metformine naar 2000 mg per dag.",
"Verlaag je insuline met 4 eenheden.",
"Neem 's avonds 2 tabletten.",
"Halveer je insulinedosis.",
"Sla de avonddosis vandaag over.",
"Stop met het innemen van metformine.",
"Je zou de metformine een week kunnen stoppen.",
"Bouw je dosis af naar 5 mg.",
],
ko: [
"메트포르민을 2000mg으로 증량하세요.",
"하루 2정 드세요.",
@@ -572,6 +588,17 @@ const DOSE_CLASS_CLEAN: Record<Locale, readonly string[]> = {
"Przyjmuje Pan 1000 mg dwa razy dziennie.",
"Nie przestawaj brać leków.",
],
nl: [
"Je neemt 1000 mg twee keer per dag.",
"Je logboek laat 2000 mg zien.",
"Sla je avonddosis niet over.",
"Vraag je arts of je de dosis moet verhogen.",
"Je dosis is in mei verhoogd naar 2000 mg.",
"Verdubbel je stappen twee weken lang.",
"Verlaag je natrium naar 1500 mg per dag.",
"Probeer je glucose na de maaltijd onder 140 mg/dL te houden.",
"Vraag je arts voordat je stopt met metformine.",
],
ko: [
"하루 두 번 1000mg을 복용하고 계세요.",
"저녁 용량을 건너뛰지 마세요.",
+152 -4
View File
@@ -64,19 +64,19 @@ export interface OutboundDecision {
* the screen recognised and passed every pattern.
*/
const COUNT_UNIT =
"(?:tablets?|pills?|capsules?|drops?|puffs?|sprays?|tabletten?|kapseln?|tropfen|hübe|hub|sprühstöße|comprimés?|gélules?|gouttes?|bouffées?|comprimidos?|pastillas?|c[áa]psulas?|gotas?|compress[ae]|pastigli[ae]|gocce|goccia|tabletk[aęiy]|tabletek|kapsułk[aęiy]|kapsułek|krople|kropli)";
"(?:tablets?|pills?|capsules?|drops?|puffs?|sprays?|tabletten?|kapseln?|tropfen|hübe|hub|sprühstöße|comprimés?|gélules?|gouttes?|bouffées?|comprimidos?|pastillas?|c[áa]psulas?|gotas?|compress[ae]|pastigli[ae]|gocce|goccia|tabletk[aęiy]|tabletek|kapsułk[aęiy]|kapsułek|krople|kropli|druppels?|pillen|pufjes?)";
/**
* Dose units for a target dose a model must never name. Mass and insulin
* units plus the count forms; `ml` is left out on purpose because
* "increase your water intake to 2000 ml" is ordinary hydration advice.
*/
const DRUG_UNIT = `(?:mg|mcg|µg|iu|i\\.u\\.|units?|unidades?|unità|unités?|jednostek|jednostki|ie|i\\.e\\.|einheiten|${COUNT_UNIT})`;
const DRUG_UNIT = `(?:mg|mcg|µg|iu|i\\.u\\.|units?|unidades?|unità|unités?|jednostek|jednostki|eenheden|eenheid|ie|i\\.e\\.|einheiten|${COUNT_UNIT})`;
/**
* Dose units, shared across every locale. The GLP-1 + general oral-dose
* vocabulary; `ie` / `i.e.` / `einheiten` / `unità` / `jednostk` cover the
* insulin-unit spellings the six locales use.
* insulin-unit spellings the locales use (`eenheden` is the Dutch one).
*/
const DOSE_UNIT = `(?:ml|${DRUG_UNIT})`;
@@ -118,12 +118,16 @@ const IT_STOP =
const PL_STOP =
"nie|i|lub|albo|ale|jeśli|czy|sód|sodu|sól|soli|kofein\\S*|cukr\\S*|wod\\S*|białk\\S*|błonnik\\S*";
const NL_STOP =
"niet|nooit|geen|en|of|maar|als|wanneer|natrium|zout|cafeïne|suiker\\S*|water|vocht|eiwit\\S*|vezels?|koffie|alcohol|stappen|calorieën";
const EN_SLOT = objectSlot(EN_STOP);
const DE_SLOT = objectSlot(DE_STOP);
const FR_SLOT = objectSlot(FR_STOP);
const ES_SLOT = objectSlot(ES_STOP);
const IT_SLOT = objectSlot(IT_STOP);
const PL_SLOT = objectSlot(PL_STOP);
const NL_SLOT = objectSlot(NL_STOP);
/**
* A tighter slot for the shapes that carry no number: an extra / one more /
@@ -146,6 +150,10 @@ const FR_SHORT = objectSlot(
const ES_SHORT = objectSlot(`${ES_STOP}|para|con|antes|después|sin|a|al`, 2);
const IT_SHORT = objectSlot(`${IT_STOP}|per|con|prima|dopo|senza|a|al`, 2);
const PL_SHORT = objectSlot(`${PL_STOP}|z|ze|do|na|po|przed|bez|dla`, 2);
const NL_SHORT = objectSlot(
`${NL_STOP}|om|voor|met|bij|in|op|van|naar|tot|na|zonder|tijdens|over|dan|te`,
2,
);
/**
* A count written as a word: "take two tablets", "nimm zwei Tabletten",
@@ -155,7 +163,7 @@ const PL_SHORT = objectSlot(`${PL_STOP}|z|ze|do|na|po|przed|bez|dla`, 2);
* count, and the "eine Tablette mehr" shape is its own pattern below.
*/
const SPELLED_COUNT =
"(?:one|two|three|four|five|six|half\\s+an?|zwei|drei|vier|fünf|sechs|eine?n?\\s+halbe|halbe|deux|trois|quatre|cinq|demi|dos|tres|cuatro|cinco|media|due|tre|quattro|cinque|mezza|dwie|dwa|trzy|cztery|pięć|jedn[ąa]|pół)";
"(?:one|two|three|four|five|six|half\\s+an?|zwei|drei|vier|fünf|sechs|eine?n?\\s+halbe|halbe|deux|trois|quatre|cinq|demi|dos|tres|cuatro|cinco|media|due|tre|quattro|cinque|mezza|dwie|dwa|trzy|cztery|pięć|jedn[ąa]|pół|één|twee|drie|vier|vijf|zes|halve)";
/** A target dose: a number, a drug unit, and an honest unit end. */
const TARGET = `(?:[\\d.,]+\\s*${DRUG_UNIT}${UNIT_END}|(?<![\\w])${SPELLED_COUNT}\\s+${COUNT_UNIT}${UNIT_END})`;
@@ -190,6 +198,7 @@ const IT_MED_NOUN =
"(?:dos[ei]|compress[ae]|pastigli[ae]|farmac[oi]|medicinal[ei]|insulina|iniezion[ei]|capsul[ae]|terapia)";
const PL_MED_NOUN =
"(?:dawk[aęiy]|dawek|tabletk[aęiy]|tabletek|lek|leki|leku|leków|insulin[aęy]|zastrzyk[ia]?|zastrzyków|kapsułk[aęiy]|pigułk[aęiy])(?=[\\s.,;:!?]|$)";
const NL_MED_NOUN = `(?:[${LETTER}]*(?:dosis|doses|dosering|tablet(?:ten|je|jes)?|medicijn(?:en)?|medicatie|medicament(?:en)?|geneesmiddel(?:en)?|insuline|injecties?|capsules?)|pil(?:len|letje|letjes)?|prik(?:ken)?|spuitjes?|inhalator)(?![${LETTER}])`;
const KO_MED_NOUN =
"(?:약을|약은|약\\s|알약|정제|인슐린|주사|용량|복용량|투약)";
@@ -204,6 +213,7 @@ const MED_CLASS: Record<Locale, string> = {
es: "(?:anticoagulantes?|antiagregantes?|estatinas?|betabloqueantes?|diuréticos?|antidepresivos?)",
it: "(?:anticoagulant[ei]|antiaggregant[ei]|statin[ae]|betabloccant[ei]|diuretic[oi]|antidepressiv[oi])",
pl: "(?:lek\\S*\\s+przeciwzakrzepow\\S*|antykoagulant\\S*|statyn\\S*|beta-?bloker\\S*|diuretyk\\S*|lek\\S*\\s+moczopędn\\S*|antydepresant\\S*)",
nl: "\\S*(?:bloedverdunner\\w*|antistolling\\w*|anticoagul\\w*|plaatjesremmer\\w*|statine\\w*|b[eè]tablokker\\w*|plastablet\\w*|diuretic\\w*|diuretica|antidepressiv\\w*|antidepressiva|slaappil\\w*|slaapmiddel\\w*|bloeddrukverlager\\w*|ace-remmer\\w*)",
ko: "(?:혈액\\s*희석제|항응고제|항혈소판제|스타틴|베타\\s*차단제|이뇨제|항우울제)",
};
@@ -303,6 +313,7 @@ function medVocabulary(names: string | null): MedVocabulary {
es: `(?:${ES_MED_NOUN}|${extra("es")})`,
it: `${elided}(?:${IT_MED_NOUN}|${extra("it")})`,
pl: `(?:${PL_MED_NOUN}|${extra("pl")})`,
nl: `(?:${NL_MED_NOUN}|${extra("nl")})`,
ko: `(?:${KO_MED_NOUN}|(?:${extra("ko")})\\S{0,3})`,
};
}
@@ -577,6 +588,65 @@ function buildDoseChangeClass(
"i",
),
],
nl: [
new RegExp(
`(?<!\\b(?:niet|nooit)\\s)\\b(?:verhoog|verhogen|verlaag|verlagen|verminder|verminderen|halveer|halveren|verdubbel|verdubbelen)\\s+${NL_SLOT}(?:van\\s+${TARGET}\\s+)?(?:naar|tot|met)\\s+${TARGET}`,
"i",
),
new RegExp(
`(?<!\\b(?:niet|nooit)\\s)\\b(?:ga|gaan|breng|brengen|bouw|bouwen|zet|zetten)\\s+${NL_SLOT}(?:van\\s+${TARGET}\\s+)?(?:omhoog\\s+|omlaag\\s+|terug\\s+|af\\s+|op\\s+)?(?:naar|tot)\\s+${TARGET}`,
"i",
),
new RegExp(
`(?<!\\b(?:niet|nooit)\\s)\\bzet\\s+${NL_SLOT}op\\s+${TARGET}`,
"i",
),
// The infinitive closes a Dutch clause: "om je metformine naar 2000 mg te
// verhogen". A past participle ("verhoogd") is a log restatement and
// does not match.
new RegExp(
`\\b(?:naar|tot|met)\\s+${TARGET}[^.?!]{0,30}?\\b(?:verhogen|verlagen|verminderen|ophogen|opbouwen|afbouwen|terugbrengen|halveren|verdubbelen)\\b`,
"i",
),
new RegExp(
`(?<!\\b(?:niet|nooit)\\s)\\b(?:neem|slik|spuit|injecteer|begin\\s+met|start\\s+met)\\s+${NL_SLOT}${TARGET}`,
"i",
),
new RegExp(
`(?<!\\b(?:niet|nooit)\\s)\\b(?:halveer|verdubbel|pauzeer|deel|onderbreek)\\s+${NL_SLOT}${m.nl}`,
"i",
),
// "Overslaan" is separable: "sla je avonddosis over". A negation between
// the object and the particle ("sla je dosis niet over") is a warning.
new RegExp(
`\\bsla\\s+${NL_SLOT}${m.nl}(?:(?!\\b(?:niet|nooit)\\b)[^.?!]){0,25}?\\bover\\b`,
"i",
),
new RegExp(
`\\bstop\\s+met\\s+(?:het\\s+)?(?:innemen|slikken|gebruiken|spuiten|nemen)\\b`,
"i",
),
// Stop, halt or leave out a named medication or a drug class: "stop je
// bloedverdunner", "laat de Eliquis morgen weg".
new RegExp(`\\b(?:stop|staak)\\s+(?:met\\s+)?${NL_SHORT}${m.nl}`, "i"),
new RegExp(
`\\blaat\\s+${NL_SLOT}${m.nl}(?:(?!\\b(?:niet|nooit)\\b)[^.?!]){0,25}?\\bweg\\b`,
"i",
),
// The same behind a recommending cue, with the verb last. A referral
// ("vraag", "overleg", "voordat", "of") or a negation ends it.
new RegExp(
`\\b(?:je\\s+zou|u\\s+zou|je\\s+kunt|u\\s+kunt|ik\\s+raad|ik\\s+adviseer|overweeg|probeer|het\\s+is\\s+(?:beter|verstandig))\\b(?:(?!\\b(?:niet|nooit|geen|vraag|overleg|voordat|of|eerst)\\b)[^.?!]){0,40}?${m.nl}(?:(?!\\b(?:niet|nooit)\\b)[^.?!]){0,30}?\\b(?:overslaan|over\\s+te\\s+slaan|stoppen|te\\s+stoppen|staken|te\\s+staken|halveren|te\\s+halveren|verdubbelen|te\\s+verdubbelen|pauzeren|te\\s+pauzeren|weglaten|weg\\s+te\\s+laten)\\b`,
"i",
),
// An extra, another or one more of a medication: "neem een extra
// ramipril", "neem nog een tablet", "neem vanavond een tablet meer".
// A negation ("geen") cannot enter the short slot.
new RegExp(
`(?<!\\b(?:niet|nooit)\\s)\\b(?:neem|slik|spuit|injecteer|voeg)\\s+${NL_SHORT}(?:(?:(?:een|één|twee|\\d+)\\s+)?(?:extra|aanvullende?|tweede)[-\\s]+${NL_SHORT}${m.nl}|nog\\s+(?:een|één|twee|\\d+)\\s+${NL_SHORT}${m.nl}|(?:een|één|twee|drie|\\d+)\\s+${NL_SHORT}${m.nl}\\s+(?:meer|extra)\\b)`,
"i",
),
],
ko: [
// A stated amount closed by an imperative or a recommendation, with or
// without a target particle: "2000mg으로 증량하세요", "하루 2정 드세요".
@@ -752,6 +822,29 @@ const DOSE_PATTERNS: Record<Locale, readonly RegExp[]> = {
`\\b(?:nast[ęe]pna|kolejna|nowa)\\s+dawka\\b[^.?!]{0,30}\\b[\\d.,]+\\s*${DOSE_UNIT}${UNIT_END}`,
"i",
),
],
nl: [
// verhoog / ga omhoog / bouw op naar|tot|met N unit
new RegExp(
`\\b(?:verhoog|verhogen|ophogen|ga\\s+omhoog|bouw\\s+op|opbouwen)(?:\\s+(?:je|jouw|de|uw)\\s+(?:dosis|dosering))?\\s+(?:naar|tot|met)\\s+[\\d.,]+\\s*${DOSE_UNIT}${UNIT_END}`,
"i",
),
// verlaag / verminder / bouw af naar|tot|met N unit
new RegExp(
`\\b(?:verlaag|verlagen|verminder|verminderen|ga\\s+omlaag|bouw\\s+af|afbouwen)(?:\\s+(?:je|jouw|de|uw)\\s+(?:dosis|dosering))?\\s+(?:naar|tot|met)\\s+[\\d.,]+\\s*${DOSE_UNIT}${UNIT_END}`,
"i",
),
// A recommending cue in front of a drug amount. `ml` is left out: "probeer
// 2000 ml water per dag" is hydration advice.
new RegExp(
`\\b(?:overweeg|probeer|je\\s+zou|u\\s+zou|ik\\s+raad|ik\\s+adviseer|ik\\s+stel\\s+voor)\\b[^.?!]{0,40}\\b[\\d.,]+\\s*${DRUG_UNIT}${UNIT_END}`,
"i",
),
new RegExp(
`\\b(?:volgende|nieuwe)\\s+(?:dosis|stap)\\b[^.?!]{0,30}\\b[\\d.,]+\\s*${DOSE_UNIT}${UNIT_END}`,
"i",
),
/\b(?:halveer|halveren|verdubbel|verdubbelen|sla\s+\S+\s+over|overslaan|stop\s+met)\b[^.?!]{0,25}(?:dosis|tablet\w*|medicijn\w*|medicatie|insuline|injectie\w*)[^.?!]{0,40}\b(?:(?:een|twee|drie|vier|\d+)\s+(?:dag|dagen|week|weken|maand|maanden)|om\s+te\s+(?:zien|testen|kijken|proberen))\b/i,
], // Korean is verb-final: the dose comes first and the change verb closes the
// clause ("2.4mg으로 늘려 보세요"), the mirror image of the Latin banks. A
// target-marking particle on the dose is required, the same demand the Latin
@@ -880,6 +973,21 @@ const RISK_NOUN_PL = "(?:ryzyk\\w*|prawdopodobieństw\\w*)";
const RESULT_VERB_PL =
"(?:umieszcza\\s+(?:cię|pana|panią)|klasyfikuje\\s+(?:cię|pana|panią)|znajdujesz\\s+się\\s+w|wpadasz\\s+w|kwalifikuje\\s+(?:cię|pana|panią))";
const SPELLED_NL =
"(?:nul|een|één|twee|drie|vier|vijf|zes|zeven|acht|negen|tien|elf|twaalf|dertien|veertien|vijftien|zestien|zeventien|achttien|negentien|twintig|dertig|veertig|vijftig|zestig)";
const PCT_WORD_NL = `${SPELLED_NL}\\s+procent`;
const QUAL_NUM_NL = `(?:\\d{1,3}\\s*%|${PCT_WORD_NL})`;
const RISK_NOUN_NL = "(?:risico\\w*|kans|waarschijnlijkheid)";
const HORIZON_NL =
"(?:(?:10|tien)[- ]?jaars?[- ]?risico\\w*|levenslang\\s+risico\\w*|risico\\w*(?:\\s+\\S+){0,3}\\s+(?:binnen|in)\\s+(?:de\\s+komende\\s+)?(?:10|tien)\\s+jaar)";
const RESULT_VERB_NL =
"(?:plaatst\\s+(?:je|u)|deelt\\s+(?:je|u)\\s+in|zet\\s+(?:je|u)\\s+in|(?:je|u)\\s+valt\\s+in|valt\\s+(?:je|u)\\s+in|brengt\\s+(?:je|u)\\s+in)";
const RISK_BAND_NL =
"(?:hoog|hoger|laag|lager|middel|matig|verhoogd)\\w*[- ]?risico(?:categorie|groep|klasse|zone)";
const RISK_LEVEL_NL =
"(?:verhoogd|hoog|hoger|matig|gemiddeld|zorgwekkend|aanzienlijk)";
const RISK_VERDICT_NL = `(?:is|ligt|lijkt|blijft|wijst\\s+op|komt\\s+uit\\s+op)\\s+(?:\\w+\\s+){0,2}${RISK_LEVEL_NL}`;
// Korean carries no `\\b` next to a Hangul token: `\\b` is an ASCII \\w boundary
// and Hangul is not \\w, so /\\b위험/ matches nothing. Spelled-out percentages are
// omitted deliberately — Korean writes "12%", and the Sino-Korean numerals
@@ -1006,6 +1114,31 @@ const RISK_PATTERNS: Record<Locale, readonly RegExp[]> = {
new RegExp(`${QUAL_NUM_PL}[^.?!]{0,50}\\b${ENGINE}\\b`, "i"),
new RegExp(`\\b${ENGINE}\\b[^.?!]{0,60}${RESULT_VERB_PL}`, "i"),
],
nl: [
new RegExp(
`\\brisico\\w*\\s+(?:van|is|ligt\\s+op|bedraagt|op)\\s+(?:ongeveer\\s+|zo['’]?n\\s+|circa\\s+|rond\\s+|~)?\\d{1,3}\\s*%`,
"i",
),
new RegExp(`\\b\\d{1,3}\\s*%\\s+${RISK_NOUN_NL}\\b`, "i"),
new RegExp(`\\b${RISK_NOUN_NL}\\b[^.?!]{0,40}\\b${PCT_WORD_NL}\\b`, "i"),
new RegExp(`\\b${PCT_WORD_NL}\\b[^.?!]{0,40}\\b${RISK_NOUN_NL}\\b`, "i"),
new RegExp(`\\b${HORIZON_NL}\\b[^.?!]{0,40}${QUAL_NUM_NL}`, "i"),
new RegExp(`${QUAL_NUM_NL}[^.?!]{0,40}\\b${HORIZON_NL}\\b`, "i"),
new RegExp(`\\b${ENGINE}\\b[^.?!]{0,50}${QUAL_NUM_NL}`, "i"),
new RegExp(`${QUAL_NUM_NL}[^.?!]{0,50}\\b${ENGINE}\\b`, "i"),
new RegExp(
`\\b(?:${ENGINE}|${HORIZON_NL})\\b[^.?!]{0,60}${RESULT_VERB_NL}`,
"i",
),
new RegExp(
`\\b(?:${ENGINE}|${HORIZON_NL})\\b[^.?!]{0,60}${RISK_BAND_NL}`,
"i",
),
new RegExp(
`\\b(?:${ENGINE}|${HORIZON_NL})\\b[^.?!]{0,40}${RISK_VERDICT_NL}`,
"i",
),
],
ko: [
new RegExp(`${RISK_NOUN_KO}[^.?!]{0,15}${QUAL_NUM_KO}`, "i"),
new RegExp(`${QUAL_NUM_KO}[^.?!]{0,15}${RISK_NOUN_KO}`, "i"),
@@ -1105,6 +1238,19 @@ const CAUSAL_PATTERNS: Record<Locale, readonly RegExp[]> = {
/\bdzi[ęe]ki\b/i,
/\bodpowiedzialn\w*\s+za\b/i,
],
nl: [
/\bomdat\b/i,
/\bdoordat\b/i,
/\bvanwege\b/i,
/\bveroorza\w*/i,
/\b(?:ten|als)\s+gevolg\s+van\b/i,
/\bleid(?:t|de|den)\s+tot\b/i,
/\bkomt\s+door\b/i,
/\bdankzij\b/i,
/\bde\s+schuld\s+van\b/i,
/\boorzaak\b/i,
/\bverantwoordelijk\s+voor\b/i,
],
ko: [
/때문(?:에|이)/,
/(?:으로|로)\s*인(?:해|하여|한)/,
@@ -1167,6 +1313,7 @@ const DOSE_CONTINUATION: Record<Locale, RegExp> = {
es: /\b(?:siga\s+(?:tomando|con)|continúe|mantenga|según\s+lo\s+prescrito)\b/i,
it: /\b(?:continui\s+(?:a|con|il)|mantenga|come\s+prescritto)\b/i,
pl: /\b(?:kontynuuj|przyjmuj\s+dalej|zgodnie\s+z\s+zaleceniem|pozosta[ńn]\s+przy)\b/i,
nl: /\b(?:blijf|ga\s+door\s+met|neem\s+\S+\s+(?:gewoon\s+)?verder|zoals\s+(?:voorgeschreven|afgesproken|gebruikelijk|gewoonlijk|altijd|eerder|je\s+gewend\s+bent)|volgens\s+(?:voorschrift|afspraak))\b/i,
ko: /(?:그대로\s*(?:유지|복용)|계속\s*(?:복용|드시|유지)|처방(?:대로|받은\s*대로)|지시(?:대로|받은\s*대로)|유지하세요)/,
};
@@ -1177,6 +1324,7 @@ const DOSE_CHANGE_STEM: Record<Locale, RegExp> = {
es: /\b(?:aument|reduzc|reduc|baj|disminu|dobl|omit|salt)\w*/i,
it: /\b(?:aument|riduc|ridur|abbass|diminu|raddoppi|salt|dimezz)\w*/i,
pl: /\b(?:zwiększ|zmniejsz|obniż|podnie[śs]|podwyższ|zreduk|pomi[ńn]|opu[śs][ćc])\w*/i,
nl: /\b(?:verho|verla|vermind|halve|verdubbel|ophog|afbouw|bouw\s+af|overslaan|sla\s+\S+\s+over|stop)\w*/i,
ko: /(?:올리|올려|늘리|늘려|증량|높이|높여|인상|내리|내려|줄이|줄여|감량|낮추|낮춰|감축|절반|반으로|두\s?배|건너뛰|중단|끊)/,
};
+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"])(
it.each(["en", "de", "fr", "es", "it", "pl", "ko", "nl"])(
"quotes those same two numbers in %s",
(locale) => {
const bundle = JSON.parse(
+2 -1
View File
@@ -34,7 +34,8 @@ function isLocale(value: string | null | undefined): value is Locale {
value === "es" ||
value === "it" ||
value === "pl" ||
value === "ko"
value === "ko" ||
value === "nl"
);
}
@@ -626,7 +626,7 @@ describe("appointments", () => {
});
describe("locales", () => {
it.each(["de", "es", "fr", "it", "pl", "ko"] as Locale[])(
it.each(["de", "es", "fr", "it", "pl", "ko", "nl"] as Locale[])(
"resolves every %s string from its own bundle",
(locale) => {
const tl = getServerTranslator(locale).t;
@@ -31,6 +31,7 @@ describe("runDocumentSummary — screened prose", () => {
it: "Questo referto suggerisce di aumentare la sua dose a 2,4 mg la prossima settimana.",
pl: "Ten raport sugeruje, aby zwiększyć dawkę do 2,4 mg w przyszłym tygodniu.",
ko: "이 보고서는 다음 주에 2.4mg으로 늘려 보라고 권해요.",
nl: "Dit rapport stelt voor om de dosis volgende week naar 2,4 mg te verhogen.",
};
for (const locale of locales) {
@@ -49,7 +49,7 @@ import {
} from "@/lib/ai/capabilities/types";
const ROOT = path.resolve(__dirname, "../../../..");
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"] as const;
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko", "nl"] as const;
interface RetiredSwitch {
/** The wire name, also the admin copy key. */
+2
View File
@@ -31,6 +31,7 @@ const INTL_LOCALE_MAP: Record<Locale, string> = {
it: "it-IT",
pl: "pl-PL",
ko: "ko-KR",
nl: "nl-NL",
};
export function resolveIntlLocale(locale: Locale): string {
@@ -350,5 +351,6 @@ export function parseLocaleFromAcceptLanguage(header: string | null): Locale {
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";
}
@@ -18,7 +18,7 @@ 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"] as const;
const NON_GERMAN_TRANSLATED = ["es", "fr", "it", "pl", "ko", "nl"] as const;
describe("plural tiers", () => {
it("gives Polish its third tier for 2-4 and keeps many for 5+", () => {
+12 -1
View File
@@ -1,4 +1,13 @@
export const locales = ["de", "en", "fr", "es", "it", "pl", "ko"] as const;
export const locales = [
"de",
"en",
"fr",
"es",
"it",
"pl",
"ko",
"nl",
] as const;
export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = "en";
@@ -33,6 +42,7 @@ export const localeLanguageNames: Record<Locale, string> = {
it: "Italian",
pl: "Polish",
ko: "Korean",
nl: "Dutch",
};
export const localeLabels: Record<Locale, string> = {
@@ -43,6 +53,7 @@ export const localeLabels: Record<Locale, string> = {
it: "Italiano",
pl: "Polski",
ko: "한국어",
nl: "Nederlands",
};
/**
+1
View File
@@ -50,6 +50,7 @@ const loaders: Record<Locale, () => Promise<{ default: MessageBundle }>> = {
it: () => import("../../../messages/it.json"),
pl: () => import("../../../messages/pl.json"),
ko: () => import("../../../messages/ko.json"),
nl: () => import("../../../messages/nl.json"),
};
function initialCache(): Partial<Record<Locale, MessageBundle>> {
+2
View File
@@ -21,6 +21,7 @@ import esMessages from "../../../messages/es.json";
import itMessages from "../../../messages/it.json";
import plMessages from "../../../messages/pl.json";
import koMessages from "../../../messages/ko.json";
import nlMessages from "../../../messages/nl.json";
import { locales, type Locale } from "./config";
import { resolveKey } from "./resolve-key";
@@ -38,6 +39,7 @@ export const allMessages: Record<Locale, Record<string, unknown>> = {
it: itMessages,
pl: plMessages,
ko: koMessages,
nl: nlMessages,
};
/**
@@ -22,6 +22,7 @@ const DOSE_VIOLATION: Record<string, string> = {
it: '{"summary":"Aumenti la sua dose a 2,4 mg la prossima settimana."}',
pl: '{"summary":"Proszę zwiększyć dawkę do 2,4 mg w przyszłym tygodniu."}',
ko: '{"summary":"다음 주에 2.4mg으로 늘려 보세요."}',
nl: '{"summary":"Verhoog je dosis volgende week naar 2,4 mg."}',
};
const CAUSAL_VIOLATION: Record<string, string> = {
@@ -32,6 +33,7 @@ const CAUSAL_VIOLATION: Record<string, string> = {
it: '{"summary":"Il suo peso è calato perché ha dormito di più."}',
pl: '{"summary":"Waga spadła, ponieważ spał Pan więcej."}',
ko: '{"summary":"수면 때문에 체중이 줄었어요."}',
nl: '{"summary":"Je gewicht daalde omdat je meer sliep."}',
};
const CLEAN: Record<string, string> = {
@@ -42,6 +44,7 @@ const CLEAN: Record<string, string> = {
it: '{"summary":"Il suo peso è in media di 82 kg questa settimana, stabile rispetto al mese scorso."}',
pl: '{"summary":"Waga wynosi średnio 82 kg w tym tygodniu, stabilnie wobec zeszłego miesiąca."}',
ko: '{"summary":"이번 주 체중은 평균 82kg으로, 지난달과 비슷해요."}',
nl: '{"summary":"Je gewicht ligt deze week gemiddeld op 82 kg, stabiel ten opzichte van vorige maand."}',
};
describe("finalizeStatusSummary — dose prescriptions are withheld", () => {
@@ -254,6 +254,7 @@ describe("tone harness — deterministic fallbacks", () => {
es: /no hay ninguna valoración/,
it: /non è disponibile alcuna valutazione/,
pl: /nie ma (?:tu )?żadnej oceny/,
nl: /geen beoordeling/,
ko: /평가가 없어요/,
};
+34
View File
@@ -99,6 +99,7 @@ const VERDICT_STEADY: Localized<string> = {
es: "Estable, como de costumbre",
it: "Stabile, come al solito",
pl: "Stabilnie, tak jak zwykle",
nl: "Stabiel en zoals gewoonlijk",
};
const VERDICT_FAVOURABLE: Localized<string> = {
@@ -108,6 +109,7 @@ const VERDICT_FAVOURABLE: Localized<string> = {
es: "Esto va en buena dirección",
it: "Sta andando nella direzione giusta",
pl: "To zmierza w dobrym kierunku",
nl: "Dit gaat de goede kant op",
};
const VERDICT_OFF_USUAL: Localized<string> = {
@@ -117,6 +119,7 @@ const VERDICT_OFF_USUAL: Localized<string> = {
es: "Últimamente algo fuera de tu rango habitual",
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",
};
function verdictLead(
@@ -188,6 +191,8 @@ const VALUE_VS_BASELINE: Localized<
`${l} è a ${c} in questo momento — ${higher ? "sopra" : "sotto"} la tua media abituale di ${b}.`,
pl: (l, c, b, higher) =>
`${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}.`,
};
const VALUE_AT_BASELINE: Localized<(label: string, current: string) => string> =
@@ -200,6 +205,8 @@ const VALUE_AT_BASELINE: Localized<(label: string, current: string) => string> =
it: (l, c) => `${l} è a ${c}, più o meno sulla tua media abituale.`,
pl: (l, c) =>
`${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.`,
};
const VALUE_ALONE: Localized<(label: string, current: string) => string> = {
@@ -209,6 +216,7 @@ const VALUE_ALONE: Localized<(label: string, current: string) => string> = {
es: (l, c) => `${l} está en ${c} ahora mismo.`,
it: (l, c) => `${l} è a ${c} in questo momento.`,
pl: (l, c) => `${l} wynosi obecnie ${c}.`,
nl: (l, c) => `${l} staat nu op ${c}.`,
};
const CLOSER_IN_RANGE: Localized<string> = {
@@ -218,6 +226,7 @@ const CLOSER_IN_RANGE: Localized<string> = {
es: "Eso queda dentro de tu rango habitual — nada que pida una reacción ahora.",
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.",
};
const CLOSER_UNKNOWN_SWING: Localized<string> = {
@@ -227,6 +236,7 @@ const CLOSER_UNKNOWN_SWING: Localized<string> = {
es: "Unas pocas mediciones más harán fiable la tendencia — obsérvala durante los próximos días.",
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.",
};
/**
@@ -300,6 +310,7 @@ const BLOOD_PRESSURE_COPY: FallbackCopy = {
es: "tu presión arterial",
it: "la tua pressione sanguigna",
pl: "twoje ciśnienie krwi",
nl: "je bloeddruk",
},
unit: "",
digits: 0,
@@ -310,6 +321,7 @@ const BLOOD_PRESSURE_COPY: FallbackCopy = {
es: "Unas cuantas mediciones en calma y en las mismas condiciones mostrarán si es la tendencia o un valor aislado.",
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.",
},
};
@@ -321,6 +333,7 @@ const WEIGHT_COPY: FallbackCopy = {
es: "tu peso",
it: "il tuo peso",
pl: "twoja waga",
nl: "je gewicht",
},
unit: "",
digits: 1,
@@ -331,6 +344,7 @@ const WEIGHT_COPY: FallbackCopy = {
es: "Pésate a la misma hora durante unos días y la fluctuación diaria normal se compensa sola.",
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.",
},
};
@@ -342,6 +356,7 @@ const PULSE_COPY: FallbackCopy = {
es: "tu pulso en reposo",
it: "il tuo battito a riposo",
pl: "twoje tętno spoczynkowe",
nl: "je rusthartslag",
},
unit: " bpm",
digits: 0,
@@ -352,6 +367,7 @@ const PULSE_COPY: FallbackCopy = {
es: "Tómatelo relajado y a la misma hora del día para ver si la dirección se mantiene.",
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.",
},
};
@@ -363,6 +379,7 @@ const BMI_COPY: FallbackCopy = {
es: "tu IMC",
it: "il tuo IMC",
pl: "twoje BMI",
nl: "je BMI",
},
unit: "",
digits: 1,
@@ -373,6 +390,7 @@ const BMI_COPY: FallbackCopy = {
es: "Léelo junto con la tendencia de tu peso y tu grasa corporal a lo largo de unas semanas, no día a día.",
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.",
},
};
@@ -384,6 +402,7 @@ const MOOD_COPY: FallbackCopy = {
es: "tu estado de ánimo",
it: "il tuo umore",
pl: "twój nastrój",
nl: "je stemming",
},
unit: "",
digits: 1,
@@ -394,6 +413,7 @@ const MOOD_COPY: FallbackCopy = {
es: "Regístralo unos días — un patrón que se repite dice más que un día suelto.",
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.",
},
};
@@ -405,6 +425,7 @@ const ADHERENCE_COPY: FallbackCopy = {
es: "tu adherencia a la medicación",
it: "la tua aderenza alla terapia",
pl: "twoja regularność przyjmowania leków",
nl: "je medicijntrouw",
},
unit: "%",
digits: 0,
@@ -415,6 +436,7 @@ const ADHERENCE_COPY: FallbackCopy = {
es: "Una hora fija o un recordatorio breve es lo que mejor recupera los olvidos repetidos.",
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.",
},
};
@@ -426,6 +448,7 @@ const GENERIC_METRIC_POINTER: Localized<string> = {
es: "Unas cuantas mediciones constantes mostrarán si es la dirección o un solo día.",
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.",
};
// ── public fallbacks ────────────────────────────────────────────────────────
@@ -485,6 +508,7 @@ const NO_READ_LEAD: Localized<string> = {
es: "Ahora mismo no hay ninguna valoración disponible.",
it: "Al momento non è disponibile alcuna valutazione.",
pl: "Na razie nie ma tu żadnej oceny.",
nl: "Voor deze kaart is er nu geen beoordeling.",
ko: "지금은 이 카드에 대한 평가가 없어요.",
};
@@ -506,6 +530,7 @@ export function getNoKeyGeneralStatusText(locale: InsightLocale): string {
es: "Estos números se vuelven legibles a lo largo de varias semanas y no en días sueltos, y tomados a horas constantes para que sigan siendo comparables. La señal que cuenta son varios indicadores derivando a la vez en la misma dirección desfavorable, no un valor en un día.",
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.",
});
}
@@ -527,6 +552,7 @@ const BIOMARKER_NAMED_LEAD: Localized<(named: string) => string> = {
es: (n) => `Ahora mismo no hay ninguna valoración${n}.`,
it: (n) => `Al momento non è disponibile alcuna valutazione${n}.`,
pl: (n) => `Na razie nie ma żadnej oceny${n}.`,
nl: (n) => `Er is nu geen beoordeling${n}.`,
ko: (n) => `지금은${n} 평가가 없어요.`,
};
@@ -537,6 +563,7 @@ const BIOMARKER_NAMED_CLAUSE: Localized<(marker: string) => string> = {
es: (m) => ` de «${m}»`,
it: (m) => ` per «${m}»`,
pl: (m) => ` dla „${m}”`,
nl: (m) => ` voor "${m}"`,
ko: (m) => ` '${m}'에 대한`,
};
@@ -555,6 +582,7 @@ export function getNoKeyBiomarkerStatusText(
es: "Un valor de laboratorio se lee primero frente a tus propias extracciones anteriores — un intervalo de referencia es un ancla aproximada, no un veredicto. Uno que apenas se ha movido a lo largo de varias extracciones cuenta algo distinto de uno que acaba de dar un salto. Tu próxima revisión médica es el lugar adecuado para ponerlo en contexto.",
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.",
})}`;
}
@@ -571,6 +599,7 @@ export function getNoKeyBloodPressureStatusText(
es: "La tensión se lee a lo largo de una serie de mediciones en calma y en condiciones comparables — la dirección a lo largo de varios días dice mucho más que cualquier valor aislado, y la sistólica y la diastólica se leen juntas y no una por una.",
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.",
})
);
}
@@ -588,6 +617,7 @@ export function getNoKeyWeightStatusText(
es: "El peso se lee como una tendencia y no como una cifra diaria — pesado en condiciones constantes, la fluctuación normal del día a día se compensa sola. Lo que sostiene es la dirección a lo largo de semanas, leída junto a la tensión y el IMC.",
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.",
})
);
}
@@ -605,6 +635,7 @@ export function getNoKeyPulseStatusText(
es: "El pulso en reposo se lee mejor tomado relajado y a la misma hora del día. Los picos breves son corrientes; lo que destaca es un patrón que se mantiene varios días, o una deriva repetida fuera de tu rango habitual.",
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.",
})
);
}
@@ -622,6 +653,7 @@ export function getNoKeyBmiStatusText(
es: "El IMC es una orientación aproximada más que un veredicto, y cobra sentido leído junto a la tendencia de tu peso y tu composición corporal. Un movimiento sostenido a lo largo de semanas sostiene donde un valor aislado no lo hace.",
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.",
})
);
}
@@ -639,6 +671,7 @@ export function getNoKeyMedicationComplianceStatusText(
es: "La adherencia se lee como constancia a lo largo de semanas y no como una racha de días perfectos — por medicamento y en el conjunto. Los olvidos repetidos a la misma hora son el patrón que cuenta, y lo que suele recuperarlos es una rutina fija.",
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.",
})
);
}
@@ -656,6 +689,7 @@ export function getNoKeyMoodStatusText(
es: "El estado de ánimo se lee a lo largo de semanas y no en días sueltos — lo que sostiene son los patrones que se repiten y cómo encajan con tus otros valores. La que merece atención es una fase baja que se prolonga.",
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.",
})
);
}
@@ -241,7 +241,7 @@ describe("enqueueSummaryCatchUp", () => {
});
describe("the toggle copy discloses the catch-up", () => {
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"] as const;
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko", "nl"] as const;
function autoReadCopy(locale: string): { subLabel: string; honesty: string } {
const bundle = JSON.parse(
@@ -227,7 +227,16 @@ describe("enqueueStatusGeneration", () => {
const send = vi.fn().mockResolvedValue("job-id");
getGlobalBoss.mockReturnValue({ send });
for (const locale of ["de", "en", "fr", "es", "it", "pl", "ko"] as const) {
for (const locale of [
"de",
"en",
"fr",
"es",
"it",
"pl",
"ko",
"nl",
] 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"] as const)(
it.each(["es", "fr", "it", "pl", "ko", "nl"] as const)(
"returns native (non-EN) strings for locale=%s",
(locale) => {
const p = buildMoodReminderPayload(locale);
+30
View File
@@ -259,6 +259,36 @@ export const RANGE_PROSE: Record<Locale, RangeProse> = {
"wartosci referencyjne",
"normy laboratoryjne",
],
},
nl: {
twoSided: ["tot"],
upper: [
"tot",
"onder",
"kleiner dan",
"minder dan",
"hoogstens",
"maximaal",
"max.",
"max",
],
lower: [
"vanaf",
"boven",
"groter dan",
"meer dan",
"minstens",
"ten minste",
"minimaal",
],
filler: [
"van",
"tussen",
"referentiewaarden",
"referentiewaarde",
"normaalwaarden",
"normaalwaarde",
],
}, // Korean marks a bound with a POSTPOSITION — "5.0 이하", "3.5 이상" — and the
// matchers here are prefix-anchored, so only the prefix-shaped notation is
// listed. A postfixed Korean range still falls through to text-only, which
@@ -80,7 +80,7 @@ describe("AUDIT-C instrument", () => {
});
it("carries a standard-drink sentence for every app language", () => {
for (const locale of ["de", "en", "es", "fr", "it", "pl", "ko"]) {
for (const locale of ["de", "en", "es", "fr", "it", "pl", "ko", "nl"]) {
expect(AUDIT_C_STANDARD_DRINK[locale]?.length).toBeGreaterThan(20);
}
});
@@ -200,6 +200,10 @@ describe("mental-health instruments", () => {
expect(hasValidatedItems("WHO5", locale)).toBe(true);
expect(hasValidatedItems("PHQ9", locale)).toBe(true);
}
// The Dutch wording has not been read against the published forms yet.
expect(hasValidatedItems("PHQ9", "nl")).toBe(false);
expect(hasValidatedItems("GAD7", "nl")).toBe(false);
expect(hasValidatedItems("WHO5", "nl")).toBe(false);
expect(hasValidatedItems("SCI", "en")).toBe(true);
expect(hasValidatedItems("SCI", "en-GB")).toBe(true);
expect(hasValidatedItems("SCI", "de")).toBe(false);
+1 -1
View File
@@ -111,7 +111,7 @@ export function auditCTotal(items: readonly number[]): number {
}
/** The app languages; anything else is read as English. */
const APP_LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"] as const;
const APP_LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko", "nl"] as const;
/** Normalise a requested or stored locale onto the app languages. */
export function normaliseAuditCLocale(
+8
View File
@@ -99,6 +99,13 @@ export interface InstrumentDefinition {
attribution: string;
}
/**
* 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.
*/
const ALL_APP_LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"] as const;
/**
@@ -559,5 +566,6 @@ export const AUDIT_C_STANDARD_DRINK: Readonly<Record<string, string>> = {
fr: "Un verre standard contient 10 g d'alcool pur, par exemple 25 cl de bière à 5°, 10 cl de vin à 12° ou 3 cl d'alcool à 40°.",
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.",
ko: "표준 1잔: 양주잔으로 양주 1잔, 와인잔으로 와인 1잔, 막걸리 1사발이 각각 표준 1잔이다. 맥주는 캔맥주 1캔 또는 작은 병맥주 1병이며 생맥주는 500 mL가 표준 1잔이다. 소주는 1/4병이 표준 1잔에 해당한다.",
};
@@ -73,10 +73,10 @@ const VALUE_GROUPS: Array<{ group: string; keys: readonly string[] }> = [
];
describe("mood context vocabulary labels", () => {
it("reads seven locales and a non-empty vocabulary", () => {
it("reads eight locales and a non-empty vocabulary", () => {
// Both ends have to be there before anything below proves anything. A
// guard that walks an empty list is a green light nobody earned.
expect(LOCALES.length).toBe(7);
expect(LOCALES.length).toBe(8);
expect(VALUE_GROUPS.length).toBeGreaterThan(0);
const total = VALUE_GROUPS.reduce((n, g) => n + g.keys.length, 0);
expect(total).toBeGreaterThan(30);
+2 -1
View File
@@ -80,7 +80,8 @@ function isLocale(value: string | null | undefined): value is Locale {
value === "es" ||
value === "it" ||
value === "pl" ||
value === "ko"
value === "ko" ||
value === "nl"
);
}
@@ -186,8 +186,8 @@ describe("connect-source state copy", () => {
) as Record<string, never>,
}));
it("covers all seven locales", () => {
expect(LOCALES.length).toBe(7);
it("covers all eight locales", () => {
expect(LOCALES.length).toBe(8);
});
it.each(LOCALES)(
@@ -1064,7 +1064,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"])
.enum(["en", "de", "es", "fr", "it", "pl", "ko", "nl"])
.optional()
.describe("Optional UI locale hint for the model."),
today: z
+1 -1
View File
@@ -126,7 +126,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"]).nullable(),
locale: z.enum(["de", "en", "es", "fr", "it", "pl", "ko", "nl"]).nullable(),
timezone: z.string(),
unitPreference: z.enum(["metric", "imperial"]),
timeFormat: z.enum(["AUTO", "H12", "H24"]),
+1
View File
@@ -15,6 +15,7 @@ const NAMES: Readonly<Record<string, Readonly<Record<SourceCountry, string>>>> =
fr: { DE: "Allemagne", KR: "Corée du Sud", US: "États-Unis" },
it: { DE: "Germania", KR: "Corea del Sud", US: "Stati Uniti" },
pl: { DE: "Niemcy", KR: "Korea Południowa", US: "Stany Zjednoczone" },
nl: { DE: "Duitsland", KR: "Zuid-Korea", US: "Verenigde Staten" },
ko: { DE: "독일", KR: "대한민국", US: "미국" },
};
+1 -1
View File
@@ -60,7 +60,7 @@ const profilePatchSchema = z
dateOfBirth: z.string().date().nullable().optional(),
gender: z.enum(["MALE", "FEMALE", "OTHER"]).nullable().optional(),
locale: z
.enum(["de", "en", "es", "fr", "it", "pl", "ko"])
.enum(["de", "en", "es", "fr", "it", "pl", "ko", "nl"])
.nullable()
.optional(),
timezone: timezoneSchema.optional(),
+1 -1
View File
@@ -32,7 +32,7 @@ const managedProfileGenderSchema = z.enum(["MALE", "FEMALE", "OTHER"]);
const managedProfileFields = {
displayName: z.string().trim().min(1).max(80),
dateOfBirth: z.iso.date().nullable(),
locale: z.enum(["de", "en", "es", "fr", "it", "pl", "ko"]),
locale: z.enum(["de", "en", "es", "fr", "it", "pl", "ko", "nl"]),
timezone: z
.string()
.min(1)
+2
View File
@@ -20,6 +20,7 @@ import frMessages from "./messages/fr.json";
import itMessages from "./messages/it.json";
import plMessages from "./messages/pl.json";
import koMessages from "./messages/ko.json";
import nlMessages from "./messages/nl.json";
primeMessages("de", deMessages);
primeMessages("en", enMessages);
@@ -28,6 +29,7 @@ primeMessages("fr", frMessages);
primeMessages("it", itMessages);
primeMessages("pl", plMessages);
primeMessages("ko", koMessages);
primeMessages("nl", nlMessages);
/**
* Leave no background work in flight when a test ends.