mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
fix(i18n): carry the count in One strings where one is more than 1
The plural tier comes from CLDR, and in Russian the one category also
holds 21, 31, 101, while French and Brazilian Portuguese file 0 there.
Thirty One strings in those bundles spelled out the 1, so 21 days read
as one day. They now take {count}, as do two Russian One strings that
spelled the number as a word, and every direct call of such a key passes
the count. A guard finds the locales whose one is wider than 1 from
Intl.PluralRules and holds both ends.
This commit is contained in:
1 parent
2adea7052b
commit
4921188f22
14 files changed
+270
-111
No files matched your search
@@ -290,7 +290,7 @@ export function InviteTokensSection() {
|
||||
const days = daysUntil(invite.expiresAt, now);
|
||||
if (days <= 0) return "—";
|
||||
return days === 1
|
||||
? t("admin.invites.expiresInDaysOne")
|
||||
? t("admin.invites.expiresInDaysOne", { count: days })
|
||||
: t("admin.invites.expiresInDaysOther", { count: days });
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ export function TrendHint({ count }: TrendHintProps) {
|
||||
|
||||
const description =
|
||||
result.remaining === 1
|
||||
? t("trendHints.remainingOne")
|
||||
? t("trendHints.remainingOne", { count: result.remaining })
|
||||
: t("trendHints.remainingMany", { count: result.remaining });
|
||||
|
||||
return (
|
||||
|
||||
@@ -74,7 +74,9 @@ export function IllnessInsightsCard({
|
||||
<div className="space-y-4 text-sm">
|
||||
<p className="text-foreground">
|
||||
{data!.episodeCount === 1
|
||||
? t("illness.insights.countOne")
|
||||
? t("illness.insights.countOne", {
|
||||
count: data!.episodeCount,
|
||||
})
|
||||
: t("illness.insights.countOther", {
|
||||
count: data!.episodeCount,
|
||||
})}
|
||||
|
||||
@@ -77,7 +77,7 @@ function NestedEpisodes({ episodes }: { episodes?: IllnessEpisodeDTO[] }) {
|
||||
{allFlares ? (
|
||||
<p className="text-muted-foreground text-xs font-medium">
|
||||
{count === 1
|
||||
? t("illness.flares.countOne")
|
||||
? t("illness.flares.countOne", { count })
|
||||
: t("illness.flares.countOther", { count })}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -52,7 +52,7 @@ export function EcgCrossLink({ enabled = true, className }: EcgCrossLinkProps) {
|
||||
|
||||
const countText =
|
||||
count === 1
|
||||
? t("insights.ecg.crossLink.recordingsOne")
|
||||
? t("insights.ecg.crossLink.recordingsOne", { count })
|
||||
: t("insights.ecg.crossLink.recordingsMany", { count });
|
||||
const resultText = resultKey
|
||||
? t("insights.ecg.crossLink.latestResult", { result: t(resultKey) })
|
||||
|
||||
@@ -123,6 +123,7 @@ export function GlucoseAdvancedDisclosure({
|
||||
{summary.count === 1
|
||||
? t(
|
||||
"insights.bloodGlucose.clinical.advanced.byContext.readingsShortOne",
|
||||
{ count: summary.count },
|
||||
)
|
||||
: t(
|
||||
"insights.bloodGlucose.clinical.advanced.byContext.readingsShort",
|
||||
|
||||
@@ -90,14 +90,14 @@ export function GlucoseClinicalPanel() {
|
||||
const hasAny = clinical.readingCount > 0;
|
||||
const readingsLabel =
|
||||
clinical.readingCount === 1
|
||||
? t("insights.bloodGlucose.clinical.readingsCountOne")
|
||||
? t("insights.bloodGlucose.clinical.readingsCountOne", { count: 1 })
|
||||
: t("insights.bloodGlucose.clinical.readingsCount", {
|
||||
count: clinical.readingCount,
|
||||
});
|
||||
const spanDays = Math.max(1, Math.round(clinical.actualSpanDays));
|
||||
const daysLabel =
|
||||
spanDays === 1
|
||||
? t("insights.bloodGlucose.clinical.daysCountOne")
|
||||
? t("insights.bloodGlucose.clinical.daysCountOne", { count: spanDays })
|
||||
: t("insights.bloodGlucose.clinical.daysCount", { days: spanDays });
|
||||
|
||||
return (
|
||||
|
||||
@@ -183,12 +183,12 @@ export function IntradayPulseChart({
|
||||
: t("insights.intradayPulse.caption");
|
||||
|
||||
const coverageNote = coverage
|
||||
? coverage.readingCount === 1
|
||||
? t("insights.intradayPulse.coverageOne", { hours: coverage.hours })
|
||||
: t("insights.intradayPulse.coverageMany", {
|
||||
count: coverage.readingCount,
|
||||
hours: coverage.hours,
|
||||
})
|
||||
? t(
|
||||
coverage.readingCount === 1
|
||||
? "insights.intradayPulse.coverageOne"
|
||||
: "insights.intradayPulse.coverageMany",
|
||||
{ count: coverage.readingCount, hours: coverage.hours },
|
||||
)
|
||||
: null;
|
||||
|
||||
const dayLabel = isToday
|
||||
|
||||
@@ -698,7 +698,7 @@ function MedicationTableRowItem({
|
||||
)}
|
||||
>
|
||||
{stock === 1
|
||||
? t("medications.tableStockDoseOne")
|
||||
? t("medications.tableStockDoseOne", { count: stock })
|
||||
: t("medications.tableStockDoses", { count: stock })}
|
||||
</span>
|
||||
{runwayDays !== null && (
|
||||
|
||||
@@ -82,6 +82,12 @@ interface ScheduleRevisionsResponse {
|
||||
revisions: ScheduleRevisionRow[];
|
||||
}
|
||||
|
||||
/** The "show earlier eras" toggle, by how many there are. */
|
||||
const SHOW_ERAS_KEY = {
|
||||
one: "medications.detail.zeitplan.history.showErasOne",
|
||||
other: "medications.detail.zeitplan.history.showErasOther",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Dose-time readout of one era: the union of its entries' times,
|
||||
* deduped + sorted ("07:00 / 19:00"). PRN-only eras have no times —
|
||||
@@ -284,11 +290,9 @@ export function ScheduleHistoryTimeline({
|
||||
/>
|
||||
{expanded
|
||||
? t("medications.detail.zeitplan.history.hideEras")
|
||||
: revisions.length === 1
|
||||
? t("medications.detail.zeitplan.history.showErasOne")
|
||||
: t("medications.detail.zeitplan.history.showErasOther", {
|
||||
count: revisions.length,
|
||||
})}
|
||||
: t(SHOW_ERAS_KEY[revisions.length === 1 ? "one" : "other"], {
|
||||
count: revisions.length,
|
||||
})}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* A `*One` string carries the count wherever "one" is more than 1.
|
||||
*
|
||||
* `pluralTier` takes the tier from CLDR, and in several shipped languages the
|
||||
* "one" category is not the number 1 alone: Russian files 21, 31, 101 under
|
||||
* it, French and Brazilian Portuguese file 0 there. A `*One` value that
|
||||
* spells the number out ("за 1 день") then reads "1" for 21 days. In those
|
||||
* locales the value takes the `{count}` that `tCount` passes, and a call that
|
||||
* reaches such a key directly passes the count too.
|
||||
*
|
||||
* Both checks are functions of their input, and each is shown failing once on
|
||||
* an input built to break it.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { readSourceFolder } from "@/__tests__/helpers/source-files";
|
||||
import { resolveIntlLocale } from "@/lib/format-locale";
|
||||
|
||||
import { locales, type Locale } from "../config";
|
||||
import { pluralKey } from "../plural";
|
||||
import { getServerTranslator } from "../server-translator";
|
||||
import { allMessages } from "../shared-resolve";
|
||||
|
||||
/** Locales whose CLDR "one" category holds an integer other than 1. */
|
||||
function localesWithWideOne(): Locale[] {
|
||||
return locales.filter((locale) => {
|
||||
const rules = new Intl.PluralRules(resolveIntlLocale(locale));
|
||||
for (let n = 0; n <= 1000; n++) {
|
||||
if (n !== 1 && rules.select(n) === "one") return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
function flatten(
|
||||
node: unknown,
|
||||
prefix = "",
|
||||
out = new Map<string, string>(),
|
||||
): Map<string, string> {
|
||||
if (typeof node === "string") {
|
||||
out.set(prefix, node);
|
||||
} else if (node && typeof node === "object") {
|
||||
for (const [key, value] of Object.entries(node)) {
|
||||
flatten(value, prefix ? `${prefix}.${key}` : key, out);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function bundle(locale: Locale): Map<string, string> {
|
||||
return flatten(allMessages[locale]);
|
||||
}
|
||||
|
||||
/** A digit 1 standing on its own, not part of a larger number. */
|
||||
const BARE_ONE = /(?<!\d)1(?!\d)/;
|
||||
|
||||
/** `*One` values that spell out the 1 instead of carrying `{count}`. */
|
||||
function spelledOutOnes(messages: Map<string, string>): string[] {
|
||||
const offenders: string[] = [];
|
||||
for (const [key, value] of messages) {
|
||||
if (!key.endsWith("One")) continue;
|
||||
if (BARE_ONE.test(value) && !value.includes("{count}")) {
|
||||
offenders.push(key);
|
||||
}
|
||||
}
|
||||
return offenders.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* `t("<key>One")` without a `count` param, for a key whose value needs
|
||||
* `{count}`: no params at all, or an inline params object that lacks it.
|
||||
*/
|
||||
function directCallsWithoutCount(
|
||||
sources: ReadonlyArray<{ file: string; text: string }>,
|
||||
needsCount: ReadonlySet<string>,
|
||||
): string[] {
|
||||
const call = /\bt\(\s*"([\w.]+One)"\s*(?:,\s*\{([^}]*)\})?\s*,?\s*\)/g;
|
||||
const offenders: string[] = [];
|
||||
for (const { file, text } of sources) {
|
||||
for (const match of text.matchAll(call)) {
|
||||
const params = match[2];
|
||||
if (params !== undefined && /\bcount\b/.test(params)) continue;
|
||||
if (needsCount.has(match[1]!)) offenders.push(`${file}: ${match[1]}`);
|
||||
}
|
||||
}
|
||||
return offenders.sort();
|
||||
}
|
||||
|
||||
describe("plural One strings", () => {
|
||||
const wide = localesWithWideOne();
|
||||
|
||||
it("finds the locales whose one is not just 1", () => {
|
||||
expect(wide).toEqual(expect.arrayContaining(["ru", "fr", "pt-BR"]));
|
||||
expect(wide).not.toContain("en");
|
||||
expect(wide).not.toContain("pl");
|
||||
});
|
||||
|
||||
it("says 21 days in Russian, not 1", () => {
|
||||
const { t } = getServerTranslator("ru");
|
||||
expect(
|
||||
t(pluralKey("timeline.symptoms.days", 21, "ru"), { count: 21 }),
|
||||
).toBe("за 21 день");
|
||||
});
|
||||
|
||||
it("gives every One string in those locales the count", () => {
|
||||
const offenders = wide.flatMap((locale) =>
|
||||
spelledOutOnes(bundle(locale)).map((key) => `${locale}: ${key}`),
|
||||
);
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("passes the count wherever such a key is called directly", () => {
|
||||
const needsCount = new Set<string>();
|
||||
for (const locale of wide) {
|
||||
for (const [key, value] of bundle(locale)) {
|
||||
if (key.endsWith("One") && value.includes("{count}")) {
|
||||
needsCount.add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(needsCount.size).toBeGreaterThan(0);
|
||||
const sources = readSourceFolder("src", {
|
||||
floor: 1000,
|
||||
include: (rel) =>
|
||||
!rel.includes("__tests__") && !rel.includes("generated/"),
|
||||
}).map(([file, text]) => ({ file, text }));
|
||||
expect(directCallsWithoutCount(sources, needsCount)).toEqual([]);
|
||||
});
|
||||
|
||||
it("would catch a spelled-out one and a call without the count", () => {
|
||||
expect(
|
||||
spelledOutOnes(
|
||||
new Map([
|
||||
["timeline.symptoms.daysOne", "за 1 день"],
|
||||
["timeline.symptoms.daysOther", "за {count} дней"],
|
||||
["a.bOne", "за {count} день"],
|
||||
["a.cOne", "Скрыто (11)"],
|
||||
]),
|
||||
),
|
||||
).toEqual(["timeline.symptoms.daysOne"]);
|
||||
expect(
|
||||
directCallsWithoutCount(
|
||||
[
|
||||
{ file: "x.tsx", text: 'n === 1 ? t("a.bOne") : t("a.bOther")' },
|
||||
{ file: "y.tsx", text: 't("a.bOne", { hours: h })' },
|
||||
{ file: "z.tsx", text: 't("a.bOne", { count: n, hours: h })' },
|
||||
],
|
||||
new Set(["a.bOne"]),
|
||||
),
|
||||
).toEqual(["x.tsx: a.bOne", "y.tsx: a.bOne"]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user