mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
fix(dates): show a stated calendar date as that date east of UTC+12
Day keys anchored at noon UTC went through the zone-aware dateShortSmart, which shows them as the next day in UTC+12..+14. A dateShortSmartCalendar formatter reads the key in UTC with the same year rule, and the predictions panel, the illness timeline, the intraday pulse day label, the nutrient chart and the coach result chart use it. The lab chart dates a date-only reading by its calendar date and a timed one in the reader's zone, as the lab lists do. The timezone formatter guard now fails on a noon anchor handed to a zone-aware formatter.
This commit is contained in:
1 parent
358ef23239
commit
9ecdda6358
9 files changed
+171
-17
No files matched your search
@@ -183,3 +183,71 @@ describe("date formatters name the calendar they render in", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A stated calendar date must not go through a zone-aware short formatter.
|
||||
*
|
||||
* A day key is anchored at noon UTC (`${key}T12:00:00Z`) so that it is the
|
||||
* same calendar date everywhere, and then `dateShortSmart` read it in the
|
||||
* profile zone and showed the NEXT day in UTC+12..+14. The calendar sibling,
|
||||
* `dateShortSmartCalendar`, reads the key in UTC. This fails on any
|
||||
* `dateShortSmart(`/`dateShort(`/`date(` call whose argument builds a noon
|
||||
* anchor, and on the old local-noon form (`T12:00:00` with no zone), which a
|
||||
* browser reads in its own zone.
|
||||
*
|
||||
* A file whose `fmt` comes from `makeBucketLabelFormatters` is skipped: that
|
||||
* formatter set is pinned to UTC, so a noon anchor is already its calendar
|
||||
* date there.
|
||||
*/
|
||||
describe("a stated date is formatted on its calendar date", () => {
|
||||
const ZONED_CALL = /\bfmt\.(?:dateShortSmart|dateShort|date)\s*\(/g;
|
||||
const CALENDAR_CALL = /\bfmt\.dateShortSmartCalendar\s*\(/g;
|
||||
const NOON_ANCHOR = /T12:00:00/;
|
||||
|
||||
function scan() {
|
||||
const files = execFileSync(
|
||||
"grep",
|
||||
["-rlE", "fmt\\.date", "src", "--include=*.ts", "--include=*.tsx"],
|
||||
{ cwd: REPO_ROOT, encoding: "utf8" },
|
||||
)
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.filter((f) => !f.includes("__tests__"));
|
||||
const offenders: string[] = [];
|
||||
let calendarCalls = 0;
|
||||
for (const file of files) {
|
||||
const source = readFileSync(resolve(REPO_ROOT, file), "utf8");
|
||||
calendarCalls += (source.match(CALENDAR_CALL) ?? []).length;
|
||||
if (/const fmt = makeBucketLabelFormatters\(/.test(source)) continue;
|
||||
ZONED_CALL.lastIndex = 0;
|
||||
for (let m = ZONED_CALL.exec(source); m; m = ZONED_CALL.exec(source)) {
|
||||
const args = argumentList(source, m.index + m[0].length - 1);
|
||||
if (NOON_ANCHOR.test(args)) {
|
||||
offenders.push(
|
||||
`${file}:${source.slice(0, m.index).split("\n").length}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { offenders, calendarCalls };
|
||||
}
|
||||
|
||||
it("finds the calendar formatter in use (the matcher still matches)", () => {
|
||||
expect(scan().calendarCalls).toBeGreaterThanOrEqual(6);
|
||||
});
|
||||
|
||||
it("never hands a noon anchor to a zone-aware formatter", () => {
|
||||
expect(scan().offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("the matcher flags the shape it exists for", () => {
|
||||
const source = "fmt.dateShortSmart(`${d}T12:00:00Z`)";
|
||||
ZONED_CALL.lastIndex = 0;
|
||||
const m = ZONED_CALL.exec(source);
|
||||
expect(m).not.toBeNull();
|
||||
expect(
|
||||
NOON_ANCHOR.test(argumentList(source, m!.index + m![0].length - 1)),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -38,11 +38,6 @@ interface NutrientDailyBarChartProps {
|
||||
referenceValue?: number | null;
|
||||
}
|
||||
|
||||
/** Noon UTC so every viewer timezone renders the same calendar day. */
|
||||
function dayToDate(day: string): Date {
|
||||
return new Date(`${day}T12:00:00.000Z`);
|
||||
}
|
||||
|
||||
export function NutrientDailyBarChart({
|
||||
days,
|
||||
unit,
|
||||
@@ -78,7 +73,7 @@ export function NutrientDailyBarChart({
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="day"
|
||||
tickFormatter={(day: string) => fmt.dateShortSmart(dayToDate(day))}
|
||||
tickFormatter={(day: string) => fmt.dateShortSmartCalendar(day)}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
stroke="var(--border)"
|
||||
minTickGap={24}
|
||||
@@ -117,7 +112,7 @@ export function NutrientDailyBarChart({
|
||||
return (
|
||||
<RichChartTooltip
|
||||
active
|
||||
label={fmt.dateShortSmart(dayToDate(point.day))}
|
||||
label={fmt.dateShortSmartCalendar(point.day)}
|
||||
rows={rows}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -32,12 +32,12 @@ import type { CyclePrediction, CycleHistoryResponse } from "./types";
|
||||
* locale, never the app's) with no year. Predictions run 1-2 cycles out, so
|
||||
* a window straddling New Year's would otherwise print an unadorned "Jan 3"
|
||||
* in December. Routes through the app-locale conditional-year formatter
|
||||
* instead; `dateShortSmart` already renders day+month in the locale's field
|
||||
* order and only adds the year when it differs from the current one.
|
||||
* instead; it renders day+month in the locale's field order and only adds
|
||||
* the year when it differs from the current one. The calendar variant reads
|
||||
* the key in UTC, so the date never rolls a day in UTC+12..+14.
|
||||
*/
|
||||
function formatDate(d: string, fmt: Formatters): string {
|
||||
// Render at noon UTC so the YYYY-MM-DD never rolls a day across tz.
|
||||
return fmt.dateShortSmart(`${d}T12:00:00Z`);
|
||||
return fmt.dateShortSmartCalendar(d);
|
||||
}
|
||||
|
||||
function confidenceTone(c: number): {
|
||||
|
||||
@@ -38,7 +38,7 @@ function DayLogRow({ log }: { log: IllnessDayLogDTO }) {
|
||||
return (
|
||||
<div className="border-border/60 space-y-2 border-l-2 pl-3">
|
||||
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||
{fmt.dateShortSmart(`${log.date}T12:00:00`)}
|
||||
{fmt.dateShortSmartCalendar(log.date)}
|
||||
</p>
|
||||
|
||||
{log.symptoms.length > 0 ? (
|
||||
|
||||
@@ -84,7 +84,7 @@ function usePeriodLabel(): (key: string) => string {
|
||||
});
|
||||
return (key: string) => {
|
||||
if (DAY_KEY.test(key)) {
|
||||
return fmt.dateShortSmart(new Date(`${key}T12:00:00Z`));
|
||||
return fmt.dateShortSmartCalendar(key);
|
||||
}
|
||||
if (MONTH_KEY.test(key)) {
|
||||
return month.format(new Date(`${key}-01T12:00:00Z`));
|
||||
|
||||
@@ -188,7 +188,7 @@ export function IntradayPulseChart({
|
||||
|
||||
const dayLabel = isToday
|
||||
? t("insights.intradayPulse.today")
|
||||
: fmt.dateShortSmart(new Date(`${dateKey}T12:00:00.000Z`));
|
||||
: fmt.dateShortSmartCalendar(dateKey);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -18,6 +18,7 @@ import { CHART_HEIGHT_PX } from "@/lib/charts/constants";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
import { formatReferenceRange } from "@/lib/labs/reference-range";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { isNoonUtcAnchor } from "@/lib/tz/date-only";
|
||||
|
||||
import { RichChartTooltip, type RichTooltipRow } from "../charts/chart-tooltip";
|
||||
import type { LabResultDto } from "./types";
|
||||
@@ -93,6 +94,15 @@ export function LabBiomarkerChart({
|
||||
const { t } = useTranslations();
|
||||
const labNumber = useLabNumber();
|
||||
const fmt = useFormatters();
|
||||
// A reading from a report that states only its date is stored at noon UTC
|
||||
// and is dated by that calendar date; one typed with its time of day is
|
||||
// dated in the reader's zone (the rule `useLabDate` applies to the lists).
|
||||
const labShortDate = (value: number | string) => {
|
||||
const instant = new Date(value);
|
||||
return isNoonUtcAnchor(instant)
|
||||
? fmt.dateShortSmartCalendar(instant)
|
||||
: fmt.dateShortSmart(instant);
|
||||
};
|
||||
const [range, setRange] = useState<RangeKey>("365");
|
||||
// Capture "now" once at mount so the recency filter stays pure across
|
||||
// re-renders (an inline `Date.now()` in render is impure).
|
||||
@@ -215,7 +225,7 @@ export function LabBiomarkerChart({
|
||||
type="number"
|
||||
scale="time"
|
||||
domain={["dataMin", "dataMax"]}
|
||||
tickFormatter={(ts: number) => fmt.dateShortSmart(new Date(ts))}
|
||||
tickFormatter={(ts: number) => labShortDate(ts)}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
stroke="var(--border)"
|
||||
minTickGap={32}
|
||||
@@ -326,7 +336,7 @@ export function LabBiomarkerChart({
|
||||
return (
|
||||
<RichChartTooltip
|
||||
active
|
||||
label={fmt.dateShortSmart(new Date(point.timestamp))}
|
||||
label={labShortDate(point.timestamp)}
|
||||
rows={rows}
|
||||
/>
|
||||
);
|
||||
@@ -348,7 +358,7 @@ export function LabBiomarkerChart({
|
||||
{sourceBand.mixed
|
||||
? t("labs.chart.sourceBandMixed")
|
||||
: t("labs.chart.sourceBandLegend", {
|
||||
date: fmt.dateShortSmart(new Date(sourceBand.takenAt)),
|
||||
date: labShortDate(sourceBand.takenAt),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { makeFormatters } from "../format-locale";
|
||||
|
||||
/**
|
||||
* `dateShortSmartCalendar` prints a stated calendar date (a day key, or its
|
||||
* noon-UTC anchor) as that date for every reader, where the zone-aware
|
||||
* `dateShortSmart` moves a noon anchor to the next day in UTC+12..+14.
|
||||
*/
|
||||
const previousTz = process.env.TZ;
|
||||
afterEach(() => {
|
||||
process.env.TZ = previousTz;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe.each(["America/Los_Angeles", "Pacific/Kiritimati"])(
|
||||
"host zone %s",
|
||||
(hostTz) => {
|
||||
it.each(["Pacific/Kiritimati", "America/Los_Angeles", "Europe/Berlin"])(
|
||||
"keeps 4 October for a profile in %s",
|
||||
(profileTz) => {
|
||||
process.env.TZ = hostTz;
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-10-10T12:00:00.000Z"));
|
||||
const fmt = makeFormatters("en", profileTz, "AUTO", "AUTO");
|
||||
|
||||
expect(fmt.dateShortSmartCalendar("2026-10-04")).toBe("10/04");
|
||||
expect(
|
||||
fmt.dateShortSmartCalendar(new Date("2026-10-04T12:00:00.000Z")),
|
||||
).toBe("10/04");
|
||||
expect(fmt.dateShortSmartCalendar("2026-10-04T12:00:00Z")).toBe(
|
||||
"10/04",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("adds the year outside the current one", () => {
|
||||
process.env.TZ = hostTz;
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-10-10T12:00:00.000Z"));
|
||||
const fmt = makeFormatters("de", "Europe/Berlin", "AUTO", "AUTO");
|
||||
expect(fmt.dateShortSmartCalendar("2025-12-31")).toBe("31.12.2025");
|
||||
expect(fmt.dateShortSmartCalendar("2026-01-02")).toBe("02.01.");
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("is the bug it replaces: the zone-aware formatter moves the date east of UTC+12", () => {
|
||||
const fmt = makeFormatters("en", "Pacific/Kiritimati", "AUTO", "AUTO");
|
||||
expect(fmt.dateShortSmart("2026-10-04T12:00:00Z")).toMatch(/^10\/05/);
|
||||
});
|
||||
@@ -150,6 +150,15 @@ export interface Formatters {
|
||||
* überall" year-disambiguation rule.
|
||||
*/
|
||||
dateShortSmart: (value: DateInput) => string;
|
||||
/**
|
||||
* `dateShortSmart` for a STATED calendar date: a day key anchored at noon
|
||||
* UTC (`YYYY-MM-DDT12:00:00Z`), or the bare `YYYY-MM-DD` key itself. Read
|
||||
* in UTC so the date never moves: the zone-aware `dateShortSmart` shows a
|
||||
* noon-UTC anchor as the next day in UTC+12..+14 (and an evening anchor as
|
||||
* the previous day west of UTC-12). The year rule is the same: the year is
|
||||
* added when the date falls outside the reader's current year.
|
||||
*/
|
||||
dateShortSmartCalendar: (value: DateInput) => string;
|
||||
/** Full date + time; hour cycle follows the `timeFormat` preference. */
|
||||
dateTime: (value: DateInput) => string;
|
||||
/** Time only, e.g. "14:30" or "2:30 PM" per the `timeFormat` preference. */
|
||||
@@ -255,6 +264,27 @@ export function makeFormatters(
|
||||
}).format(d);
|
||||
},
|
||||
|
||||
dateShortSmartCalendar: (value) => {
|
||||
const d =
|
||||
typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value)
|
||||
? new Date(`${value}T12:00:00.000Z`)
|
||||
: asDate(value);
|
||||
const year = getDateTimeFormat(dateLocale, {
|
||||
timeZone: "UTC",
|
||||
year: "numeric",
|
||||
}).format(d);
|
||||
const currentYear = getDateTimeFormat(dateLocale, {
|
||||
timeZone: tz,
|
||||
year: "numeric",
|
||||
}).format(new Date());
|
||||
return getDateTimeFormat(dateLocale, {
|
||||
timeZone: "UTC",
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
...(year === currentYear ? {} : { year: "numeric" as const }),
|
||||
}).format(d);
|
||||
},
|
||||
|
||||
dateTime: (value) =>
|
||||
getDateTimeFormat(dateLocale, {
|
||||
timeZone: tz,
|
||||
|
||||
Reference in new issue
Block a user