mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
f31a12e174
commit
323b111a24
6 files changed
+119
-16
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
|
||||
Reference in new issue
Block a user