Files
HealthLog/src/lib/i18n/plural.ts
T
Marc-André Bombeck c23f2545cc Give every locale its own strings on the non-prompt surfaces
Four locales received English on surfaces whose translations were
already complete, because the resolving code was a de/en binary.

- Medication reminder push and the Telegram inline-keyboard labels
  admitted only de/en, so es/fr/it/pl got the English template on every
  channel. Route the resolver through a shared coerceLocale().
- The Coach's trailing "reply now in X" clause said English for any
  non-German locale, overriding the correct system-prompt directive.
  Read the language name from a shared map instead.
- Coach refusal copy was a de/en constant pair, answering four locales
  in English at the one turn the Coach is declining to help. Move the
  copy into the bundles; the EN/DE constants now derive from them.
- The sleep-stage chart formatted axis ticks as en-US for four locales;
  the cycle calendar and the device-score tile followed the *browser*
  locale rather than the app locale.
- Three components carried a byte-identical duration formatter that
  branched on German. Lift it to one key-driven helper.

Polish needs a third plural tier: 2-4 takes a "few" form a two-form
scheme cannot express, so "2 godzin temu" read as "2 of-hours ago".
Add a pluralKey() helper driven by Intl.PluralRules and a Few tier to
the relative-time and stale-hint keys. The five locales whose rules
have no "few" category resolve exactly as before by construction, and
their Few values mirror Other. Teach the reverse-coverage guard to
expand a pluralKey() base into its three tiers.
2026-07-19 06:02:27 +02:00

68 lines
2.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Plural-tier selection for the count-bearing message keys.
*
* The bundle originally carried a two-form scheme per countable string —
* `<base>One` for 1 and `<base>Other` for everything else. That expresses
* English, German, Spanish, French and Italian correctly, but not Polish:
* Polish splits the non-singular range into a "few" form (2–4, 22–24, 32–34,
* …) and a "many" form (0, 5–21, …), so a two-form scheme rendered
* "2 godzin temu" where the language wants "2 godziny temu".
*
* Rather than hand-roll the modulo arithmetic, the tier comes from
* `Intl.PluralRules`, which carries the CLDR rules for every locale we ship.
* The three tiers map as:
*
* CLDR "one" → `One`
* CLDR "few" → `Few`
* CLDR "many" / "other" → `Other`
*
* For de / en / es / fr / it the "few" category never fires on an integer, so
* those locales resolve exactly as they did before this module existed — the
* change is inert for them by construction, not by a special case. For Polish
* the surviving `Other` tier carries the "many" form, which is what the key
* already held, so only the new `Few` tier needed fresh strings.
*
* `Other` is the floor: an unknown category, an unsupported locale, or an
* `Intl.PluralRules` that throws all resolve to it, which is the pre-existing
* behaviour. A caller can therefore add the `Few` key for Polish alone and
* every other bundle keeps working.
*/
import type { Locale } from "./config";
import { resolveIntlLocale } from "@/lib/format-locale";
export type PluralTier = "One" | "Few" | "Other";
/**
* Select the plural tier for `count` under `locale`'s CLDR rules.
*
* Counts are compared as integers — every call site floors its value before
* reaching here (minutes, hours, days, weeks, months), so the CLDR fractional
* categories never come into play.
*/
export function pluralTier(count: number, locale: Locale): PluralTier {
let category: Intl.LDMLPluralRule;
try {
category = new Intl.PluralRules(resolveIntlLocale(locale)).select(count);
} catch {
// A runtime without the locale data still gets the two-form behaviour
// rather than a thrown render.
return count === 1 ? "One" : "Other";
}
if (category === "one") return "One";
if (category === "few") return "Few";
return "Other";
}
/**
* Build the message key for `count` from a base key.
*
* `pluralKey("insights.relativeHoursAgo", 3, "pl")` → `insights.relativeHoursAgoFew`.
*/
export function pluralKey(
baseKey: string,
count: number,
locale: Locale,
): string {
return `${baseKey}${pluralTier(count, locale)}`;
}