fix(i18n): formatDateOrRelative dispatches to One/Other plural keys (v1.4.49.2)

Marc reported raw `insights.relativeHoursAgo` rendering verbatim across
medication cards, recent-achievements, admin sections, and the iOS
notification preview — anywhere `formatDateOrRelative` paints a
timestamp older than 60 s. The helper called `t("insights.relative
MinutesAgo", { count })` / `t("insights.relativeHoursAgo", { count })`
against a translation bundle that ships only the pluralised `*One` /
`*Other` variants; the i18n surface performs no auto-pluralisation so
the bare key bounced through `t()`'s identity fallback into the UI.

The fix mirrors the One/Other dispatch pattern that the v1.4.43 H6
sweep added to the canonical `src/lib/i18n/relative-time.ts:24-48`
helper. The format-twin in `src/lib/format.ts` was missed in that
sweep because the two helpers had drifted into byte-different siblings
since v1.4.20.

Audit confirmed the leak was isolated to these two keys: I cross-
checked every `t(…, { count })` call in the repo against
`messages/en.json`. The other 17 call sites (`insights.dayStreak`,
`medications.importDuplicatesSkipped`, `achievements.metricPercent`,
`passwordStrength.minLength`, `settings.integrationPill.*`,
`targets.*`, `admin.section.backups.uploadSuccess`,
`trendHints.remainingMany`, `dashboard.staleHint`, …) all use plain
non-pluralised keys that exist verbatim in the bundle.

Regression guard added to `format-date-or-relative.test.ts`: drives
every relative bucket through the helper, collects the `t()` keys it
emits, and asserts each one has a matching entry in `messages/en.json`.
Pre-fix this guard fails on the two bare keys; future twin-helper
divergence (e.g. someone adds a third date-formatting helper with a
copy-pasted body that lacks the pluralisation step) trips it before
it reaches prod.

Bundles v1.4.49.1's analytics rollup-tier delegation that the Coolify
auto-deploy never picked up.

Tests: 5269 unit + integration pass, lint clean, typecheck clean.
This commit is contained in:
Marc-André Bombeck committed 2026-05-23 13:53:04 +02:00
1 parent 6b7b8a7fdf
commit 19bc31b0bc
4 files changed
+91 -12

No files matched your search

