diff --git a/src/__tests__/timezone-explicit-formatter-guard.test.ts b/src/__tests__/timezone-explicit-formatter-guard.test.ts index 553a6a971..9eaf1456e 100644 --- a/src/__tests__/timezone-explicit-formatter-guard.test.ts +++ b/src/__tests__/timezone-explicit-formatter-guard.test.ts @@ -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); + }); +}); diff --git a/src/components/charts/nutrient-daily-bar-chart.tsx b/src/components/charts/nutrient-daily-bar-chart.tsx index 074418f28..f66395121 100644 --- a/src/components/charts/nutrient-daily-bar-chart.tsx +++ b/src/components/charts/nutrient-daily-bar-chart.tsx @@ -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({ /> 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 ( ); diff --git a/src/components/cycle/predictions-panel.tsx b/src/components/cycle/predictions-panel.tsx index 66e428f0d..2b14a619d 100644 --- a/src/components/cycle/predictions-panel.tsx +++ b/src/components/cycle/predictions-panel.tsx @@ -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): { diff --git a/src/components/illness/illness-day-timeline.tsx b/src/components/illness/illness-day-timeline.tsx index 3dd029e55..37e327d46 100644 --- a/src/components/illness/illness-day-timeline.tsx +++ b/src/components/illness/illness-day-timeline.tsx @@ -38,7 +38,7 @@ function DayLogRow({ log }: { log: IllnessDayLogDTO }) { return (

- {fmt.dateShortSmart(`${log.date}T12:00:00`)} + {fmt.dateShortSmartCalendar(log.date)}

{log.symptoms.length > 0 ? ( diff --git a/src/components/insights/coach-panel/result-chart.tsx b/src/components/insights/coach-panel/result-chart.tsx index 35cfa9d26..9c9064f7c 100644 --- a/src/components/insights/coach-panel/result-chart.tsx +++ b/src/components/insights/coach-panel/result-chart.tsx @@ -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`)); diff --git a/src/components/insights/intraday-pulse-chart.tsx b/src/components/insights/intraday-pulse-chart.tsx index 5fac93a99..cb50fcd43 100644 --- a/src/components/insights/intraday-pulse-chart.tsx +++ b/src/components/insights/intraday-pulse-chart.tsx @@ -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 (
{ + const instant = new Date(value); + return isNoonUtcAnchor(instant) + ? fmt.dateShortSmartCalendar(instant) + : fmt.dateShortSmart(instant); + }; const [range, setRange] = useState("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 ( ); @@ -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), })}

) : null} diff --git a/src/lib/__tests__/format-locale-calendar-date.test.ts b/src/lib/__tests__/format-locale-calendar-date.test.ts new file mode 100644 index 000000000..2ff1004ff --- /dev/null +++ b/src/lib/__tests__/format-locale-calendar-date.test.ts @@ -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/); +}); diff --git a/src/lib/format-locale.ts b/src/lib/format-locale.ts index 73a2bc669..5186efdf0 100644 --- a/src/lib/format-locale.ts +++ b/src/lib/format-locale.ts @@ -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,