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);