mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user