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};