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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-26 12:14:56 +02:00
1 parent d8ff18948f
commit 37e539d0bf
4 files changed
+338 -6

No files matched your search

+110
View File
@@ -0,0 +1,110 @@
/**
* Date-order preference resolver + formatter (v1.21.0).
*
* `formatDate` and `resolveDateLocale` are the single source of truth for the
* field order the `<DateField>` primitive and `useFormatters().date()` render.
* AUTO defers to the active locale; DMY / MDY / YMD pin the order regardless.
*/
import { describe, expect, it } from "vitest";
import {
formatDate,
parseIsoDate,
resolveDateLocale,
isDateFormatPreference,
DATE_FORMAT_PREFERENCES,
DATE_FORMAT_OPTIONS,
} from "../date-format";
const ISO = "2026-02-19";
describe("resolveDateLocale", () => {
it("AUTO follows the active locale", () => {
expect(resolveDateLocale("AUTO", "de")).toBe("de-DE");
expect(resolveDateLocale("AUTO", "en")).toBe("en-US");
expect(resolveDateLocale("AUTO", "fr")).toBe("fr-FR");
});
it("DMY / MDY / YMD pin a canonical order locale regardless of UI locale", () => {
expect(resolveDateLocale("DMY", "en")).toBe("de-DE");
expect(resolveDateLocale("MDY", "de")).toBe("en-US");
expect(resolveDateLocale("YMD", "de")).toBe("en-CA");
});
});
describe("formatDate", () => {
it("renders DMY as dd.MM.yyyy", () => {
expect(formatDate(ISO, "DMY", "en")).toBe("19.02.2026");
});
it("renders MDY as MM/dd/yyyy", () => {
expect(formatDate(ISO, "MDY", "de")).toBe("02/19/2026");
});
it("renders YMD as yyyy-MM-dd (ISO)", () => {
expect(formatDate(ISO, "YMD", "de")).toBe("2026-02-19");
});
it("AUTO follows the locale convention", () => {
// de → dd.MM.yyyy, en → MM/dd/yyyy
expect(formatDate(ISO, "AUTO", "de")).toBe("19.02.2026");
expect(formatDate(ISO, "AUTO", "en")).toBe("02/19/2026");
});
it("accepts a Date and an ISO string identically", () => {
const d = new Date(Date.UTC(2026, 1, 19));
expect(formatDate(d, "DMY", "en")).toBe(formatDate(ISO, "DMY", "en"));
});
it("does not drift across a timezone boundary (UTC calendar date)", () => {
// A bare yyyy-MM-dd is a calendar date; it must render the same day in
// every order, never the day before/after.
expect(formatDate("2026-01-01", "DMY", "en")).toBe("01.01.2026");
expect(formatDate("2026-12-31", "YMD", "en")).toBe("2026-12-31");
});
it("returns an empty string for empty / unparseable input", () => {
expect(formatDate("", "AUTO", "en")).toBe("");
expect(formatDate(null, "AUTO", "en")).toBe("");
expect(formatDate(undefined, "AUTO", "en")).toBe("");
expect(formatDate("not-a-date", "AUTO", "en")).toBe("");
});
});
describe("parseIsoDate", () => {
it("parses a valid ISO calendar date to a UTC midnight Date", () => {
const d = parseIsoDate("2026-02-19");
expect(d).not.toBeNull();
expect(d!.getUTCFullYear()).toBe(2026);
expect(d!.getUTCMonth()).toBe(1);
expect(d!.getUTCDate()).toBe(19);
});
it("rejects a non-ISO shape and an impossible date", () => {
expect(parseIsoDate("19.02.2026")).toBeNull();
expect(parseIsoDate("2026-02-31")).toBeNull();
expect(parseIsoDate("2026-13-01")).toBeNull();
});
});
describe("preference list", () => {
it("isDateFormatPreference is a strict guard", () => {
expect(isDateFormatPreference("AUTO")).toBe(true);
expect(isDateFormatPreference("DMY")).toBe(true);
expect(isDateFormatPreference("XYZ")).toBe(false);
expect(isDateFormatPreference(null)).toBe(false);
});
it("exposes all four preferences and matching options", () => {
expect(DATE_FORMAT_PREFERENCES).toEqual(["AUTO", "DMY", "MDY", "YMD"]);
expect(DATE_FORMAT_OPTIONS.map((o) => o.value)).toEqual([
"AUTO",
"DMY",
"MDY",
"YMD",
]);
for (const opt of DATE_FORMAT_OPTIONS) {
expect(opt.labelKey).toMatch(/^settings\.dateFormat\./);
}
});
});
+162
View File
@@ -0,0 +1,162 @@
/**
* 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;
}
+46 -4
View File
@@ -50,6 +50,14 @@ export function resolveIntlLocale(locale: Locale): string {
*/
export type TimeFormatPreference = "AUTO" | "H12" | "H24";
/**
* Date-order display preference (mirrors the `DateFormatPreference` Prisma
* enum). AUTO defers to the locale's own field order (de-DE → dd.MM.yyyy,
* en-US → MM/dd/yyyy); DMY pins day-month-year, MDY pins month-day-year,
* YMD pins ISO yyyy-MM-dd. Display-time only — stored instants stay UTC.
*/
export type DateFormatPreference = "AUTO" | "DMY" | "MDY" | "YMD";
/**
* Intl options for the requested hour cycle. AUTO contributes nothing so
* `Intl.DateTimeFormat` falls back to the locale default.
@@ -67,6 +75,34 @@ function hourCycleOptions(
}
}
/**
* The Intl locale tag a non-AUTO date preference renders through. The
* field order is a property of the BCP-47 locale, not a `DateTimeFormat`
* option, so we pin a canonical locale whose default numeric date order
* matches the requested preference and reuse the user's own locale only
* for AUTO. DMY is rendered through de-DE so it carries the app-wide
* dotted separator (dd.MM.yyyy); MDY through en-US (MM/dd/yyyy); YMD
* through the ISO-canonical en-CA / sv (yyyy-MM-dd). The day/month/year
* `2-digit`/`numeric` field set is supplied by the caller.
*/
function dateOrderLocale(
dateFormat: DateFormatPreference,
localeTag: string,
): string {
switch (dateFormat) {
case "DMY":
return "de-DE";
case "MDY":
return "en-US";
case "YMD":
// en-CA renders numeric dates as yyyy-MM-dd across engines; it is the
// ISO-8601 default order without pulling a locale the app doesn't ship.
return "en-CA";
default:
return localeTag;
}
}
type DateInput = Date | string | number;
function asDate(value: DateInput): Date {
@@ -98,10 +134,16 @@ export function makeFormatters(
locale: Locale,
userTz?: string,
timeFormat: TimeFormatPreference = "AUTO",
dateFormat: DateFormatPreference = "AUTO",
): Formatters {
const intlLocale = resolveIntlLocale(locale);
const tz = userTz && userTz.length > 0 ? userTz : DISPLAY_TIMEZONE;
const hourOpts = hourCycleOptions(timeFormat);
// Field-order locale for the numeric date renderers. AUTO keeps the
// user's own locale; DMY/MDY/YMD pin a canonical locale whose default
// numeric order matches. `monthShort` deliberately stays on `intlLocale`
// so axis month names follow the UI language, not the date-order pin.
const dateLocale = dateOrderLocale(dateFormat, intlLocale);
return {
number: (value, fractionDigits) =>
@@ -123,7 +165,7 @@ export function makeFormatters(
}).format(value),
date: (value) =>
asDate(value).toLocaleDateString(intlLocale, {
asDate(value).toLocaleDateString(dateLocale, {
timeZone: tz,
day: "2-digit",
month: "2-digit",
@@ -131,14 +173,14 @@ export function makeFormatters(
}),
dateShort: (value) =>
asDate(value).toLocaleDateString(intlLocale, {
asDate(value).toLocaleDateString(dateLocale, {
timeZone: tz,
day: "2-digit",
month: "2-digit",
}),
dateTime: (value) =>
asDate(value).toLocaleString(intlLocale, {
asDate(value).toLocaleString(dateLocale, {
timeZone: tz,
day: "2-digit",
month: "2-digit",
@@ -157,7 +199,7 @@ export function makeFormatters(
}),
dateWithWeekday: (value) =>
asDate(value).toLocaleDateString(intlLocale, {
asDate(value).toLocaleDateString(dateLocale, {
timeZone: tz,
weekday: "short",
day: "2-digit",
+20 -2
View File
@@ -16,8 +16,10 @@ import {
makeFormatters,
type Formatters,
type TimeFormatPreference,
type DateFormatPreference,
} from "../format-locale";
import { readStoredTimeFormat, subscribeTimeFormat } from "../time-format";
import { readStoredDateFormat, subscribeDateFormat } from "../date-format";
import { resolveKey } from "./resolve-key";
import {
fallbackMessages,
@@ -248,6 +250,21 @@ export function useTimeFormatPreference(): TimeFormatPreference {
);
}
/**
* The user's date-order preference (AUTO / DMY / MDY / YMD), reactive to
* changes. Backed by the same localStorage-mirror pattern as the hour-cycle
* preference — `useAuth`'s `/api/auth/me` fetch and the profile date-format
* select keep it in sync with the server value, no QueryClient required.
* SSR resolves AUTO.
*/
export function useDateFormatPreference(): DateFormatPreference {
return useSyncExternalStore(
subscribeDateFormat,
readStoredDateFormat,
() => "AUTO" as const,
);
}
/**
* Locale-aware formatters tied to the active UI locale. Use for every number,
* date, and time rendered in the UI so regional conventions (70,5 vs 70.5,
@@ -258,8 +275,9 @@ export function useTimeFormatPreference(): TimeFormatPreference {
export function useFormatters(): Formatters {
const { locale } = useTranslations();
const timeFormat = useTimeFormatPreference();
const dateFormat = useDateFormatPreference();
return useMemo(
() => makeFormatters(locale, undefined, timeFormat),
[locale, timeFormat],
() => makeFormatters(locale, undefined, timeFormat, dateFormat),
[locale, timeFormat, dateFormat],
);
}