diff --git a/src/__tests__/i18n-reverse-coverage.test.ts b/src/__tests__/i18n-reverse-coverage.test.ts index d7e76859c..5f2f3f5b6 100644 --- a/src/__tests__/i18n-reverse-coverage.test.ts +++ b/src/__tests__/i18n-reverse-coverage.test.ts @@ -108,12 +108,14 @@ function collectReferences( /(?One` / `Few` / `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 `One` / + // `Few` / `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}`); diff --git a/src/app/achievements/__tests__/page.test.tsx b/src/app/achievements/__tests__/page.test.tsx index 93f3149c0..e29878045 100644 --- a/src/app/achievements/__tests__/page.test.tsx +++ b/src/app/achievements/__tests__/page.test.tsx @@ -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, diff --git a/src/components/charts/trend-card.tsx b/src/components/charts/trend-card.tsx index ebcbf52a3..fe0e873be 100644 --- a/src/components/charts/trend-card.tsx +++ b/src/components/charts/trend-card.tsx @@ -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); })()} )} diff --git a/src/components/mental-health/__tests__/mental-wellbeing.test.tsx b/src/components/mental-health/__tests__/mental-wellbeing.test.tsx index e5dc8a3df..b2c0565e3 100644 --- a/src/components/mental-health/__tests__/mental-wellbeing.test.tsx +++ b/src/components/mental-health/__tests__/mental-wellbeing.test.tsx @@ -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( {node} diff --git a/src/lib/i18n/__tests__/t-count.test.tsx b/src/lib/i18n/__tests__/t-count.test.tsx new file mode 100644 index 000000000..cf8f5ca10 --- /dev/null +++ b/src/lib/i18n/__tests__/t-count.test.tsx @@ -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( + + + , + ); +} + +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", + ); + }); +}); diff --git a/src/lib/i18n/context.tsx b/src/lib/i18n/context.tsx index 8165d3c8a..d17631a16 100644 --- a/src/lib/i18n/context.tsx +++ b/src/lib/i18n/context.tsx @@ -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; + /** + * Plural-aware `t`. Resolves `` 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; } const I18nContext = createContext(null); @@ -281,9 +298,18 @@ export function I18nProvider({ [locale, messages], ); + const tCount = useCallback( + ( + baseKey: string, + count: number, + params?: Record, + ): 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 {children};