mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
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
This commit is contained in:
1 parent
0913e8f2b7
commit
031600d69d
27 files changed
+1086
-87
No files matched your search
@@ -35,7 +35,11 @@ import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
import { computePaddedYDomain } from "@/lib/insights/chart-y-domain";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import {
|
||||
useDateFormatPreference,
|
||||
useTranslations,
|
||||
useFormatters,
|
||||
} from "@/lib/i18n/context";
|
||||
import { makeBucketLabelFormatters } from "@/lib/charts/bucket-label";
|
||||
import {
|
||||
bucketTimeSeries,
|
||||
@@ -631,7 +635,13 @@ export function HealthChart({
|
||||
// encodings through a profile-tz formatter shifted week/month bucket
|
||||
// labels a day/month back for zones west of Berlin. Day-key BUCKETING
|
||||
// below still uses `userTimezone` — only labels are pinned.
|
||||
const tzFmt = useMemo(() => makeBucketLabelFormatters(locale), [locale]);
|
||||
// Issue #922 — the UTC pin decides WHICH day the label names; the
|
||||
// profile's date order decides how it is spelled. Both travel.
|
||||
const dateFormat = useDateFormatPreference();
|
||||
const tzFmt = useMemo(
|
||||
() => makeBucketLabelFormatters(locale, dateFormat),
|
||||
[locale, dateFormat],
|
||||
);
|
||||
// v1.4.25 W7b — per-row day-key formatter used to bucket measurement
|
||||
// rows by the user's local calendar day. Memoised on userTimezone so
|
||||
// the inner per-row loop reuses a single Intl.DateTimeFormat.
|
||||
|
||||
@@ -51,7 +51,11 @@ import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import {
|
||||
useDateFormatPreference,
|
||||
useTranslations,
|
||||
useFormatters,
|
||||
} from "@/lib/i18n/context";
|
||||
import { formatDateShort } from "@/lib/format";
|
||||
import { makeFormatters } from "@/lib/format-locale";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -221,9 +225,13 @@ export function MedicationComplianceChart({
|
||||
const [days, setDays] = useState<RangeDays>(30);
|
||||
// v1.4.25 W7b — tz-aware date formatter for the x-axis labels and
|
||||
// the tooltip's `dateLabel`. Same pattern as health-chart + mood-chart.
|
||||
// Issue #922 — the axis labels and the tooltip date follow the profile's
|
||||
// date order. The hour cycle is AUTO on purpose: this formatter renders
|
||||
// days only (`dateShortSmart` / `date`), never a clock.
|
||||
const dateFormat = useDateFormatPreference();
|
||||
const tzFmt = useMemo(
|
||||
() => makeFormatters(locale, userTimezone),
|
||||
[locale, userTimezone],
|
||||
() => makeFormatters(locale, userTimezone, "AUTO", dateFormat),
|
||||
[locale, userTimezone, dateFormat],
|
||||
);
|
||||
|
||||
// v1.4.18 — three overlay toggles persisted per chart. The 7-day
|
||||
|
||||
@@ -20,7 +20,7 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Card, CardHeader, CardContent } from "@/components/ui/card";
|
||||
import { TagChip } from "@/components/ui/tag-chip";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useDateFormatPreference, useTranslations } from "@/lib/i18n/context";
|
||||
import { makeBucketLabelFormatters } from "@/lib/charts/bucket-label";
|
||||
import { readStoredTimezone } from "@/lib/timezone-mirror";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
@@ -326,7 +326,13 @@ export function MoodChart({
|
||||
// labels render UTC-pinned via `makeBucketLabelFormatters` — the
|
||||
// encoded day paints verbatim in every profile zone (a profile-tz
|
||||
// formatter shifted week/month bucket labels for zones west of Berlin).
|
||||
const tzFmt = useMemo(() => makeBucketLabelFormatters(locale), [locale]);
|
||||
// Issue #922 — the UTC pin decides WHICH day the label names; the
|
||||
// profile's date order decides how it is spelled. Both travel.
|
||||
const dateFormat = useDateFormatPreference();
|
||||
const tzFmt = useMemo(
|
||||
() => makeBucketLabelFormatters(locale, dateFormat),
|
||||
[locale, dateFormat],
|
||||
);
|
||||
// The bucket BOUNDARIES follow the profile, even though the bucket LABELS
|
||||
// stay UTC-pinned (see above): a week that starts on the wrong day puts a
|
||||
// reading in the wrong bar, which is a different mistake from a label that
|
||||
|
||||
@@ -239,6 +239,8 @@ describe("<ClinicianView> resolves every label in every locale", () => {
|
||||
report: FULL_RECORD,
|
||||
selection: selectionFromLeaves(ALL_LEAF_IDS),
|
||||
locale,
|
||||
timeFormat: "AUTO",
|
||||
dateFormat: "AUTO",
|
||||
}),
|
||||
);
|
||||
// Strip the tags, so the `data-leaf` enum lists and the class names go
|
||||
|
||||
@@ -66,7 +66,12 @@ function render(
|
||||
extra?: Partial<
|
||||
Pick<
|
||||
React.ComponentProps<typeof ClinicianView>,
|
||||
"documents" | "token" | "locale" | "selection"
|
||||
| "documents"
|
||||
| "token"
|
||||
| "locale"
|
||||
| "selection"
|
||||
| "timeFormat"
|
||||
| "dateFormat"
|
||||
>
|
||||
>,
|
||||
) {
|
||||
@@ -78,6 +83,8 @@ function render(
|
||||
expiresAt="2026-03-01T00:00:00.000Z"
|
||||
report={report}
|
||||
selection={selectionFromLeaves(ALL_LEAF_IDS)}
|
||||
timeFormat="AUTO"
|
||||
dateFormat="AUTO"
|
||||
{...extra}
|
||||
/>,
|
||||
);
|
||||
@@ -295,3 +302,56 @@ describe("<ClinicianView>", () => {
|
||||
expect(html).toContain("Referral letter");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Issue #922 — a share link shows the OWNER's record, so it is spelled the
|
||||
* owner's way. The page passed neither the owner's hour cycle nor their date
|
||||
* order, while the PDF one route over already read the hour cycle off the
|
||||
* owner row — so the same record could carry two different spellings
|
||||
* depending on which button the practice pressed.
|
||||
*/
|
||||
describe("<ClinicianView> renders in the owner's date order", () => {
|
||||
// 2026-03-01: day, month and year are mutually distinguishable, so the
|
||||
// assertion reads the ORDER rather than a coincidence of equal fields.
|
||||
const CASES = [
|
||||
{ dateFormat: "DMY" as const, expected: "01.03.2026" },
|
||||
{ dateFormat: "MDY" as const, expected: "03/01/2026" },
|
||||
{ dateFormat: "YMD" as const, expected: "2026-03-01" },
|
||||
];
|
||||
|
||||
for (const { dateFormat, expected } of CASES) {
|
||||
it(`spells the expiry ${expected} under ${dateFormat}`, () => {
|
||||
const { t } = getServerTranslator("en");
|
||||
const html = renderToStaticMarkup(
|
||||
<ClinicianView
|
||||
t={(k, v) => t(k, v)}
|
||||
label="Cardiology clinic"
|
||||
expiresAt="2026-03-01T12:00:00.000Z"
|
||||
report={makeReport()}
|
||||
selection={selectionFromLeaves(ALL_LEAF_IDS)}
|
||||
timezone="UTC"
|
||||
timeFormat="AUTO"
|
||||
dateFormat={dateFormat}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain(expected);
|
||||
});
|
||||
}
|
||||
|
||||
it("follows the viewer locale under AUTO", () => {
|
||||
const { t } = getServerTranslator("en");
|
||||
const html = renderToStaticMarkup(
|
||||
<ClinicianView
|
||||
t={(k, v) => t(k, v)}
|
||||
label="Cardiology clinic"
|
||||
expiresAt="2026-03-01T12:00:00.000Z"
|
||||
report={makeReport()}
|
||||
selection={selectionFromLeaves(ALL_LEAF_IDS)}
|
||||
timezone="UTC"
|
||||
timeFormat="AUTO"
|
||||
dateFormat="AUTO"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("03/01/2026");
|
||||
});
|
||||
});
|
||||
@@ -30,7 +30,11 @@
|
||||
* document list in `./documents-list`, the downloads in `./download-actions`.
|
||||
*/
|
||||
import type { DoctorReportData } from "@/lib/doctor-report-data";
|
||||
import { makeFormatters } from "@/lib/format-locale";
|
||||
import {
|
||||
makeFormatters,
|
||||
type DateFormatPreference,
|
||||
type TimeFormatPreference,
|
||||
} from "@/lib/format-locale";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import type { ShareViewDocument } from "@/lib/clinician-share/share-view-data";
|
||||
import type { ReportLeafId } from "@/lib/report-selection/catalogue";
|
||||
@@ -112,6 +116,15 @@ interface ClinicianViewProps {
|
||||
* aggregation behind the stats (and with the doctor-report PDF).
|
||||
*/
|
||||
timezone?: string;
|
||||
/**
|
||||
* Issue #922 — the share OWNER's hour cycle and date order. This is the
|
||||
* owner's record, so it is spelled the owner's way; the viewing clinician
|
||||
* has no profile here. Both travel the same route as `timezone` above
|
||||
* (resolved off the owner row in the page), and both are REQUIRED so a
|
||||
* future caller cannot quietly drop the preference again.
|
||||
*/
|
||||
timeFormat: TimeFormatPreference;
|
||||
dateFormat: DateFormatPreference;
|
||||
}
|
||||
|
||||
export function ClinicianView({
|
||||
@@ -126,10 +139,13 @@ export function ClinicianView({
|
||||
token = "",
|
||||
locale = "en",
|
||||
timezone,
|
||||
timeFormat,
|
||||
dateFormat,
|
||||
}: ClinicianViewProps) {
|
||||
// Owner-tz, locale-aware date rendering (issue #490) — `makeFormatters`
|
||||
// guards the zone and falls back to Europe/Berlin on garbage/absence.
|
||||
const fmt = makeFormatters(locale, timezone);
|
||||
// Owner hour cycle + date order ride along (issue #922).
|
||||
const fmt = makeFormatters(locale, timezone, timeFormat, dateFormat);
|
||||
const fmtDate = (iso: string) => fmt.date(new Date(iso));
|
||||
const fmtDateTime = (iso: string) => fmt.dateTime(new Date(iso));
|
||||
const fmtNum = (n: number) => Math.round(n * 100) / 100;
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
ReferenceArea,
|
||||
} from "recharts";
|
||||
import { makeFormatters } from "@/lib/format-locale";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useDateFormatPreference, useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
export interface EfficacyChartTarget {
|
||||
label: string;
|
||||
@@ -67,9 +67,13 @@ export function EfficacyChart({
|
||||
mode = "absolute",
|
||||
}: EfficacyChartProps) {
|
||||
const { locale } = useTranslations();
|
||||
// Issue #922 — the axis ticks and the tooltip label follow the profile's
|
||||
// date order. The hour cycle is AUTO on purpose: this formatter renders
|
||||
// days only (`fmt.date`), never a clock.
|
||||
const dateFormat = useDateFormatPreference();
|
||||
const fmt = useMemo(
|
||||
() => makeFormatters(locale, timezone),
|
||||
[locale, timezone],
|
||||
() => makeFormatters(locale, timezone, "AUTO", dateFormat),
|
||||
[locale, timezone, dateFormat],
|
||||
);
|
||||
|
||||
const seriesData = useMemo(
|
||||
|
||||
Reference in new issue
Block a user