From 19bc31b0bc499f18ccba82e1e372f29708b9f48d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 23 May 2026 13:53:04 +0200 Subject: [PATCH] fix(i18n): formatDateOrRelative dispatches to One/Other plural keys (v1.4.49.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 16 +++++ package.json | 2 +- .../__tests__/format-date-or-relative.test.ts | 61 ++++++++++++++++--- src/lib/format.ts | 24 +++++++- 4 files changed, 91 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c9c866aa8..017422665 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/package.json b/package.json index 151a6c200..a4c86f488 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/lib/__tests__/format-date-or-relative.test.ts b/src/lib/__tests__/format-date-or-relative.test.ts index 9ebca3e2b..362b1e7d2 100644 --- a/src/lib/__tests__/format-date-or-relative.test.ts +++ b/src/lib/__tests__/format-date-or-relative.test.ts @@ -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(); + const collectingT = (key: string, params?: Record) => { + 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` not `Record`. 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; + const top = (root.default as Record | undefined) ?? root; + const insightsBundle = top.insights as Record | undefined; + expect(insightsBundle).toBeTruthy(); + for (const fullKey of bundleKeysCalled) { + // Each key is "insights." — 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(); + } }); }); diff --git a/src/lib/format.ts b/src/lib/format.ts index 44ada56af..aa4633746 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -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); }