From 031600d69d2874306cc9761e1021edb5df515bf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 5 Sep 2026 16:49:19 +0200 Subject: [PATCH] fix(format): carry the date-order preference to every numeric date The per-user date order reached the entry form and nothing else. A reporter switched away from MM/DD/YYYY, the date picker followed, and the dashboard chart axes and the measurements list kept the old order. `makeFormatters` took `timeFormat` and `dateFormat` as optional parameters defaulting to "AUTO", so six of the seven constructions in the tree silently rendered the locale default and nothing anywhere said the setting had been dropped. Drop both defaults. The compiler then names every caller: the chart bucket labels, the legacy `@/lib/format` helpers the measurements list renders through, the medication compliance and efficacy charts, the doctor-report PDF and the clinician share view. Thread the preference: - `makeBucketLabelFormatters` takes the order; the health and mood charts read `useDateFormatPreference()`. The UTC pin decides which day the label names, the preference decides how it is spelled, and the two are separate questions. - `format.ts` reads `readStoredDateFormat()` beside the timezone and hour-cycle mirrors it already read. - The PDF takes `dateFormat` as a required render option, alongside `timeFormat`, which was optional for the same reason. - The clinician share view spells the OWNER's dates the owner's way. It also picked up the owner's hour cycle, which it had been ignoring while the PDF route beside it already honoured it. Both resolve through the new `resolveUserFormatPreferences`, the counterpart to `resolveUserTimezone`. Textual months stay on the UI locale. The preference pins numeric field order by rendering through de-DE / en-US / en-CA, and a textual month through those would change the month name's language. Add `date-order-explicit-formatter-guard`: a raw numeric date renderer must take its locale from the preference or carry a written reason, and a formatter construction may not hard-code "AUTO" for the order. Its allowlist holds thirteen calendar-day key builders, each with the reason it has no order to get wrong. The sibling timezone guard passes every one of them, because naming a calendar says nothing about spelling a date. Refs #922 --- CHANGELOG.md | 23 + ...ate-order-explicit-formatter-guard.test.ts | 441 ++++++++++++++++++ .../share-view-leaf-render-guard.test.ts | 4 + src/app/api/export/health-record/route.ts | 9 +- src/app/c/[token]/page.tsx | 9 + src/app/c/[token]/report.pdf/route.ts | 1 + src/components/charts/health-chart.tsx | 14 +- .../charts/medication-compliance-chart.tsx | 14 +- src/components/charts/mood-chart.tsx | 10 +- .../__tests__/clinician-view-locales.test.tsx | 2 + .../__tests__/clinician-view.test.tsx | 62 ++- src/components/clinician/clinician-view.tsx | 20 +- .../detail/efficacy/efficacy-chart.tsx | 10 +- .../date-format-preference-surfaces.test.ts | 151 ++++++ .../__tests__/doctor-report-pdf-core.test.ts | 143 +++++- src/lib/__tests__/format-locale.test.ts | 57 +-- src/lib/__tests__/timezone-mirror.test.ts | 4 +- src/lib/charts/__tests__/bucket-label.test.ts | 18 +- src/lib/charts/bucket-label.ts | 22 +- src/lib/clinician-share/report-download.ts | 25 +- src/lib/doctor-report-pdf-core.ts | 20 +- src/lib/export/health-record-artefacts.ts | 7 +- src/lib/format-locale.ts | 19 +- src/lib/format.ts | 13 +- .../__tests__/relative-time-updated.test.ts | 10 +- src/lib/user-format-preferences.ts | 51 ++ .../doctor-report-sections.test.ts | 14 +- 27 files changed, 1086 insertions(+), 87 deletions(-) create mode 100644 src/__tests__/date-order-explicit-formatter-guard.test.ts create mode 100644 src/lib/__tests__/date-format-preference-surfaces.test.ts create mode 100644 src/lib/user-format-preferences.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index db9d51039..fd031bb1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/__tests__/date-order-explicit-formatter-guard.test.ts b/src/__tests__/date-order-explicit-formatter-guard.test.ts new file mode 100644 index 000000000..faec9324c --- /dev/null +++ b/src/__tests__/date-order-explicit-formatter-guard.test.ts @@ -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 = { + 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"); + }); +}); diff --git a/src/__tests__/share-view-leaf-render-guard.test.ts b/src/__tests__/share-view-leaf-render-guard.test.ts index 5a745006a..1d42a23a7 100644 --- a/src/__tests__/share-view-leaf-render-guard.test.ts +++ b/src/__tests__/share-view-leaf-render-guard.test.ts @@ -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", }), ); } diff --git a/src/app/api/export/health-record/route.ts b/src/app/api/export/health-record/route.ts index 758fc31e4..9937fa4b8 100644 --- a/src/app/api/export/health-record/route.ts +++ b/src/app/api/export/health-record/route.ts @@ -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, }; diff --git a/src/app/c/[token]/page.tsx b/src/app/c/[token]/page.tsx index 567292d68..57aa5ec9c 100644 --- a/src/app/c/[token]/page.tsx +++ b/src/app/c/[token]/page.tsx @@ -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} /> ); } diff --git a/src/app/c/[token]/report.pdf/route.ts b/src/app/c/[token]/report.pdf/route.ts index d6351723b..eafbc2d29 100644 --- a/src/app/c/[token]/report.pdf/route.ts +++ b/src/app/c/[token]/report.pdf/route.ts @@ -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, }); diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index 3528294f9..675dd643c 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -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. diff --git a/src/components/charts/medication-compliance-chart.tsx b/src/components/charts/medication-compliance-chart.tsx index 8c6d2e30e..57ec29259 100644 --- a/src/components/charts/medication-compliance-chart.tsx +++ b/src/components/charts/medication-compliance-chart.tsx @@ -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(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 diff --git a/src/components/charts/mood-chart.tsx b/src/components/charts/mood-chart.tsx index bcb1134c8..c89c109a6 100644 --- a/src/components/charts/mood-chart.tsx +++ b/src/components/charts/mood-chart.tsx @@ -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 diff --git a/src/components/clinician/__tests__/clinician-view-locales.test.tsx b/src/components/clinician/__tests__/clinician-view-locales.test.tsx index ba729e5e8..4ba27dab5 100644 --- a/src/components/clinician/__tests__/clinician-view-locales.test.tsx +++ b/src/components/clinician/__tests__/clinician-view-locales.test.tsx @@ -239,6 +239,8 @@ describe(" 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 diff --git a/src/components/clinician/__tests__/clinician-view.test.tsx b/src/components/clinician/__tests__/clinician-view.test.tsx index e04ee32b3..cb62d4f82 100644 --- a/src/components/clinician/__tests__/clinician-view.test.tsx +++ b/src/components/clinician/__tests__/clinician-view.test.tsx @@ -66,7 +66,12 @@ function render( extra?: Partial< Pick< React.ComponentProps, - "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("", () => { 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(" 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( + 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( + 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"); + }); +}); diff --git a/src/components/clinician/clinician-view.tsx b/src/components/clinician/clinician-view.tsx index 3443b1371..12e45cc89 100644 --- a/src/components/clinician/clinician-view.tsx +++ b/src/components/clinician/clinician-view.tsx @@ -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; diff --git a/src/components/medications/detail/efficacy/efficacy-chart.tsx b/src/components/medications/detail/efficacy/efficacy-chart.tsx index 1975c0507..b1e29bcca 100644 --- a/src/components/medications/detail/efficacy/efficacy-chart.tsx +++ b/src/components/medications/detail/efficacy/efficacy-chart.tsx @@ -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( diff --git a/src/lib/__tests__/date-format-preference-surfaces.test.ts b/src/lib/__tests__/date-format-preference-surfaces.test.ts new file mode 100644 index 000000000..3e65e448c --- /dev/null +++ b/src/lib/__tests__/date-format-preference-surfaces.test.ts @@ -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 `` 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(); + + 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 `` 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, + ]); + }); +}); diff --git a/src/lib/__tests__/doctor-report-pdf-core.test.ts b/src/lib/__tests__/doctor-report-pdf-core.test.ts index 48092270c..0c95abaf3 100644 --- a/src/lib/__tests__/doctor-report-pdf-core.test.ts +++ b/src/lib/__tests__/doctor-report-pdf-core.test.ts @@ -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"); + }); +}); diff --git a/src/lib/__tests__/format-locale.test.ts b/src/lib/__tests__/format-locale.test.ts index 505b8af00..c62ffe823 100644 --- a/src/lib/__tests__/format-locale.test.ts +++ b/src/lib/__tests__/format-locale.test.ts @@ -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. diff --git a/src/lib/__tests__/timezone-mirror.test.ts b/src/lib/__tests__/timezone-mirror.test.ts index 45559f5af..b48a4a93a 100644 --- a/src/lib/__tests__/timezone-mirror.test.ts +++ b/src/lib/__tests__/timezone-mirror.test.ts @@ -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"); }); diff --git a/src/lib/charts/__tests__/bucket-label.test.ts b/src/lib/charts/__tests__/bucket-label.test.ts index de7fc42a5..6e2405714 100644 --- a/src/lib/charts/__tests__/bucket-label.test.ts +++ b/src/lib/charts/__tests__/bucket-label.test.ts @@ -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"); }); }); diff --git a/src/lib/charts/bucket-label.ts b/src/lib/charts/bucket-label.ts index 12f660d5a..592ea8d26 100644 --- a/src/lib/charts/bucket-label.ts +++ b/src/lib/charts/bucket-label.ts @@ -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); } diff --git a/src/lib/clinician-share/report-download.ts b/src/lib/clinician-share/report-download.ts index a71c7f94a..320e78dff 100644 --- a/src/lib/clinician-share/report-download.ts +++ b/src/lib/clinician-share/report-download.ts @@ -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, }; } diff --git a/src/lib/doctor-report-pdf-core.ts b/src/lib/doctor-report-pdf-core.ts index d193958ad..fc7aaaa4b 100644 --- a/src/lib/doctor-report-pdf-core.ts +++ b/src/lib/doctor-report-pdf-core.ts @@ -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); diff --git a/src/lib/export/health-record-artefacts.ts b/src/lib/export/health-record-artefacts.ts index 3fc625d32..9f08f7eba 100644 --- a/src/lib/export/health-record-artefacts.ts +++ b/src/lib/export/health-record-artefacts.ts @@ -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; } @@ -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, }); diff --git a/src/lib/format-locale.ts b/src/lib/format-locale.ts index c7d31e7cc..bbe587dfe 100644 --- a/src/lib/format-locale.ts +++ b/src/lib/format-locale.ts @@ -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` diff --git a/src/lib/format.ts b/src/lib/format.ts index 52e9b9073..2da828699 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -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 `` obeyed. SSR reads AUTO + "" (→ Berlin) — same + // post-hydration caveat as `activeLocale()` above. return makeFormatters( activeLocale(), readStoredTimezone(), readStoredTimeFormat(), + readStoredDateFormat(), ); } diff --git a/src/lib/i18n/__tests__/relative-time-updated.test.ts b/src/lib/i18n/__tests__/relative-time-updated.test.ts index da609547c..def03f95d 100644 --- a/src/lib/i18n/__tests__/relative-time-updated.test.ts +++ b/src/lib/i18n/__tests__/relative-time-updated.test.ts @@ -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, diff --git a/src/lib/user-format-preferences.ts b/src/lib/user-format-preferences.ts new file mode 100644 index 000000000..7f93d515e --- /dev/null +++ b/src/lib/user-format-preferences.ts @@ -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 { + 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" }; + } +} diff --git a/tests/integration/doctor-report-sections.test.ts b/tests/integration/doctor-report-sections.test.ts index a034efd03..3cbbbb93a 100644 --- a/tests/integration/doctor-report-sections.test.ts +++ b/tests/integration/doctor-report-sections.test.ts @@ -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);