mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge pull request #925 from MBombeck/fix/date-format-everywhere
fix(format): carry the date-order preference to every numeric date
This commit is contained in:
27 files changed
+1086
-87
No files matched your search
@@ -24,6 +24,29 @@
|
||||
- **The import guide link under Settings → Import went the long way round.**
|
||||
Both the CSV and the JSON card now link straight to the guide instead of
|
||||
through a redirect.
|
||||
- **The date order you pick under Settings only reached the entry form.** A
|
||||
reporter switched away from MM/DD/YYYY, watched the date picker follow, and
|
||||
then found the dashboard chart axes and the measurements list still spelling
|
||||
every date the old way. The setting now reaches every surface that renders a
|
||||
numeric date: the health and mood chart axes and their tooltips, the
|
||||
measurements list, the medication compliance and efficacy charts, the
|
||||
doctor-report PDF, and the shared clinician view, which spells dates the way
|
||||
the person who owns the record reads them rather than the way the practice
|
||||
that opened the link does. That view also picked up the owner's 12-/24-hour
|
||||
preference on the way; it had been ignoring that while the PDF one route
|
||||
over already honoured it.
|
||||
|
||||
Textual months are deliberately unchanged. "18 Feb" versus "Feb 18" follows
|
||||
the app's language, because the alternative is a German month name in an
|
||||
English report.
|
||||
|
||||
The cause is worth naming, because it is a shape rather than an oversight:
|
||||
the formatter took the preference as an optional argument with a default, so
|
||||
the six places that never passed it compiled cleanly and silently rendered
|
||||
the locale default. The argument is required now, which turned "who forgot
|
||||
this" into a question the compiler answers, and a new check refuses a
|
||||
numeric date rendered outside the preference unless the reason is written
|
||||
down next to it.
|
||||
|
||||
## [1.38.8] — 2026-09-03
|
||||
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* A formatter that spells a date out of numbers has to say whose order it
|
||||
* spells it in.
|
||||
*
|
||||
* `timezone-explicit-formatter-guard.test.ts` asks the neighbouring question —
|
||||
* WHICH day is this — and a site answers it by passing `timeZone`. Answering
|
||||
* that one says nothing about the second: `new Intl.DateTimeFormat("en-US", {
|
||||
* timeZone: tz, day: "2-digit", month: "2-digit" })` names its calendar and
|
||||
* still prints 04/18 to a person who set the profile to day-first. That is
|
||||
* issue #922 — a reporter moved the profile off MM/DD/YYYY, the entry form
|
||||
* followed, the chart axes and the measurements list did not, and every
|
||||
* timezone check on those surfaces was green throughout.
|
||||
*
|
||||
* Two ratchets:
|
||||
*
|
||||
* 1. Every `makeFormatters` / `makeBucketLabelFormatters` construction is
|
||||
* inspected for a hard-coded "AUTO" in the DATE-ORDER slot. The compiler
|
||||
* now demands the argument (the `= "AUTO"` default is gone), so the only
|
||||
* way left to drop the preference is to type the literal — which reads
|
||||
* like a decision and usually is not one.
|
||||
* 2. Every raw `Intl.DateTimeFormat` / `toLocaleDateString` whose options
|
||||
* render an ORDER-BEARING numeric date must take its locale from the
|
||||
* preference (`resolveDateLocale` / `dateOrderLocale`), or be listed
|
||||
* below with a reason.
|
||||
*
|
||||
* What "order-bearing" excludes, deliberately:
|
||||
*
|
||||
* - A TEXTUAL month ("short" / "long"). The preference pins numeric field
|
||||
* order by rendering through a canonical locale (de-DE / en-US / en-CA),
|
||||
* and a textual month rendered through those would change the month
|
||||
* NAME's language — which belongs to the UI locale, not to this setting.
|
||||
* `makeFormatters().monthShort` already stays on the UI locale for the
|
||||
* same reason.
|
||||
* - `formatToParts()`. It hands back the fields and the caller reassembles
|
||||
* them, so there is no rendered order to get wrong. This is the shape
|
||||
* almost every calendar-day KEY in this tree is written in.
|
||||
*
|
||||
* What it cannot see, written down so the next reader does not assume
|
||||
* otherwise: a date assembled by hand out of `getUTCDate()` / `getMonth()`
|
||||
* and a template literal, a formatter built in one file and rendered in
|
||||
* another, and a locale argument that reaches the preference through a
|
||||
* variable this matcher does not recognise by name. It is a source-shape
|
||||
* ratchet, and its job is to make a new bare formatter arguable in writing
|
||||
* before it ships — not to prove the tree is clean.
|
||||
*/
|
||||
|
||||
const REPO_ROOT = resolve(__dirname, "../..");
|
||||
|
||||
const SOURCE_FILES = (): string[] =>
|
||||
execFileSync(
|
||||
"grep",
|
||||
[
|
||||
"-rlE",
|
||||
"new Intl\\.DateTimeFormat|\\.toLocale(Date|Time)String\\(|makeFormatters\\(|makeBucketLabelFormatters\\(",
|
||||
"src",
|
||||
"--include=*.ts",
|
||||
"--include=*.tsx",
|
||||
],
|
||||
{ cwd: REPO_ROOT, encoding: "utf8" },
|
||||
)
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.filter((f) => !f.includes("__tests__"));
|
||||
|
||||
/** The argument list starting at the `(` at or after `from`, plus its end. */
|
||||
function argumentList(
|
||||
source: string,
|
||||
from: number,
|
||||
): { args: string; end: number } {
|
||||
const open = source.indexOf("(", from);
|
||||
if (open === -1) return { args: "", end: from };
|
||||
let depth = 0;
|
||||
for (let i = open; i < source.length; i += 1) {
|
||||
if (source[i] === "(") depth += 1;
|
||||
else if (source[i] === ")") {
|
||||
depth -= 1;
|
||||
if (depth === 0) return { args: source.slice(open, i + 1), end: i + 1 };
|
||||
}
|
||||
}
|
||||
return { args: source.slice(open), end: source.length };
|
||||
}
|
||||
|
||||
/** Top-level comma split of an argument list including its parentheses. */
|
||||
export function splitArguments(list: string): string[] {
|
||||
const body = list.slice(1, -1);
|
||||
const out: string[] = [];
|
||||
let depth = 0;
|
||||
let quote: string | null = null;
|
||||
let current = "";
|
||||
for (const ch of body) {
|
||||
if (quote !== null) {
|
||||
current += ch;
|
||||
if (ch === quote) quote = null;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'" || ch === "`") {
|
||||
quote = ch;
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
if ("([{".includes(ch)) depth += 1;
|
||||
else if (")]}".includes(ch)) depth -= 1;
|
||||
if (ch === "," && depth === 0) {
|
||||
out.push(current.trim());
|
||||
current = "";
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
}
|
||||
if (current.trim() !== "") out.push(current.trim());
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the option bag renders a date whose FIELD ORDER is visible: a
|
||||
* numeric month next to a numeric day or a year. A textual month carries its
|
||||
* own order in the language and is not this preference's business.
|
||||
*/
|
||||
export function isOrderBearing(options: string): boolean {
|
||||
const numericMonth = /\bmonth\s*:\s*["'](?:2-digit|numeric)["']/.test(
|
||||
options,
|
||||
);
|
||||
if (!numericMonth) return false;
|
||||
return /\bday\s*:/.test(options) || /\byear\s*:/.test(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* `toLocaleDateString` with no field options at all renders the locale's
|
||||
* DEFAULT full numeric date — every field, in the locale's own order. That
|
||||
* is as order-bearing as an explicit `{ day, month, year }` bag and it was
|
||||
* the shape the first draft of this matcher walked straight past.
|
||||
*/
|
||||
export function rendersLocaleDefaultDate(args: string): boolean {
|
||||
return !/\b(day|month|year|weekday|hour|minute|second|dateStyle|timeStyle)\s*:/.test(
|
||||
args,
|
||||
);
|
||||
}
|
||||
|
||||
/** True when the locale argument is derived from the user's preference. */
|
||||
export function isPreferenceDrivenLocale(localeArg: string): boolean {
|
||||
return /resolveDateLocale\s*\(|dateOrderLocale\s*\(|\bdateLocale\b/.test(
|
||||
localeArg,
|
||||
);
|
||||
}
|
||||
|
||||
interface Site {
|
||||
file: string;
|
||||
line: number;
|
||||
}
|
||||
|
||||
interface Scan {
|
||||
/** Order-bearing raw renderers that do not take the preference. */
|
||||
bare: Site[];
|
||||
/** Order-bearing raw renderers that do. */
|
||||
pinned: Site[];
|
||||
/** Formatter constructions passing a literal "AUTO" date order. */
|
||||
literalAuto: Site[];
|
||||
/** Formatter constructions passing a real preference. */
|
||||
threaded: Site[];
|
||||
}
|
||||
|
||||
const RAW_PATTERNS = [
|
||||
/new Intl\.DateTimeFormat\s*\(/g,
|
||||
/\.toLocale(?:Date|Time)String\s*\(/g,
|
||||
];
|
||||
const BUILDER_PATTERN = /\b(makeFormatters|makeBucketLabelFormatters)\s*\(/g;
|
||||
|
||||
/** The argument index that carries the date-order preference. */
|
||||
const DATE_ORDER_ARG: Record<string, number> = {
|
||||
makeFormatters: 3,
|
||||
makeBucketLabelFormatters: 1,
|
||||
};
|
||||
|
||||
function scan(): Scan {
|
||||
const out: Scan = { bare: [], pinned: [], literalAuto: [], threaded: [] };
|
||||
for (const file of SOURCE_FILES()) {
|
||||
const source = readFileSync(resolve(REPO_ROOT, file), "utf8");
|
||||
const lineOf = (index: number) => source.slice(0, index).split("\n").length;
|
||||
|
||||
for (const pattern of RAW_PATTERNS) {
|
||||
pattern.lastIndex = 0;
|
||||
for (let m = pattern.exec(source); m !== null; m = pattern.exec(source)) {
|
||||
const { args, end } = argumentList(source, m.index + m[0].length - 1);
|
||||
const defaultDate =
|
||||
m[0].includes("toLocaleDateString") && rendersLocaleDefaultDate(args);
|
||||
if (!isOrderBearing(args) && !defaultDate) continue;
|
||||
// `.formatToParts(...)` produces fields, not an ordered string.
|
||||
if (/^\s*\.formatToParts\s*\(/.test(source.slice(end))) continue;
|
||||
const parts = splitArguments(args);
|
||||
const site = { file, line: lineOf(m.index) };
|
||||
if (isPreferenceDrivenLocale(parts[0] ?? "")) out.pinned.push(site);
|
||||
else out.bare.push(site);
|
||||
}
|
||||
}
|
||||
|
||||
BUILDER_PATTERN.lastIndex = 0;
|
||||
for (
|
||||
let m = BUILDER_PATTERN.exec(source);
|
||||
m !== null;
|
||||
m = BUILDER_PATTERN.exec(source)
|
||||
) {
|
||||
// Skip the declarations and the import lines themselves.
|
||||
const lineStart = source.lastIndexOf("\n", m.index) + 1;
|
||||
const prefix = source.slice(lineStart, m.index);
|
||||
if (/\b(export\s+)?function\s*$/.test(prefix) || /^import\b/.test(prefix))
|
||||
continue;
|
||||
const { args } = argumentList(source, m.index + m[0].length - 1);
|
||||
const parts = splitArguments(args);
|
||||
const index = DATE_ORDER_ARG[m[1]];
|
||||
const supplied = parts[index];
|
||||
if (supplied === undefined) continue;
|
||||
const site = { file, line: lineOf(m.index) };
|
||||
if (/^"AUTO"$|^'AUTO'$/.test(supplied)) out.literalAuto.push(site);
|
||||
else out.threaded.push(site);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Order-bearing numeric renderers that are correct without the preference.
|
||||
* Every entry says why. Keep it an inventory of exceptions, not a parking
|
||||
* space: a new one has to be argued for in writing before it can ship.
|
||||
*
|
||||
* Every entry here is the same shape — a CALENDAR-DAY KEY. The string is a
|
||||
* map key, a dedup key, a request payload or a DOM attribute; nobody reads
|
||||
* it, so it has no order to get wrong and pinning it to a user preference
|
||||
* would make the key move when the preference did.
|
||||
*/
|
||||
const RAW_ALLOWLIST: ReadonlyArray<{ file: string; why: string }> = [
|
||||
{
|
||||
file: "src/app/api/measurement-reminders/[id]/snooze/route.ts",
|
||||
why: "An ISO day probe split back into numbers to do day arithmetic on the postpone target. Never rendered.",
|
||||
},
|
||||
{
|
||||
file: "src/components/ui/calendar.tsx",
|
||||
why: "The `data-day` attribute on a calendar cell. It identifies a cell to the DOM, it does not spell a date to a reader.",
|
||||
},
|
||||
{
|
||||
file: "src/components/medications/dose-history-ledger-compute.ts",
|
||||
why: "The `yyyy-MM-dd` key the ledger groups doses by. The heading a reader sees is rendered separately, through the preference-aware `formatDateWithWeekdaySmart`.",
|
||||
},
|
||||
{
|
||||
file: "src/components/medications/dose-history-ledger.tsx",
|
||||
why: "The same key shape for `today`, compared against those buckets. A comparison, not a label.",
|
||||
},
|
||||
{
|
||||
file: "src/components/charts/health-chart.tsx",
|
||||
why: "`makeDayKeyFormatter` — the per-row bucketing key. Its fields are read back through `formatToParts` and reassembled as `YYYY-MM-DD`.",
|
||||
},
|
||||
{
|
||||
file: "src/components/measurement-reminders/vorsorge-section.tsx",
|
||||
why: "The ISO day the postpone request writes to the API. A wire value, and the wire format is ISO whatever the reader prefers.",
|
||||
},
|
||||
{
|
||||
file: "src/lib/gamification/achievements.ts",
|
||||
why: "The day key streak arithmetic counts on.",
|
||||
},
|
||||
{
|
||||
file: "src/lib/charts/bucket-time-series.ts",
|
||||
why: "Bucket boundary arithmetic. The bucket LABELS are a separate step and go through `makeBucketLabelFormatters`.",
|
||||
},
|
||||
{
|
||||
file: "src/lib/ai/prompts/insight-generator.ts",
|
||||
why: "An ISO day inside a model prompt. The model reads ISO; the user never sees this string.",
|
||||
},
|
||||
{
|
||||
file: "src/lib/jobs/measurement-reminder.ts",
|
||||
why: "The local day in the notification dedup key. Changing it with a display preference would re-send a claimed reminder.",
|
||||
},
|
||||
{
|
||||
file: "src/lib/jobs/reminder/medication-reminder-check.ts",
|
||||
why: "The same dedup key, plus the ISO day carried in notification metadata for the per-slot message ledger.",
|
||||
},
|
||||
{
|
||||
file: "src/lib/i18n/relative-time.ts",
|
||||
why: "The en-CA day keys that decide today / yesterday. A comparison between two keys, never printed.",
|
||||
},
|
||||
{
|
||||
file: "src/lib/tz/resolver.ts",
|
||||
why: "The Berlin day formatter the zone arithmetic itself is built on.",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Formatter constructions that hard-code "AUTO" for the date order because
|
||||
* there is genuinely no user preference to reach for. Empty today, and that
|
||||
* is the point: the compiler demands the argument, so an entry here is a
|
||||
* written admission that a surface renders in the locale default.
|
||||
*/
|
||||
const AUTO_ALLOWLIST: ReadonlyArray<{ file: string; why: string }> = [];
|
||||
|
||||
describe("numeric dates render in the user's field order", () => {
|
||||
const found = scan();
|
||||
|
||||
it("finds order-bearing sites at all (the guard must not pass vacuously)", () => {
|
||||
// If any of these drops to zero the matcher has stopped matching and
|
||||
// every assertion below became free. `pinned` proves the guard can
|
||||
// recognise a CORRECT site, not only flag every site it sees.
|
||||
expect(found.bare.length + found.pinned.length).toBeGreaterThan(10);
|
||||
expect(found.pinned.length).toBeGreaterThan(0);
|
||||
expect(found.threaded.length).toBeGreaterThan(5);
|
||||
});
|
||||
|
||||
it("has no unexplained numeric date outside the user's field order", () => {
|
||||
const unexplained = found.bare.filter(
|
||||
(h) => !RAW_ALLOWLIST.some((a) => a.file === h.file),
|
||||
);
|
||||
if (unexplained.length > 0) {
|
||||
throw new Error(
|
||||
`${unexplained.length} formatter call(s) spell a numeric date without the user's field order:\n\n` +
|
||||
unexplained.map((h) => ` ${h.file}:${h.line}`).join("\n") +
|
||||
"\n\nA profile set to day-first still reads 04/18 here. Render through " +
|
||||
"`useFormatters()` (client), `makeFormatters(locale, tz, timeFormat, dateFormat)` " +
|
||||
"(server), or `resolveDateLocale(pref, locale)` for a bare `Intl` call. " +
|
||||
"If the string is a calendar-day KEY rather than a label, add the file to " +
|
||||
"RAW_ALLOWLIST in this test with the reason.",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("has no formatter construction that hard-codes AUTO for the date order", () => {
|
||||
// The compiler already demands the argument. This catches the other half:
|
||||
// typing the literal to make it compile, which reads like a decision.
|
||||
const unexplained = found.literalAuto.filter(
|
||||
(h) => !AUTO_ALLOWLIST.some((a) => a.file === h.file),
|
||||
);
|
||||
expect(
|
||||
unexplained.map((h) => `${h.file}:${h.line}`),
|
||||
"a formatter was built with a hard-coded AUTO date order; pass the user's preference or add the file to AUTO_ALLOWLIST with a reason",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("carries no allowlist entry that no longer has a bare formatter", () => {
|
||||
// A stale exception documents a decision about code that has moved on,
|
||||
// and quietly covers whatever lands in that file next.
|
||||
for (const entry of RAW_ALLOWLIST) {
|
||||
expect(
|
||||
found.bare.some((h) => h.file === entry.file),
|
||||
`stale allowlist entry — ${entry.file} no longer has a bare order-bearing formatter`,
|
||||
).toBe(true);
|
||||
}
|
||||
for (const entry of AUTO_ALLOWLIST) {
|
||||
expect(
|
||||
found.literalAuto.some((h) => h.file === entry.file),
|
||||
`stale allowlist entry — ${entry.file} no longer hard-codes AUTO`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the preference parameters mandatory on makeFormatters", () => {
|
||||
// The ratchets above both read CALL SITES, and a call site that simply
|
||||
// stops passing the argument is invisible to them — it is the compiler
|
||||
// that catches that, and only while the parameter has no default. Put
|
||||
// the default back and every dropped preference compiles again, silently,
|
||||
// which is the exact state issue #922 shipped in. So pin the signature.
|
||||
const source = readFileSync(
|
||||
resolve(REPO_ROOT, "src/lib/format-locale.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const signature = source.slice(
|
||||
source.indexOf("export function makeFormatters("),
|
||||
source.indexOf("): Formatters {"),
|
||||
);
|
||||
expect(signature, "makeFormatters signature not found").toContain(
|
||||
"dateFormat",
|
||||
);
|
||||
expect(
|
||||
/dateFormat\s*:\s*DateFormatPreference\s*=/.test(signature),
|
||||
"dateFormat has a default again — every caller that forgets it now compiles silently",
|
||||
).toBe(false);
|
||||
expect(
|
||||
/timeFormat\s*:\s*TimeFormatPreference\s*=/.test(signature),
|
||||
"timeFormat has a default again — same failure mode, one preference over",
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("tells an order-bearing option bag from a textual one", () => {
|
||||
// The distinction the whole guard rests on. A textual month carries its
|
||||
// order in the language and is the UI locale's business, not this
|
||||
// preference's — pinning it to de-DE would rename "Apr" to "Apr." and
|
||||
// "February" to "Februar" for an English reader.
|
||||
expect(isOrderBearing('{ day: "2-digit", month: "2-digit" }')).toBe(true);
|
||||
expect(isOrderBearing('{ month: "numeric", year: "numeric" }')).toBe(true);
|
||||
expect(isOrderBearing('{ day: "numeric", month: "short" }')).toBe(false);
|
||||
expect(isOrderBearing('{ month: "long", year: "numeric" }')).toBe(false);
|
||||
expect(isOrderBearing('{ weekday: "short" }')).toBe(false);
|
||||
expect(isOrderBearing('{ hour: "2-digit", minute: "2-digit" }')).toBe(
|
||||
false,
|
||||
);
|
||||
// A bare `toLocaleDateString` renders every field in the locale's order.
|
||||
expect(rendersLocaleDefaultDate("()")).toBe(true);
|
||||
expect(rendersLocaleDefaultDate('("sv-SE", { timeZone: tz })')).toBe(true);
|
||||
expect(rendersLocaleDefaultDate('("en", { weekday: "short" })')).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("recognises a preference-driven locale argument", () => {
|
||||
expect(isPreferenceDrivenLocale("resolveDateLocale(pref, locale)")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isPreferenceDrivenLocale("dateLocale")).toBe(true);
|
||||
expect(isPreferenceDrivenLocale('"en-US"')).toBe(false);
|
||||
expect(isPreferenceDrivenLocale("resolveIntlLocale(locale)")).toBe(false);
|
||||
});
|
||||
|
||||
it("reads the date-order argument out of the right slot", () => {
|
||||
// `makeFormatters(locale, tz, timeFormat, dateFormat)` — the preference
|
||||
// is the FOURTH argument, and an off-by-one here would read the hour
|
||||
// cycle instead and report every correct site as a violation.
|
||||
expect(
|
||||
splitArguments('(locale, userTimezone, "AUTO", dateFormat)')[
|
||||
DATE_ORDER_ARG.makeFormatters
|
||||
],
|
||||
).toBe("dateFormat");
|
||||
expect(
|
||||
splitArguments('(locale, "UTC", "AUTO", "AUTO")')[
|
||||
DATE_ORDER_ARG.makeFormatters
|
||||
],
|
||||
).toBe('"AUTO"');
|
||||
expect(
|
||||
splitArguments("(locale, dateFormat)")[
|
||||
DATE_ORDER_ARG.makeBucketLabelFormatters
|
||||
],
|
||||
).toBe("dateFormat");
|
||||
// Commas inside a nested call or object must not shift the slot.
|
||||
expect(
|
||||
splitArguments('(locale, resolveTz({ a: 1, b: 2 }), "AUTO", pref)')[
|
||||
DATE_ORDER_ARG.makeFormatters
|
||||
],
|
||||
).toBe("pref");
|
||||
});
|
||||
});
|
||||
@@ -95,6 +95,8 @@ function render(
|
||||
report,
|
||||
selection: selectionFromLeaves(leaves),
|
||||
unavailableLeaves,
|
||||
timeFormat: "AUTO",
|
||||
dateFormat: "AUTO",
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -529,6 +531,8 @@ describe("clinician view — the machine-format downloads", () => {
|
||||
selection: selectionFromLeaves(["WEIGHT"]),
|
||||
documentOnly,
|
||||
token,
|
||||
timeFormat: "AUTO",
|
||||
dateFormat: "AUTO",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -169,7 +169,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
resolveUserTimezone(user.id),
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { insuranceNumberEncrypted: true, timeFormat: true },
|
||||
select: {
|
||||
insuranceNumberEncrypted: true,
|
||||
timeFormat: true,
|
||||
// Issue #922 — the exported PDF is spelled the way the person
|
||||
// who exported it reads dates, not the locale default.
|
||||
dateFormat: true,
|
||||
},
|
||||
}),
|
||||
// Structured records are always-available reference data (not
|
||||
// time-windowed), so they ride alongside the report rather than through
|
||||
@@ -220,6 +226,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
locale,
|
||||
userTz,
|
||||
timeFormat: userRow?.timeFormat ?? "AUTO",
|
||||
dateFormat: userRow?.dateFormat ?? "AUTO",
|
||||
t,
|
||||
};
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { resolveShareViewLocale } from "@/lib/clinician-share/request-locale";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { resolveUserFormatPreferences } from "@/lib/user-format-preferences";
|
||||
import { ClinicianView } from "@/components/clinician/clinician-view";
|
||||
import { ShareUnlockGate } from "@/components/clinician/share-unlock-gate";
|
||||
|
||||
@@ -96,10 +97,16 @@ export default async function ClinicianSharePage({
|
||||
{ report, selection, unavailableLeaves, documents, documentOnly },
|
||||
locale,
|
||||
ownerTimezone,
|
||||
// Issue #922 — the owner's hour cycle and date order, alongside their
|
||||
// zone. The PDF at `report.pdf` already read the hour cycle off the
|
||||
// owner row; this page read neither, so the same record printed two
|
||||
// different spellings depending on which button the practice pressed.
|
||||
ownerPrefs,
|
||||
] = await Promise.all([
|
||||
loadShareViewData(context),
|
||||
resolveShareViewLocale(),
|
||||
resolveUserTimezone(context.ownerUserId),
|
||||
resolveUserFormatPreferences(context.ownerUserId),
|
||||
]);
|
||||
const { t } = getServerTranslator(locale);
|
||||
|
||||
@@ -116,6 +123,8 @@ export default async function ClinicianSharePage({
|
||||
token={token}
|
||||
locale={locale}
|
||||
timezone={ownerTimezone}
|
||||
timeFormat={ownerPrefs.timeFormat}
|
||||
dateFormat={ownerPrefs.dateFormat}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -42,6 +42,7 @@ export const GET = apiHandler(
|
||||
// off from the page it was downloaded from.
|
||||
userTz: resolved.ownerTz,
|
||||
timeFormat: resolved.ownerTimeFormat,
|
||||
dateFormat: resolved.ownerDateFormat,
|
||||
insuranceNumber: null,
|
||||
includeCharts: true,
|
||||
});
|
||||
|
||||
@@ -35,7 +35,11 @@ import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
import { computePaddedYDomain } from "@/lib/insights/chart-y-domain";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import {
|
||||
useDateFormatPreference,
|
||||
useTranslations,
|
||||
useFormatters,
|
||||
} from "@/lib/i18n/context";
|
||||
import { makeBucketLabelFormatters } from "@/lib/charts/bucket-label";
|
||||
import {
|
||||
bucketTimeSeries,
|
||||
@@ -631,7 +635,13 @@ export function HealthChart({
|
||||
// encodings through a profile-tz formatter shifted week/month bucket
|
||||
// labels a day/month back for zones west of Berlin. Day-key BUCKETING
|
||||
// below still uses `userTimezone` — only labels are pinned.
|
||||
const tzFmt = useMemo(() => makeBucketLabelFormatters(locale), [locale]);
|
||||
// Issue #922 — the UTC pin decides WHICH day the label names; the
|
||||
// profile's date order decides how it is spelled. Both travel.
|
||||
const dateFormat = useDateFormatPreference();
|
||||
const tzFmt = useMemo(
|
||||
() => makeBucketLabelFormatters(locale, dateFormat),
|
||||
[locale, dateFormat],
|
||||
);
|
||||
// v1.4.25 W7b — per-row day-key formatter used to bucket measurement
|
||||
// rows by the user's local calendar day. Memoised on userTimezone so
|
||||
// the inner per-row loop reuses a single Intl.DateTimeFormat.
|
||||
|
||||
@@ -51,7 +51,11 @@ import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import {
|
||||
useDateFormatPreference,
|
||||
useTranslations,
|
||||
useFormatters,
|
||||
} from "@/lib/i18n/context";
|
||||
import { formatDateShort } from "@/lib/format";
|
||||
import { makeFormatters } from "@/lib/format-locale";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -221,9 +225,13 @@ export function MedicationComplianceChart({
|
||||
const [days, setDays] = useState<RangeDays>(30);
|
||||
// v1.4.25 W7b — tz-aware date formatter for the x-axis labels and
|
||||
// the tooltip's `dateLabel`. Same pattern as health-chart + mood-chart.
|
||||
// Issue #922 — the axis labels and the tooltip date follow the profile's
|
||||
// date order. The hour cycle is AUTO on purpose: this formatter renders
|
||||
// days only (`dateShortSmart` / `date`), never a clock.
|
||||
const dateFormat = useDateFormatPreference();
|
||||
const tzFmt = useMemo(
|
||||
() => makeFormatters(locale, userTimezone),
|
||||
[locale, userTimezone],
|
||||
() => makeFormatters(locale, userTimezone, "AUTO", dateFormat),
|
||||
[locale, userTimezone, dateFormat],
|
||||
);
|
||||
|
||||
// v1.4.18 — three overlay toggles persisted per chart. The 7-day
|
||||
|
||||
@@ -20,7 +20,7 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Card, CardHeader, CardContent } from "@/components/ui/card";
|
||||
import { TagChip } from "@/components/ui/tag-chip";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useDateFormatPreference, useTranslations } from "@/lib/i18n/context";
|
||||
import { makeBucketLabelFormatters } from "@/lib/charts/bucket-label";
|
||||
import { readStoredTimezone } from "@/lib/timezone-mirror";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
@@ -326,7 +326,13 @@ export function MoodChart({
|
||||
// labels render UTC-pinned via `makeBucketLabelFormatters` — the
|
||||
// encoded day paints verbatim in every profile zone (a profile-tz
|
||||
// formatter shifted week/month bucket labels for zones west of Berlin).
|
||||
const tzFmt = useMemo(() => makeBucketLabelFormatters(locale), [locale]);
|
||||
// Issue #922 — the UTC pin decides WHICH day the label names; the
|
||||
// profile's date order decides how it is spelled. Both travel.
|
||||
const dateFormat = useDateFormatPreference();
|
||||
const tzFmt = useMemo(
|
||||
() => makeBucketLabelFormatters(locale, dateFormat),
|
||||
[locale, dateFormat],
|
||||
);
|
||||
// The bucket BOUNDARIES follow the profile, even though the bucket LABELS
|
||||
// stay UTC-pinned (see above): a week that starts on the wrong day puts a
|
||||
// reading in the wrong bar, which is a different mistake from a label that
|
||||
|
||||
@@ -239,6 +239,8 @@ describe("<ClinicianView> resolves every label in every locale", () => {
|
||||
report: FULL_RECORD,
|
||||
selection: selectionFromLeaves(ALL_LEAF_IDS),
|
||||
locale,
|
||||
timeFormat: "AUTO",
|
||||
dateFormat: "AUTO",
|
||||
}),
|
||||
);
|
||||
// Strip the tags, so the `data-leaf` enum lists and the class names go
|
||||
|
||||
@@ -66,7 +66,12 @@ function render(
|
||||
extra?: Partial<
|
||||
Pick<
|
||||
React.ComponentProps<typeof ClinicianView>,
|
||||
"documents" | "token" | "locale" | "selection"
|
||||
| "documents"
|
||||
| "token"
|
||||
| "locale"
|
||||
| "selection"
|
||||
| "timeFormat"
|
||||
| "dateFormat"
|
||||
>
|
||||
>,
|
||||
) {
|
||||
@@ -78,6 +83,8 @@ function render(
|
||||
expiresAt="2026-03-01T00:00:00.000Z"
|
||||
report={report}
|
||||
selection={selectionFromLeaves(ALL_LEAF_IDS)}
|
||||
timeFormat="AUTO"
|
||||
dateFormat="AUTO"
|
||||
{...extra}
|
||||
/>,
|
||||
);
|
||||
@@ -295,3 +302,56 @@ describe("<ClinicianView>", () => {
|
||||
expect(html).toContain("Referral letter");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Issue #922 — a share link shows the OWNER's record, so it is spelled the
|
||||
* owner's way. The page passed neither the owner's hour cycle nor their date
|
||||
* order, while the PDF one route over already read the hour cycle off the
|
||||
* owner row — so the same record could carry two different spellings
|
||||
* depending on which button the practice pressed.
|
||||
*/
|
||||
describe("<ClinicianView> renders in the owner's date order", () => {
|
||||
// 2026-03-01: day, month and year are mutually distinguishable, so the
|
||||
// assertion reads the ORDER rather than a coincidence of equal fields.
|
||||
const CASES = [
|
||||
{ dateFormat: "DMY" as const, expected: "01.03.2026" },
|
||||
{ dateFormat: "MDY" as const, expected: "03/01/2026" },
|
||||
{ dateFormat: "YMD" as const, expected: "2026-03-01" },
|
||||
];
|
||||
|
||||
for (const { dateFormat, expected } of CASES) {
|
||||
it(`spells the expiry ${expected} under ${dateFormat}`, () => {
|
||||
const { t } = getServerTranslator("en");
|
||||
const html = renderToStaticMarkup(
|
||||
<ClinicianView
|
||||
t={(k, v) => t(k, v)}
|
||||
label="Cardiology clinic"
|
||||
expiresAt="2026-03-01T12:00:00.000Z"
|
||||
report={makeReport()}
|
||||
selection={selectionFromLeaves(ALL_LEAF_IDS)}
|
||||
timezone="UTC"
|
||||
timeFormat="AUTO"
|
||||
dateFormat={dateFormat}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain(expected);
|
||||
});
|
||||
}
|
||||
|
||||
it("follows the viewer locale under AUTO", () => {
|
||||
const { t } = getServerTranslator("en");
|
||||
const html = renderToStaticMarkup(
|
||||
<ClinicianView
|
||||
t={(k, v) => t(k, v)}
|
||||
label="Cardiology clinic"
|
||||
expiresAt="2026-03-01T12:00:00.000Z"
|
||||
report={makeReport()}
|
||||
selection={selectionFromLeaves(ALL_LEAF_IDS)}
|
||||
timezone="UTC"
|
||||
timeFormat="AUTO"
|
||||
dateFormat="AUTO"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("03/01/2026");
|
||||
});
|
||||
});
|
||||
@@ -30,7 +30,11 @@
|
||||
* document list in `./documents-list`, the downloads in `./download-actions`.
|
||||
*/
|
||||
import type { DoctorReportData } from "@/lib/doctor-report-data";
|
||||
import { makeFormatters } from "@/lib/format-locale";
|
||||
import {
|
||||
makeFormatters,
|
||||
type DateFormatPreference,
|
||||
type TimeFormatPreference,
|
||||
} from "@/lib/format-locale";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import type { ShareViewDocument } from "@/lib/clinician-share/share-view-data";
|
||||
import type { ReportLeafId } from "@/lib/report-selection/catalogue";
|
||||
@@ -112,6 +116,15 @@ interface ClinicianViewProps {
|
||||
* aggregation behind the stats (and with the doctor-report PDF).
|
||||
*/
|
||||
timezone?: string;
|
||||
/**
|
||||
* Issue #922 — the share OWNER's hour cycle and date order. This is the
|
||||
* owner's record, so it is spelled the owner's way; the viewing clinician
|
||||
* has no profile here. Both travel the same route as `timezone` above
|
||||
* (resolved off the owner row in the page), and both are REQUIRED so a
|
||||
* future caller cannot quietly drop the preference again.
|
||||
*/
|
||||
timeFormat: TimeFormatPreference;
|
||||
dateFormat: DateFormatPreference;
|
||||
}
|
||||
|
||||
export function ClinicianView({
|
||||
@@ -126,10 +139,13 @@ export function ClinicianView({
|
||||
token = "",
|
||||
locale = "en",
|
||||
timezone,
|
||||
timeFormat,
|
||||
dateFormat,
|
||||
}: ClinicianViewProps) {
|
||||
// Owner-tz, locale-aware date rendering (issue #490) — `makeFormatters`
|
||||
// guards the zone and falls back to Europe/Berlin on garbage/absence.
|
||||
const fmt = makeFormatters(locale, timezone);
|
||||
// Owner hour cycle + date order ride along (issue #922).
|
||||
const fmt = makeFormatters(locale, timezone, timeFormat, dateFormat);
|
||||
const fmtDate = (iso: string) => fmt.date(new Date(iso));
|
||||
const fmtDateTime = (iso: string) => fmt.dateTime(new Date(iso));
|
||||
const fmtNum = (n: number) => Math.round(n * 100) / 100;
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
ReferenceArea,
|
||||
} from "recharts";
|
||||
import { makeFormatters } from "@/lib/format-locale";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useDateFormatPreference, useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
export interface EfficacyChartTarget {
|
||||
label: string;
|
||||
@@ -67,9 +67,13 @@ export function EfficacyChart({
|
||||
mode = "absolute",
|
||||
}: EfficacyChartProps) {
|
||||
const { locale } = useTranslations();
|
||||
// Issue #922 — the axis ticks and the tooltip label follow the profile's
|
||||
// date order. The hour cycle is AUTO on purpose: this formatter renders
|
||||
// days only (`fmt.date`), never a clock.
|
||||
const dateFormat = useDateFormatPreference();
|
||||
const fmt = useMemo(
|
||||
() => makeFormatters(locale, timezone),
|
||||
[locale, timezone],
|
||||
() => makeFormatters(locale, timezone, "AUTO", dateFormat),
|
||||
[locale, timezone, dateFormat],
|
||||
);
|
||||
|
||||
const seriesData = useMemo(
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* 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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -97,6 +97,8 @@ describe("renderDoctorReportPdfBytes", () => {
|
||||
it("returns a Uint8Array starting with the %PDF- header", () => {
|
||||
const { t } = getServerTranslator("de");
|
||||
const bytes = renderDoctorReportPdfBytes(makeData(), {
|
||||
timeFormat: "AUTO",
|
||||
dateFormat: "AUTO",
|
||||
t,
|
||||
locale: "de",
|
||||
now: FIXED_NOW,
|
||||
@@ -109,6 +111,8 @@ describe("renderDoctorReportPdfBytes", () => {
|
||||
it("produces a non-trivial document (> 1 KB)", () => {
|
||||
const { t } = getServerTranslator("de");
|
||||
const bytes = renderDoctorReportPdfBytes(makeData(), {
|
||||
timeFormat: "AUTO",
|
||||
dateFormat: "AUTO",
|
||||
t,
|
||||
locale: "de",
|
||||
now: FIXED_NOW,
|
||||
@@ -123,11 +127,15 @@ describe("renderDoctorReportPdfBytes", () => {
|
||||
// strict bytewise compare.
|
||||
const { t } = getServerTranslator("de");
|
||||
const a = renderDoctorReportPdfBytes(makeData(), {
|
||||
timeFormat: "AUTO",
|
||||
dateFormat: "AUTO",
|
||||
t,
|
||||
locale: "de",
|
||||
now: FIXED_NOW,
|
||||
});
|
||||
const b = renderDoctorReportPdfBytes(makeData(), {
|
||||
timeFormat: "AUTO",
|
||||
dateFormat: "AUTO",
|
||||
t,
|
||||
locale: "de",
|
||||
now: FIXED_NOW,
|
||||
@@ -142,11 +150,15 @@ describe("renderDoctorReportPdfBytes", () => {
|
||||
|
||||
it("renders both DE and EN locales without errors", () => {
|
||||
const de = renderDoctorReportPdfBytes(makeData(), {
|
||||
timeFormat: "AUTO",
|
||||
dateFormat: "AUTO",
|
||||
t: getServerTranslator("de").t,
|
||||
locale: "de",
|
||||
now: FIXED_NOW,
|
||||
});
|
||||
const en = renderDoctorReportPdfBytes(makeData(), {
|
||||
timeFormat: "AUTO",
|
||||
dateFormat: "AUTO",
|
||||
t: getServerTranslator("en").t,
|
||||
locale: "en",
|
||||
now: FIXED_NOW,
|
||||
@@ -165,6 +177,8 @@ describe("renderDoctorReportPdfBytes", () => {
|
||||
bmi: null,
|
||||
});
|
||||
const bytes = renderDoctorReportPdfBytes(empty, {
|
||||
timeFormat: "AUTO",
|
||||
dateFormat: "AUTO",
|
||||
t: getServerTranslator("de").t,
|
||||
locale: "de",
|
||||
now: FIXED_NOW,
|
||||
@@ -189,6 +203,8 @@ describe("renderDoctorReportPdfBytes", () => {
|
||||
},
|
||||
});
|
||||
const bytes = renderDoctorReportPdfBytes(data, {
|
||||
timeFormat: "AUTO",
|
||||
dateFormat: "AUTO",
|
||||
t: getServerTranslator("en").t,
|
||||
locale: "en",
|
||||
now: FIXED_NOW,
|
||||
@@ -206,6 +222,8 @@ describe("renderDoctorReportPdfBytes", () => {
|
||||
|
||||
it("omits the sleep vitals row when there are no sleep stats", async () => {
|
||||
const bytes = renderDoctorReportPdfBytes(makeData(), {
|
||||
timeFormat: "AUTO",
|
||||
dateFormat: "AUTO",
|
||||
t: getServerTranslator("en").t,
|
||||
locale: "en",
|
||||
now: FIXED_NOW,
|
||||
@@ -218,6 +236,8 @@ describe("renderDoctorReportPdfBytes", () => {
|
||||
describe("buildDoctorReportPdfDocument", () => {
|
||||
it("returns a jsPDF doc with at least one page", () => {
|
||||
const doc = buildDoctorReportPdfDocument(makeData(), {
|
||||
timeFormat: "AUTO",
|
||||
dateFormat: "AUTO",
|
||||
t: getServerTranslator("de").t,
|
||||
locale: "de",
|
||||
now: FIXED_NOW,
|
||||
@@ -256,6 +276,8 @@ describe("sanitiseForPdf", () => {
|
||||
} as unknown as DoctorReportData["glp1"],
|
||||
});
|
||||
const bytes = renderDoctorReportPdfBytes(data, {
|
||||
timeFormat: "AUTO",
|
||||
dateFormat: "AUTO",
|
||||
t: getServerTranslator("de").t,
|
||||
locale: "de",
|
||||
now: FIXED_NOW,
|
||||
@@ -282,6 +304,8 @@ describe("doctor-report sparkline time axis", () => {
|
||||
},
|
||||
});
|
||||
const bytes = renderDoctorReportPdfBytes(data, {
|
||||
timeFormat: "AUTO",
|
||||
dateFormat: "AUTO",
|
||||
t: getServerTranslator("de").t,
|
||||
locale: "de",
|
||||
includeCharts: true,
|
||||
@@ -308,6 +332,8 @@ describe("doctor-report pagination", () => {
|
||||
};
|
||||
}
|
||||
const doc = buildDoctorReportPdfDocument(makeData({ compliance }), {
|
||||
timeFormat: "AUTO",
|
||||
dateFormat: "AUTO",
|
||||
t: getServerTranslator("de").t,
|
||||
locale: "de",
|
||||
now: FIXED_NOW,
|
||||
@@ -362,6 +388,8 @@ describe("doctor-report-pdf-core type-map coverage", () => {
|
||||
},
|
||||
});
|
||||
const bytes = renderDoctorReportPdfBytes(data, {
|
||||
timeFormat: "AUTO",
|
||||
dateFormat: "AUTO",
|
||||
t: getServerTranslator("de").t,
|
||||
locale: "de",
|
||||
now: FIXED_NOW,
|
||||
@@ -387,6 +415,8 @@ describe("doctor-report-pdf-core type-map coverage", () => {
|
||||
},
|
||||
});
|
||||
const bytes = renderDoctorReportPdfBytes(data, {
|
||||
timeFormat: "AUTO",
|
||||
dateFormat: "AUTO",
|
||||
t: getServerTranslator("de").t,
|
||||
locale: "de",
|
||||
now: FIXED_NOW,
|
||||
@@ -402,6 +432,8 @@ describe("doctor-report-pdf-core type-map coverage", () => {
|
||||
it("renders the practice name on the cover when supplied (DE)", async () => {
|
||||
const data = makeData({ practiceName: "Praxis Dr. Müller" });
|
||||
const bytes = renderDoctorReportPdfBytes(data, {
|
||||
timeFormat: "AUTO",
|
||||
dateFormat: "AUTO",
|
||||
t: getServerTranslator("de").t,
|
||||
locale: "de",
|
||||
now: FIXED_NOW,
|
||||
@@ -414,6 +446,8 @@ describe("doctor-report-pdf-core type-map coverage", () => {
|
||||
it("renders the practice name on the cover when supplied (EN)", async () => {
|
||||
const data = makeData({ practiceName: "Family Practice Smith & Co." });
|
||||
const bytes = renderDoctorReportPdfBytes(data, {
|
||||
timeFormat: "AUTO",
|
||||
dateFormat: "AUTO",
|
||||
t: getServerTranslator("en").t,
|
||||
locale: "en",
|
||||
now: FIXED_NOW,
|
||||
@@ -426,6 +460,8 @@ describe("doctor-report-pdf-core type-map coverage", () => {
|
||||
it("omits the practice line when practiceName is null", async () => {
|
||||
const data = makeData({ practiceName: null });
|
||||
const bytes = renderDoctorReportPdfBytes(data, {
|
||||
timeFormat: "AUTO",
|
||||
dateFormat: "AUTO",
|
||||
t: getServerTranslator("de").t,
|
||||
locale: "de",
|
||||
now: FIXED_NOW,
|
||||
@@ -445,6 +481,8 @@ describe("doctor-report-pdf-core type-map coverage", () => {
|
||||
},
|
||||
});
|
||||
const bytes = renderDoctorReportPdfBytes(data, {
|
||||
timeFormat: "AUTO",
|
||||
dateFormat: "AUTO",
|
||||
t: getServerTranslator("de").t,
|
||||
locale: "de",
|
||||
now: FIXED_NOW,
|
||||
@@ -472,6 +510,8 @@ describe("doctor-report-pdf-core type-map coverage", () => {
|
||||
},
|
||||
});
|
||||
const bytes = renderDoctorReportPdfBytes(data, {
|
||||
timeFormat: "AUTO",
|
||||
dateFormat: "AUTO",
|
||||
t: getServerTranslator("en").t,
|
||||
locale: "en",
|
||||
now: FIXED_NOW,
|
||||
@@ -510,6 +550,8 @@ describe("doctor-report clinical glucose panel", () => {
|
||||
});
|
||||
expect(data.glucoseClinical.readingCount).toBeGreaterThan(0);
|
||||
const bytes = renderDoctorReportPdfBytes(data, {
|
||||
timeFormat: "AUTO",
|
||||
dateFormat: "AUTO",
|
||||
t: getServerTranslator("en").t,
|
||||
locale: "en",
|
||||
now: FIXED_NOW,
|
||||
@@ -523,6 +565,8 @@ describe("doctor-report clinical glucose panel", () => {
|
||||
it("omits the clinical panel when there are no glucose readings", async () => {
|
||||
const data = makeData(); // empty (zero-reading) glucose panel
|
||||
const bytes = renderDoctorReportPdfBytes(data, {
|
||||
timeFormat: "AUTO",
|
||||
dateFormat: "AUTO",
|
||||
t: getServerTranslator("en").t,
|
||||
locale: "en",
|
||||
now: FIXED_NOW,
|
||||
@@ -549,6 +593,8 @@ describe("doctor-report illness section", () => {
|
||||
],
|
||||
});
|
||||
const bytes = renderDoctorReportPdfBytes(data, {
|
||||
timeFormat: "AUTO",
|
||||
dateFormat: "AUTO",
|
||||
t: getServerTranslator("en").t,
|
||||
locale: "en",
|
||||
now: FIXED_NOW,
|
||||
@@ -571,6 +617,8 @@ describe("doctor-report illness section", () => {
|
||||
],
|
||||
});
|
||||
const bytes = renderDoctorReportPdfBytes(data, {
|
||||
timeFormat: "AUTO",
|
||||
dateFormat: "AUTO",
|
||||
t: getServerTranslator("en").t,
|
||||
locale: "en",
|
||||
now: FIXED_NOW,
|
||||
@@ -582,6 +630,8 @@ describe("doctor-report illness section", () => {
|
||||
|
||||
it("omits the section entirely when there are no episodes", async () => {
|
||||
const bytes = renderDoctorReportPdfBytes(makeData(), {
|
||||
timeFormat: "AUTO",
|
||||
dateFormat: "AUTO",
|
||||
t: getServerTranslator("en").t,
|
||||
locale: "en",
|
||||
now: FIXED_NOW,
|
||||
@@ -611,7 +661,13 @@ describe("doctor-report emergency first page", () => {
|
||||
it("prints the emergency sheet on page one when emergency data is present", async () => {
|
||||
const { t } = getServerTranslator("en");
|
||||
const data = makeData({ emergency: EMERGENCY });
|
||||
const options = { t, locale: "en" as const, now: FIXED_NOW };
|
||||
const options = {
|
||||
t,
|
||||
locale: "en" as const,
|
||||
now: FIXED_NOW,
|
||||
timeFormat: "AUTO" as const,
|
||||
dateFormat: "AUTO" as const,
|
||||
};
|
||||
const text = await extractText(renderDoctorReportPdfBytes(data, options));
|
||||
expect(text).toContain("Emergency information");
|
||||
expect(text).toContain("O negative");
|
||||
@@ -626,7 +682,13 @@ describe("doctor-report emergency first page", () => {
|
||||
it("withholds the emergency page when the leaf was not admitted (payload null)", async () => {
|
||||
const { t } = getServerTranslator("en");
|
||||
const data = makeData({ emergency: null });
|
||||
const options = { t, locale: "en" as const, now: FIXED_NOW };
|
||||
const options = {
|
||||
t,
|
||||
locale: "en" as const,
|
||||
now: FIXED_NOW,
|
||||
timeFormat: "AUTO" as const,
|
||||
dateFormat: "AUTO" as const,
|
||||
};
|
||||
const text = await extractText(renderDoctorReportPdfBytes(data, options));
|
||||
expect(text).not.toContain("Emergency information");
|
||||
// No emergency page, no extra page break: back to the two-page baseline.
|
||||
@@ -647,6 +709,8 @@ describe("extracted doctor-report section boundaries", () => {
|
||||
},
|
||||
});
|
||||
const bytes = renderDoctorReportPdfBytes(data, {
|
||||
timeFormat: "AUTO",
|
||||
dateFormat: "AUTO",
|
||||
t: getServerTranslator("en").t,
|
||||
locale: "en",
|
||||
now: FIXED_NOW,
|
||||
@@ -663,6 +727,8 @@ describe("extracted doctor-report section boundaries", () => {
|
||||
const { t } = getServerTranslator("en");
|
||||
const text = await extractText(
|
||||
renderDoctorReportPdfBytes(makeData(), {
|
||||
timeFormat: "AUTO",
|
||||
dateFormat: "AUTO",
|
||||
t,
|
||||
locale: "en",
|
||||
now: FIXED_NOW,
|
||||
@@ -683,7 +749,13 @@ describe("extracted doctor-report section boundaries", () => {
|
||||
wellnessScores: [],
|
||||
cycle: null,
|
||||
}),
|
||||
{ t, locale: "en", now: FIXED_NOW },
|
||||
{
|
||||
timeFormat: "AUTO",
|
||||
dateFormat: "AUTO",
|
||||
t,
|
||||
locale: "en",
|
||||
now: FIXED_NOW,
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(text).not.toContain(t("doctorReport.complianceTitle"));
|
||||
@@ -704,7 +776,13 @@ describe("extracted doctor-report section boundaries", () => {
|
||||
}
|
||||
const { t } = getServerTranslator("en");
|
||||
const data = makeData({ compliance });
|
||||
const options = { t, locale: "en" as const, now: FIXED_NOW };
|
||||
const options = {
|
||||
t,
|
||||
locale: "en" as const,
|
||||
now: FIXED_NOW,
|
||||
timeFormat: "AUTO" as const,
|
||||
dateFormat: "AUTO" as const,
|
||||
};
|
||||
const doc = buildDoctorReportPdfDocument(data, options);
|
||||
const text = await extractText(renderDoctorReportPdfBytes(data, options));
|
||||
const footerCount =
|
||||
@@ -717,7 +795,13 @@ describe("extracted doctor-report section boundaries", () => {
|
||||
it("preserves the baseline section order and exact page count", async () => {
|
||||
const { t } = getServerTranslator("en");
|
||||
const data = makeData();
|
||||
const options = { t, locale: "en" as const, now: FIXED_NOW };
|
||||
const options = {
|
||||
t,
|
||||
locale: "en" as const,
|
||||
now: FIXED_NOW,
|
||||
timeFormat: "AUTO" as const,
|
||||
dateFormat: "AUTO" as const,
|
||||
};
|
||||
const text = await extractText(renderDoctorReportPdfBytes(data, options));
|
||||
const orderedHeadings = [
|
||||
t("doctorReport.title"),
|
||||
@@ -741,3 +825,52 @@ describe("extracted doctor-report section boundaries", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Issue #922 — the report is the artefact a person hands to someone else, so
|
||||
* it has to be spelled the way they read dates. `dateFormat` used to default
|
||||
* to AUTO in the render options, and the share-link route that had already
|
||||
* learned to pass the owner's HOUR CYCLE still let the field order fall to
|
||||
* the locale. Same fixed instant, three preferences, three orders.
|
||||
*/
|
||||
describe("doctor report — the user's date order", () => {
|
||||
// `period.end` is 2026-05-03: day, month and year are all distinguishable
|
||||
// from one another, so the assertion reads the ORDER and not a coincidence.
|
||||
const CASES = [
|
||||
{ dateFormat: "DMY" as const, expected: "03.05.2026" },
|
||||
{ dateFormat: "MDY" as const, expected: "05/03/2026" },
|
||||
{ dateFormat: "YMD" as const, expected: "2026-05-03" },
|
||||
];
|
||||
|
||||
for (const { dateFormat, expected } of CASES) {
|
||||
it(`renders the report period as ${expected} under ${dateFormat}`, async () => {
|
||||
const { t } = getServerTranslator("en");
|
||||
const text = await extractText(
|
||||
renderDoctorReportPdfBytes(makeData(), {
|
||||
t,
|
||||
locale: "en",
|
||||
now: FIXED_NOW,
|
||||
userTz: "UTC",
|
||||
timeFormat: "AUTO",
|
||||
dateFormat,
|
||||
}),
|
||||
);
|
||||
expect(text).toContain(expected);
|
||||
});
|
||||
}
|
||||
|
||||
it("follows the locale under AUTO", async () => {
|
||||
const { t } = getServerTranslator("en");
|
||||
const text = await extractText(
|
||||
renderDoctorReportPdfBytes(makeData(), {
|
||||
t,
|
||||
locale: "en",
|
||||
now: FIXED_NOW,
|
||||
userTz: "UTC",
|
||||
timeFormat: "AUTO",
|
||||
dateFormat: "AUTO",
|
||||
}),
|
||||
);
|
||||
expect(text).toContain("05/03/2026");
|
||||
});
|
||||
});
|
||||
@@ -44,8 +44,8 @@ describe("parseLocaleFromAcceptLanguage", () => {
|
||||
});
|
||||
|
||||
describe("makeFormatters", () => {
|
||||
const deFmt = makeFormatters("de");
|
||||
const enFmt = makeFormatters("en");
|
||||
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", () => {
|
||||
@@ -79,8 +79,8 @@ describe("makeFormatters", () => {
|
||||
|
||||
describe("hour-cycle preference (v1.15.20)", () => {
|
||||
it("AUTO follows the locale default for time + dateTime", () => {
|
||||
const de = makeFormatters("de", undefined, "AUTO");
|
||||
const en = makeFormatters("en", undefined, "AUTO");
|
||||
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");
|
||||
@@ -88,16 +88,16 @@ describe("makeFormatters", () => {
|
||||
});
|
||||
|
||||
it("H12 forces AM/PM regardless of locale", () => {
|
||||
const de = makeFormatters("de", undefined, "H12");
|
||||
const en = makeFormatters("en", undefined, "H12");
|
||||
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");
|
||||
const en = makeFormatters("en", undefined, "H24");
|
||||
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");
|
||||
@@ -105,13 +105,13 @@ describe("makeFormatters", () => {
|
||||
|
||||
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");
|
||||
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");
|
||||
const h24 = makeFormatters("en", undefined, "H24");
|
||||
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));
|
||||
});
|
||||
@@ -119,20 +119,20 @@ describe("makeFormatters", () => {
|
||||
|
||||
// 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");
|
||||
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");
|
||||
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");
|
||||
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/);
|
||||
@@ -147,19 +147,19 @@ describe("makeFormatters", () => {
|
||||
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");
|
||||
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");
|
||||
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");
|
||||
const fmt = makeFormatters("en", "Asia/Manila", "H24", "AUTO");
|
||||
expect(fmt.time(sample)).toBe("22:30");
|
||||
expect(fmt.date(sample)).toBe("04/18/2026");
|
||||
});
|
||||
@@ -167,7 +167,12 @@ describe("makeFormatters", () => {
|
||||
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");
|
||||
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");
|
||||
@@ -195,7 +200,7 @@ describe("makeFormatters", () => {
|
||||
// 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");
|
||||
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).
|
||||
@@ -204,7 +209,7 @@ describe("makeFormatters", () => {
|
||||
});
|
||||
|
||||
it("renders across the Berlin 2026-10-25 fall-back (doubled hour)", () => {
|
||||
const fmt = makeFormatters("de", "Europe/Berlin", "H24");
|
||||
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).
|
||||
@@ -231,16 +236,16 @@ describe("makeFormatters", () => {
|
||||
});
|
||||
|
||||
it("dateShortSmart omits the year for a date in the current year", () => {
|
||||
const de = makeFormatters("de");
|
||||
const en = makeFormatters("en");
|
||||
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");
|
||||
const en = makeFormatters("en");
|
||||
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");
|
||||
@@ -248,7 +253,7 @@ describe("makeFormatters", () => {
|
||||
});
|
||||
|
||||
it("dateWithWeekdaySmart mirrors the same boundary", () => {
|
||||
const de = makeFormatters("de");
|
||||
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");
|
||||
@@ -259,7 +264,7 @@ describe("makeFormatters", () => {
|
||||
// 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");
|
||||
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.
|
||||
|
||||
@@ -101,7 +101,7 @@ describe("timezone mirror (issue #490)", () => {
|
||||
expect(readStoredTimezone()).toBe("");
|
||||
// …and the value must never reach Intl: the formatter chain renders
|
||||
// the Berlin fallback instead of throwing.
|
||||
const fmt = makeFormatters("en", readStoredTimezone(), "H24");
|
||||
const fmt = makeFormatters("en", readStoredTimezone(), "H24", "AUTO");
|
||||
expect(fmt.time(new Date("2026-04-18T14:30:00Z"))).toBe("16:30");
|
||||
});
|
||||
|
||||
@@ -111,7 +111,7 @@ describe("timezone mirror (issue #490)", () => {
|
||||
// mirrors Manila until the next `/api/auth/me` fetch. That must
|
||||
// render (in the stale zone), never throw.
|
||||
store.set(STORAGE_KEY, "Asia/Manila");
|
||||
const fmt = makeFormatters("en", readStoredTimezone(), "H24");
|
||||
const fmt = makeFormatters("en", readStoredTimezone(), "H24", "AUTO");
|
||||
expect(fmt.time(new Date("2026-04-18T14:30:00Z"))).toBe("22:30");
|
||||
});
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ describe("makeBucketLabelFormatters (#490)", () => {
|
||||
const ts = monthBucket.points[0].timestamp;
|
||||
// The bucket encodes the Berlin month start as UTC midnight.
|
||||
expect(ts).toBe(Date.UTC(2026, 6, 1));
|
||||
const label = makeBucketLabelFormatters("en");
|
||||
const label = makeBucketLabelFormatters("en", "AUTO");
|
||||
expect(label.monthShort(new Date(ts))).toBe("Jul");
|
||||
expect(label.date(new Date(ts))).toBe("07/01/2026");
|
||||
});
|
||||
@@ -43,13 +43,13 @@ describe("makeBucketLabelFormatters (#490)", () => {
|
||||
// Jul 1 00:00 UTC = Jun 30 20:00 in New York — the exact month-label
|
||||
// slide the UTC pin exists to prevent. If this expectation ever
|
||||
// changes, the label pin above is what protects users.
|
||||
const newYork = makeFormatters("en", "America/New_York");
|
||||
const newYork = makeFormatters("en", "America/New_York", "AUTO", "AUTO");
|
||||
expect(newYork.monthShort(new Date(ts))).toBe("Jun");
|
||||
});
|
||||
|
||||
it("stays byte-identical to the legacy Berlin rendering", () => {
|
||||
const berlin = makeFormatters("en", "Europe/Berlin");
|
||||
const label = makeBucketLabelFormatters("en");
|
||||
const berlin = makeFormatters("en", "Europe/Berlin", "AUTO", "AUTO");
|
||||
const label = makeBucketLabelFormatters("en", "AUTO");
|
||||
// Month bucket start (UTC midnight of a Berlin day)…
|
||||
const bucketTs = new Date(monthBucket.points[0].timestamp);
|
||||
expect(label.date(bucketTs)).toBe(berlin.date(bucketTs));
|
||||
@@ -72,10 +72,10 @@ describe("makeBucketLabelFormatters (#490)", () => {
|
||||
);
|
||||
const ts = weekBucket.points[0].timestamp;
|
||||
expect(ts).toBe(Date.UTC(2026, 6, 13));
|
||||
const label = makeBucketLabelFormatters("en");
|
||||
const label = makeBucketLabelFormatters("en", "AUTO");
|
||||
expect(label.dateWithWeekday(new Date(ts))).toContain("Mon");
|
||||
// A New-York-tz render would name it "Sun" — the week-label slide.
|
||||
const newYork = makeFormatters("en", "America/New_York");
|
||||
const newYork = makeFormatters("en", "America/New_York", "AUTO", "AUTO");
|
||||
expect(newYork.dateWithWeekday(new Date(ts))).toContain("Sun");
|
||||
});
|
||||
|
||||
@@ -83,8 +83,10 @@ describe("makeBucketLabelFormatters (#490)", () => {
|
||||
// Day rows encode noon UTC; an Auckland (UTC+13 in January) profile
|
||||
// formatter would render the NEXT day. The UTC pin renders the key.
|
||||
const dayTs = new Date(Date.UTC(2026, 0, 14, 12));
|
||||
expect(makeBucketLabelFormatters("en").date(dayTs)).toBe("01/14/2026");
|
||||
const auckland = makeFormatters("en", "Pacific/Auckland");
|
||||
expect(makeBucketLabelFormatters("en", "AUTO").date(dayTs)).toBe(
|
||||
"01/14/2026",
|
||||
);
|
||||
const auckland = makeFormatters("en", "Pacific/Auckland", "AUTO", "AUTO");
|
||||
expect(auckland.date(dayTs)).toBe("01/15/2026");
|
||||
});
|
||||
});
|
||||
@@ -23,9 +23,25 @@
|
||||
* (DSTMIG-scarred); this is the label-side half of the contract.
|
||||
*/
|
||||
|
||||
import { makeFormatters, type Formatters } from "@/lib/format-locale";
|
||||
import {
|
||||
makeFormatters,
|
||||
type DateFormatPreference,
|
||||
type Formatters,
|
||||
} from "@/lib/format-locale";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
|
||||
export function makeBucketLabelFormatters(locale: Locale): Formatters {
|
||||
return makeFormatters(locale, "UTC");
|
||||
/**
|
||||
* The UTC pin above is about WHICH DAY the label names. The date-order
|
||||
* preference is about how that day is spelled, which is a separate
|
||||
* question and a per-user one — issue #922: the axis kept rendering
|
||||
* MM/DD/YYYY for a profile set to day-first, because this call passed no
|
||||
* preference and the parameter defaulted. Chart callers pass
|
||||
* `useDateFormatPreference()`; the hour cycle stays AUTO because these
|
||||
* labels carry no clock.
|
||||
*/
|
||||
export function makeBucketLabelFormatters(
|
||||
locale: Locale,
|
||||
dateFormat: DateFormatPreference,
|
||||
): Formatters {
|
||||
return makeFormatters(locale, "UTC", "AUTO", dateFormat);
|
||||
}
|
||||
@@ -35,9 +35,12 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import type { DoctorReportData } from "@/lib/doctor-report-data";
|
||||
import type { ReportSelection } from "@/lib/report-selection/selection";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import type { TimeFormatPreference } from "@/generated/prisma/client";
|
||||
import { resolveUserFormatPreferences } from "@/lib/user-format-preferences";
|
||||
import type {
|
||||
DateFormatPreference,
|
||||
TimeFormatPreference,
|
||||
} from "@/lib/format-locale";
|
||||
|
||||
/**
|
||||
* 20 per hour per link. A practice saves the record once, maybe twice; a
|
||||
@@ -61,7 +64,8 @@ export type ReportDownloadResult =
|
||||
report: DoctorReportData;
|
||||
selection: ReportSelection;
|
||||
/**
|
||||
* The OWNER's timezone and clock preference, not the reader's.
|
||||
* The OWNER's timezone, clock preference and date order, not the
|
||||
* reader's.
|
||||
*
|
||||
* A reading was recorded at a moment in the owner's life, and the date
|
||||
* printed beside it has to be the date it was that day where they were.
|
||||
@@ -69,9 +73,14 @@ export type ReportDownloadResult =
|
||||
* so the same reading could carry one date on screen and another in the
|
||||
* PDF a practice files. Resolved here rather than per route so the two
|
||||
* download formats cannot drift apart the way the page and the PDF did.
|
||||
*
|
||||
* Issue #922 — `ownerDateFormat` joined them. The field ORDER is the
|
||||
* same class of question as the zone: it belongs to whose record this
|
||||
* is, not to whoever opened the link.
|
||||
*/
|
||||
ownerTz: string;
|
||||
ownerTimeFormat: TimeFormatPreference;
|
||||
ownerDateFormat: DateFormatPreference;
|
||||
}
|
||||
| { ok: false; response: Response };
|
||||
|
||||
@@ -147,12 +156,9 @@ export async function resolveShareReportDownload(
|
||||
meta: { format, leafCount: view.selection.leaves.length },
|
||||
});
|
||||
|
||||
const [ownerTz, ownerRow] = await Promise.all([
|
||||
const [ownerTz, ownerPrefs] = await Promise.all([
|
||||
resolveUserTimezone(context.ownerUserId),
|
||||
prisma.user.findUnique({
|
||||
where: { id: context.ownerUserId },
|
||||
select: { timeFormat: true },
|
||||
}),
|
||||
resolveUserFormatPreferences(context.ownerUserId),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -160,6 +166,7 @@ export async function resolveShareReportDownload(
|
||||
report: view.report,
|
||||
selection: view.selection,
|
||||
ownerTz,
|
||||
ownerTimeFormat: ownerRow?.timeFormat ?? "AUTO",
|
||||
ownerTimeFormat: ownerPrefs.timeFormat,
|
||||
ownerDateFormat: ownerPrefs.dateFormat,
|
||||
};
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import { getUnitForType } from "./validations/measurement";
|
||||
import {
|
||||
makeFormatters,
|
||||
DISPLAY_TIMEZONE,
|
||||
type DateFormatPreference,
|
||||
type TimeFormatPreference,
|
||||
} from "./format-locale";
|
||||
import type { Locale } from "./i18n/config";
|
||||
@@ -148,10 +149,18 @@ export interface DoctorReportRenderOptions {
|
||||
/**
|
||||
* v1.25.4 — the user's hour-cycle preference. Threaded into the formatters
|
||||
* so the footer "generated at" timestamp (and any other clock the report
|
||||
* prints) honours H12 / H24 rather than falling to the locale default. When
|
||||
* omitted it stays AUTO (locale default), matching the legacy contract.
|
||||
* prints) honours H12 / H24 rather than falling to the locale default.
|
||||
*
|
||||
* Issue #922 — both preferences are REQUIRED. They used to default to
|
||||
* AUTO here, which reads as harmless and is not: a report is exactly the
|
||||
* artefact a person hands to someone else, and "the caller forgot" and
|
||||
* "the user chose the locale default" have to be different states or
|
||||
* nobody ever finds the first one. A caller with genuinely no user in
|
||||
* hand passes "AUTO" and says so at the call site.
|
||||
*/
|
||||
timeFormat?: TimeFormatPreference;
|
||||
timeFormat: TimeFormatPreference;
|
||||
/** The user's date-order preference (AUTO / DMY / MDY / YMD). */
|
||||
dateFormat: DateFormatPreference;
|
||||
/**
|
||||
* v1.7.0 — decrypted KVNR (German insurance number). Printed on the
|
||||
* cover when present; the column is encrypted at rest, so the route
|
||||
@@ -217,11 +226,12 @@ export function buildDoctorReportPdfDocument(
|
||||
locale,
|
||||
now = new Date(),
|
||||
userTz,
|
||||
timeFormat = "AUTO",
|
||||
timeFormat,
|
||||
dateFormat,
|
||||
insuranceNumber = null,
|
||||
includeCharts = true,
|
||||
} = options;
|
||||
const formatters = makeFormatters(locale, userTz, timeFormat);
|
||||
const formatters = makeFormatters(locale, userTz, timeFormat, dateFormat);
|
||||
const num = (value: number, decimals = 1) =>
|
||||
formatters.number(value, decimals);
|
||||
const fmtDate = (iso: string) => formatters.date(iso);
|
||||
|
||||
@@ -14,7 +14,10 @@ import { renderDoctorReportPdfBytes } from "@/lib/doctor-report-pdf-core";
|
||||
import { buildFhirDocumentBundle } from "@/lib/fhir/build-bundle";
|
||||
import type { FhirRecordInputs } from "@/lib/fhir/build-bundle";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import type { TimeFormatPreference } from "@/lib/format-locale";
|
||||
import type {
|
||||
DateFormatPreference,
|
||||
TimeFormatPreference,
|
||||
} from "@/lib/format-locale";
|
||||
|
||||
export interface ArtefactInputs {
|
||||
data: DoctorReportData;
|
||||
@@ -26,6 +29,7 @@ export interface ArtefactInputs {
|
||||
locale: Locale;
|
||||
userTz: string;
|
||||
timeFormat: TimeFormatPreference;
|
||||
dateFormat: DateFormatPreference;
|
||||
t: (key: string, vars?: Record<string, string | number>) => string;
|
||||
}
|
||||
|
||||
@@ -48,6 +52,7 @@ export function buildPdfBytes(input: ArtefactInputs): Uint8Array {
|
||||
locale: input.locale,
|
||||
userTz: input.userTz,
|
||||
timeFormat: input.timeFormat,
|
||||
dateFormat: input.dateFormat,
|
||||
insuranceNumber: input.insuranceNumber,
|
||||
includeCharts: input.includeCharts,
|
||||
});
|
||||
|
||||
@@ -174,11 +174,24 @@ export interface Formatters {
|
||||
monthShort: (value: DateInput) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the locale-aware formatter set.
|
||||
*
|
||||
* Every parameter is REQUIRED, deliberately. `timeFormat` and `dateFormat`
|
||||
* used to default to "AUTO", and a preference parameter with a default is
|
||||
* how "the setting works in one place" ships: issue #922 — a reporter moved
|
||||
* the profile off MM/DD/YYYY, the entry form followed, and the chart axes
|
||||
* and the measurements list did not, because every construction except
|
||||
* `useFormatters()` silently took the default. A caller with nothing to
|
||||
* pass writes "AUTO" and thereby says so. Same for `userTz`: pass the
|
||||
* resolved profile zone, or an explicit `undefined` to accept the
|
||||
* `DISPLAY_TIMEZONE` fallback.
|
||||
*/
|
||||
export function makeFormatters(
|
||||
locale: Locale,
|
||||
userTz?: string,
|
||||
timeFormat: TimeFormatPreference = "AUTO",
|
||||
dateFormat: DateFormatPreference = "AUTO",
|
||||
userTz: string | undefined,
|
||||
timeFormat: TimeFormatPreference,
|
||||
dateFormat: DateFormatPreference,
|
||||
): Formatters {
|
||||
const intlLocale = resolveIntlLocale(locale);
|
||||
// Poison guard: an invalid IANA name would make `Intl.DateTimeFormat`
|
||||
|
||||
+9
-4
@@ -24,6 +24,7 @@
|
||||
*/
|
||||
|
||||
import { makeFormatters } from "./format-locale";
|
||||
import { readStoredDateFormat } from "./date-format";
|
||||
import { readStoredTimeFormat } from "./time-format";
|
||||
import { readStoredTimezone } from "./timezone-mirror";
|
||||
import { locales, type Locale } from "./i18n/config";
|
||||
@@ -47,14 +48,18 @@ function activeLocale(): Locale {
|
||||
}
|
||||
|
||||
function formatters() {
|
||||
// Honour the mirrored hour-cycle preference AND the mirrored profile
|
||||
// timezone (issue #490) so these legacy helpers render the same clock and
|
||||
// zone as `useFormatters()` call sites. SSR reads AUTO + "" (→ Berlin) —
|
||||
// same post-hydration caveat as `activeLocale()` above.
|
||||
// Honour the mirrored hour-cycle preference, the mirrored profile timezone
|
||||
// (issue #490) AND the mirrored date order (issue #922) so these legacy
|
||||
// helpers render the same clock, zone and field order as `useFormatters()`
|
||||
// call sites. The date mirror was the one of the three that was never read
|
||||
// here, which is why the measurements list ignored the profile setting the
|
||||
// entry form's `<DateField>` obeyed. SSR reads AUTO + "" (→ Berlin) — same
|
||||
// post-hydration caveat as `activeLocale()` above.
|
||||
return makeFormatters(
|
||||
activeLocale(),
|
||||
readStoredTimezone(),
|
||||
readStoredTimeFormat(),
|
||||
readStoredDateFormat(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ describe("formatUpdatedLabel", () => {
|
||||
// selected the "today" caption must never carry AM/PM, even for an en user
|
||||
// whose locale default is 12-hour.
|
||||
it("renders today's time without AM/PM when fed an H24 formatter", () => {
|
||||
const fmt = makeFormatters("en", "UTC", "H24");
|
||||
const fmt = makeFormatters("en", "UTC", "H24", "AUTO");
|
||||
const now = new Date();
|
||||
const out = formatUpdatedLabel(
|
||||
now.toISOString(),
|
||||
@@ -78,7 +78,7 @@ describe("formatUpdatedLabel", () => {
|
||||
});
|
||||
|
||||
it("renders today's time with AM/PM when fed an H12 formatter", () => {
|
||||
const fmt = makeFormatters("en", "UTC", "H12");
|
||||
const fmt = makeFormatters("en", "UTC", "H12", "AUTO");
|
||||
// A fixed afternoon instant so the assertion is deterministic regardless
|
||||
// of when the suite runs; bucketed as "today" via the UTC day boundary.
|
||||
const today = new Date();
|
||||
@@ -118,7 +118,7 @@ describe("formatUpdatedLabel boundary-zone closure (#490)", () => {
|
||||
it("mirror empty (timeZone undefined) → Berlin boundary, not the host's", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(NOW);
|
||||
const fmt = makeFormatters("en", undefined, "H24"); // clock: Berlin fallback
|
||||
const fmt = makeFormatters("en", undefined, "H24", "AUTO"); // clock: Berlin fallback
|
||||
const out = formatUpdatedLabel(TARGET, t, fmt.dateShort, fmt.time);
|
||||
expect(out).toBe("Updated yesterday");
|
||||
});
|
||||
@@ -126,7 +126,7 @@ describe("formatUpdatedLabel boundary-zone closure (#490)", () => {
|
||||
it("poison zone → Berlin boundary AND Berlin clock, no throw", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(NOW);
|
||||
const fmt = makeFormatters("en", "Mars/Olympus", "H24"); // clock: Berlin
|
||||
const fmt = makeFormatters("en", "Mars/Olympus", "H24", "AUTO"); // clock: Berlin
|
||||
const out = formatUpdatedLabel(
|
||||
TARGET,
|
||||
t,
|
||||
@@ -141,7 +141,7 @@ describe("formatUpdatedLabel boundary-zone closure (#490)", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(NOW);
|
||||
// Manila: now = 06:30 Jul 15, target = 05:00 Jul 15 → "today, 05:00".
|
||||
const fmt = makeFormatters("en", "Asia/Manila", "H24");
|
||||
const fmt = makeFormatters("en", "Asia/Manila", "H24", "AUTO");
|
||||
const out = formatUpdatedLabel(
|
||||
TARGET,
|
||||
t,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* The per-user display preferences that are NOT the timezone: the hour cycle
|
||||
* and the date order.
|
||||
*
|
||||
* `resolveUserTimezone()` already answers "which calendar does this user read
|
||||
* in". These two answer "how is a clock and a date spelled for them", and
|
||||
* every server surface that renders a date on a user's behalf needs all
|
||||
* three. They lived as an inline `prisma.user.findUnique` in the share-link
|
||||
* download path and nowhere else, which is part of why the clinician page
|
||||
* rendered the owner's dates in the display default while the PDF beside it
|
||||
* rendered them in the owner's hour cycle (issue #922).
|
||||
*
|
||||
* Deliberately NOT cached: unlike the timezone (read on nearly every request
|
||||
* and invalidated by hand on profile write), these are read on the handful of
|
||||
* server-rendered document surfaces, so a cache would add an invalidation
|
||||
* obligation to every write path in exchange for nothing measurable.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import type {
|
||||
DateFormatPreference,
|
||||
TimeFormatPreference,
|
||||
} from "@/lib/format-locale";
|
||||
|
||||
export interface UserFormatPreferences {
|
||||
timeFormat: TimeFormatPreference;
|
||||
dateFormat: DateFormatPreference;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read `users.time_format` + `users.date_format`. An unknown id, a null
|
||||
* column, or an unreachable database all resolve to AUTO — the same
|
||||
* "follow the locale" answer the columns' own default carries, so a
|
||||
* document still renders rather than failing on a preference lookup.
|
||||
*/
|
||||
export async function resolveUserFormatPreferences(
|
||||
userId: string,
|
||||
): Promise<UserFormatPreferences> {
|
||||
if (!userId) return { timeFormat: "AUTO", dateFormat: "AUTO" };
|
||||
try {
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { timeFormat: true, dateFormat: true },
|
||||
});
|
||||
return {
|
||||
timeFormat: row?.timeFormat ?? "AUTO",
|
||||
dateFormat: row?.dateFormat ?? "AUTO",
|
||||
};
|
||||
} catch {
|
||||
return { timeFormat: "AUTO", dateFormat: "AUTO" };
|
||||
}
|
||||
}
|
||||
@@ -249,8 +249,18 @@ describe("doctor-report — per-section toggles", () => {
|
||||
// same `data` payload so both must be clean.
|
||||
const { t: tDe } = getServerTranslator("de");
|
||||
const { t: tEn } = getServerTranslator("en");
|
||||
const pdfDe = renderDoctorReportPdfBytes(data, { t: tDe, locale: "de" });
|
||||
const pdfEn = renderDoctorReportPdfBytes(data, { t: tEn, locale: "en" });
|
||||
const pdfDe = renderDoctorReportPdfBytes(data, {
|
||||
t: tDe,
|
||||
locale: "de",
|
||||
timeFormat: "AUTO",
|
||||
dateFormat: "AUTO",
|
||||
});
|
||||
const pdfEn = renderDoctorReportPdfBytes(data, {
|
||||
t: tEn,
|
||||
locale: "en",
|
||||
timeFormat: "AUTO",
|
||||
dateFormat: "AUTO",
|
||||
});
|
||||
|
||||
expect(pdfContainsText(pdfDe, "Stimmung")).toBe(false);
|
||||
expect(pdfContainsText(pdfEn, "Mood")).toBe(false);
|
||||
|
||||
Reference in new issue
Block a user