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:
Marc-André Bombeck committed 2026-10-03 18:44:06 +02:00
1 parent 358ef23239
commit 9ecdda6358
9 files changed
+171 -17

No files matched your search

@@ -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}
/>
);
+4 -4
View File
@@ -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
+13 -3
View File
@@ -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}