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

+16
View File
@@ -1,5 +1,21 @@
# Changelog
## [1.4.49.2] — 2026-05-23 — Raw i18n key leak on relative-time helper
Marc reported the raw string `insights.relativeHoursAgo` rendering verbatim on medication cards, recent-achievements, admin sections, the iOS notification preview, and every other consumer of `formatDateOrRelative`. Audit across the full `t(…, { count })` surface (~20 call sites) confirmed exactly two leaks: `insights.relativeMinutesAgo` and `insights.relativeHoursAgo`. Both pointed to keys that have NEVER existed in the translation bundle — only the pluralised `*One` / `*Other` variants ship.
### Fixed
- `formatDateOrRelative` in `src/lib/format.ts` now dispatches to `insights.relativeMinutesAgoOne` / `relativeMinutesAgoOther` / `relativeHoursAgoOne` / `relativeHoursAgoOther` based on `count === 1`, mirroring the `src/lib/i18n/relative-time.ts:24-48` pattern that the v1.4.43 H6 sweep added to its twin helper. The format-twin was missed in that sweep — `t()` performs no auto-pluralisation, so the bare key passed straight through `t()`'s identity fallback into the UI.
### Test hygiene
- `src/lib/__tests__/format-date-or-relative.test.ts` expectations updated to the new `*One` / `*Other` dispatch, plus a new regression guard that drives every relative bucket and asserts each `t()` key has a matching entry in `messages/en.json`. Pre-fix this guard would have failed on `relativeMinutesAgo` and `relativeHoursAgo`; future twin-helper divergence will trip the same check before it reaches prod.
### Why the audit found no other leaks
Cross-checked every `t(…, { count })` call across the codebase against `messages/en.json`. The other 17 sites (`insights.dayStreak`, `medications.importDuplicatesSkipped`, `achievements.metricPercent`, `passwordStrength.minLength`, `settings.integrationPill.*`, `targets.relativeDay.daysAgo`, `targets.card.streak`, `admin.section.backups.uploadSuccess`, `trendHints.remainingMany`, `dashboard.staleHint`, …) all use plain (non-pluralised) keys that exist verbatim in the bundle. The bug was isolated to the format-helper twin.
## [1.4.49.1] — 2026-05-23 — Default `/api/analytics` cold-path fix (rollup-tier delegation)
v1.4.49 shipped the slim-slice (`?slice=summaries`) cold-fallback fix, but the production HAR captured against the new image still showed `GET /api/analytics` at 8 s cold on the 467 745-row tenant — and crucially, the supposedly-fixed `?slice=summaries` request observed the same 8 s when fired concurrently. Investigation found the slim slice was queuing behind the default slice's 15-way per-type `fetchMeasurementSeriesChunked` live walk: the fan-out held the `p-limit(4)` lanes saturated and the 20-slot Prisma pool packed for ~8 s, starving every other Prisma client (including the concurrent slim slice) until it drained.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.4.49.1",
"version": "1.4.49.2",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "AGPL-3.0-only",
"homepage": "https://healthlog.dev",
@@ -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);
}