Files
HealthLog/src/lib/__tests__/date-format-preference-surfaces.test.ts
T
Marc-André Bombeck 031600d69d fix(format): carry the date-order preference to every numeric date
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
2026-09-05 17:06:34 +02:00

152 lines
5.0 KiB
TypeScript

/**
* Issue #922 — the per-user date order has to reach every surface, not just
* the entry form.
*
* A reporter switched the profile away from MM/DD/YYYY. The `<DateField>` in
* the entry form followed; the dashboard chart axes and the measurements list
* did not. Both of those render through a formatter that was constructed
* without the preference, and the parameter carried a `= "AUTO"` default, so
* nothing anywhere said the setting had been dropped.
*
* The assertions check FIELD ORDER, not exact strings: DMY renders through
* de-DE (dots), MDY through en-US (slashes) and YMD through en-CA (dashes),
* and pinning the punctuation would make these break on an ICU update that
* has nothing to do with the bug.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { makeBucketLabelFormatters } from "@/lib/charts/bucket-label";
/** A day whose three fields are mutually distinguishable. */
const SAMPLE = Date.UTC(2026, 3, 18, 12); // 2026-04-18, noon UTC
/** The numeric fields of a rendered date, in the order they appear. */
function fieldOrder(rendered: string): number[] {
return (rendered.match(/\d+/g) ?? []).map(Number);
}
/** Day / month / year of SAMPLE, as the renderers emit them. */
const DAY = 18;
const MONTH = 4;
const YEAR = 2026;
describe("issue #922 — chart axis labels honour the date-order preference", () => {
it("renders day first under DMY", () => {
const fmt = makeBucketLabelFormatters("en", "DMY");
expect(fieldOrder(fmt.date(SAMPLE))).toEqual([DAY, MONTH, YEAR]);
expect(fieldOrder(fmt.dateShortSmart(SAMPLE)).slice(0, 2)).toEqual([
DAY,
MONTH,
]);
});
it("renders month first under MDY", () => {
const fmt = makeBucketLabelFormatters("en", "MDY");
expect(fieldOrder(fmt.date(SAMPLE))).toEqual([MONTH, DAY, YEAR]);
expect(fieldOrder(fmt.dateShortSmart(SAMPLE)).slice(0, 2)).toEqual([
MONTH,
DAY,
]);
});
it("renders year first under YMD", () => {
const fmt = makeBucketLabelFormatters("en", "YMD");
expect(fieldOrder(fmt.date(SAMPLE))).toEqual([YEAR, MONTH, DAY]);
});
it("keeps following the locale under AUTO", () => {
expect(
fieldOrder(makeBucketLabelFormatters("en", "AUTO").date(SAMPLE)),
).toEqual([MONTH, DAY, YEAR]);
expect(
fieldOrder(makeBucketLabelFormatters("de", "AUTO").date(SAMPLE)),
).toEqual([DAY, MONTH, YEAR]);
});
it("still pins the label calendar to UTC (issue #490)", async () => {
const { makeFormatters } = await import("@/lib/format-locale");
// A noon-UTC day key must not slide a day for a profile at UTC+13.
const auckland = makeFormatters("en", "Pacific/Auckland", "AUTO", "DMY");
const label = makeBucketLabelFormatters("en", "DMY");
expect(fieldOrder(label.date(SAMPLE))).toEqual([DAY, MONTH, YEAR]);
expect(auckland.date(SAMPLE)).not.toBe(label.date(SAMPLE));
});
});
interface Globals {
document?: { cookie: string };
window?: { localStorage?: { getItem: (key: string) => string | null } };
}
describe("issue #922 — the measurements list honours the date-order preference", () => {
const globalAny = globalThis as unknown as Globals;
const mirror = new Map<string, string>();
beforeEach(() => {
vi.resetModules();
mirror.clear();
globalAny.document = { cookie: "healthlog-locale=en; path=/" };
globalAny.window = {
localStorage: { getItem: (key) => mirror.get(key) ?? null },
};
});
afterEach(() => {
delete globalAny.document;
delete globalAny.window;
});
/**
* `measurement-list.tsx` renders its rows through the legacy
* `@/lib/format` helpers, which mirror the timezone and the hour cycle out
* of localStorage but never read the date-order mirror `<DateField>` uses.
*/
async function legacy(pref: string) {
mirror.set("healthlog-date-format", pref);
mirror.set("healthlog-timezone", "UTC");
return import("../format");
}
it("renders day first under DMY", async () => {
const { formatDate, formatDateTime } = await legacy("DMY");
expect(fieldOrder(formatDate(new Date(SAMPLE)))).toEqual([
DAY,
MONTH,
YEAR,
]);
expect(fieldOrder(formatDateTime(new Date(SAMPLE))).slice(0, 3)).toEqual([
DAY,
MONTH,
YEAR,
]);
});
it("renders month first under MDY", async () => {
const { formatDate } = await legacy("MDY");
expect(fieldOrder(formatDate(new Date(SAMPLE)))).toEqual([
MONTH,
DAY,
YEAR,
]);
});
it("renders year first under YMD", async () => {
const { formatDate, formatDateShort } = await legacy("YMD");
expect(fieldOrder(formatDate(new Date(SAMPLE)))).toEqual([
YEAR,
MONTH,
DAY,
]);
expect(fieldOrder(formatDateShort(new Date(SAMPLE)))).toEqual([MONTH, DAY]);
});
it("keeps following the locale under AUTO", async () => {
const { formatDate } = await legacy("AUTO");
expect(fieldOrder(formatDate(new Date(SAMPLE)))).toEqual([
MONTH,
DAY,
YEAR,
]);
});
});