@@ -42,31 +42,36 @@ describe("formatDateOrRelative", () => {
);
});
it("returns minutesAgo at exactly the 1-minute mark", () => {
// v1.4.49.2 — One/Other pluralisation. Pre-fix the helper called
// bare `insights.relativeMinutesAgo` / `insights.relativeHoursAgo`
// keys; the translation bundle only carries the pluralised `*One` /
// `*Other` variants so `t()` echoed the bare key into the UI. These
// assertions now pin the correct dispatch.
it("returns minutesAgoOne at exactly the 1-minute mark", () => {
const iso = new Date(NOW - 1 * MINUTE).toISOString();
expect(formatDateOrRelative(iso, fakeT, NOW)).toBe(
"insights.relativeMinutesAgo:count=1",
"insights.relativeMinutesAgoOne:count=1",
);
});
it("returns minutesAgo within the [1m, 1h) window", () => {
it("returns minutesAgoOther within the [2m, 1h) window", () => {
const iso = new Date(NOW - 30 * MINUTE).toISOString();
expect(formatDateOrRelative(iso, fakeT, NOW)).toBe(
"insights.relativeMinutesAgo:count=30",
"insights.relativeMinutesAgoOther:count=30",
);
});
it("returns hoursAgo at exactly the 1-hour mark", () => {
it("returns hoursAgoOne at exactly the 1-hour mark", () => {
const iso = new Date(NOW - 1 * HOUR).toISOString();
expect(formatDateOrRelative(iso, fakeT, NOW)).toBe(
"insights.relativeHoursAgo:count=1",
"insights.relativeHoursAgoOne:count=1",
);
});
it("returns hoursAgo at the 23h59m edge", () => {
it("returns hoursAgoOther at the 23h59m edge", () => {
const iso = new Date(NOW - (23 * HOUR + 59 * MINUTE)).toISOString();
expect(formatDateOrRelative(iso, fakeT, NOW)).toBe(
"insights.relativeHoursAgo:count=23",
"insights.relativeHoursAgoOther:count=23",
);
});
@@ -103,6 +108,44 @@ describe("formatDateOrRelative", () => {
it("accepts a Date instance as well as ISO string", () => {
expect(
formatDateOrRelative(new Date(NOW - 30 * MINUTE), fakeT, NOW),
).toBe("insights.relativeMinutesAgo:count=30");
).toBe("insights.relativeMinutesAgoOther:count=30");
});
// v1.4.49.2 — regression guard. The helper's `t()` calls must dispatch
// to keys that actually exist in `messages/en.json`. Pre-fix the bare
// `insights.relativeMinutesAgo` and `insights.relativeHoursAgo` keys
// passed straight through `t()` to the UI because no translation
// matched. Pin the contract here so a future twin-helper-divergence
// (same shape, different keys) fails fast instead of leaking raw keys
// into production.
it("emits only keys that exist in the translation bundle", async () => {
const en = await import("../../../messages/en.json");
const bundleKeysCalled = new Set<string>();
const collectingT = (key: string, params?: Record<string, string | number>) => {
bundleKeysCalled.add(key);
return params ? `${key}:${JSON.stringify(params)}` : key;
};
// Drive every relative bucket so we hit `t()` on each branch.
formatDateOrRelative(new Date(NOW - 30 * SECOND).toISOString(), collectingT, NOW);
formatDateOrRelative(new Date(NOW - 1 * MINUTE).toISOString(), collectingT, NOW);
formatDateOrRelative(new Date(NOW - 30 * MINUTE).toISOString(), collectingT, NOW);
formatDateOrRelative(new Date(NOW - 1 * HOUR).toISOString(), collectingT, NOW);
formatDateOrRelative(new Date(NOW - 12 * HOUR).toISOString(), collectingT, NOW);
expect(bundleKeysCalled.size).toBeGreaterThan(0);
// `insights.*` carries a mix of string and nested-object children
// (e.g. `personalRecord: { badge, tooltip }`), so the bundle type
// is `Record<string, unknown>` not `Record<string, string>`. We
// only need shallow `key in object` membership here — the value
// shape doesn't matter for this regression guard.
const root = en as unknown as Record<string, unknown>;
const top = (root.default as Record<string, unknown> | undefined) ?? root;
const insightsBundle = top.insights as Record<string, unknown> | undefined;
expect(insightsBundle).toBeTruthy();
for (const fullKey of bundleKeysCalled) {
// Each key is "insights.<short>" — assert the short key exists in
// the en bundle so future renames or typos surface in CI.
const short = fullKey.replace(/^insights\./, "");
expect(insightsBundle?.[short]).toBeDefined();
}
});
});
+22 -2
View File
@@ -109,13 +109,33 @@ export function formatDateOrRelative(
// model them and "in 3 min" would mislead in a health-log context.
if (diffMs < 0) return formatDateTime(iso);
if (diffMs < 60_000) return t("insights.relativeJustNow");
// v1.4.49.2 — One/Other pluralisation. Mirrors the
// `src/lib/i18n/relative-time.ts:24-48` pattern (which got this fix in
// v1.4.43 H6 for the "vor 1 Minuten" bug). This twin helper was missed
// in the v1.4.43 sweep, so the bare `t("insights.relativeMinutesAgo",
// { count })` and `t("insights.relativeHoursAgo", { count })` calls
// returned the key itself — the translation bundle only carries the
// pluralised `*One` / `*Other` variants. Reported by Marc as raw
// `insights.relativeHoursAgo` leaking onto medication cards, recent-
// achievements, admin sections, and every other consumer of
// `formatDateOrRelative`.
const minutes = Math.floor(diffMs / 60_000);
if (minutes < 60) {
return t("insights.relativeMinutesAgo", { count: minutes });
return t(
minutes === 1
? "insights.relativeMinutesAgoOne"
: "insights.relativeMinutesAgoOther",
{ count: minutes },
);
}
const hours = Math.floor(minutes / 60);
if (hours < 24) {
return t("insights.relativeHoursAgo", { count: hours });
return t(
hours === 1
? "insights.relativeHoursAgoOne"
: "insights.relativeHoursAgoOther",
{ count: hours },
);
}
return formatDateTime(iso);
}