mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
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
321 lines
14 KiB
TypeScript
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/);
|
|
});
|
|
});
|