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

@@ -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;