feat(charts): x-axis ticks render in user displayTimezone

Four chart components (HealthChart, MoodChart, MedicationComplianceChart,
ComplianceLineChart) now accept a userTimezone prop, default
"Europe/Berlin" for backwards compat. Inside each chart, the X-axis
tick formatter, tooltip date label, and per-day bucketing all run
through a tz-aware makeFormatters(locale, userTz) instance instead of
the legacy formatDateShort() which pinned to Europe/Berlin.

Mount sites that already pull useAuth() pass user?.timezone:
dashboard /page.tsx, trends-row, recommendation-card,
insight-advisor-card. Ad-hoc usages without a user (storybook-style
or test harnesses) keep the default Europe/Berlin rendering
bit-for-bit.

The HealthChart's daily-aggregation key (toDayKey) was also rewritten
to depend on the userTimezone prop so a 23:50 Pacific/Auckland reading
buckets to the Auckland day instead of the Berlin day for chart data
display. The TanStack Query cache key gains userTimezone so a tz
change inside a session re-fetches and re-buckets correctly.

Tests: 109 chart-component tests stay green.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 13:04:12 +02:00
1 parent 095578fa57
commit eeaa563026
9 files changed
+164 -51

No files matched your search

+8
View File
@@ -957,6 +957,7 @@ export default function DashboardPage() {
unit="kg"
valueBands={weightBands}
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
/>
),
});
@@ -1002,6 +1003,7 @@ export default function DashboardPage() {
yAxisUnit="Hg"
targetZones={bpTargetZones}
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
/>
),
});
@@ -1021,6 +1023,7 @@ export default function DashboardPage() {
unit="bpm"
valueBands={pulseBands}
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
/>
),
});
@@ -1040,6 +1043,7 @@ export default function DashboardPage() {
unit="%"
valueBands={bodyFatBands}
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
/>
),
});
@@ -1054,6 +1058,7 @@ export default function DashboardPage() {
key="mood-chart"
compareBaseline={compareBaseline}
chartKey="mood"
userTimezone={user?.timezone}
/>
),
});
@@ -1072,6 +1077,7 @@ export default function DashboardPage() {
colors={["#8be9fd"]}
unit="h"
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
/>
),
});
@@ -1089,6 +1095,7 @@ export default function DashboardPage() {
title={t("dashboard.steps") ?? "Steps"}
colors={["#50fa7b"]}
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
/>
),
});
@@ -1106,6 +1113,7 @@ export default function DashboardPage() {
<MedicationComplianceChart
key="medications"
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
/>
),
});
@@ -12,9 +12,10 @@ import {
ReferenceLine,
} from "recharts";
import { Button } from "@/components/ui/button";
import { formatDateShort } from "@/lib/format";
import { useViewportWidth } from "@/hooks/use-viewport-width";
import { chooseTickInterval } from "@/lib/charts/x-axis-density";
import { useTranslations } from "@/lib/i18n/context";
import { makeFormatters } from "@/lib/format-locale";
interface DailyData {
expected: number;
@@ -30,6 +31,12 @@ interface ComplianceLineChartProps {
rangePoints?: 30 | 90 | 0;
onRangePointsChange?: (value: 30 | 90 | 0) => void;
showRangeControls?: boolean;
/**
* v1.4.25 W7b — per-user display timezone for the X-axis labels.
* Defaults to "Europe/Berlin" so older callers keep their previous
* rendering bit-for-bit.
*/
userTimezone?: string;
}
const TIME_RANGES = [
@@ -43,6 +50,7 @@ export function ComplianceLineChart({
rangePoints,
onRangePointsChange,
showRangeControls = true,
userTimezone = "Europe/Berlin",
}: ComplianceLineChartProps) {
const [internalRangePoints, setInternalRangePoints] = useState<30 | 90 | 0>(
30,
@@ -50,6 +58,13 @@ export function ComplianceLineChart({
const activeRangePoints = rangePoints ?? internalRangePoints;
const setActiveRangePoints = onRangePointsChange ?? setInternalRangePoints;
const viewportWidth = useViewportWidth();
const { locale } = useTranslations();
// v1.4.25 W7b — tz-aware date label for the X-axis. Falls back to
// legacy Europe/Berlin when the prop is omitted.
const tzFmt = useMemo(
() => makeFormatters(locale, userTimezone),
[locale, userTimezone],
);
const chartData = useMemo(() => {
const points = Object.entries(dailyCompliance)
@@ -58,14 +73,14 @@ export function ComplianceLineChart({
.map(([dateKey, data]) => {
const date = new Date(`${dateKey}T12:00:00.000Z`);
return {
date: formatDateShort(date, true),
date: tzFmt.date(date),
rate: Math.min(100, Math.round((data.taken / data.expected) * 100)),
timestamp: date.getTime(),
};
});
return activeRangePoints > 0 ? points.slice(-activeRangePoints) : points;
}, [dailyCompliance, activeRangePoints]);
}, [dailyCompliance, activeRangePoints, tzFmt]);
return (
<div>
+54 -21
View File
@@ -20,8 +20,8 @@ import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip";
import { ChartEmptyState } from "./chart-empty-state";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { Button } from "@/components/ui/button";
import { formatDateShort } from "@/lib/format";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { makeFormatters } from "@/lib/format-locale";
import {
bucketTimeSeries,
pickBucket,
@@ -138,6 +138,15 @@ interface HealthChartProps {
* that needs the same behaviour).
*/
annotations?: Array<{ date: string; label: string; color: string }>;
/**
* v1.4.25 W7b — per-user display timezone. When passed (mount sites
* thread `useAuth().user?.timezone`), x-axis tick labels and the
* per-day bucket keys both render in the user's zone instead of the
* legacy Europe/Berlin pin. Defaults to "Europe/Berlin" so older
* callers that haven't yet adopted the prop keep their previous
* behaviour bit-for-bit.
*/
userTimezone?: string;
}
interface ChartDataPoint {
@@ -191,15 +200,22 @@ function getTypeLabel(
return key ? t(key) : type;
}
const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
});
/**
* Build a `YYYY-MM-DD` day-key formatter pinned to the requested
* timezone. Memoised per-tz inside the component so we don't allocate a
* new `Intl.DateTimeFormat` on every measurement-row pass.
*/
function makeDayKeyFormatter(tz: string): Intl.DateTimeFormat {
return new Intl.DateTimeFormat("en-US", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
});
}
function toBerlinDayKey(value: string): string {
const parts = BERLIN_DAY_FORMATTER.formatToParts(new Date(value));
function toDayKey(value: string, formatter: Intl.DateTimeFormat): string {
const parts = formatter.formatToParts(new Date(value));
const year = parts.find((part) => part.type === "year")?.value;
const month = parts.find((part) => part.type === "month")?.value;
const day = parts.find((part) => part.type === "day")?.value;
@@ -367,10 +383,26 @@ export function HealthChart({
compareBaseline = "none",
chartKey,
annotations,
userTimezone = "Europe/Berlin",
}: HealthChartProps) {
const { isAuthenticated, user } = useAuth();
const { t } = useTranslations();
const { t, locale } = useTranslations();
const fmt = useFormatters();
// v1.4.25 W7b — tz-aware formatter for x-axis tick labels + tooltip
// date strings. `useFormatters()` reads the active UI locale only;
// this builds a locale + userTz pair so a Pacific/Auckland user reads
// their own day on the axis.
const tzFmt = useMemo(
() => makeFormatters(locale, userTimezone),
[locale, userTimezone],
);
// 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.
const dayKeyFormatter = useMemo(
() => makeDayKeyFormatter(userTimezone),
[userTimezone],
);
// v1.4.16 B5c: when a windowOverride is supplied, seed the range
// state from it so the chart pins to that window. Mini mode also
// hides the range tabs, so the user can't change it.
@@ -407,6 +439,10 @@ export function HealthChart({
types.join(","),
valueMode,
bmiDivisor ?? "no-bmi",
// v1.4.25 W7b — bucket keys + tick labels depend on the active
// user timezone, so re-key the cache when it changes. Without
// this, a tz change inside a session would render stale buckets.
userTimezone,
],
queryFn: async () => {
const params = new URLSearchParams();
@@ -451,7 +487,7 @@ export function HealthChart({
continue;
}
const dayKey = toBerlinDayKey(measurement.measuredAt);
const dayKey = toDayKey(measurement.measuredAt, dayKeyFormatter);
const bucket = dailyAggregates.get(dayKey) ?? {
timestamp: dayKeyToTimestamp(dayKey),
values: {},
@@ -475,7 +511,7 @@ export function HealthChart({
const allData: ChartDataPoint[] = Array.from(dailyAggregates.values())
.map((bucket) => {
const point: ChartDataPoint = {
date: formatDateShort(new Date(bucket.timestamp)),
date: tzFmt.dateShort(new Date(bucket.timestamp)),
timestamp: bucket.timestamp,
};
@@ -525,7 +561,7 @@ export function HealthChart({
).points.map<ChartDataPoint>((point) => {
const date = new Date(point.timestamp);
const out: ChartDataPoint = {
date: formatDateShort(date),
date: tzFmt.dateShort(date),
timestamp: point.timestamp,
};
for (const [type, value] of Object.entries(point.values)) {
@@ -590,7 +626,7 @@ export function HealthChart({
}
return enriched;
}, [data, rangePoints, showMA, showTrend, types]);
}, [data, rangePoints, showMA, showTrend, types, tzFmt]);
// v1.4.16 phase B8 — comparison overlay.
//
@@ -624,7 +660,7 @@ export function HealthChart({
// Index shifted rows by the same day-key the chart already uses.
const shiftedByDay = new Map<string, Record<string, number>>();
for (const row of shifted) {
const dayKey = formatDateShort(new Date(row.timestamp));
const dayKey = tzFmt.dateShort(new Date(row.timestamp));
const slot = shiftedByDay.get(dayKey) ?? {};
for (const [type, value] of Object.entries(row.values)) {
if (typeof value === "number" && Number.isFinite(value)) {
@@ -646,7 +682,7 @@ export function HealthChart({
}
return merged;
});
}, [chartData, effectiveCompareBaseline, data, types]);
}, [chartData, effectiveCompareBaseline, data, types, tzFmt]);
/**
* v1.4.16 phase B8 — true when at least one visible day has a prior-
@@ -1101,11 +1137,10 @@ export function HealthChart({
tickLine={false}
axisLine={false}
tickFormatter={(value) =>
formatDateShort(
tzFmt.date(
new Date(
chartData?.[Math.round(value)]?.timestamp ?? Date.now(),
),
true,
)
}
interval={chooseTickInterval(
@@ -1267,9 +1302,7 @@ export function HealthChart({
(typeof rechartsLabel === "number"
? chartData?.[Math.round(rechartsLabel)]?.timestamp
: undefined);
const dateLabel = ts
? formatDateShort(new Date(ts), true)
: "";
const dateLabel = ts ? tzFmt.date(new Date(ts)) : "";
const rows: RichTooltipRow[] = [];
// Build a quick lookup of compare values for this
// hover-day so the current-period row can attach
@@ -49,6 +49,7 @@ import { Button } from "@/components/ui/button";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { formatDateShort } from "@/lib/format";
import { makeFormatters } from "@/lib/format-locale";
import { cn } from "@/lib/utils";
import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip";
import { ChartEmptyState } from "./chart-empty-state";
@@ -86,17 +87,25 @@ type RangeDays = (typeof RANGE_DAYS)[number];
* intakes are skipped — compliance is undefined when nothing was due.
*
* Pure & deterministic so the unit test pins exact rates.
*
* v1.4.25 W7b — the `dateFormatter` argument lets the chart pass a
* tz-aware formatter so a Pacific/Auckland user's X-axis tick label
* for "2026-05-15" reads as 15.05. in their tz. Default keeps the
* legacy `formatDateShort` so the existing unit test (which calls the
* exported helper without arguments) stays byte-identical.
*/
export function aggregateMedicationCompliance(
points: DailyCompliancePoint[],
dateFormatter: (date: Date) => string = (d) => formatDateShort(d, false),
): ChartPoint[] {
return points
.filter((p) => p.scheduled > 0)
.sort((a, b) => a.date.localeCompare(b.date))
.map((p) => {
const [y, m, d] = p.date.split("-").map(Number);
// Anchor the timestamp at noon UTC of the Berlin day so a tooltip
// and tick formatter never disagree across DST boundaries.
// Anchor the timestamp at noon UTC of the (server-side) calendar
// day so a tooltip and tick formatter never disagree across DST
// boundaries.
const ts = Date.UTC(y, m - 1, d, 12);
const rate = Math.min(100, Math.round((p.taken / p.scheduled) * 100));
return {
@@ -106,7 +115,7 @@ export function aggregateMedicationCompliance(
// tooltip below still derives its label from `timestamp` with
// `includeYear=true`, so the full date is preserved for the
// tooltip read.
date: formatDateShort(new Date(ts), false),
date: dateFormatter(new Date(ts)),
rate,
timestamp: ts,
};
@@ -171,15 +180,28 @@ interface MedicationComplianceChartProps {
* thinking the toggle is broken.
*/
compareBaseline?: "none" | "lastMonth" | "lastYear";
/**
* v1.4.25 W7b — per-user display timezone for x-axis tick + tooltip
* date strings. Defaults to "Europe/Berlin" so older callers stay
* byte-identical.
*/
userTimezone?: string;
}
export function MedicationComplianceChart({
title,
userTimezone = "Europe/Berlin",
}: MedicationComplianceChartProps) {
const { isAuthenticated } = useAuth();
const { t } = useTranslations();
const { t, locale } = useTranslations();
const fmt = useFormatters();
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.
const tzFmt = useMemo(
() => makeFormatters(locale, userTimezone),
[locale, userTimezone],
);
// v1.4.18 — three overlay toggles persisted per chart. The 7-day
// trend chip and the goal/threshold reference lines used to render
@@ -210,8 +232,11 @@ export function MedicationComplianceChart({
});
const chartData = useMemo(
() => (data ? aggregateMedicationCompliance(data) : []),
[data],
() =>
data
? aggregateMedicationCompliance(data, (d) => tzFmt.dateShort(d))
: [],
[data, tzFmt],
);
// v1.4.16 A6 — 7-day trend chip. Computed off the *full* range so a
@@ -395,9 +420,7 @@ export function MedicationComplianceChart({
};
if (!active || !payload?.length) return null;
const ts = payload[0]?.payload?.timestamp;
const dateLabel = ts
? formatDateShort(new Date(ts), true)
: "";
const dateLabel = ts ? tzFmt.date(new Date(ts)) : "";
const rate = payload[0]?.value;
if (typeof rate !== "number") return null;
// Delta vs. the 100 % goal — a positive delta means
+22 -12
View File
@@ -17,8 +17,8 @@ import { Loader2 } from "lucide-react";
import { useState, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { formatDateShort } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { makeFormatters } from "@/lib/format-locale";
import type { DataSummary } from "@/lib/analytics/trends";
import {
bucketTimeSeries,
@@ -84,6 +84,12 @@ interface MoodChartProps {
* only surface — the cog stays hidden and overlays are clean.
*/
chartKey?: ChartOverlayKey;
/**
* v1.4.25 W7b — per-user display timezone. When passed, the x-axis
* tick labels and tooltip date render in the user's tz. Defaults to
* "Europe/Berlin" so older mount sites stay byte-identical.
*/
userTimezone?: string;
}
// --- Constants ---
@@ -275,9 +281,16 @@ export function MoodChart({
windowOverride,
compareBaseline = "none",
chartKey,
userTimezone = "Europe/Berlin",
}: MoodChartProps) {
const { isAuthenticated } = useAuth();
const { t } = useTranslations();
const { t, locale } = useTranslations();
// v1.4.25 W7b — tz-aware formatter for x-axis tick labels + tooltip
// date strings.
const tzFmt = useMemo(
() => makeFormatters(locale, userTimezone),
[locale, userTimezone],
);
const initialRangePoints = windowOverride
? MINI_RANGE_POINTS[windowOverride]
: 30;
@@ -313,7 +326,7 @@ export function MoodChart({
const allPoints: ChartDataPoint[] = data.entries
.map((entry) => ({
date: formatDateShort(new Date(dayKeyToTimestamp(entry.date))),
date: tzFmt.dateShort(new Date(dayKeyToTimestamp(entry.date))),
timestamp: dayKeyToTimestamp(entry.date),
pointIndex: 0,
score: entry.score,
@@ -349,7 +362,7 @@ export function MoodChart({
})),
{ bucket: bucketType },
).points.map((point) => ({
date: formatDateShort(new Date(point.timestamp)),
date: tzFmt.dateShort(new Date(point.timestamp)),
timestamp: point.timestamp,
pointIndex: 0,
score: point.values.score,
@@ -401,7 +414,7 @@ export function MoodChart({
}
return enriched;
}, [data, rangePoints, showMA, showTrend]);
}, [data, rangePoints, showMA, showTrend, tzFmt]);
/**
* v1.4.16 phase B8 — comparison overlay merged into chartData.
@@ -426,7 +439,7 @@ export function MoodChart({
const shiftedByDay = new Map<string, number>();
for (const row of shifted) {
const dayKey = formatDateShort(new Date(row.timestamp));
const dayKey = tzFmt.dateShort(new Date(row.timestamp));
if (typeof row.score === "number" && Number.isFinite(row.score)) {
shiftedByDay.set(dayKey, row.score);
}
@@ -439,7 +452,7 @@ export function MoodChart({
}
return point;
});
}, [chartData, effectiveCompareBaseline, data]);
}, [chartData, effectiveCompareBaseline, data, tzFmt]);
const hasComparisonData = useMemo(() => {
if (effectiveCompareBaseline === "none" || !chartDataWithCompare)
@@ -689,11 +702,10 @@ export function MoodChart({
tickLine={false}
axisLine={false}
tickFormatter={(value: number) =>
formatDateShort(
tzFmt.date(
new Date(
chartData[Math.round(value)]?.timestamp ?? Date.now(),
),
true,
)
}
interval={chooseTickInterval(
@@ -741,9 +753,7 @@ export function MoodChart({
(typeof rechartsLabel === "number"
? chartData?.[Math.round(rechartsLabel)]?.timestamp
: undefined);
const dateLabel = ts
? formatDateShort(new Date(ts), true)
: "";
const dateLabel = ts ? tzFmt.date(new Date(ts)) : "";
const rows: RichTooltipRow[] = [];
const hoverPoint = payload[0]?.payload as
| ChartDataPoint
@@ -18,6 +18,7 @@ import {
Info,
} from "lucide-react";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
import { HealthChart } from "@/components/charts/health-chart";
import { MoodChart } from "@/components/charts/mood-chart";
import {
@@ -252,6 +253,7 @@ function InlineCharts({
mini?: boolean;
}) {
const { t } = useTranslations();
const { user } = useAuth();
if (tokens.length === 0) return null;
// Dedup while preserving the order the model emitted them.
@@ -279,9 +281,14 @@ function InlineCharts({
data-mini={mini ? "true" : "false"}
>
{kind === "mood" ? (
<MoodChart mini={mini} />
<MoodChart mini={mini} userTimezone={user?.timezone} />
) : (
<HealthChart types={[metric]} title={title} mini={mini} />
<HealthChart
types={[metric]}
title={title}
mini={mini}
userTimezone={user?.timezone}
/>
)}
</div>
);
@@ -4,6 +4,7 @@ import { useState } from "react";
import { ChevronDown, ChevronUp, ExternalLink } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
import type {
InsightRecommendation,
InsightRecommendationRationale,
@@ -194,6 +195,7 @@ function RationaleCard({
} | null;
}) {
const { t } = useTranslations();
const { user } = useAuth();
const metricType = metricSource?.type;
const chartTypes = metricTypeToChartTypes(metricType);
const showLowConfidenceCaption =
@@ -224,13 +226,18 @@ function RationaleCard({
compliance get dedicated wrappers; everything else routes
through HealthChart with the metric-key vocabulary. */}
{isMoodMetric(metricType) ? (
<MoodChart mini windowOverride={rationale.dataWindow} />
<MoodChart
mini
windowOverride={rationale.dataWindow}
userTimezone={user?.timezone}
/>
) : isComplianceMetric(metricType) ? null : chartTypes.length > 0 ? (
<HealthChart
types={chartTypes}
title={metricType ?? ""}
mini
windowOverride={rationale.dataWindow}
userTimezone={user?.timezone}
/>
) : null}
+6 -1
View File
@@ -2,6 +2,7 @@
import dynamic from "next/dynamic";
import { useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
import {
TrendAnnotation,
type TrendAnnotationConfidenceBand,
@@ -75,6 +76,8 @@ interface TrendsRowProps {
export function TrendsRow({ annotations, confidence }: TrendsRowProps) {
const { t } = useTranslations();
const { user } = useAuth();
const userTimezone = user?.timezone;
const bpAnnotation = annotations?.bp ?? null;
const weightAnnotation = annotations?.weight ?? null;
const moodAnnotation = annotations?.mood ?? null;
@@ -117,6 +120,7 @@ export function TrendsRow({ annotations, confidence }: TrendsRowProps) {
unit="mmHg"
yAxisUnit="Hg"
mini
userTimezone={userTimezone}
/>
<TrendAnnotation
metric="bp"
@@ -135,6 +139,7 @@ export function TrendsRow({ annotations, confidence }: TrendsRowProps) {
colors={["#bd93f9"]}
unit="kg"
mini
userTimezone={userTimezone}
/>
<TrendAnnotation
metric="weight"
@@ -147,7 +152,7 @@ export function TrendsRow({ annotations, confidence }: TrendsRowProps) {
data-metric="mood"
className="flex h-full min-h-[300px] flex-col gap-2"
>
<MoodChart title={t("charts.mood")} mini />
<MoodChart title={t("charts.mood")} mini userTimezone={userTimezone} />
<TrendAnnotation
metric="mood"
annotation={moodAnnotation}
+7 -2
View File
@@ -359,10 +359,15 @@ describe("per-user timezone — Pacific/Auckland end-to-end", () => {
}
const { GET } = await import("@/app/api/dashboard/summary/route");
const res = await GET(
// The handler is typed `() => Promise<Response>`, but the
// `apiHandler` wrapper unconditionally reads `args[0]` as the
// Next.js Request — cast through unknown to push the test fixture
// through without weakening the runtime type.
const handler = GET as unknown as (req: Request) => Promise<Response>;
const res = await handler(
new Request("http://localhost/api/dashboard/summary", {
method: "GET",
}) as Parameters<typeof GET>[0],
}),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {