Files
HealthLog/src/lib/date-format.ts
T
Marc-André Bombeck 37e539d0bf feat(format): resolve and apply the date-format preference
Add a date-order resolver + formatter mirroring the hour-cycle path:
AUTO defers to the active locale, DMY/MDY/YMD pin the field order
through a canonical Intl locale. Thread it through makeFormatters and
expose useDateFormatPreference from the i18n context, backed by the
same localStorage mirror as the time-format preference.
2026-06-26 12:14:56 +02:00

163 lines
5.6 KiB
TypeScript

/**
* Client-side mirror of the per-user date-order preference, plus the
* display helpers the `<DateField>` primitive renders through.
*
* The server-authoritative value lives on the user row (`users.date_format`,
* surfaced by `/api/auth/me` and editable via the profile PATCH). The mirror
* follows the `time-format.ts` pattern exactly: `fetchMe` writes the resolved
* value into localStorage so that
*
* 1. `useDateFormatPreference()` can read it through `useSyncExternalStore`
* without requiring a QueryClient in the tree, and
* 2. `<DateField>` (and any non-context helper) renders the same field
* order as the locale-aware `useFormatters().date()`.
*
* SSR always resolves AUTO (`window` is undefined); the same caveat as the
* locale formatters applies — call sites render their formatted strings
* post-fetch, so there is no hydration-mismatch path today.
*/
import type { Locale } from "./i18n/config";
import { type DateFormatPreference, resolveIntlLocale } from "./format-locale";
export type { DateFormatPreference } from "./format-locale";
const STORAGE_KEY = "healthlog-date-format";
const CHANGE_EVENT = "healthlog:date-format-change";
export const DATE_FORMAT_PREFERENCES = ["AUTO", "DMY", "MDY", "YMD"] as const;
/**
* Ordered option list for the profile dropdown. `labelKey` resolves through
* `t()` against the `settings.dateFormat.*` bundle; mirrors how the
* hour-format select hard-codes its option order in JSX.
*/
export const DATE_FORMAT_OPTIONS: ReadonlyArray<{
value: DateFormatPreference;
labelKey: string;
}> = [
{ value: "AUTO", labelKey: "settings.dateFormat.auto" },
{ value: "DMY", labelKey: "settings.dateFormat.dmy" },
{ value: "MDY", labelKey: "settings.dateFormat.mdy" },
{ value: "YMD", labelKey: "settings.dateFormat.ymd" },
];
export function isDateFormatPreference(
value: unknown,
): value is DateFormatPreference {
return (
typeof value === "string" &&
(DATE_FORMAT_PREFERENCES as readonly string[]).includes(value)
);
}
/** Best-effort read of the mirrored preference. AUTO on SSR / no mirror. */
export function readStoredDateFormat(): DateFormatPreference {
if (typeof window === "undefined") return "AUTO";
try {
const stored = window.localStorage?.getItem(STORAGE_KEY);
return isDateFormatPreference(stored) ? stored : "AUTO";
} catch {
return "AUTO";
}
}
/**
* Persist the mirror and notify same-tab subscribers. Cross-tab updates ride
* the browser's native `storage` event.
*/
export function storeDateFormat(value: DateFormatPreference): void {
if (typeof window === "undefined") return;
try {
const previous = window.localStorage?.getItem(STORAGE_KEY);
if (previous === value) return;
window.localStorage?.setItem(STORAGE_KEY, value);
} catch {
return;
}
window.dispatchEvent(new Event(CHANGE_EVENT));
}
/** `useSyncExternalStore`-shaped subscription for the mirror. */
export function subscribeDateFormat(onChange: () => void): () => void {
if (typeof window === "undefined") return () => {};
window.addEventListener(CHANGE_EVENT, onChange);
window.addEventListener("storage", onChange);
return () => {
window.removeEventListener(CHANGE_EVENT, onChange);
window.removeEventListener("storage", onChange);
};
}
/**
* The Intl locale tag a (preference, locale) pair renders numeric dates
* through. Mirrors `dateOrderLocale` in `format-locale.ts`: AUTO keeps the
* user's own locale, DMY/MDY/YMD pin a canonical locale whose default
* numeric order matches (de-DE → dd.MM.yyyy, en-US → MM/dd/yyyy, en-CA →
* yyyy-MM-dd). Exported so `<DateField>` and the formatter agree on order.
*/
export function resolveDateLocale(
pref: DateFormatPreference,
locale: Locale,
): string {
const localeTag = resolveIntlLocale(locale);
switch (pref) {
case "DMY":
return "de-DE";
case "MDY":
return "en-US";
case "YMD":
return "en-CA";
default:
return localeTag;
}
}
/**
* Format an ISO `yyyy-MM-dd` date string (or a `Date`) in the field order
* the (preference, locale) pair selects — the user-visible string the
* `<DateField>` paints over its hidden native input. Returns "" for an
* empty / unparseable value so the field renders its placeholder.
*
* The instant is read in UTC (a bare `yyyy-MM-dd` has no zone) so the day
* never drifts across a timezone boundary — the value contract is a plain
* calendar date, not an instant.
*/
export function formatDate(
value: Date | string | null | undefined,
pref: DateFormatPreference,
locale: Locale,
): string {
if (value === null || value === undefined || value === "") return "";
const date = value instanceof Date ? value : parseIsoDate(value);
if (date === null || Number.isNaN(date.getTime())) return "";
return date.toLocaleDateString(resolveDateLocale(pref, locale), {
timeZone: "UTC",
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
/**
* Parse a `yyyy-MM-dd` string to a UTC `Date` at midnight. Returns null on
* a value that is not exactly the ISO calendar-date shape so callers can
* fall back to the placeholder rather than rendering "Invalid Date".
*/
export function parseIsoDate(value: string): Date | null {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value.trim());
if (!match) return null;
const [, y, m, d] = match;
const date = new Date(Date.UTC(Number(y), Number(m) - 1, Number(d)));
// Guard against rollover (e.g. 2026-02-31 → Mar 03): a real calendar date
// round-trips its parts unchanged.
if (
date.getUTCFullYear() !== Number(y) ||
date.getUTCMonth() !== Number(m) - 1 ||
date.getUTCDate() !== Number(d)
) {
return null;
}
return date;
}