mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
d8ff18948f
commit
37e539d0bf
4 files changed
+338
-6
No files matched your search
@@ -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\./);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user