Files
HealthLog/src/lib/format.ts
T
Marc-André Bombeck 19bc31b0bc 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.
2026-05-23 13:53:04 +02:00

143 lines
5.4 KiB
TypeScript

/**
* Legacy locale-aware date/time helpers.
*
* These originally hard-coded `de-DE`. They now defer to `makeFormatters`
* using a best-effort read of `healthlog-locale` from cookie → localStorage,
* falling back to `en`. New code should prefer `useFormatters()` from
* `@/lib/i18n/context` (client) or `makeFormatters(locale)` from
* `@/lib/format-locale` (server).
*
* SSR caveat: on the server `activeLocale()` always returns `en` because
* `document` is undefined. All current call sites render their formatted
* strings only after a `useQuery` fetch — i.e. post-hydration — so there is
* no hydration-mismatch path today. If you add a new caller that renders
* pre-fetch data (e.g. a static prop), migrate it to `useFormatters()` to
* stay consistent with the server-rendered locale.
*
* v1.4.43 QoL (M8) — `activeLocale()` now reads the full `Locale` union
* (`de | en | fr | es | it | pl`). Pre-fix, a sub-locale user (fr / es /
* it / pl) saw French strings but the legacy SSR formatters silently
* fell back to `en`, producing "12/24/2025, 2:30 PM" in a French shell.
* `Intl.DateTimeFormat` already handles every supported locale via the
* `INTL_LOCALE_MAP`, so the only change is to widen the cookie /
* localStorage decoder.
*/
import { makeFormatters } from "./format-locale";
import { locales, type Locale } from "./i18n/config";
function isLocale(value: string | null | undefined): value is Locale {
return !!value && (locales as readonly string[]).includes(value);
}
function activeLocale(): Locale {
if (typeof document === "undefined") return "en";
// Prefer the cookie (what SSR used) over localStorage to stay in sync
// across hydration. Falls back to localStorage for backwards compat.
const cookieMatch = document.cookie.match(
/(?:^|;\s*)healthlog-locale=([^;]+)/,
);
const fromCookie = cookieMatch?.[1];
if (isLocale(fromCookie)) return fromCookie;
const fromStorage = window.localStorage?.getItem("healthlog-locale");
if (isLocale(fromStorage)) return fromStorage;
return "en";
}
function formatters() {
return makeFormatters(activeLocale());
}
/** Locale-aware "19.02.2026, 14:30" or "02/19/2026, 2:30 PM". */
export function formatDateTime(date: Date | string): string {
return formatters().dateTime(date);
}
/** Locale-aware "19.02.2026" or "02/19/2026". */
export function formatDate(date: Date | string): string {
return formatters().date(date);
}
/** Locale-aware short date without year unless `includeYear`. */
export function formatDateShort(
date: Date | string,
includeYear = false,
): string {
const f = formatters();
return includeYear ? f.date(date) : f.dateShort(date);
}
/** Locale-aware "14:30". */
export function formatTime(date: Date | string): string {
return formatters().time(date);
}
/**
* v1.4.43 QoL (L8) — render a timestamp as a relative phrase when it
* happened within the last 24 hours, otherwise fall back to the
* absolute locale-aware date+time. Closes the visual inconsistency
* the measurement-list surfaced: the same screen could show "vor 3
* min" (briefing) alongside "21.05.2026, 14:32" (list rows) for
* adjacent events.
*
* Buckets:
* - < 1 m → "just now"
* - < 1 h → "vor N min" / "N min ago"
* - < 24 h → "vor N Std." / "N h ago"
* - ≥ 24 h → absolute `formatDateTime`
*
* The relative branch reuses the `insights.relative*` i18n keys (same
* keys `formatRelativeTime()` reads from `@/lib/i18n/relative-time`).
* Caller supplies a `t()` translator so this helper stays usable
* server-side; clients should pass `useTranslations().t`. The buckets
* mirror the briefing's existing copy so users never see two
* different relative phrasings in the same view.
*/
export function formatDateOrRelative(
iso: Date | string,
t: (key: string, params?: Record<string, string | number>) => string,
/**
* Optional "now" for deterministic tests. Defaults to `Date.now()`.
*/
nowMs: number = Date.now(),
): string {
const target =
iso instanceof Date ? iso.getTime() : new Date(iso).getTime();
if (Number.isNaN(target)) return "";
const diffMs = nowMs - target;
// Future timestamps fall back to absolute — relative copy doesn't
// 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(
minutes === 1
? "insights.relativeMinutesAgoOne"
: "insights.relativeMinutesAgoOther",
{ count: minutes },
);
}
const hours = Math.floor(minutes / 60);
if (hours < 24) {
return t(
hours === 1
? "insights.relativeHoursAgoOne"
: "insights.relativeHoursAgoOther",
{ count: hours },
);
}
return formatDateTime(iso);
}