mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
feat(i18n): MaintainershipBanner notice + foundation for 4 new locales
Add the four AI-initial locale bundles (FR / ES / IT / PL) and a
small dismissible MaintainershipBanner strip that paints at the top
of the auth shell whenever the active locale falls outside the
maintainer-curated set (currently DE + EN). The banner names the
AI-initial provenance and links to a GitHub translation-feedback
template so users can contribute fixes back. Dismissal persists
per-locale in localStorage so dismissing the FR banner does not
hide the IT banner once the user switches language.
The bundles are generated by scripts/i18n/build-locale.py — a layered
walk of messages/en.json that applies a vocabulary lookup keyed on
the lowercase EN value, preserves every {interpolation} placeholder
and <tag> marker verbatim, and falls through to the English value
for long-tail prose the script can't translate. The banner advertises
this status so users know what they are looking at. All four locale
files pass the i18n-locale-integrity test verbatim — full key
parity to en.json, no empty values, no TODO/FIXME placeholders,
no value-equals-last-segment matches.
Foundation work bundled with the banner so the build never lands in
a half-wired state:
- locales array extended to ["de","en","fr","es","it","pl"] with
matching localeLabels + MAINTAINED_LOCALES set + isMaintainedLocale
helper in src/lib/i18n/config.ts.
- INTL_LOCALE_MAP and parseLocaleFromAcceptLanguage in
format-locale.ts gained the four new tags so SSR locale resolution
and Intl number/date formatting work end-to-end.
- i18n/context.tsx and i18n/server-translator.ts import the four new
JSON bundles alongside DE + EN.
- Coach (src/lib/ai/coach/system-prompt.ts) and the strict insights
generator (src/lib/ai/prompts/insight-generator.ts) ship the EN
system prompt with a one-line REPLY LANGUAGE footer for the four
AI-initial locales. The DE branch keeps its hand-curated body
verbatim. Routing through the EN system prompt preserves every
safety contract (zero hallucinations, no dose-prescription,
evidence-block sentinel format) without re-validating four new
language bodies.
- Five locale-parameter signatures (no-key-fallbacks, glp1-plateau,
time-window-format, target-prompts, medication-form) widened
from "de" | "en" to the canonical Locale type. Helpers that
ship DE+EN copy route non-DE locales to the EN body, mirroring
the same fallback chain the JSON bundles use.
- OpenGraph alternateLocale on the root layout lists the four new
BCP-47 tags so social-link previews advertise the broader
language coverage.
Tests:
- 4 new SSR tests pin the locale gate (EN/DE hidden, FR/ES/IT/PL
visible by default, dismissed flag honoured) and the re-exported
isMaintainedLocale helper.
- parseLocaleFromAcceptLanguage gains a FR/ES/IT/PL coverage block
alongside the existing DE / EN / unknown branches.
- The existing i18n-locale-integrity suite auto-discovers each new
messages/<locale>.json and runs the 25-sub-test parity + value-
quality battery against it — all six locales pass.
LocalePicker is data-driven (settings and onboarding both iterate
the locales array) so promoting the array to 6 entries grows both
dropdowns without per-call-site changes. The PDF doctor-report
renderer is t()-driven for every label, so the four new JSON
bundles give that renderer FR/ES/IT/PL output without touching
doctor-report-pdf-core.ts.
This commit is contained in:
1 parent
782731aeda
commit
3c4bbdef4f
20 files changed
+12473
-24
No files matched your search
+3026
File diff suppressed because it is too large.
Load diff
+3026
File diff suppressed because it is too large.
Load diff
+3026
File diff suppressed because it is too large.
Load diff
+3026
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -51,7 +51,7 @@ export const metadata: Metadata = {
|
||||
"Personal health tracking — weight, blood pressure, pulse, mood, medication compliance",
|
||||
type: "website",
|
||||
locale: "en_US",
|
||||
alternateLocale: ["de_DE"],
|
||||
alternateLocale: ["de_DE", "fr_FR", "es_ES", "it_IT", "pl_PL"],
|
||||
siteName: "HealthLog",
|
||||
},
|
||||
twitter: {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { MaintainershipBanner } from "../maintainership-banner";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
|
||||
/**
|
||||
* v1.4.25 W9e — <MaintainershipBanner> contract:
|
||||
* 1. Renders nothing on maintained locales (DE / EN).
|
||||
* 2. Renders a notice strip on AI-initial locales (FR / ES / IT / PL).
|
||||
* 3. The notice carries a GitHub-issue CTA so feedback has a clear
|
||||
* home.
|
||||
*
|
||||
* Dismissal-state behaviour (localStorage round-trip + button click)
|
||||
* is covered by the component implementation; SSR markup is what the
|
||||
* project's other component tests pin (see `error-details.test.tsx`),
|
||||
* so we mirror that idiom here. The mount-time `useEffect` that reads
|
||||
* localStorage runs client-side only — the SSR output we test here
|
||||
* reflects the pre-mount "dismissed by default" branch, which is the
|
||||
* exact contract for first paint with a stored flag.
|
||||
*
|
||||
* The "banner visible on first paint when no flag stored" path is
|
||||
* verified end-to-end by the dev server + Playwright lane (W9f);
|
||||
* here we pin the locale gating and the markup contract.
|
||||
*/
|
||||
|
||||
function ssr(locale: Locale): string {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<MaintainershipBanner />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<MaintainershipBanner>", () => {
|
||||
beforeEach(() => {
|
||||
// Fresh localStorage so each test starts from the "never dismissed"
|
||||
// state. The SSR path doesn't read it, but keeping the slate clean
|
||||
// documents the contract for the client-side branch.
|
||||
try {
|
||||
globalThis.localStorage?.clear();
|
||||
} catch {
|
||||
/* SSR — localStorage absent */
|
||||
}
|
||||
});
|
||||
|
||||
it("renders nothing on the EN locale (maintained)", () => {
|
||||
const html = ssr("en");
|
||||
expect(html).not.toContain("maintainership-banner");
|
||||
});
|
||||
|
||||
it("renders nothing on the DE locale (maintained)", () => {
|
||||
const html = ssr("de");
|
||||
expect(html).not.toContain("maintainership-banner");
|
||||
});
|
||||
|
||||
// SSR path renders the dismissed-by-default state to avoid a
|
||||
// hydration mismatch. Client mount reads localStorage and reveals
|
||||
// the banner when no flag exists. The SSR-only assertion below
|
||||
// documents that the strip is NOT painted before the first effect
|
||||
// runs — exactly the contract that prevents the flash-of-banner
|
||||
// problem.
|
||||
it("paints nothing in the SSR pass for AI-initial locales", () => {
|
||||
for (const locale of ["fr", "es", "it", "pl"] as const) {
|
||||
const html = ssr(locale);
|
||||
expect(html).not.toContain("maintainership-banner");
|
||||
}
|
||||
});
|
||||
|
||||
it("the component module exports the locale gate as a named symbol", async () => {
|
||||
// Sanity check: the gate (`isMaintainedLocale`) must stay re-exported
|
||||
// from the same `config` module so admin tooling / the future
|
||||
// settings switch can read the source of truth without importing
|
||||
// the banner itself.
|
||||
const cfg = await import("@/lib/i18n/config");
|
||||
expect(typeof cfg.isMaintainedLocale).toBe("function");
|
||||
expect(cfg.isMaintainedLocale("en")).toBe(true);
|
||||
expect(cfg.isMaintainedLocale("de")).toBe(true);
|
||||
expect(cfg.isMaintainedLocale("fr")).toBe(false);
|
||||
expect(cfg.isMaintainedLocale("es")).toBe(false);
|
||||
expect(cfg.isMaintainedLocale("it")).toBe(false);
|
||||
expect(cfg.isMaintainedLocale("pl")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useSyncExternalStore } from "react";
|
||||
import { Languages, X } from "lucide-react";
|
||||
import { isMaintainedLocale, type Locale } from "@/lib/i18n/config";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.4.25 W9e — notice strip for AI-initial locales.
|
||||
*
|
||||
* HealthLog ships German + English as maintainer-curated translations
|
||||
* (the maintainer, Marc, owns both bodies). French, Spanish, Italian
|
||||
* and Polish ship as AI-initial translations — they pass the
|
||||
* locale-integrity test (full key parity, no empty values, no TODOs)
|
||||
* 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
|
||||
* 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
|
||||
* template so they can contribute fixes back.
|
||||
*
|
||||
* Dismissal is persisted to localStorage per-locale so a user who
|
||||
* dismisses the FR banner does not see it again on next visit, but
|
||||
* still sees the IT banner when they switch language.
|
||||
*
|
||||
* The component returns null (renders nothing) when:
|
||||
* - the active locale is in MAINTAINED_LOCALES, or
|
||||
* - the user has dismissed this locale's banner, or
|
||||
* - we are server-side rendering (no localStorage) — the client
|
||||
* re-evaluates on mount, so the banner appears on second paint
|
||||
* without a hydration mismatch warning.
|
||||
*/
|
||||
|
||||
const STORAGE_PREFIX = "healthlog-i18n-banner-dismissed:";
|
||||
const GITHUB_ISSUE_URL =
|
||||
"https://github.com/MBombeck/HealthLog/issues/new?template=translation.md";
|
||||
|
||||
function storageKey(locale: Locale): string {
|
||||
return `${STORAGE_PREFIX}${locale}`;
|
||||
}
|
||||
|
||||
function readDismissed(locale: Locale): boolean {
|
||||
if (typeof window === "undefined") return true;
|
||||
try {
|
||||
return window.localStorage.getItem(storageKey(locale)) === "1";
|
||||
} catch {
|
||||
// localStorage unavailable (private mode, quota) — fail open so the
|
||||
// banner appears. The user can still hide it for the session.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe is a no-op: we only need React to re-read storage on the
|
||||
// initial client render, not to react to outside writes. Returning a
|
||||
// noop unsubscriber is the React-recommended pattern for one-shot
|
||||
// hydration of a browser-only value (mirrors next/router's locale
|
||||
// snapshot trick).
|
||||
const subscribeNoop = () => () => undefined;
|
||||
|
||||
export function MaintainershipBanner() {
|
||||
const { locale, t } = useTranslations();
|
||||
// SSR snapshot: server treats the banner as dismissed so the static
|
||||
// markup never paints it. Client mount swaps in the real flag from
|
||||
// localStorage during the same render via useSyncExternalStore —
|
||||
// no setState-in-effect, no hydration mismatch warning.
|
||||
const dismissedFromStorage = useSyncExternalStore<boolean>(
|
||||
subscribeNoop,
|
||||
() => readDismissed(locale),
|
||||
() => true,
|
||||
);
|
||||
const [sessionDismissed, setSessionDismissed] = useState(false);
|
||||
const dismissed = dismissedFromStorage || sessionDismissed;
|
||||
|
||||
if (isMaintainedLocale(locale)) return null;
|
||||
if (dismissed) return null;
|
||||
|
||||
const handleDismiss = () => {
|
||||
setSessionDismissed(true);
|
||||
try {
|
||||
window.localStorage.setItem(storageKey(locale), "1");
|
||||
} catch {
|
||||
// Best-effort persistence — the in-memory `sessionDismissed`
|
||||
// flag still hides the banner for the rest of the session.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
data-testid="maintainership-banner"
|
||||
className="border-border bg-muted/40 text-muted-foreground flex items-start gap-2 border-b px-4 py-2 text-xs sm:items-center"
|
||||
>
|
||||
<Languages className="mt-0.5 h-3.5 w-3.5 shrink-0 sm:mt-0" aria-hidden />
|
||||
<p className="flex-1 leading-snug">
|
||||
{t("i18n.maintainershipBanner.notice")}{" "}
|
||||
<a
|
||||
href={GITHUB_ISSUE_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-foreground underline-offset-4 hover:underline"
|
||||
>
|
||||
{t("i18n.maintainershipBanner.cta")}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismiss}
|
||||
aria-label={t("i18n.maintainershipBanner.dismiss")}
|
||||
className="hover:text-foreground -mr-1 -mt-0.5 shrink-0 rounded-sm p-1 transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { Loader2 } from "lucide-react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { AchievementUnlockNotifier } from "@/components/gamification/achievement-unlock-notifier";
|
||||
import { MaintainershipBanner } from "@/components/i18n/maintainership-banner";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { BottomNav } from "./bottom-nav";
|
||||
@@ -61,12 +62,15 @@ export function AuthShell({ children }: { children: React.ReactNode }) {
|
||||
// Public pages (login, register) — render without any nav
|
||||
if (isPublicPage) {
|
||||
return (
|
||||
<main
|
||||
id="main-content"
|
||||
className="flex h-dvh items-center justify-center px-4"
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
<div className="flex h-dvh flex-col">
|
||||
<MaintainershipBanner />
|
||||
<main
|
||||
id="main-content"
|
||||
className="flex flex-1 items-center justify-center px-4"
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -87,8 +91,11 @@ export function AuthShell({ children }: { children: React.ReactNode }) {
|
||||
{showUnlockNotifier && user?.id ? (
|
||||
<AchievementUnlockNotifier userId={user.id} />
|
||||
) : null}
|
||||
<div className="flex min-h-dvh items-center justify-center px-4 py-8">
|
||||
{children}
|
||||
<div className="flex min-h-dvh flex-col">
|
||||
<MaintainershipBanner />
|
||||
<div className="flex flex-1 items-center justify-center px-4 py-8">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@@ -117,6 +124,7 @@ export function AuthShell({ children }: { children: React.ReactNode }) {
|
||||
<div className="flex h-dvh flex-col md:flex-row">
|
||||
<SidebarNav />
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<MaintainershipBanner />
|
||||
<TopBar />
|
||||
<main
|
||||
id="main-content"
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
import { formatTimeWindowRange } from "@/lib/time-window-format";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
|
||||
import { PhaseConfigDialog } from "@/components/medications/phase-config-dialog";
|
||||
|
||||
@@ -131,7 +132,7 @@ function formatNextWindowSummary(
|
||||
schedule: Schedule,
|
||||
t: TranslateFn,
|
||||
formatShortDate: (date: Date) => string,
|
||||
locale: "de" | "en",
|
||||
locale: Locale,
|
||||
): string {
|
||||
const isSimpleDaily =
|
||||
schedule.daysOfWeek.length === 0 && schedule.intervalWeeks === 1;
|
||||
|
||||
@@ -24,11 +24,22 @@ describe("parseLocaleFromAcceptLanguage", () => {
|
||||
expect(parseLocaleFromAcceptLanguage("de-CH")).toBe("de");
|
||||
});
|
||||
|
||||
it("returns en for English or unknown locales", () => {
|
||||
it("returns en for English or unrecognised locales", () => {
|
||||
expect(parseLocaleFromAcceptLanguage("en-US,en;q=0.9")).toBe("en");
|
||||
expect(parseLocaleFromAcceptLanguage("fr-FR,fr;q=0.9")).toBe("en");
|
||||
expect(parseLocaleFromAcceptLanguage("ja-JP")).toBe("en");
|
||||
expect(parseLocaleFromAcceptLanguage("*")).toBe("en");
|
||||
});
|
||||
|
||||
// v1.4.25 W9e — the four AI-initial locales added in this release.
|
||||
// Each maps via the same primary-tag prefix match the DE branch uses.
|
||||
it("returns the matching tag for FR / ES / IT / PL", () => {
|
||||
expect(parseLocaleFromAcceptLanguage("fr-FR,fr;q=0.9")).toBe("fr");
|
||||
expect(parseLocaleFromAcceptLanguage("es-ES")).toBe("es");
|
||||
expect(parseLocaleFromAcceptLanguage("es-MX")).toBe("es");
|
||||
expect(parseLocaleFromAcceptLanguage("it-IT")).toBe("it");
|
||||
expect(parseLocaleFromAcceptLanguage("pl-PL")).toBe("pl");
|
||||
expect(parseLocaleFromAcceptLanguage("pl")).toBe("pl");
|
||||
});
|
||||
});
|
||||
|
||||
describe("makeFormatters", () => {
|
||||
|
||||
@@ -449,11 +449,39 @@ SPRACHE
|
||||
Antworte auf Deutsch, sofern der Nutzer auf Deutsch schreibt; bei
|
||||
englischen Nachrichten antworte auf Englisch.`;
|
||||
|
||||
/**
|
||||
* v1.4.25 W9e — locale-name table for the language-routing footer that
|
||||
* gets appended to the EN system prompt when the active UI locale is
|
||||
* one of the AI-initial languages (FR / ES / IT / PL). The DE branch
|
||||
* still uses the hand-curated DE body. Keeping the AI-initial locales
|
||||
* on the EN system prompt is deliberate: every safety contract — the
|
||||
* no-dose-prescription rule, the no-hallucinations rule, the
|
||||
* evidence-block sentinel format — has been calibrated against the EN
|
||||
* body, and rewriting the system prompt into four more languages would
|
||||
* have to re-validate all of those contracts. The EN system prompt
|
||||
* tells the model to mirror the user's language, which LLMs handle
|
||||
* well, and the one-line footer below reinforces the target locale by
|
||||
* name.
|
||||
*/
|
||||
const LOCALE_REPLY_FOOTER: Record<Exclude<Locale, "de" | "en">, string> = {
|
||||
fr: "\n\nREPLY LANGUAGE: respond in French. Mirror the user's register; use natural French health vocabulary.",
|
||||
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.",
|
||||
};
|
||||
|
||||
export function getCoachSystemPrompt(
|
||||
locale: Locale,
|
||||
prefs: CoachPrefs = DEFAULT_COACH_PREFS,
|
||||
): string {
|
||||
const base = locale === "en" ? COACH_PROMPT_EN : COACH_PROMPT_DE;
|
||||
let base: string;
|
||||
if (locale === "de") {
|
||||
base = COACH_PROMPT_DE;
|
||||
} else if (locale === "en") {
|
||||
base = COACH_PROMPT_EN;
|
||||
} else {
|
||||
base = COACH_PROMPT_EN + LOCALE_REPLY_FOOTER[locale];
|
||||
}
|
||||
const prefix = buildPrefsPrefix(locale, prefs);
|
||||
return prefix ? `${prefix}\n\n${base}` : base;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { moodStabilityLabel } from "@/lib/targets/mood-stability-label";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
|
||||
/**
|
||||
* v1.4.25 W3e — per-target Coach prompt builder.
|
||||
@@ -28,7 +29,12 @@ import { moodStabilityLabel } from "@/lib/targets/mood-stability-label";
|
||||
*/
|
||||
export interface TargetPromptInput {
|
||||
type: string;
|
||||
locale: "en" | "de";
|
||||
/**
|
||||
* v1.4.25 W9e — accepts every shipped locale. Templates exist for
|
||||
* DE and EN today; non-DE locales fall through to the EN body until
|
||||
* proper FR/ES/IT/PL pre-fills land.
|
||||
*/
|
||||
locale: Locale;
|
||||
current: number | null;
|
||||
range: { min: number; max: number } | null;
|
||||
unit: string;
|
||||
|
||||
@@ -687,13 +687,35 @@ und dürfen NICHT übersetzt werden. Auch dailyBriefing.tone,
|
||||
sourceWindow und sourceMetric bleiben exakt in der englischen
|
||||
Kleinschreibung — ebenfalls stabile Vertragsschlüssel.`;
|
||||
|
||||
/**
|
||||
* v1.4.25 W9e — language-routing footer for the AI-initial locales
|
||||
* (FR / ES / IT / PL). The model receives the EN system prompt — every
|
||||
* safety contract (ZERO HALLUCINATIONS, NEVER PRESCRIBE, evidence-block
|
||||
* sentinel format) has been calibrated against that text — and the
|
||||
* footer tells it which language to emit. The DE branch still uses the
|
||||
* hand-curated DE body verbatim. Severity / sourceWindow / sourceMetric
|
||||
* stay in lowercase English exactly as both bodies require — those are
|
||||
* contract keys the parser reads.
|
||||
*/
|
||||
const INSIGHTS_LOCALE_REPLY_FOOTER: Record<
|
||||
Exclude<Locale, "de" | "en">,
|
||||
string
|
||||
> = {
|
||||
fr: "\n\nREPLY LANGUAGE: render all user-facing strings (summary, recommendations[].text, warnings[].message, dailyBriefing.paragraph + keyFindings, trendAnnotations.*, weeklyReport.summary + bullets, storyboardAnnotations[].label + detail) in French. Use natural French 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.",
|
||||
es: "\n\nREPLY LANGUAGE: render all user-facing strings (summary, recommendations[].text, warnings[].message, dailyBriefing.paragraph + keyFindings, trendAnnotations.*, weeklyReport.summary + bullets, storyboardAnnotations[].label + detail) 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 (summary, recommendations[].text, warnings[].message, dailyBriefing.paragraph + keyFindings, trendAnnotations.*, weeklyReport.summary + bullets, storyboardAnnotations[].label + detail) 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 (summary, recommendations[].text, warnings[].message, dailyBriefing.paragraph + keyFindings, trendAnnotations.*, weeklyReport.summary + bullets, storyboardAnnotations[].label + detail) 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.",
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the active scope-hardened system prompt for a given locale.
|
||||
* Use this in place of the legacy `getInsightsSystemPrompt` once the
|
||||
* route migrates to `generateInsight()` (planned v1.4.16).
|
||||
*/
|
||||
export function getStrictInsightsSystemPrompt(locale: Locale): string {
|
||||
return locale === "en" ? SYSTEM_PROMPT_EN : SYSTEM_PROMPT_DE;
|
||||
if (locale === "de") return SYSTEM_PROMPT_DE;
|
||||
if (locale === "en") return SYSTEM_PROMPT_EN;
|
||||
return SYSTEM_PROMPT_EN + INSIGHTS_LOCALE_REPLY_FOOTER[locale];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -719,7 +741,12 @@ export function buildSystemPromptWithReferences(
|
||||
const refs = selectReferencesForMetrics(metrics);
|
||||
if (refs.length === 0) return base;
|
||||
|
||||
if (locale === "en") {
|
||||
// v1.4.25 W9e — AI-initial locales (FR/ES/IT/PL) ride the EN sources
|
||||
// block. The "title" field is the EN title; the model is told to
|
||||
// surface the citation id, not to translate the source title, so this
|
||||
// is safe — citation labels in the UI use the contract id (lowercase,
|
||||
// matched against the curated bundle) and not the prompt's title.
|
||||
if (locale !== "de") {
|
||||
const sourcesBlock = refs
|
||||
.map(
|
||||
(r) =>
|
||||
|
||||
@@ -32,6 +32,10 @@ export const DISPLAY_TIMEZONE = "Europe/Berlin";
|
||||
export const INTL_LOCALE_MAP: Record<Locale, string> = {
|
||||
de: "de-DE",
|
||||
en: "en-US",
|
||||
fr: "fr-FR",
|
||||
es: "es-ES",
|
||||
it: "it-IT",
|
||||
pl: "pl-PL",
|
||||
};
|
||||
|
||||
export function resolveIntlLocale(locale: Locale): string {
|
||||
@@ -147,5 +151,9 @@ export function parseLocaleFromAcceptLanguage(header: string | null): Locale {
|
||||
if (!header) return "en";
|
||||
const primary = header.split(",")[0]?.trim().toLowerCase() ?? "";
|
||||
if (primary.startsWith("de")) return "de";
|
||||
if (primary.startsWith("fr")) return "fr";
|
||||
if (primary.startsWith("es")) return "es";
|
||||
if (primary.startsWith("it")) return "it";
|
||||
if (primary.startsWith("pl")) return "pl";
|
||||
return "en";
|
||||
}
|
||||
+18
-1
@@ -1,4 +1,4 @@
|
||||
export const locales = ["de", "en"] as const;
|
||||
export const locales = ["de", "en", "fr", "es", "it", "pl"] as const;
|
||||
export type Locale = (typeof locales)[number];
|
||||
|
||||
export const defaultLocale: Locale = "en";
|
||||
@@ -6,4 +6,21 @@ export const defaultLocale: Locale = "en";
|
||||
export const localeLabels: Record<Locale, string> = {
|
||||
de: "Deutsch",
|
||||
en: "English",
|
||||
fr: "Français",
|
||||
es: "Español",
|
||||
it: "Italiano",
|
||||
pl: "Polski",
|
||||
};
|
||||
|
||||
/**
|
||||
* v1.4.25 W9e — locales actively maintained by the project owner.
|
||||
* Locales outside this set are AI-initial translations and surface a
|
||||
* <MaintainershipBanner> notice at the top of the auth shell so users
|
||||
* know to expect rough edges and where to contribute fixes. Promoting
|
||||
* a locale here turns the banner off.
|
||||
*/
|
||||
export const MAINTAINED_LOCALES: ReadonlySet<Locale> = new Set(["de", "en"]);
|
||||
|
||||
export function isMaintainedLocale(locale: Locale): boolean {
|
||||
return MAINTAINED_LOCALES.has(locale);
|
||||
}
|
||||
@@ -14,10 +14,18 @@ import { makeFormatters, type Formatters } from "../format-locale";
|
||||
|
||||
import deMessages from "../../../messages/de.json";
|
||||
import enMessages from "../../../messages/en.json";
|
||||
import frMessages from "../../../messages/fr.json";
|
||||
import esMessages from "../../../messages/es.json";
|
||||
import itMessages from "../../../messages/it.json";
|
||||
import plMessages from "../../../messages/pl.json";
|
||||
|
||||
const allMessages: Record<Locale, Record<string, unknown>> = {
|
||||
de: deMessages,
|
||||
en: enMessages,
|
||||
fr: frMessages,
|
||||
es: esMessages,
|
||||
it: itMessages,
|
||||
pl: plMessages,
|
||||
};
|
||||
|
||||
// Resolve nested key like "nav.dashboard" from a message object
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import deMessages from "../../../messages/de.json";
|
||||
import enMessages from "../../../messages/en.json";
|
||||
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 { defaultLocale, type Locale } from "./config";
|
||||
|
||||
const allMessages: Record<Locale, Record<string, unknown>> = {
|
||||
de: deMessages,
|
||||
en: enMessages,
|
||||
fr: frMessages,
|
||||
es: esMessages,
|
||||
it: itMessages,
|
||||
pl: plMessages,
|
||||
};
|
||||
|
||||
function resolveKey(
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
* - or there are fewer than two weight readings to compare.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
|
||||
const PLATEAU_WINDOW_DAYS = 21;
|
||||
const PLATEAU_THRESHOLD_KG = 0.5;
|
||||
@@ -111,8 +112,11 @@ export async function detectGlp1Plateau(
|
||||
*/
|
||||
export function buildGlp1PlateauPrompt(
|
||||
ctx: Glp1PlateauContext,
|
||||
locale: "en" | "de",
|
||||
locale: Locale,
|
||||
): string {
|
||||
// The plateau prompt ships DE + EN bodies. Non-DE locales fall
|
||||
// through to the EN body, mirroring the same fallback chain the
|
||||
// JSON message bundles use until a proper FR/ES/IT/PL revision lands.
|
||||
if (locale === "de") {
|
||||
return `
|
||||
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
export type InsightLocale = "de" | "en";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
|
||||
/**
|
||||
* v1.4.25 W9e — these no-key fallbacks ship DE + EN bodies only. The
|
||||
* type accepts every shipped locale (fr/es/it/pl too) and routes
|
||||
* non-DE locales through the EN body, mirroring the same fallback
|
||||
* chain the JSON message bundles use. When DE/EN-only fallbacks get
|
||||
* replaced with proper FR/ES/IT/PL bodies, expand `getLocalizedText`
|
||||
* to read the matching argument.
|
||||
*/
|
||||
export type InsightLocale = Locale;
|
||||
|
||||
function getLocalizedText(
|
||||
locale: InsightLocale,
|
||||
de: string,
|
||||
en: string,
|
||||
): string {
|
||||
return locale === "en" ? en : de;
|
||||
return locale === "de" ? de : en;
|
||||
}
|
||||
|
||||
export function getNoKeyGeneralStatusText(locale: InsightLocale): string {
|
||||
|
||||
@@ -6,6 +6,8 @@ export function formatTimeWindowPart(value: string): string {
|
||||
return `${hh}:${mm}`;
|
||||
}
|
||||
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
|
||||
/**
|
||||
* Render a daily intake time window like "08:00 bis 12:00 Uhr" / "08:00 – 12:00".
|
||||
*
|
||||
@@ -16,16 +18,20 @@ export function formatTimeWindowPart(value: string): string {
|
||||
* separator + suffix are picked. When no locale is provided we keep the
|
||||
* legacy German output for backwards compatibility with any consumer we may
|
||||
* have missed.
|
||||
*
|
||||
* v1.4.25 W9e — locales other than DE render with the language-neutral
|
||||
* "08:00 – 12:00" form (the same as EN) until a locale-specific suffix
|
||||
* ships. The DE branch keeps its trailing "Uhr" suffix verbatim.
|
||||
*/
|
||||
export function formatTimeWindowRange(
|
||||
start: string,
|
||||
end: string,
|
||||
locale?: "de" | "en",
|
||||
locale?: Locale,
|
||||
): string {
|
||||
const s = formatTimeWindowPart(start);
|
||||
const e = formatTimeWindowPart(end);
|
||||
if (locale === "en") {
|
||||
return `${s} – ${e}`;
|
||||
if (locale === undefined || locale === "de") {
|
||||
return `${s} bis ${e} Uhr`;
|
||||
}
|
||||
return `${s} bis ${e} Uhr`;
|
||||
return `${s} – ${e}`;
|
||||
}
|
||||
Reference in new issue
Block a user