mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
The per-user date order reached the entry form and nothing else. A reporter switched away from MM/DD/YYYY, the date picker followed, and the dashboard chart axes and the measurements list kept the old order. `makeFormatters` took `timeFormat` and `dateFormat` as optional parameters defaulting to "AUTO", so six of the seven constructions in the tree silently rendered the locale default and nothing anywhere said the setting had been dropped. Drop both defaults. The compiler then names every caller: the chart bucket labels, the legacy `@/lib/format` helpers the measurements list renders through, the medication compliance and efficacy charts, the doctor-report PDF and the clinician share view. Thread the preference: - `makeBucketLabelFormatters` takes the order; the health and mood charts read `useDateFormatPreference()`. The UTC pin decides which day the label names, the preference decides how it is spelled, and the two are separate questions. - `format.ts` reads `readStoredDateFormat()` beside the timezone and hour-cycle mirrors it already read. - The PDF takes `dateFormat` as a required render option, alongside `timeFormat`, which was optional for the same reason. - The clinician share view spells the OWNER's dates the owner's way. It also picked up the owner's hour cycle, which it had been ignoring while the PDF route beside it already honoured it. Both resolve through the new `resolveUserFormatPreferences`, the counterpart to `resolveUserTimezone`. Textual months stay on the UI locale. The preference pins numeric field order by rendering through de-DE / en-US / en-CA, and a textual month through those would change the month name's language. Add `date-order-explicit-formatter-guard`: a raw numeric date renderer must take its locale from the preference or carry a written reason, and a formatter construction may not hard-code "AUTO" for the order. Its allowlist holds thirteen calendar-day key builders, each with the reason it has no order to get wrong. The sibling timezone guard passes every one of them, because naming a calendar says nothing about spelling a date. Refs #922
156 lines
6.2 KiB
TypeScript
156 lines
6.2 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 { readStoredDateFormat } from "./date-format";
|
|
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, the mirrored profile timezone
|
|
// (issue #490) AND the mirrored date order (issue #922) so these legacy
|
|
// helpers render the same clock, zone and field order as `useFormatters()`
|
|
// call sites. The date mirror was the one of the three that was never read
|
|
// here, which is why the measurements list ignored the profile setting the
|
|
// entry form's `<DateField>` obeyed. SSR reads AUTO + "" (→ Berlin) — same
|
|
// post-hydration caveat as `activeLocale()` above.
|
|
return makeFormatters(
|
|
activeLocale(),
|
|
readStoredTimezone(),
|
|
readStoredTimeFormat(),
|
|
readStoredDateFormat(),
|
|
);
|
|
}
|
|
|
|
/** 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);
|
|
}
|