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:
Marc-André Bombeck committed 2026-05-14 15:40:34 +02:00
1 parent 782731aeda
commit 3c4bbdef4f
20 files changed
+12473 -24

No files matched your search

+3026
View File
File diff suppressed because it is too large. Load diff
+3026
View File
File diff suppressed because it is too large. Load diff
+3026
View File
File diff suppressed because it is too large. Load diff
+3026
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -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>
);
}
+16 -8
View File
@@ -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;
+13 -2
View File
@@ -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", () => {
+29 -1
View File
@@ -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;
}
+7 -1
View File
@@ -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;
+29 -2
View File
@@ -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) =>
+8
View File
@@ -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
View File
@@ -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);
}
+8
View File
@@ -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
+8
View File
@@ -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(
+5 -1
View File
@@ -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 `
+12 -2
View File
@@ -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 {
+10 -4
View File
@@ -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}`;
}