Resolve plural tiers through the i18n context

Importing the plural helper directly from the dashboard trend tile cost
~78 KB gz across the route chunks: the module is tiny, but a new edge
from a widely-instantiated component gets duplicated into every chunk
that contains it rather than hoisted. The provider is already in every
chunk, so expose a plural-aware tCount() there and let components call
that instead. Total client JS goes back under budget.

Also fix the two tests the honest-loading-state change invalidated: the
mental-wellbeing landing tests seeded a never-resolving read and so now
render skeletons rather than instrument cards, and the achievements page
needs a router in scope for its module-gate redirect.
This commit is contained in:
Marc-André Bombeck committed 2026-07-19 06:39:29 +02:00
1 parent f31a12e174
commit 323b111a24
6 files changed
+119 -16

No files matched your search

+8 -6
View File
@@ -108,12 +108,14 @@ function collectReferences(
/(?<![a-zA-Z0-9_])t\(\s*["']([a-zA-Z][a-zA-Z0-9_.-]+)["']/g;
for (const m of allText.matchAll(literalKeyRe)) literalKeys.add(m[1]);
// Plural-tier composition: `pluralKey("dashboard.staleHintWeeks", n, locale)`
// resolves to `<base>One` / `<base>Few` / `<base>Other` at runtime, so the
// base literal accounts for exactly those three leaves and no others. Kept
// as an explicit tier list rather than a prefix match so a stray sibling key
// under the same base still reports as an orphan.
const pluralKeyRe = /pluralKey\(\s*["']([a-zA-Z][a-zA-Z0-9_.-]+)["']/g;
// Plural-tier composition: `tCount("dashboard.staleHintWeeks", n)` and the
// underlying `pluralKey("…", n, locale)` both resolve to `<base>One` /
// `<base>Few` / `<base>Other` at runtime, so the base literal accounts for
// exactly those three leaves and no others. Kept as an explicit tier list
// rather than a prefix match so a stray sibling key under the same base still
// reports as an orphan.
const pluralKeyRe =
/(?:pluralKey|tCount)\(\s*["']([a-zA-Z][a-zA-Z0-9_.-]+)["']/g;
for (const m of allText.matchAll(pluralKeyRe)) {
for (const tier of ["One", "Few", "Other"])
literalKeys.add(`${m[1]}${tier}`);
@@ -25,6 +25,12 @@ import type { AchievementProgress } from "@/lib/gamification/achievements";
* matching the `onboarding` page's existing test pattern.
*/
// The page bounces a disabled-module direct hit to /insights (it used to
// render a blank page), so the SSR render needs a router in scope.
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() }),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
isAuthenticated: true,
+3 -8
View File
@@ -10,7 +10,6 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { pluralKey } from "@/lib/i18n/plural";
import type { ComparisonBaseline } from "@/lib/dashboard-layout";
import {
getTrendSentiment,
@@ -131,7 +130,7 @@ export function TrendCard({
emptyHint = null,
valueAdornment = null,
}: TrendCardProps) {
const { t, locale } = useTranslations();
const { t, tCount } = useTranslations();
const fmt = useFormatters();
// v1.4.33 F5 — pick a single signal to drive both the arrow and the
@@ -371,14 +370,10 @@ export function TrendCard({
}
if (staleDays <= 60) {
const weeks = Math.floor(staleDays / 7);
return t(pluralKey("dashboard.staleHintWeeks", weeks, locale), {
count: weeks,
});
return tCount("dashboard.staleHintWeeks", weeks);
}
const months = Math.floor(staleDays / 30);
return t(pluralKey("dashboard.staleHintMonths", months, locale), {
count: months,
});
return tCount("dashboard.staleHintMonths", months);
})()}
</span>
)}
@@ -3,6 +3,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import en from "../../../../messages/en.json";
/**
@@ -35,6 +36,15 @@ function withProviders(node: React.ReactNode): string {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
// The landing now distinguishes loading from an empty record: while the
// history read is in flight it paints skeletons rather than the instrument
// cards' "not taken yet" copy, since that copy is a claim about the user's
// record. These tests exercise the LOADED landing, so seed the read as a
// settled empty result. The failed and in-flight states are covered in
// `failed-read-never-empty.test.tsx`.
queryClient.setQueryData(queryKeys.mentalHealthAssessments(), {
assessments: [],
});
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">{node}</I18nProvider>
+64
View File
@@ -0,0 +1,64 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider, useTranslations } from "@/lib/i18n/context";
import type { Locale } from "@/lib/i18n/config";
/**
* `tCount` is the plural-aware `t` the components call.
*
* The tier selection lives on the context rather than in a helper each
* component imports, because a new module edge from a widely-instantiated
* component duplicates that module into every chunk containing it. That makes
* the context the only place the Polish third plural tier is exercised from the
* UI, so it needs its own coverage — the pure `pluralTier` tests cannot see a
* regression in this wiring.
*/
function Probe({ base, count }: { base: string; count: number }) {
const { tCount } = useTranslations();
return <>{tCount(base, count)}</>;
}
function render(locale: Locale, base: string, count: number): string {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<Probe base={base} count={count} />
</I18nProvider>,
);
}
describe("tCount", () => {
it("resolves the Polish few form for 2-4 and the many form for 5+", () => {
expect(render("pl", "dashboard.staleHintWeeks", 3)).toBe(
"Ostatni odczyt 3 tygodnie temu",
);
expect(render("pl", "dashboard.staleHintWeeks", 7)).toBe(
"Ostatni odczyt 7 tygodni temu",
);
expect(render("pl", "dashboard.staleHintMonths", 2)).toBe(
"Ostatni odczyt 2 miesiące temu",
);
expect(render("pl", "dashboard.staleHintMonths", 8)).toBe(
"Ostatni odczyt 8 miesięcy temu",
);
});
it("keeps the singular for 1", () => {
expect(render("pl", "dashboard.staleHintWeeks", 1)).toBe(
"Ostatni odczyt 1 tydzień temu",
);
expect(render("en", "dashboard.staleHintWeeks", 1)).toBe(
"Last reading 1 week ago",
);
});
it("leaves a locale without a few category on its two forms", () => {
expect(render("en", "dashboard.staleHintWeeks", 3)).toBe(
"Last reading 3 weeks ago",
);
expect(render("de", "dashboard.staleHintMonths", 3)).toBe(
"Letzter Wert vor 3 Monaten",
);
});
});
+28 -2
View File
@@ -23,6 +23,7 @@ import { readStoredTimeFormat, subscribeTimeFormat } from "../time-format";
import { readStoredDateFormat, subscribeDateFormat } from "../date-format";
import { readStoredTimezone, subscribeTimezone } from "../timezone-mirror";
import { resolveKey } from "./resolve-key";
import { pluralKey } from "./plural";
import {
getCachedMessages,
getFallbackMessages,
@@ -42,6 +43,22 @@ interface I18nContextValue {
*/
pendingLocale: Locale | null;
t: (key: string, params?: Record<string, string | number>) => string;
/**
* Plural-aware `t`. Resolves `<baseKey><One|Few|Other>` under the active
* locale's CLDR rules and passes `count` through as a parameter.
*
* Lives on the context rather than as a standalone helper each component
* imports: `pluralTier` is tiny, but a new module edge from a
* widely-instantiated component (the dashboard trend tile) gets duplicated
* into every chunk that contains it, which measured ~78 KB gz across the
* route chunks. The provider is already in every chunk, so routing through
* it is free.
*/
tCount: (
baseKey: string,
count: number,
params?: Record<string, string | number>,
) => string;
}
const I18nContext = createContext<I18nContextValue | null>(null);
@@ -281,9 +298,18 @@ export function I18nProvider({
[locale, messages],
);
const tCount = useCallback(
(
baseKey: string,
count: number,
params?: Record<string, string | number>,
): string => t(pluralKey(baseKey, count, locale), { count, ...params }),
[t, locale],
);
const value = useMemo(
() => ({ locale, setLocale, pendingLocale, t }),
[locale, setLocale, pendingLocale, t],
() => ({ locale, setLocale, pendingLocale, t, tCount }),
[locale, setLocale, pendingLocale, t, tCount],
);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;