mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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.
160 lines
5.0 KiB
TypeScript
160 lines
5.0 KiB
TypeScript
/**
|
|
* Locale-aware formatters.
|
|
*
|
|
* HealthLog's UI language is user-configurable (DE/EN). Numbers, dates, and
|
|
* times must render in the active locale — not in the hard-coded de-DE that
|
|
* lingered from the German-first era.
|
|
*
|
|
* Call via the `useFormatters()` client hook. For server-only code paths
|
|
* (jobs, API routes, PDF generation) pass the locale explicitly through
|
|
* `makeFormatters(locale)`. The optional second argument carries the
|
|
* per-user timezone (v1.4.25 W7) — when omitted the formatter falls back
|
|
* to `DISPLAY_TIMEZONE` so legacy callers keep rendering in Europe/Berlin.
|
|
*
|
|
* UTC is always preserved in the database.
|
|
*/
|
|
|
|
import type { Locale } from "./i18n/config";
|
|
|
|
/**
|
|
* Fallback timezone for surfaces that are not yet user-scoped (admin
|
|
* tables, audit log viewer, anything without a resolved user). User-
|
|
* scoped surfaces should call `makeFormatters(locale, userTz)` with
|
|
* the value from `resolveUserTimezone()`.
|
|
*/
|
|
export const DISPLAY_TIMEZONE = "Europe/Berlin";
|
|
|
|
/**
|
|
* Map our short locale ("de" / "en") to a full BCP-47 tag suitable for
|
|
* `Intl.*`. When we add locales, extend this map — do not rely on the short
|
|
* tag directly since browsers differ in how they handle "en" vs "en-US".
|
|
*/
|
|
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 {
|
|
return INTL_LOCALE_MAP[locale] ?? "en-US";
|
|
}
|
|
|
|
type DateInput = Date | string | number;
|
|
|
|
function asDate(value: DateInput): Date {
|
|
return value instanceof Date ? value : new Date(value);
|
|
}
|
|
|
|
export interface Formatters {
|
|
/** Integer or decimal with the active locale's decimal separator. */
|
|
number: (value: number, fractionDigits?: number) => string;
|
|
/** Same as `number` but thousands-grouped. */
|
|
integer: (value: number) => string;
|
|
/** Percentage, e.g. 0.835 → "83,5 %" in DE / "83.5%" in EN. */
|
|
percent: (value: number, fractionDigits?: number) => string;
|
|
/** Short date, e.g. "19.02.2026" / "02/19/2026". */
|
|
date: (value: DateInput) => string;
|
|
/** Short date without year, e.g. "19.02." / "Feb 19". */
|
|
dateShort: (value: DateInput) => string;
|
|
/** Full date + time in 24h. */
|
|
dateTime: (value: DateInput) => string;
|
|
/** Time only in 24h, e.g. "14:30". */
|
|
time: (value: DateInput) => string;
|
|
/** Short weekday + date, e.g. "Do., 19.02." / "Thu, Feb 19". */
|
|
dateWithWeekday: (value: DateInput) => string;
|
|
/** For axis labels: 3-letter month abbreviation. */
|
|
monthShort: (value: DateInput) => string;
|
|
}
|
|
|
|
export function makeFormatters(locale: Locale, userTz?: string): Formatters {
|
|
const intlLocale = resolveIntlLocale(locale);
|
|
const tz = userTz && userTz.length > 0 ? userTz : DISPLAY_TIMEZONE;
|
|
|
|
return {
|
|
number: (value, fractionDigits) =>
|
|
new Intl.NumberFormat(intlLocale, {
|
|
minimumFractionDigits: fractionDigits,
|
|
maximumFractionDigits: fractionDigits,
|
|
}).format(value),
|
|
|
|
integer: (value) =>
|
|
new Intl.NumberFormat(intlLocale, { maximumFractionDigits: 0 }).format(
|
|
value,
|
|
),
|
|
|
|
percent: (value, fractionDigits = 0) =>
|
|
new Intl.NumberFormat(intlLocale, {
|
|
style: "percent",
|
|
minimumFractionDigits: fractionDigits,
|
|
maximumFractionDigits: fractionDigits,
|
|
}).format(value),
|
|
|
|
date: (value) =>
|
|
asDate(value).toLocaleDateString(intlLocale, {
|
|
timeZone: tz,
|
|
day: "2-digit",
|
|
month: "2-digit",
|
|
year: "numeric",
|
|
}),
|
|
|
|
dateShort: (value) =>
|
|
asDate(value).toLocaleDateString(intlLocale, {
|
|
timeZone: tz,
|
|
day: "2-digit",
|
|
month: "2-digit",
|
|
}),
|
|
|
|
dateTime: (value) =>
|
|
asDate(value).toLocaleString(intlLocale, {
|
|
timeZone: tz,
|
|
day: "2-digit",
|
|
month: "2-digit",
|
|
year: "numeric",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
hour12: false,
|
|
}),
|
|
|
|
time: (value) =>
|
|
asDate(value).toLocaleTimeString(intlLocale, {
|
|
timeZone: tz,
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
hour12: false,
|
|
}),
|
|
|
|
dateWithWeekday: (value) =>
|
|
asDate(value).toLocaleDateString(intlLocale, {
|
|
timeZone: tz,
|
|
weekday: "short",
|
|
day: "2-digit",
|
|
month: "2-digit",
|
|
}),
|
|
|
|
monthShort: (value) =>
|
|
asDate(value).toLocaleDateString(intlLocale, {
|
|
timeZone: tz,
|
|
month: "short",
|
|
}),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Parse locale from a `Accept-Language` header or `healthlog-locale` cookie.
|
|
* Server-side helper for jobs, API routes, and PDF generation where there is
|
|
* no React context.
|
|
*/
|
|
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";
|
|
}
|