Files
HealthLog/src/lib/__tests__/format-locale.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

321 lines
14 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import {
hourCycleOptions,
makeFormatters,
parseLocaleFromAcceptLanguage,
resolveIntlLocale,
} from "../format-locale";
describe("resolveIntlLocale", () => {
it("maps short locales to BCP-47 tags", () => {
expect(resolveIntlLocale("de")).toBe("de-DE");
expect(resolveIntlLocale("en")).toBe("en-US");
});
});
describe("parseLocaleFromAcceptLanguage", () => {
it("returns en for null / empty", () => {
expect(parseLocaleFromAcceptLanguage(null)).toBe("en");
expect(parseLocaleFromAcceptLanguage("")).toBe("en");
});
it("returns de for primary German tag", () => {
expect(parseLocaleFromAcceptLanguage("de-DE,en;q=0.9")).toBe("de");
expect(parseLocaleFromAcceptLanguage("de")).toBe("de");
expect(parseLocaleFromAcceptLanguage("de-CH")).toBe("de");
});
it("returns en for English or unrecognised locales", () => {
expect(parseLocaleFromAcceptLanguage("en-US,en;q=0.9")).toBe("en");
expect(parseLocaleFromAcceptLanguage("ja-JP")).toBe("en");
expect(parseLocaleFromAcceptLanguage("*")).toBe("en");
});
// v1.4.25 W9e — the four AI-initial locales added in this release.
// Each maps via the same primary-tag prefix match the DE branch uses.
it("returns the matching tag for FR / ES / IT / PL", () => {
expect(parseLocaleFromAcceptLanguage("fr-FR,fr;q=0.9")).toBe("fr");
expect(parseLocaleFromAcceptLanguage("es-ES")).toBe("es");
expect(parseLocaleFromAcceptLanguage("es-MX")).toBe("es");
expect(parseLocaleFromAcceptLanguage("it-IT")).toBe("it");
expect(parseLocaleFromAcceptLanguage("pl-PL")).toBe("pl");
expect(parseLocaleFromAcceptLanguage("pl")).toBe("pl");
});
});
describe("makeFormatters", () => {
const deFmt = makeFormatters("de", undefined, "AUTO", "AUTO");
const enFmt = makeFormatters("en", undefined, "AUTO", "AUTO");
const sample = new Date("2026-04-18T14:30:00Z"); // 16:30 Europe/Berlin (CEST)
it("formats numbers with regional decimal separators", () => {
expect(deFmt.number(70.5, 1)).toMatch(/70,5/);
expect(enFmt.number(70.5, 1)).toMatch(/70\.5/);
});
it("formats integers with thousands separators", () => {
expect(deFmt.integer(1234567)).toMatch(/1[.\u202f]234[.\u202f]567/);
expect(enFmt.integer(1234567)).toBe("1,234,567");
});
it("formats percent", () => {
expect(deFmt.percent(0.835, 1)).toMatch(/83,5\s*%/);
expect(enFmt.percent(0.835, 1)).toMatch(/83\.5%/);
});
it("formats date in Europe/Berlin", () => {
// Both should include day/month/year; locale-specific order differs.
expect(deFmt.date(sample)).toContain("2026");
expect(enFmt.date(sample)).toContain("2026");
});
// v1.15.20 — the hour cycle follows the locale convention by default
// (AUTO). Pre-v1.15.20 both locales were pinned to 24h via `hour12:
// false`; the explicit preference replaced the pin.
it("formats time per the locale convention under AUTO", () => {
expect(deFmt.time(sample)).toBe("16:30");
expect(enFmt.time(sample)).toBe("04:30 PM");
});
describe("hour-cycle preference (v1.15.20)", () => {
it("AUTO follows the locale default for time + dateTime", () => {
const de = makeFormatters("de", undefined, "AUTO", "AUTO");
const en = makeFormatters("en", undefined, "AUTO", "AUTO");
expect(de.time(sample)).toBe("16:30");
expect(en.time(sample)).toBe("04:30 PM");
expect(de.dateTime(sample)).toBe("18.04.2026, 16:30");
expect(en.dateTime(sample)).toBe("04/18/2026, 04:30 PM");
});
it("H12 forces AM/PM regardless of locale", () => {
const de = makeFormatters("de", undefined, "H12", "AUTO");
const en = makeFormatters("en", undefined, "H12", "AUTO");
expect(de.time(sample)).toContain("PM");
expect(en.time(sample)).toBe("04:30 PM");
expect(de.dateTime(sample)).toContain("PM");
});
it("H24 forces the 24-hour clock regardless of locale", () => {
const de = makeFormatters("de", undefined, "H24", "AUTO");
const en = makeFormatters("en", undefined, "H24", "AUTO");
expect(de.time(sample)).toBe("16:30");
expect(en.time(sample)).toBe("16:30");
expect(en.dateTime(sample)).toBe("04/18/2026, 16:30");
});
it("H24 renders midnight as 00, never 24 (h23 cycle)", () => {
const midnight = new Date("2026-04-18T22:30:00Z"); // 00:30 Berlin CEST
const en = makeFormatters("en", undefined, "H24", "AUTO");
expect(en.time(midnight)).toBe("00:30");
});
it("does not affect date-only formatters", () => {
const auto = makeFormatters("en", undefined, "AUTO", "AUTO");
const h24 = makeFormatters("en", undefined, "H24", "AUTO");
expect(auto.date(sample)).toBe(h24.date(sample));
expect(auto.dateShort(sample)).toBe(h24.dateShort(sample));
});
});
// v1.4.25 W7 — formatters accept a per-user timezone override.
it("renders time in the user's tz when userTz is passed", () => {
const tokyo = makeFormatters("en", "Asia/Tokyo", "H24", "AUTO");
// 14:30 UTC = 23:30 Tokyo
expect(tokyo.time(sample)).toBe("23:30");
});
it("renders time in Europe/Berlin when userTz is empty", () => {
const fallback = makeFormatters("en", "", "H24", "AUTO");
expect(fallback.time(sample)).toBe("16:30");
});
it("renders date in the user's tz when userTz is passed", () => {
// 23:30 UTC on May 10 = 01:30 May 11 Tokyo
const lateUtc = new Date("2026-05-10T23:30:00Z");
const tokyo = makeFormatters("en", "Asia/Tokyo", "AUTO", "AUTO");
expect(tokyo.date(lateUtc)).toMatch(/2026/);
// The narrow check: day-of-month should be 11, not 10.
expect(tokyo.date(lateUtc)).toMatch(/11/);
});
// ── Issue #490 — profile-timezone display matrix ──────────────────────
//
// The client formatters now receive the mirrored profile zone. Pin the
// three matrix zones (east + DST, west of Berlin + DST, east no-DST)
// and the poison values that must NEVER reach `Intl.DateTimeFormat`
// unguarded (RangeError there would white-screen every date render).
describe("profile-timezone matrix (#490)", () => {
// sample = 2026-04-18T14:30:00Z (see above).
it("renders Pacific/Auckland (UTC+12, NZST after the April DST end)", () => {
const fmt = makeFormatters("en", "Pacific/Auckland", "H24", "AUTO");
expect(fmt.time(sample)).toBe("02:30"); // next local day
expect(fmt.date(sample)).toBe("04/19/2026");
});
it("renders America/New_York (west of Berlin, EDT)", () => {
const fmt = makeFormatters("en", "America/New_York", "H24", "AUTO");
expect(fmt.time(sample)).toBe("10:30");
expect(fmt.date(sample)).toBe("04/18/2026");
});
it("renders Asia/Manila (UTC+8, no DST)", () => {
const fmt = makeFormatters("en", "Asia/Manila", "H24", "AUTO");
expect(fmt.time(sample)).toBe("22:30");
expect(fmt.date(sample)).toBe("04/18/2026");
});
it.each([["Mars/Olympus"], ["garbage"], [""], [undefined]])(
"poison zone %s never throws and falls back to Berlin",
(zone) => {
const fmt = makeFormatters(
"en",
zone as string | undefined,
"H24",
"AUTO",
);
expect(fmt.time(sample)).toBe("16:30");
expect(fmt.date(sample)).toBe("04/18/2026");
expect(fmt.dateTime(sample)).toBe("04/18/2026, 16:30");
expect(fmt.dateWithWeekday(sample)).toContain("18");
expect(fmt.monthShort(sample)).toBe("Apr");
},
);
// The 23:30-Manila boundary pin: the rendered day label must equal the
// server's profile-tz day key for the same instant — the exact split
// #490 reported (Manila-grouped data under Berlin labels).
it("renders the same calendar day the server day-keys (Asia/Manila)", () => {
// YMD preference → the date renders as the ISO day key itself.
const manila = makeFormatters("en", "Asia/Manila", "H24", "YMD");
// 15:30 UTC = 23:30 Manila, still Jul 14 in Manila.
const lateManilaEvening = new Date("2026-07-14T15:30:00Z");
expect(manila.date(lateManilaEvening)).toBe("2026-07-14");
// 17:30 UTC = 01:30 Jul 15 Manila — Berlin (19:30 Jul 14) would
// label the previous day; the profile zone must win.
const pastManilaMidnight = new Date("2026-07-14T17:30:00Z");
expect(manila.date(pastManilaMidnight)).toBe("2026-07-15");
expect(manila.time(pastManilaMidnight)).toBe("01:30");
});
// Berlin DST pins — the zone maths must follow the IANA rules at the
// instant, never a cached offset.
it("renders across the Berlin 2026-03-29 spring-forward", () => {
const fmt = makeFormatters("de", "Europe/Berlin", "H24", "AUTO");
// 00:30 UTC = 01:30 CET (before the 02:00→03:00 jump).
expect(fmt.time(new Date("2026-03-29T00:30:00Z"))).toBe("01:30");
// 01:30 UTC = 03:30 CEST (the 02:xx hour does not exist).
expect(fmt.time(new Date("2026-03-29T01:30:00Z"))).toBe("03:30");
expect(fmt.date(new Date("2026-03-29T01:30:00Z"))).toBe("29.03.2026");
});
it("renders across the Berlin 2026-10-25 fall-back (doubled hour)", () => {
const fmt = makeFormatters("de", "Europe/Berlin", "H24", "AUTO");
// 00:30 UTC = 02:30 CEST (first pass through the doubled hour).
expect(fmt.time(new Date("2026-10-25T00:30:00Z"))).toBe("02:30");
// 01:30 UTC = 02:30 CET (second pass).
expect(fmt.time(new Date("2026-10-25T01:30:00Z"))).toBe("02:30");
expect(fmt.date(new Date("2026-10-25T01:30:00Z"))).toBe("25.10.2026");
});
});
// Issue #66 (date-format sweep) — `dateShortSmart` / `dateWithWeekdaySmart`
// add the year only when it differs from "now"'s year, so a chart axis,
// history list, or timeline never reads a bare "19.02." for a reading
// from a prior year and mistakes it for this year's. "Now" is faked so
// the boundary is deterministic regardless of the day this suite runs.
describe("conditional-year formatters (#66)", () => {
const RIGHT_NOW = new Date("2026-07-10T12:00:00Z");
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(RIGHT_NOW);
});
afterEach(() => {
vi.useRealTimers();
});
it("dateShortSmart omits the year for a date in the current year", () => {
const de = makeFormatters("de", undefined, "AUTO", "AUTO");
const en = makeFormatters("en", undefined, "AUTO", "AUTO");
const thisYear = new Date("2026-02-19T10:00:00Z");
expect(de.dateShortSmart(thisYear)).toBe("19.02.");
expect(en.dateShortSmart(thisYear)).not.toContain("2026");
});
it("dateShortSmart includes the year for a date from a prior year", () => {
const de = makeFormatters("de", undefined, "AUTO", "AUTO");
const en = makeFormatters("en", undefined, "AUTO", "AUTO");
// A December-of-last-year date — the exact regression this guards.
const lastDecember = new Date("2025-12-16T10:00:00Z");
expect(de.dateShortSmart(lastDecember)).toBe("16.12.2025");
expect(en.dateShortSmart(lastDecember)).toContain("2025");
});
it("dateWithWeekdaySmart mirrors the same boundary", () => {
const de = makeFormatters("de", undefined, "AUTO", "AUTO");
const thisYear = new Date("2026-02-19T10:00:00Z");
const lastDecember = new Date("2025-12-16T10:00:00Z");
expect(de.dateWithWeekdaySmart(thisYear)).not.toContain("2026");
expect(de.dateWithWeekdaySmart(lastDecember)).toContain("2025");
});
it("compares the year in the formatter's own timezone, not the host's", () => {
// 2026-01-01T00:30Z is still 2025-12-31 in America/New_York (UTC-5) —
// the profile-tz formatter must agree with what it actually prints,
// not with a UTC or host-local read of "now"/the value.
const nyFmt = makeFormatters("en", "America/New_York", "AUTO", "AUTO");
const newYearUtcEve = new Date("2026-01-01T00:30:00Z");
// RIGHT_NOW (2026-07-10) is year 2026 in New York too, so a value
// that prints as 2025 in New York must carry the year.
expect(nyFmt.dateShortSmart(newYearUtcEve)).toContain("2025");
});
});
});
// v1.25.3 — the time-format preference must reach every hour/minute renderer,
// not only the `makeFormatters().time` path. Some surfaces build their own
// `Intl.DateTimeFormat` because they render in the browser timezone (chart
// axes) or combine weekday + time in one label (workout list/detail). They now
// spread `hourCycleOptions(preference)` into the options. These tests pin both
// the helper contract and the exact call-site pattern, so a future renderer
// that drops the spread (re-introducing AM/PM under H24) fails here.
describe("hourCycleOptions", () => {
it("AUTO contributes nothing (locale default applies)", () => {
expect(hourCycleOptions("AUTO")).toEqual({});
});
it("H12 forces a 12-hour clock", () => {
expect(hourCycleOptions("H12")).toEqual({ hour12: true });
});
it("H24 forces the 24-hour h23 cycle", () => {
expect(hourCycleOptions("H24")).toEqual({ hourCycle: "h23" });
});
// The shared call-site shape used by sleep-hypnogram, workout-list and
// workout-detail: an `en-US` formatter (AM/PM by default) with hour+minute.
const sample = new Date("2026-04-18T14:30:00Z"); // 16:30 Europe/Berlin (CEST)
const buildLabel = (preference: Parameters<typeof hourCycleOptions>[0]) =>
new Intl.DateTimeFormat("en-US", {
timeZone: "Europe/Berlin",
hour: "2-digit",
minute: "2-digit",
...hourCycleOptions(preference),
}).format(sample);
it("an en-US hour/minute formatter drops AM/PM under H24", () => {
const label = buildLabel("H24");
expect(label).not.toMatch(/[AP]M/i);
expect(label).toBe("16:30");
});
it("an en-US hour/minute formatter keeps AM/PM under H12", () => {
expect(buildLabel("H12")).toMatch(/PM/);
});
it("an en-US hour/minute formatter follows the locale (AM/PM) under AUTO", () => {
expect(buildLabel("AUTO")).toMatch(/PM/);
});
});