mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
6b7b8a7fdf
commit
19bc31b0bc
4 files changed
+91
-12
No files matched your search
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user