mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
Client-rendered times fell back to the hardcoded display zone (Europe/Berlin) because neither useFormatters() nor the legacy format.ts helpers ever received users.timezone. A third localStorage mirror (timezone-mirror.ts, following the time-format/date-format pattern) carries the validated profile zone from /api/auth/me into both read paths; the fallback chain is strictly profile → Europe/Berlin — never the browser zone, so screen, PDF, and export cannot disagree. makeFormatters validates its zone before Intl (a poison value falls back instead of throwing inside every render). Same pass, per the two-document audit: formatUpdatedLabel boundary and clock now resolve one zone in every state (the undefined-tz browser hole is closed); @db.Date calendar fields render UTC-pinned so profiles west of UTC cannot shift a day; dose-history day headings and GLP-1 weekday labels derive from the display zone; chart day/month labels are encoding-derived and UTC-pinned (bucket math untouched, byte-identical for Berlin profiles); the clinician share view renders in the patient's timezone instead of the container's; Playwright pins Europe/Berlin. Server day-key semantics, streak anchoring, insight cache keys, and verbatim wall-clock strings are untouched by construction and pinned by the new Auckland/New_York/Manila + DST test matrix.
151 lines
5.9 KiB
TypeScript
151 lines
5.9 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 { readStoredTimeFormat } from "./time-format";
|
|
import { readStoredTimezone } from "./timezone-mirror";
|
|
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() {
|
|
// Honour the mirrored hour-cycle preference AND the mirrored profile
|
|
// timezone (issue #490) so these legacy helpers render the same clock and
|
|
// zone as `useFormatters()` call sites. SSR reads AUTO + "" (→ Berlin) —
|
|
// same post-hydration caveat as `activeLocale()` above.
|
|
return makeFormatters(
|
|
activeLocale(),
|
|
readStoredTimezone(),
|
|
readStoredTimeFormat(),
|
|
);
|
|
}
|
|
|
|
/** 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 the maintainer 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);
|
|
}
|