mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Sweeps every surface that renders a date from before this year without a year suffix — illness episodes/flares/day-logs, lab and custom-metric charts, the mood chart family, medication titration/side-effects/dose- history/next-dose captions (incl. the Dec→Jan rollover), cycle predictions and history, the BBT chart, the intraday-pulse day-nav, the date-range filter chip, and the nutrient/compliance charts. Each now routes through dateShortSmart / dateWithWeekdaySmart (format-locale.ts) or the matching date-format.ts helper, which add the year only when it differs from the current one — mirroring the workout list's existing model instead of a bare "19.02." that reads as this year's. Also fixes two sites that bypassed the app's locale entirely: the trusted-devices expiry (bare toLocaleDateString, host locale) and the admin token-name timestamp (hard-coded de-DE + Europe/Berlin) now both render through the user's own locale, hour-cycle, and timezone preference.
188 lines
6.6 KiB
TypeScript
188 lines
6.6 KiB
TypeScript
/**
|
|
* 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import {
|
|
formatDate,
|
|
formatDateWithWeekday,
|
|
formatDateWithWeekdaySmart,
|
|
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("");
|
|
});
|
|
});
|
|
|
|
// ── Issue #490 — `@db.Date` / day-key rendering regressions ─────────────
|
|
//
|
|
// A serialised calendar date (`@db.Date` → UTC-midnight instant, or a raw
|
|
// profile-tz `yyyy-MM-dd` day key) must render the ENCODED day in every
|
|
// display zone — the pre-fix paths re-read the encoding in a timezone-aware
|
|
// formatter and shifted the day for zones west of UTC (medication one-shot
|
|
// `startsOn`) or for browser ≠ profile pairs (dose-ledger day headings).
|
|
describe("formatDateWithWeekday (#490)", () => {
|
|
// 2026-07-14 is a Tuesday.
|
|
it("renders a day key with its zone-independent weekday", () => {
|
|
expect(formatDateWithWeekday("2026-07-14", "AUTO", "en")).toBe(
|
|
"Tue, 07/14",
|
|
);
|
|
expect(formatDateWithWeekday("2026-07-14", "AUTO", "de")).toBe(
|
|
"Di., 14.07.",
|
|
);
|
|
});
|
|
|
|
it("honours the date-order preference like the formatter hook", () => {
|
|
expect(formatDateWithWeekday("2026-07-14", "DMY", "en")).toBe(
|
|
"Di., 14.07.",
|
|
);
|
|
expect(formatDateWithWeekday("2026-07-14", "MDY", "de")).toBe("Tue, 07/14");
|
|
});
|
|
|
|
it("renders a UTC-midnight Date on its encoded day (never the day before)", () => {
|
|
// The `@db.Date` wire shape — the exact value class the medication
|
|
// one-shot `startsOn` fix feeds through `formatDate`.
|
|
const utcMidnight = new Date("2026-07-01T00:00:00.000Z");
|
|
expect(formatDateWithWeekday(utcMidnight, "AUTO", "en")).toBe("Wed, 07/01");
|
|
expect(formatDate(utcMidnight, "AUTO", "en")).toBe("07/01/2026");
|
|
});
|
|
|
|
it("returns an empty string for empty / unparseable input", () => {
|
|
expect(formatDateWithWeekday("", "AUTO", "en")).toBe("");
|
|
expect(formatDateWithWeekday(null, "AUTO", "en")).toBe("");
|
|
expect(formatDateWithWeekday("14.07.2026", "AUTO", "en")).toBe("");
|
|
});
|
|
});
|
|
|
|
// Issue #66 (date-format sweep) — the dose-history ledger scrolls day
|
|
// groups across year boundaries; a bare "Di., 16.12." from last December
|
|
// must not read as this year's. "Now" is faked so the boundary is
|
|
// deterministic regardless of the day this suite runs.
|
|
describe("formatDateWithWeekdaySmart (#66)", () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-07-10T12:00:00Z"));
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("omits the year for a day key in the current UTC year", () => {
|
|
expect(formatDateWithWeekdaySmart("2026-07-14", "AUTO", "en")).toBe(
|
|
"Tue, 07/14",
|
|
);
|
|
});
|
|
|
|
it("adds the year for a day key from a prior UTC year", () => {
|
|
expect(formatDateWithWeekdaySmart("2025-12-16", "AUTO", "en")).toBe(
|
|
"Tue, 12/16/2025",
|
|
);
|
|
expect(formatDateWithWeekdaySmart("2025-12-16", "AUTO", "de")).toBe(
|
|
"Di., 16.12.2025",
|
|
);
|
|
});
|
|
|
|
it("returns an empty string for empty / unparseable input", () => {
|
|
expect(formatDateWithWeekdaySmart("", "AUTO", "en")).toBe("");
|
|
expect(formatDateWithWeekdaySmart(null, "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\./);
|
|
}
|
|
});
|
|
});
|