mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
Merge pull request #849 from MBombeck/feat/korean-locale
feat: Korean locale (#839, @aucun6352) — the seventh interface language
This commit is contained in:
78 files changed
+10886
-71
No files matched your search
@@ -1,5 +1,19 @@
|
||||
# Changelog
|
||||
|
||||
## [1.37.32] — 2026-08-28
|
||||
|
||||
Korean joins as a seventh interface language, contributed by @aucun6352, and the three things that had to be fixed before it could actually work.
|
||||
|
||||
### Added
|
||||
|
||||
- Korean (`ko`) ships as a seventh interface language, AI-initial and community-maintained alongside French, Spanish, Italian and Polish. The whole surface speaks it: dashboard, settings, share views, the clinician report, notifications and the AI prompts. The PHQ-9, GAD-7 and WHO-5 questionnaires carry their officially published Korean wording so a score stays comparable to the Korean literature; the sleep questionnaire stays in its English-validated form with an honest note. The Coach and the daily briefing take the reviewed English body with a Korean reply directive rather than a hand-curated Korean one, the same route the other community locales took.
|
||||
|
||||
### Fixed
|
||||
|
||||
- A matcher that reads app text against text a person or a lab typed matched nothing at all in Korean. The shared fold decomposes with NFD and strips the combining marks, which turns Latin accents into plain letters, but Hangul decomposes into jamo, and jamo are not combining marks, so they survived and the folded text no longer resembled any table the app carries. Both readers of that fold were affected: the normative-claim check that grades whether a recommendation needs a citation, and the parser that reads a printed reference range. The fold recomposes now, and every Latin, Polish and German fold is byte-identical to before.
|
||||
- No Korean analyte name could resolve to a LOINC code. The key normaliser kept `[a-z0-9]` and dropped everything else, so every Korean lab name in the catalogue normalised to the empty string and the derived index held none of them, and a Korean account's cholesterol result reached a FHIR export uncoded.
|
||||
- The reference-range parser now reads the tilde a Korean lab prints (`3.5~5.0`), in both the ASCII and fullwidth forms. It had the hyphen and the dashes and not the separator the reports actually use.
|
||||
|
||||
## [1.37.31] — 2026-08-27
|
||||
|
||||
The retry ledger knew when an AI provider was failing; the operator had no way to see it.
|
||||
|
||||
+1
-1
@@ -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`), and Polish (`pl`) 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`), 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.
|
||||
|
||||
### Improving an existing locale
|
||||
|
||||
|
||||
@@ -44,7 +44,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 nothing leaves your network.
|
||||
- **Clinician-ready output.** A doctor-report PDF generated client-side, 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 with zero-downtime key rotation, 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, and Polish 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, and Korean end to end.
|
||||
|
||||
The full feature tour, integration guides, and API reference live at [docs.healthlog.dev](https://docs.healthlog.dev).
|
||||
|
||||
|
||||
+2
-2
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch — the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands — here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged.",
|
||||
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch \u2014 the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands \u2014 here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged. Raised to 3700 at v1.37.32 for the seventh interface language (Korean): the lazy message catalogs went 6->7 chunks (1026 KB gz measured), pushing the aggregate to 3631 against the old 3500. The raise is entirely the new catalog; no route budget moved, and the four eager route ceilings are unchanged and still passing. Most of the aggregate is the dynamic locale-switch and fallback path that a first paint never loads (see issue #253), so this ceiling guards bytes almost no visitor requests; 3700 leaves ~69 KB for ordinary drift until that double-count is cleaned up.",
|
||||
"routesKbGz": {
|
||||
"/page": 460,
|
||||
"/insights/page": 445,
|
||||
"/measurements/page": 445,
|
||||
"/insights/mood/page": 460
|
||||
},
|
||||
"totalClientKbGz": 3500,
|
||||
"totalClientKbGz": 3700,
|
||||
"maxRechartsChunks": 1
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.37.31
|
||||
version: 1.37.32
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
@@ -20248,6 +20248,7 @@ components:
|
||||
- es
|
||||
- it
|
||||
- pl
|
||||
- ko
|
||||
practiceName:
|
||||
type: string
|
||||
maxLength: 120
|
||||
@@ -22343,6 +22344,7 @@ components:
|
||||
- fr
|
||||
- it
|
||||
- pl
|
||||
- ko
|
||||
today:
|
||||
description: "Optional override of the reference date used to resolve relative phrases (\"tomorrow\", \"next Monday\").
|
||||
Format: `YYYY-MM-DD`. Defaults to the server's UTC day."
|
||||
@@ -23234,6 +23236,7 @@ components:
|
||||
- es
|
||||
- it
|
||||
- pl
|
||||
- ko
|
||||
description: One of the interface locales this build ships.
|
||||
required:
|
||||
- locale
|
||||
@@ -27714,6 +27717,7 @@ components:
|
||||
- fr
|
||||
- it
|
||||
- pl
|
||||
- ko
|
||||
timezone:
|
||||
type: string
|
||||
minLength: 1
|
||||
@@ -27929,6 +27933,7 @@ components:
|
||||
- fr
|
||||
- it
|
||||
- pl
|
||||
- ko
|
||||
- type: "null"
|
||||
timezone:
|
||||
type: string
|
||||
@@ -46021,6 +46026,7 @@ components:
|
||||
- es
|
||||
- it
|
||||
- pl
|
||||
- ko
|
||||
required:
|
||||
- locale
|
||||
additionalProperties: false
|
||||
@@ -66553,6 +66559,7 @@ components:
|
||||
- fr
|
||||
- it
|
||||
- pl
|
||||
- ko
|
||||
- type: "null"
|
||||
timezone:
|
||||
type: string
|
||||
|
||||
+10545
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.37.31",
|
||||
"version": "1.37.32",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ try {
|
||||
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
|
||||
const CACHE_VERSION =
|
||||
(typeof self !== "undefined" && self.__APP_VERSION__) ||
|
||||
/* @sw-version-fallback */ "v1.37.31";
|
||||
/* @sw-version-fallback */ "v1.37.32";
|
||||
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
|
||||
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
|
||||
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
|
||||
|
||||
@@ -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"];
|
||||
const LOCALES = ["de", "en", "fr", "es", "it", "pl", "ko"];
|
||||
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
|
||||
const LOCALES = ["de", "en", "es", "fr", "it", "pl"];
|
||||
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"];
|
||||
const file = (loc) => `messages/${loc}.json`;
|
||||
|
||||
function read(loc) {
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
|
||||
const LOCALES = ["de", "en", "es", "fr", "it", "pl"];
|
||||
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"];
|
||||
const BOUNDARY = /(?<=[.!?])\s+(?=[A-ZÄÖÜÀÂÉÈÊÎÔÙÛÇŁŚŹŻĄĆĘŃÓ0-9„"„«])/u;
|
||||
|
||||
/**
|
||||
|
||||
@@ -40,7 +40,7 @@ import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const MESSAGES = join(__dirname, "../../messages");
|
||||
const LOCALES = ["de", "en", "es", "fr", "it", "pl"] as const;
|
||||
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"] as const;
|
||||
|
||||
function bundle(locale: string): Record<string, unknown> {
|
||||
return JSON.parse(readFileSync(join(MESSAGES, `${locale}.json`), "utf8"));
|
||||
@@ -78,6 +78,7 @@ const FINALITY_PHRASES: Record<string, readonly string[]> = {
|
||||
"permanente",
|
||||
],
|
||||
pl: ["nie można cofnąć", "nie można tego cofnąć", "trwale", "nieodwracaln"],
|
||||
ko: ["되돌릴 수 없", "복구할 수 없", "영구", "완전히 삭제"],
|
||||
};
|
||||
|
||||
/** Per-locale phrase that promises the short undo window. */
|
||||
@@ -88,6 +89,7 @@ const UNDO_PHRASES: Record<string, string> = {
|
||||
fr: "annuler cette action",
|
||||
it: "annullare",
|
||||
pl: "cofnąć",
|
||||
ko: "되돌릴 수 있",
|
||||
};
|
||||
|
||||
/** Confirmation bodies whose surface renders an Undo affordance. */
|
||||
@@ -171,6 +173,7 @@ describe("sleep debt is labelled as a balance, not a summed deficit", () => {
|
||||
fr: ["cumulé", "cumulée", "déficit"],
|
||||
it: ["cumulato", "cumulata", "accumulato", "deficit"],
|
||||
pl: ["skumulowan", "niedobór", "niedoboru"],
|
||||
ko: ["누적", "부족분", "적자", "쌓인"],
|
||||
};
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
@@ -296,6 +299,7 @@ describe("the mood prognosis never states a cause", () => {
|
||||
"peggiora il tuo",
|
||||
],
|
||||
pl: ["ponieważ", "powoduje", "z powodu", "wywołuje", "poprawia twój"],
|
||||
ko: ["때문", "탓", "원인", "유발", "초래", "야기", "덕분", "인해"],
|
||||
};
|
||||
|
||||
/** Every leaf string under `insights.mood.prognosis`, flattened. */
|
||||
@@ -394,6 +398,7 @@ describe("the MCP token copy never claims the mint is read-only", () => {
|
||||
fr: ["jeton en lecture seule"],
|
||||
it: ["token in sola lettura"],
|
||||
pl: ["token tylko do odczytu"],
|
||||
ko: ["읽기 전용 토큰", "읽기전용 토큰"],
|
||||
};
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
|
||||
@@ -166,13 +166,14 @@ describe("dynamic-key exhaustiveness (enum-derived)", () => {
|
||||
expect(LOCALES.length).toBeGreaterThanOrEqual(6);
|
||||
});
|
||||
|
||||
it("discovers all six shipped locales", () => {
|
||||
it("discovers all seven shipped locales", () => {
|
||||
expect(LOCALES.map((l) => l.locale).sort()).toEqual([
|
||||
"de",
|
||||
"en",
|
||||
"es",
|
||||
"fr",
|
||||
"it",
|
||||
"ko",
|
||||
"pl",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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"] as const;
|
||||
const LEAK_LOCALES = ["es", "fr", "it", "pl", "ko"] as const;
|
||||
|
||||
function flattenValues(
|
||||
obj: unknown,
|
||||
@@ -193,6 +193,14 @@ const LEGIT_IDENTICAL = new Set<string>([
|
||||
"mentalHealth.sciOptions.problemDuration.2",
|
||||
"mentalHealth.sciOptions.problemDuration.1",
|
||||
"mentalHealth.sciOptions.problemDuration.0",
|
||||
// Proper nouns, a URL label and an acronym — the accepted written form in
|
||||
// Korean is the Latin original, so these stay identical to en.json.
|
||||
"auth.emailPlaceholder",
|
||||
"settings.fitbitTag",
|
||||
"settings.googleHealthTag",
|
||||
"settings.nightscoutUrl",
|
||||
"admin.aiServerKey.baseUrlLabel",
|
||||
"admin.webPushVapidSubject",
|
||||
]);
|
||||
|
||||
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"] as const;
|
||||
const LOCALES = ["en", "de", "es", "fr", "it", "pl", "ko"] 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"] as const;
|
||||
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"] 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. */
|
||||
|
||||
@@ -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"] as const;
|
||||
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"] as const;
|
||||
|
||||
/** Every path the retired bridge occupied. */
|
||||
const REMOVED_PATHS = [
|
||||
|
||||
@@ -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"].map((locale) =>
|
||||
...["de", "en", "es", "fr", "it", "pl", "ko"].map((locale) =>
|
||||
readFile(fromRoot(`messages/${locale}.json`), "utf8"),
|
||||
),
|
||||
]);
|
||||
|
||||
@@ -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"] as const;
|
||||
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"] as const;
|
||||
|
||||
/** The published description of the level, off the Zod schema itself. */
|
||||
function accessDescription(): string {
|
||||
@@ -109,10 +109,14 @@ describe("the published boundary matches the one the code draws", () => {
|
||||
// accent: the section is "Anamnèse" in French and "Anamneza" in Polish,
|
||||
// so anything past "anamn" is already language-specific and a matcher
|
||||
// tuned to one of them reports the others as broken.
|
||||
if (!/anamn/i.test(text)) missing.push(locale);
|
||||
// Korean shares no Latin stem for either concept, so it is matched on
|
||||
// its own words: "문진" is the anamnesis section, "계정" the account.
|
||||
if (!/anamn|문진/i.test(text)) missing.push(locale);
|
||||
// And it must still say the account itself stays with its owner —
|
||||
// dropping that would fix one over-claim by removing the boundary.
|
||||
expect(text, locale).toMatch(/konto|account|compte|cuenta|konta|koncie/i);
|
||||
expect(text, locale).toMatch(
|
||||
/konto|account|compte|cuenta|konta|koncie|계정/i,
|
||||
);
|
||||
}
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -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"] as const;
|
||||
const LOCALES = ["en", "de", "es", "fr", "it", "pl", "ko"] as const;
|
||||
|
||||
const REQUIRED_KEYS = [
|
||||
// Security: adding a passkey now requires an existing sign-in factor.
|
||||
|
||||
@@ -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"] as const;
|
||||
const LOCALES = ["en", "de", "fr", "es", "it", "pl", "ko"] as const;
|
||||
|
||||
interface Messages {
|
||||
notFound: {
|
||||
|
||||
@@ -69,7 +69,8 @@ function isLocale(value: string | null | undefined): value is Locale {
|
||||
value === "fr" ||
|
||||
value === "es" ||
|
||||
value === "it" ||
|
||||
value === "pl"
|
||||
value === "pl" ||
|
||||
value === "ko"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ const MAX_OUTPUT_TOKENS = 600;
|
||||
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"]).optional(),
|
||||
locale: z.enum(["en", "de", "es", "fr", "it", "pl", "ko"]).optional(),
|
||||
/**
|
||||
* Optional override of the reference date used to resolve relative
|
||||
* phrases ("tomorrow", "next Monday"). Defaults to the server's
|
||||
|
||||
@@ -32,7 +32,8 @@ function isLocale(value: string | null | undefined): value is Locale {
|
||||
value === "fr" ||
|
||||
value === "es" ||
|
||||
value === "it" ||
|
||||
value === "pl"
|
||||
value === "pl" ||
|
||||
value === "ko"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@ function isLocale(value: string | null | undefined): value is Locale {
|
||||
value === "fr" ||
|
||||
value === "es" ||
|
||||
value === "it" ||
|
||||
value === "pl"
|
||||
value === "pl" ||
|
||||
value === "ko"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"] as const;
|
||||
const locales = ["en", "de", "fr", "es", "it", "pl", "ko"] 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 six shipped locales", () => {
|
||||
it("covers all seven shipped locales", () => {
|
||||
// A floor: an empty locale list would make the loop below assert nothing.
|
||||
expect(locales.length).toBe(6);
|
||||
expect(locales.length).toBe(7);
|
||||
});
|
||||
|
||||
for (const locale of locales) {
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
* but the prose has not been hand-reviewed for register, regional
|
||||
* vocabulary, or domain-specific nuance.
|
||||
*
|
||||
* This banner renders ONLY on non-maintained locales (FR/ES/IT/PL
|
||||
* This banner renders ONLY on non-maintained locales (FR/ES/IT/PL/KO
|
||||
* today) and surfaces a small, dismissible strip at the top of the
|
||||
* authenticated app shell. It tells the user the translation is
|
||||
* AI-maintained and links to the GitHub translation-feedback issue
|
||||
|
||||
@@ -66,7 +66,7 @@ describe("<OfflineBanner>", () => {
|
||||
});
|
||||
|
||||
it("ships the message key in every supported locale", () => {
|
||||
for (const locale of ["en", "de", "fr", "es", "it", "pl"] as const) {
|
||||
for (const locale of ["en", "de", "fr", "es", "it", "pl", "ko"] as const) {
|
||||
const m = loadMessages(locale);
|
||||
expect(m.offlineBanner.message.length, locale).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
@@ -41,13 +41,14 @@ const REQUIRED_KEYS = [
|
||||
];
|
||||
|
||||
describe("measurement-reminder + bulk-share dynamic i18n keys", () => {
|
||||
it("discovers all six shipped locales", () => {
|
||||
it("discovers all seven shipped locales", () => {
|
||||
expect(LOCALES.map((l) => l.locale).sort()).toEqual([
|
||||
"de",
|
||||
"en",
|
||||
"es",
|
||||
"fr",
|
||||
"it",
|
||||
"ko",
|
||||
"pl",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -71,7 +71,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";
|
||||
locale?: "en" | "de" | "es" | "fr" | "it" | "pl" | "ko";
|
||||
/**
|
||||
* 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"] as const)(
|
||||
it.each(["en", "de", "es", "fr", "it", "pl", "ko"] as const)(
|
||||
"renders localized estimate disclosure for %s",
|
||||
(locale) => {
|
||||
const html = render(<AppleHealthEstimateWarning days={3} />, locale);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { ALL_METRICS } from "@/lib/validations/thresholds";
|
||||
|
||||
import { ManagedRecordSettingsForm } from "../managed-record-settings-section";
|
||||
|
||||
const LOCALES = ["de", "en", "es", "fr", "it", "pl"] as const;
|
||||
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"] as const;
|
||||
|
||||
const render = (
|
||||
family: Parameters<typeof ManagedRecordSettingsForm>[0]["family"],
|
||||
|
||||
@@ -276,7 +276,7 @@ describe("the managed-profile card, in the shared-access section", () => {
|
||||
/<select[^>]*data-slot="managed-profile-locale"[\s\S]*?<\/select>/
|
||||
.exec(html)?.[0]
|
||||
.match(/<option/g) ?? [];
|
||||
expect(options).toHaveLength(6);
|
||||
expect(options).toHaveLength(7);
|
||||
});
|
||||
|
||||
it("lists the profiles this account looks after, and only those", () => {
|
||||
|
||||
@@ -218,7 +218,7 @@ describe("<AppleHealthCard>", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["en", "de", "es", "fr", "it", "pl"] as const)(
|
||||
it.each(["en", "de", "es", "fr", "it", "pl", "ko"] as const)(
|
||||
"renders localized Apple Health copy for %s without leaking keys",
|
||||
(locale) => {
|
||||
statusPayload = {
|
||||
|
||||
@@ -400,7 +400,7 @@ function ProfileSettingsForm({
|
||||
<option value="">
|
||||
{t("settings.sharedRecord.managedSettings.profile.systemDefault")}
|
||||
</option>
|
||||
{["de", "en", "es", "fr", "it", "pl"].map((locale) => (
|
||||
{["de", "en", "es", "fr", "it", "pl", "ko"].map((locale) => (
|
||||
<option key={locale} value={locale}>
|
||||
{locale}
|
||||
</option>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import * as React from "react";
|
||||
import { CalendarIcon } from "lucide-react";
|
||||
import type { Matcher } from "react-day-picker";
|
||||
import { de, enUS, es, fr, it, pl } from "date-fns/locale";
|
||||
import { de, enUS, es, fr, it, ko, pl } from "date-fns/locale";
|
||||
import type { Locale as DateFnsLocale } from "date-fns";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -325,6 +325,8 @@ function resolveDateFnsLocale(
|
||||
return it;
|
||||
case "pl":
|
||||
return pl;
|
||||
case "ko":
|
||||
return ko;
|
||||
default:
|
||||
return enUS;
|
||||
}
|
||||
|
||||
@@ -170,7 +170,7 @@ describe("offline-geo runtime detection (v1.4.27 R5)", () => {
|
||||
// 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"] as const;
|
||||
const locales = ["de", "en", "es", "fr", "it", "pl", "ko"] 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.",
|
||||
ko: "수축기 혈압은 목표인 130 mmHg 아래로 유지해야 해요.",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -266,6 +267,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.",
|
||||
ko: "7일 평균이 90일 중앙값보다 4 mmHg 더 커요.",
|
||||
};
|
||||
|
||||
it.each(locales)("carries a non-empty keyword bank for %s", (locale) => {
|
||||
|
||||
@@ -141,6 +141,17 @@ const NORMATIVE_KEYWORDS: Record<Locale, readonly string[]> = {
|
||||
"powyzej",
|
||||
"ponizej",
|
||||
],
|
||||
ko: [
|
||||
"목표",
|
||||
"권장",
|
||||
"정상 범위",
|
||||
"해야",
|
||||
"하세요",
|
||||
"이상",
|
||||
"이하",
|
||||
"초과",
|
||||
"미만",
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -20,11 +20,11 @@ import type { Locale } from "@/lib/i18n/config";
|
||||
* - grounding: the literal "SNAPSHOT" token, which the grounding rule's
|
||||
* `must_contain` pins in every locale.
|
||||
* - GLP-1 dose safety: the brand registry (Mounjaro / Ozempic), which is
|
||||
* identical across all six locales (an international registry, never
|
||||
* identical across all seven locales (an international registry, never
|
||||
* translated) and only ever appears inside the dose-safety contract.
|
||||
*/
|
||||
|
||||
const ALL_LOCALES: Locale[] = ["de", "en", "fr", "es", "it", "pl"];
|
||||
const ALL_LOCALES: Locale[] = ["de", "en", "fr", "es", "it", "pl", "ko"];
|
||||
|
||||
describe("resolved Coach system prompt — grounding + GLP-1 per locale", () => {
|
||||
for (const locale of ALL_LOCALES) {
|
||||
|
||||
@@ -867,6 +867,17 @@ 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,
|
||||
], // 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+.
|
||||
ko: [
|
||||
/정상\s*범위(?:는|가|를|로|의)/,
|
||||
/(?:참고|기준|권장)\s*(?:범위|치|값)/,
|
||||
/(?:건강한|대부분의)\s*(?:성인|사람들?|인구)/,
|
||||
/일반\s*인구(?:는|에서|의)/,
|
||||
/(?:지침|가이드라인|의사들?|전문가들?)(?:은|이|에서|에서는|에 따르면)?[^.?!]{0,25}(?:권(?:장|해)|권고|제안|조언)/,
|
||||
/(?:성인|사람들?|대부분의?\s*사람들?)(?:은|이|의)?\s*(?:보통|일반적으로|대개|평균적으로)\s*[^.?!]{0,20}(?:필요|권장|적당)/,
|
||||
/(?:보통|일반적으로|대개)\s*[^.?!]{0,15}(?:사이|정도|범위)(?:예요|에요|입니다|이에요)/,
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -1130,6 +1130,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.",
|
||||
ko: "\n\nREPLY LANGUAGE: respond in Korean. Use the 해요체 register throughout (never 합쇼체 or plain 한다체); use natural Korean health vocabulary.",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -1157,6 +1158,9 @@ 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 {
|
||||
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"],
|
||||
ko: ["농도", "혈중", "최고", "최저", "Cmax", "치료"],
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -39,6 +39,7 @@ const EXPECTED_LANGUAGE_NAME: Record<Locale, string> = {
|
||||
es: "Spanish",
|
||||
it: "Italian",
|
||||
pl: "Polish",
|
||||
ko: "Korean",
|
||||
};
|
||||
|
||||
/** Which body each locale is expected to compose. */
|
||||
@@ -49,6 +50,7 @@ const EXPECTED_BODY: Record<Locale, "de" | "en"> = {
|
||||
es: "en",
|
||||
it: "en",
|
||||
pl: "en",
|
||||
ko: "en",
|
||||
};
|
||||
|
||||
describe("output-language helper", () => {
|
||||
|
||||
@@ -982,6 +982,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.",
|
||||
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.",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -991,6 +992,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;
|
||||
}
|
||||
try {
|
||||
return buildNativeInsightsPrompt(locale, PROMPT_VERSION);
|
||||
} catch {
|
||||
|
||||
@@ -54,6 +54,8 @@ export function targetLanguageName(locale: Locale): string {
|
||||
return "Italian";
|
||||
case "pl":
|
||||
return "Polish";
|
||||
case "ko":
|
||||
return "Korean";
|
||||
default:
|
||||
return "English";
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
ko: "다음 주에 2.4mg으로 늘려 보세요.",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -37,6 +38,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.",
|
||||
ko: "3주째 7.5mg을 복용 중이고, 체중은 내려가고 있어요.",
|
||||
};
|
||||
|
||||
describe("screenModelOutput — dose-prescription, all six locales", () => {
|
||||
@@ -72,6 +74,7 @@ const RISK_VIOLATIONS: Record<Locale, string> = {
|
||||
es: "Su riesgo es del 12%.",
|
||||
it: "Il suo rischio è del 12%.",
|
||||
pl: "Pana ryzyko wynosi 12%.",
|
||||
ko: "10년 심혈관 위험도는 약 12%예요.",
|
||||
};
|
||||
|
||||
/** A plain percentage that is NOT a clinical risk claim must pass. */
|
||||
@@ -82,6 +85,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.",
|
||||
ko: "지난 30일 복약 순응도는 92%였어요.",
|
||||
};
|
||||
|
||||
describe("screenModelOutput — risk-score, all six locales", () => {
|
||||
@@ -324,6 +328,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.",
|
||||
ko: "수면 때문에 체중이 줄었어요.",
|
||||
};
|
||||
|
||||
/** Descriptive association framing is what the contract REQUIRES. */
|
||||
@@ -334,6 +339,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.",
|
||||
ko: "이번 주 체중은 수면과 함께 움직였어요.",
|
||||
};
|
||||
|
||||
describe("screenModelOutput — causal claims, all six locales", () => {
|
||||
|
||||
@@ -202,6 +202,35 @@ const DOSE_PATTERNS: Record<Locale, readonly RegExp[]> = {
|
||||
`\\b(?:nast[ęe]pna|kolejna|nowa)\\s+dawka\\b[^.?!]{0,30}\\b[\\d.,]+\\s*${DOSE_UNIT}\\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
|
||||
// banks make of "to" / "by" / "auf" — without it the permitted restatement
|
||||
// "7.5mg을 복용 중이고, 체중은 내려가고 있어요" matched the lowering pattern.
|
||||
ko: [
|
||||
new RegExp(
|
||||
`[\\d.,]+\\s*${DOSE_UNIT}\\s*(?:으로|로|까지)[^.?!]{0,20}(?:올리|올려|늘리|늘려|증량|높이|높여|인상)`,
|
||||
"i",
|
||||
),
|
||||
new RegExp(
|
||||
`[\\d.,]+\\s*${DOSE_UNIT}\\s*(?:으로|로|까지)[^.?!]{0,20}(?:내리|내려|줄이|줄여|감량|낮추|낮춰|감축)`,
|
||||
"i",
|
||||
),
|
||||
new RegExp(
|
||||
`(?:권해|권장|권고|추천|제안)[^.?!]{0,40}[\\d.,]+\\s*${DOSE_UNIT}\\b`,
|
||||
"i",
|
||||
),
|
||||
new RegExp(
|
||||
`[\\d.,]+\\s*${DOSE_UNIT}\\b[^.?!]{0,40}(?:권해|권장|권고|추천|제안|드심|드셔|복용하세요|시도해)`,
|
||||
"i",
|
||||
),
|
||||
new RegExp(
|
||||
`(?:다음|새)\\s*(?:용량|단계|복용량)[^.?!]{0,30}[\\d.,]+\\s*${DOSE_UNIT}\\b`,
|
||||
"i",
|
||||
),
|
||||
// Both a medication object AND a trial cue are required, as in the EN bank,
|
||||
// so "걸음 수를 두 배로 늘려 2주간 해보세요" never trips.
|
||||
/(?:약|알|정|인슐린|주사|복용|용량)[^.?!]{0,25}(?:절반|반으로|두\s?배|건너뛰|중단|끊)[^.?!]{0,40}(?:주간|주 동안|일 동안|달 동안|보세요|보십시오|보자|해 ?보)/,
|
||||
],
|
||||
};
|
||||
|
||||
@@ -301,6 +330,20 @@ 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ą))";
|
||||
|
||||
// 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
|
||||
// ("이", "삼") collide with common particles.
|
||||
const QUAL_NUM_KO = "(?:\\d{1,3}\\s*(?:%|퍼센트|프로))";
|
||||
const RISK_NOUN_KO = "(?:위험(?:도|률|성)?|확률|가능성)";
|
||||
const HORIZON_KO = "(?:10년|십\\s?년|평생)\\s*(?:위험(?:도|률)?|리스크)";
|
||||
const RESULT_VERB_KO =
|
||||
"(?:(?:에|으로|로)\\s*(?:분류|해당|속하|들어가)|군에\\s*(?:속하|해당))";
|
||||
const RISK_BAND_KO =
|
||||
"(?:고|중간|중등도|저|높은|낮은)\\s*위험\\s*(?:군|범위|등급|구간)";
|
||||
const RISK_LEVEL_KO = "(?:높|상승|증가|중등도|경계|우려)";
|
||||
const RISK_VERDICT_KO = `(?:은|는|이|가)\\s*[^.?!]{0,10}${RISK_LEVEL_KO}`;
|
||||
|
||||
const RISK_PATTERNS: Record<Locale, readonly RegExp[]> = {
|
||||
en: [
|
||||
// (1) digit percent adjacent to a risk noun, either order
|
||||
@@ -413,6 +456,26 @@ 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"),
|
||||
],
|
||||
ko: [
|
||||
new RegExp(`${RISK_NOUN_KO}[^.?!]{0,15}${QUAL_NUM_KO}`, "i"),
|
||||
new RegExp(`${QUAL_NUM_KO}[^.?!]{0,15}${RISK_NOUN_KO}`, "i"),
|
||||
new RegExp(`${HORIZON_KO}[^.?!]{0,40}${QUAL_NUM_KO}`, "i"),
|
||||
new RegExp(`${QUAL_NUM_KO}[^.?!]{0,40}${HORIZON_KO}`, "i"),
|
||||
new RegExp(`\\b${ENGINE}\\b[^.?!]{0,50}${QUAL_NUM_KO}`, "i"),
|
||||
new RegExp(`${QUAL_NUM_KO}[^.?!]{0,50}\\b${ENGINE}\\b`, "i"),
|
||||
new RegExp(
|
||||
`(?:\\b${ENGINE}\\b|${HORIZON_KO})[^.?!]{0,60}${RESULT_VERB_KO}`,
|
||||
"i",
|
||||
),
|
||||
new RegExp(
|
||||
`(?:\\b${ENGINE}\\b|${HORIZON_KO})[^.?!]{0,60}${RISK_BAND_KO}`,
|
||||
"i",
|
||||
),
|
||||
new RegExp(
|
||||
`(?:\\b${ENGINE}\\b|${HORIZON_KO})[^.?!]{0,40}${RISK_VERDICT_KO}`,
|
||||
"i",
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -492,6 +555,18 @@ const CAUSAL_PATTERNS: Record<Locale, readonly RegExp[]> = {
|
||||
/\bdzi[ęe]ki\b/i,
|
||||
/\bodpowiedzialn\w*\s+za\b/i,
|
||||
],
|
||||
ko: [
|
||||
/때문(?:에|이)/,
|
||||
/(?:으로|로)\s*인(?:해|하여|한)/,
|
||||
/탓(?:에|이|으로)/,
|
||||
/(?:이|가)\s*원인/,
|
||||
/원인(?:은|이에요|입니다|이라|으로)/,
|
||||
/유발/,
|
||||
/초래/,
|
||||
/야기/,
|
||||
/덕분(?:에|이)/,
|
||||
/(?:영향을|결과로)\s*(?:줘서|줬|나타났|이어졌)/,
|
||||
],
|
||||
};
|
||||
|
||||
const BANKS: Record<OutboundContract, Record<Locale, readonly RegExp[]>> = {
|
||||
@@ -542,6 +617,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,
|
||||
ko: /(?:그대로\s*(?:유지|복용)|계속\s*(?:복용|드시|유지)|처방(?:대로|받은\s*대로)|지시(?:대로|받은\s*대로)|유지하세요)/,
|
||||
};
|
||||
|
||||
const DOSE_CHANGE_STEM: Record<Locale, RegExp> = {
|
||||
@@ -551,6 +627,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,
|
||||
ko: /(?:올리|올려|늘리|늘려|증량|높이|높여|인상|내리|내려|줄이|줄여|감량|낮추|낮춰|감축|절반|반으로|두\s?배|건너뛰|중단|끊)/,
|
||||
};
|
||||
|
||||
/** Sentence-level split — the dose exemption must be scoped to one sentence. */
|
||||
|
||||
@@ -510,7 +510,7 @@ describe("the scale the popover quotes", () => {
|
||||
).toBeLessThan(100);
|
||||
});
|
||||
|
||||
it.each(["en", "de", "fr", "es", "it", "pl"])(
|
||||
it.each(["en", "de", "fr", "es", "it", "pl", "ko"])(
|
||||
"quotes those same two numbers in %s",
|
||||
(locale) => {
|
||||
const bundle = JSON.parse(
|
||||
|
||||
@@ -33,7 +33,8 @@ function isLocale(value: string | null | undefined): value is Locale {
|
||||
value === "fr" ||
|
||||
value === "es" ||
|
||||
value === "it" ||
|
||||
value === "pl"
|
||||
value === "pl" ||
|
||||
value === "ko"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ describe("runDocumentSummary — screened prose", () => {
|
||||
es: "Este informe sugiere aumentar su dosis a 2,4 mg la próxima semana.",
|
||||
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으로 늘려 보라고 권해요.",
|
||||
};
|
||||
|
||||
for (const locale of locales) {
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
} from "@/lib/feature-flags";
|
||||
|
||||
const ROOT = path.resolve(__dirname, "../../../..");
|
||||
const LOCALES = ["de", "en", "es", "fr", "it", "pl"] as const;
|
||||
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"] as const;
|
||||
|
||||
/** Names the retired switch went by, on the wire and in the database. */
|
||||
const RETIRED_NAMES = [
|
||||
|
||||
@@ -62,7 +62,10 @@ export interface LabLoincMapping {
|
||||
* resolved before resolves to the same key now.
|
||||
*/
|
||||
export function normaliseLabKey(s: string): string {
|
||||
return stripDiacritics(s.toLowerCase()).replace(/[^a-z0-9]/g, "");
|
||||
// Hangul syllables are kept alongside the ASCII range: stripping to
|
||||
// [a-z0-9] alone reduced every Korean analyte name to the empty string, so
|
||||
// `buildLocalisedAliases` indexed none of them and no Korean label resolved.
|
||||
return stripDiacritics(s.toLowerCase()).replace(/[^a-z0-9가-힣]/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -38,6 +38,7 @@ const INTL_LOCALE_MAP: Record<Locale, string> = {
|
||||
es: "es-ES",
|
||||
it: "it-IT",
|
||||
pl: "pl-PL",
|
||||
ko: "ko-KR",
|
||||
};
|
||||
|
||||
export function resolveIntlLocale(locale: Locale): string {
|
||||
@@ -310,5 +311,6 @@ export function parseLocaleFromAcceptLanguage(header: string | null): Locale {
|
||||
if (primary.startsWith("es")) return "es";
|
||||
if (primary.startsWith("it")) return "it";
|
||||
if (primary.startsWith("pl")) return "pl";
|
||||
if (primary.startsWith("ko")) return "ko";
|
||||
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"] as const;
|
||||
const NON_GERMAN_TRANSLATED = ["es", "fr", "it", "pl", "ko"] as const;
|
||||
|
||||
describe("plural tiers", () => {
|
||||
it("gives Polish its third tier for 2-4 and keeps many for 5+", () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const locales = ["de", "en", "fr", "es", "it", "pl"] as const;
|
||||
export const locales = ["de", "en", "fr", "es", "it", "pl", "ko"] as const;
|
||||
export type Locale = (typeof locales)[number];
|
||||
|
||||
export const defaultLocale: Locale = "en";
|
||||
@@ -32,6 +32,7 @@ export const localeLanguageNames: Record<Locale, string> = {
|
||||
es: "Spanish",
|
||||
it: "Italian",
|
||||
pl: "Polish",
|
||||
ko: "Korean",
|
||||
};
|
||||
|
||||
export const localeLabels: Record<Locale, string> = {
|
||||
@@ -41,6 +42,7 @@ export const localeLabels: Record<Locale, string> = {
|
||||
es: "Español",
|
||||
it: "Italiano",
|
||||
pl: "Polski",
|
||||
ko: "한국어",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -51,7 +51,12 @@ const COMBINING_MARKS = /[\u0300-\u036f]/gu;
|
||||
* printed range's "3,5-5,0", a unit's "mg/dL".
|
||||
*/
|
||||
export function stripDiacritics(raw: string): string {
|
||||
return raw.normalize("NFD").replace(COMBINING_MARKS, "");
|
||||
// Recomposed with NFC after the strip because NFD splits Hangul into JAMO,
|
||||
// and jamo are not in the COMBINING_MARKS range, so they survive: without
|
||||
// this the folded form of "목표" is a jamo sequence that no precomposed
|
||||
// literal in any table can match. Latin/Polish/German folds are unaffected —
|
||||
// the combining marks are already gone, so there is nothing to recompose.
|
||||
return raw.normalize("NFD").replace(COMBINING_MARKS, "").normalize("NFC");
|
||||
}
|
||||
|
||||
/** Collapse every apostrophe variant onto the straight ASCII one. */
|
||||
|
||||
@@ -49,6 +49,7 @@ const loaders: Record<Locale, () => Promise<{ default: MessageBundle }>> = {
|
||||
es: () => import("../../../messages/es.json"),
|
||||
it: () => import("../../../messages/it.json"),
|
||||
pl: () => import("../../../messages/pl.json"),
|
||||
ko: () => import("../../../messages/ko.json"),
|
||||
};
|
||||
|
||||
function initialCache(): Partial<Record<Locale, MessageBundle>> {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* Since the i18n bundle split, `allMessages` is SERVER-ONLY: the root
|
||||
* layout picks the active locale's bundle out of it for the RSC handoff
|
||||
* and `getServerTranslator()` resolves against it. Do NOT import this
|
||||
* module from client code — the six static JSON imports would drag
|
||||
* module from client code — the seven static JSON imports would drag
|
||||
* every locale back into the client chunk (the ~1.4 MiB regression the
|
||||
* split removed). Client code uses `./load-locale` (static EN fallback
|
||||
* floor + dynamic per-locale imports) and `./resolve-key` instead.
|
||||
@@ -20,6 +20,7 @@ import frMessages from "../../../messages/fr.json";
|
||||
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 { locales, type Locale } from "./config";
|
||||
import { resolveKey } from "./resolve-key";
|
||||
|
||||
@@ -36,6 +37,7 @@ export const allMessages: Record<Locale, Record<string, unknown>> = {
|
||||
es: esMessages,
|
||||
it: itMessages,
|
||||
pl: plMessages,
|
||||
ko: koMessages,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,6 +21,7 @@ const DOSE_VIOLATION: Record<string, string> = {
|
||||
es: '{"summary":"Aumente su dosis a 2,4 mg la próxima semana."}',
|
||||
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으로 늘려 보세요."}',
|
||||
};
|
||||
|
||||
const CAUSAL_VIOLATION: Record<string, string> = {
|
||||
@@ -30,6 +31,7 @@ const CAUSAL_VIOLATION: Record<string, string> = {
|
||||
es: '{"summary":"Su peso bajó porque durmió más."}',
|
||||
it: '{"summary":"Il suo peso è calato perché ha dormito di più."}',
|
||||
pl: '{"summary":"Waga spadła, ponieważ spał Pan więcej."}',
|
||||
ko: '{"summary":"수면 때문에 체중이 줄었어요."}',
|
||||
};
|
||||
|
||||
const CLEAN: Record<string, string> = {
|
||||
@@ -39,6 +41,7 @@ const CLEAN: Record<string, string> = {
|
||||
es: '{"summary":"Su peso promedia 82 kg esta semana, estable frente al mes pasado."}',
|
||||
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으로, 지난달과 비슷해요."}',
|
||||
};
|
||||
|
||||
describe("finalizeStatusSummary — dose prescriptions are withheld", () => {
|
||||
|
||||
@@ -249,6 +249,7 @@ describe("tone harness — deterministic fallbacks", () => {
|
||||
es: /no hay ninguna valoración/,
|
||||
it: /non è disponibile alcuna valutazione/,
|
||||
pl: /nie ma (?:tu )?żadnej oceny/,
|
||||
ko: /평가가 없어요/,
|
||||
};
|
||||
|
||||
describe.each(FLOORS)("$name floor", ({ build }) => {
|
||||
|
||||
@@ -479,6 +479,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.",
|
||||
ko: "지금은 이 카드에 대한 평가가 없어요.",
|
||||
};
|
||||
|
||||
function noReadLead(locale: InsightLocale): string {
|
||||
@@ -520,6 +521,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}.`,
|
||||
ko: (n) => `지금은${n} 평가가 없어요.`,
|
||||
};
|
||||
|
||||
const BIOMARKER_NAMED_CLAUSE: Localized<(marker: string) => string> = {
|
||||
@@ -529,6 +531,7 @@ const BIOMARKER_NAMED_CLAUSE: Localized<(marker: string) => string> = {
|
||||
es: (m) => ` de «${m}»`,
|
||||
it: (m) => ` per «${m}»`,
|
||||
pl: (m) => ` dla „${m}”`,
|
||||
ko: (m) => ` '${m}'에 대한`,
|
||||
};
|
||||
|
||||
export function getNoKeyBiomarkerStatusText(
|
||||
|
||||
@@ -173,7 +173,7 @@ describe("enqueueSummaryCatchUp", () => {
|
||||
});
|
||||
|
||||
describe("the toggle copy discloses the catch-up", () => {
|
||||
const LOCALES = ["de", "en", "es", "fr", "it", "pl"] as const;
|
||||
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"] as const;
|
||||
|
||||
function autoReadCopy(locale: string): { subLabel: string; honesty: string } {
|
||||
const bundle = JSON.parse(
|
||||
|
||||
@@ -163,7 +163,7 @@ describe("enqueueStatusGeneration", () => {
|
||||
const send = vi.fn().mockResolvedValue("job-id");
|
||||
getGlobalBoss.mockReturnValue({ send });
|
||||
|
||||
for (const locale of ["de", "en", "fr", "es", "it", "pl"] as const) {
|
||||
for (const locale of ["de", "en", "fr", "es", "it", "pl", "ko"] as const) {
|
||||
await enqueueStatusGeneration({ userId: "u1", metric: "mood", locale });
|
||||
}
|
||||
|
||||
|
||||
@@ -217,7 +217,7 @@ describe("buildMoodReminderPayload", () => {
|
||||
expect(p.body).toBe("How are you feeling today?");
|
||||
});
|
||||
|
||||
it.each(["es", "fr", "it", "pl"] as const)(
|
||||
it.each(["es", "fr", "it", "pl", "ko"] as const)(
|
||||
"returns native (non-EN) strings for locale=%s",
|
||||
(locale) => {
|
||||
const p = buildMoodReminderPayload(locale);
|
||||
|
||||
@@ -259,6 +259,15 @@ export const RANGE_PROSE: Record<Locale, RangeProse> = {
|
||||
"wartosci referencyjne",
|
||||
"normy laboratoryjne",
|
||||
],
|
||||
}, // 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
|
||||
// `isUnreadableRange` reports rather than hides.
|
||||
ko: {
|
||||
twoSided: ["에서", "부터"],
|
||||
upper: ["최대", "최고"],
|
||||
lower: ["최소", "최저"],
|
||||
filler: ["참고치", "참고범위", "정상치", "정상범위", "기준치", "기준범위"],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -291,7 +300,10 @@ function proseAlternation(field: keyof RangeProse): string {
|
||||
* "e" and "y" are one letter long, and a bare `e` that could sit between two
|
||||
* digits would read "3.5e5" as a window.
|
||||
*/
|
||||
const SYMBOL_SEP = String.raw`-|–|—|‒|…|\.{2,3}`;
|
||||
// U+007E and the fullwidth U+FF5E are the separator a Korean lab prints
|
||||
// ("3.5~5.0"); both are added here rather than to `twoSided` because they are
|
||||
// symbols, and the symbol branch needs no surrounding whitespace.
|
||||
const SYMBOL_SEP = String.raw`-|–|—|‒|~|~|…|\.{2,3}`;
|
||||
|
||||
/** Prefixes that state a ceiling only. */
|
||||
const UPPER_PREFIX = `(?:<=|<|≤|${proseAlternation("upper")})`;
|
||||
|
||||
@@ -337,9 +337,9 @@ describe("resolveRichMetric — localised metric names", () => {
|
||||
expect(partial.sort()).toEqual([
|
||||
"BODY_TEMPERATURE_DEVIATION: en",
|
||||
"BREATHING_DISTURBANCE_EVENT: en",
|
||||
"FAT_FREE_MASS: de,en,es",
|
||||
"FAT_MASS: de,en,es,pl",
|
||||
"LEAN_BODY_MASS: de,en,es",
|
||||
"FAT_FREE_MASS: de,en,es,ko",
|
||||
"FAT_MASS: de,en,es,pl,ko",
|
||||
"LEAN_BODY_MASS: de,en,es,ko",
|
||||
"WALKING_STEADINESS_EVENT: en",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -196,7 +196,7 @@ describe("mental-health instruments", () => {
|
||||
});
|
||||
|
||||
it("declares validated item locales honestly (SCI = English only)", () => {
|
||||
for (const locale of ["de", "en", "es", "fr", "it", "pl"]) {
|
||||
for (const locale of ["de", "en", "es", "fr", "it", "pl", "ko"]) {
|
||||
expect(hasValidatedItems("WHO5", locale)).toBe(true);
|
||||
expect(hasValidatedItems("PHQ9", locale)).toBe(true);
|
||||
}
|
||||
|
||||
@@ -11,8 +11,9 @@
|
||||
* - PHQ-9: Kroenke 2001; GAD-7: Spitzer 2006 (public-domain Pfizer grant).
|
||||
* - WHO-5: WHO/UCN/MSD/MHE/2024.1 (© WHO 2024, CC BY-NC-SA 3.0 IGO) — raw
|
||||
* 0–25, × 4 → 0–100; "a percentage score below 50 … an indication for
|
||||
* further assessment". WHO publishes official translations for all six
|
||||
* app locales; they ship verbatim.
|
||||
* further assessment". Six app locales ship the WHO/PRU translations
|
||||
* verbatim; Korean, which neither distribution carries, ships the
|
||||
* published Kim 2010 translation instead.
|
||||
* - SCI: Espie et al., BMJ Open 2014;4:e004183 (© Sleepio Ltd, licensed
|
||||
* free for non-commercial use, CC BY-NC) — 8 items, 0–4 each, total 0–32,
|
||||
* HIGHER = better sleep; "a score of ≤16 … seems reasonable to detect
|
||||
@@ -98,7 +99,7 @@ export interface InstrumentDefinition {
|
||||
attribution: string;
|
||||
}
|
||||
|
||||
const ALL_APP_LOCALES = ["de", "en", "es", "fr", "it", "pl"] as const;
|
||||
const ALL_APP_LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"] as const;
|
||||
|
||||
/**
|
||||
* The PHQ family is public-domain (Pfizer 2010); this is the canonical footer
|
||||
@@ -188,11 +189,17 @@ const WHO5: InstrumentDefinition = {
|
||||
actionThreshold: 50,
|
||||
safetyItemIndex: null,
|
||||
// WHO publishes official de/en/es/fr/it/pl versions — all bundled verbatim.
|
||||
// There is no Korean one, so `ko` carries the published forward/back-
|
||||
// translation from Kim 2010 instead, which is CC BY-NC and so
|
||||
// redistributable. Its validation sample was community-dwelling elderly,
|
||||
// not general adults — narrower than the other six arms.
|
||||
validatedItemLocales: ALL_APP_LOCALES,
|
||||
attribution:
|
||||
"World Health Organization. The World Health Organization-Five Well-Being " +
|
||||
"Index (WHO-5). Geneva: World Health Organization; 2024. Licence: " +
|
||||
"CC BY-NC-SA 3.0 IGO. WHO does not endorse this application.",
|
||||
"CC BY-NC-SA 3.0 IGO. WHO does not endorse this application. Korean " +
|
||||
"translation: Kim HJ, Moon YS, Son BK, Lee SK, Rho HJ, Kim DH. J Korean " +
|
||||
"Geriatr Psychiatry 2010;14(2):90-96 (CC BY-NC 3.0).",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -73,10 +73,10 @@ const VALUE_GROUPS: Array<{ group: string; keys: readonly string[] }> = [
|
||||
];
|
||||
|
||||
describe("mood context vocabulary labels", () => {
|
||||
it("reads six locales and a non-empty vocabulary", () => {
|
||||
it("reads seven 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(6);
|
||||
expect(LOCALES.length).toBe(7);
|
||||
expect(VALUE_GROUPS.length).toBeGreaterThan(0);
|
||||
const total = VALUE_GROUPS.reduce((n, g) => n + g.keys.length, 0);
|
||||
expect(total).toBeGreaterThan(30);
|
||||
|
||||
@@ -79,7 +79,8 @@ function isLocale(value: string | null | undefined): value is Locale {
|
||||
value === "fr" ||
|
||||
value === "es" ||
|
||||
value === "it" ||
|
||||
value === "pl"
|
||||
value === "pl" ||
|
||||
value === "ko"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -855,7 +855,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"])
|
||||
.enum(["en", "de", "es", "fr", "it", "pl", "ko"])
|
||||
.optional()
|
||||
.describe("Optional UI locale hint for the model."),
|
||||
today: z
|
||||
|
||||
@@ -119,7 +119,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"]).nullable(),
|
||||
locale: z.enum(["de", "en", "es", "fr", "it", "pl", "ko"]).nullable(),
|
||||
timezone: z.string(),
|
||||
unitPreference: z.enum(["metric", "imperial"]),
|
||||
timeFormat: z.enum(["AUTO", "H12", "H24"]),
|
||||
|
||||
@@ -59,7 +59,10 @@ 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"]).nullable().optional(),
|
||||
locale: z
|
||||
.enum(["de", "en", "es", "fr", "it", "pl", "ko"])
|
||||
.nullable()
|
||||
.optional(),
|
||||
timezone: timezoneSchema.optional(),
|
||||
unitPreference: z.enum(["metric", "imperial"]).optional(),
|
||||
timeFormat: z.enum(["AUTO", "H12", "H24"]).optional(),
|
||||
|
||||
@@ -19,7 +19,7 @@ export const createManagedProfileSchema = z
|
||||
.object({
|
||||
displayName: z.string().trim().min(1).max(80),
|
||||
dateOfBirth: z.iso.date().nullable().optional(),
|
||||
locale: z.enum(["de", "en", "es", "fr", "it", "pl"]),
|
||||
locale: z.enum(["de", "en", "es", "fr", "it", "pl", "ko"]),
|
||||
timezone: z
|
||||
.string()
|
||||
.min(1)
|
||||
|
||||
+57
-2
@@ -63,6 +63,55 @@ const suggestionsDe: Record<string, string> = {
|
||||
'Vorhersehbare Ersetzungen wie „@" statt „a" helfen nicht wesentlich',
|
||||
};
|
||||
|
||||
const warningsKo: Record<string, string> = {
|
||||
"Straight rows of keys are easy to guess":
|
||||
"키보드에서 일렬로 놓인 키는 쉽게 추측돼요",
|
||||
"Short keyboard patterns are easy to guess":
|
||||
"짧은 키보드 패턴은 쉽게 추측돼요",
|
||||
'Repeats like "aaa" are easy to guess': '"aaa" 같은 반복은 쉽게 추측돼요',
|
||||
'Repeats like "abcabc" are easy to guess':
|
||||
'"abcabc" 같은 반복은 쉽게 추측돼요',
|
||||
"Sequences like abc or 6543 are easy to guess":
|
||||
"abc나 6543 같은 연속은 쉽게 추측돼요",
|
||||
"Recent years are easy to guess": "최근 연도는 쉽게 추측돼요",
|
||||
"Dates are often easy to guess": "날짜는 대체로 쉽게 추측돼요",
|
||||
"This is a top-10 common password": "가장 흔한 비밀번호 10개 안에 들어요",
|
||||
"This is a top-100 common password": "가장 흔한 비밀번호 100개 안에 들어요",
|
||||
"This is a very common password": "아주 흔한 비밀번호예요",
|
||||
"This is similar to a commonly used password":
|
||||
"자주 쓰이는 비밀번호와 비슷해요",
|
||||
"A word by itself is easy to guess": "단어 하나만으로는 쉽게 추측돼요",
|
||||
"Names and surnames by themselves are easy to guess":
|
||||
"이름이나 성만으로는 쉽게 추측돼요",
|
||||
"Common names and surnames are easy to guess":
|
||||
"흔한 이름과 성은 쉽게 추측돼요",
|
||||
};
|
||||
|
||||
const suggestionsKo: Record<string, string> = {
|
||||
"Use a few words, avoid common phrases":
|
||||
"단어를 여러 개 쓰고, 흔한 문구는 피해 주세요",
|
||||
"No need for symbols, digits, or uppercase letters":
|
||||
"기호, 숫자, 대문자를 꼭 넣지 않아도 돼요",
|
||||
"Add another word or two. Uncommon words are better.":
|
||||
"단어를 한두 개 더 붙여 주세요. 흔하지 않은 단어일수록 좋아요.",
|
||||
"Use a longer keyboard pattern with more turns":
|
||||
"방향이 자주 바뀌는 더 긴 키보드 패턴을 써 주세요",
|
||||
"Avoid repeated words and characters": "반복되는 단어와 문자는 피해 주세요",
|
||||
"Avoid sequences": "연속된 배열은 피해 주세요",
|
||||
"Avoid recent years": "최근 연도는 피해 주세요",
|
||||
"Avoid years that are associated with you":
|
||||
"본인과 관련된 연도는 피해 주세요",
|
||||
"Avoid dates and years that are associated with you":
|
||||
"본인과 관련된 날짜와 연도는 피해 주세요",
|
||||
"Capitalization doesn't help very much": "대문자로 바꿔도 큰 도움이 안 돼요",
|
||||
"All-uppercase is almost as easy to guess as all-lowercase":
|
||||
"전부 대문자로 써도 전부 소문자만큼 쉽게 추측돼요",
|
||||
"Reversed words aren't much harder to guess":
|
||||
"단어를 거꾸로 써도 추측 난이도는 별로 오르지 않아요",
|
||||
"Predictable substitutions like '@' instead of 'a' don't help very much":
|
||||
"'a' 대신 '@'처럼 뻔한 치환은 큰 도움이 안 돼요",
|
||||
};
|
||||
|
||||
// English: identity map for all known strings (the library already returns
|
||||
// English, but we wrap them so unknown values still pass through unchanged).
|
||||
const warningsEn: Record<string, string> = Object.fromEntries(
|
||||
@@ -77,8 +126,14 @@ export interface ZxcvbnTranslations {
|
||||
}
|
||||
|
||||
export function getZxcvbnTranslations(locale: Locale): ZxcvbnTranslations {
|
||||
const warnings = locale === "de" ? warningsDe : warningsEn;
|
||||
const suggestions = locale === "de" ? suggestionsDe : suggestionsEn;
|
||||
const warnings =
|
||||
locale === "de" ? warningsDe : locale === "ko" ? warningsKo : warningsEn;
|
||||
const suggestions =
|
||||
locale === "de"
|
||||
? suggestionsDe
|
||||
: locale === "ko"
|
||||
? suggestionsKo
|
||||
: suggestionsEn;
|
||||
return {
|
||||
translate(text: string): string {
|
||||
return warnings[text] ?? suggestions[text] ?? text;
|
||||
|
||||
@@ -17,6 +17,7 @@ import esMessages from "./messages/es.json";
|
||||
import frMessages from "./messages/fr.json";
|
||||
import itMessages from "./messages/it.json";
|
||||
import plMessages from "./messages/pl.json";
|
||||
import koMessages from "./messages/ko.json";
|
||||
|
||||
primeMessages("de", deMessages);
|
||||
primeMessages("en", enMessages);
|
||||
@@ -24,3 +25,4 @@ primeMessages("es", esMessages);
|
||||
primeMessages("fr", frMessages);
|
||||
primeMessages("it", itMessages);
|
||||
primeMessages("pl", plMessages);
|
||||
primeMessages("ko", koMessages);
|
||||
Reference in new issue
Block a user