diff --git a/src/app/page.tsx b/src/app/page.tsx
index 4d557f97c..211d36d04 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -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() {
),
});
diff --git a/src/components/charts/compliance-line-chart.tsx b/src/components/charts/compliance-line-chart.tsx
index cbba86eec..9932c2c89 100644
--- a/src/components/charts/compliance-line-chart.tsx
+++ b/src/components/charts/compliance-line-chart.tsx
@@ -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 (
diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx
index 8fd90f54e..9b901d7b7 100644
--- a/src/components/charts/health-chart.tsx
+++ b/src/components/charts/health-chart.tsx
@@ -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((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>();
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
diff --git a/src/components/charts/medication-compliance-chart.tsx b/src/components/charts/medication-compliance-chart.tsx
index bdf9f6db5..eb8af2ce4 100644
--- a/src/components/charts/medication-compliance-chart.tsx
+++ b/src/components/charts/medication-compliance-chart.tsx
@@ -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(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
diff --git a/src/components/charts/mood-chart.tsx b/src/components/charts/mood-chart.tsx
index 16208ce11..d17f14cda 100644
--- a/src/components/charts/mood-chart.tsx
+++ b/src/components/charts/mood-chart.tsx
@@ -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();
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
diff --git a/src/components/insights/insight-advisor-card.tsx b/src/components/insights/insight-advisor-card.tsx
index 6127c7f46..9753d4bda 100644
--- a/src/components/insights/insight-advisor-card.tsx
+++ b/src/components/insights/insight-advisor-card.tsx
@@ -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" ? (
-
+
) : (
-
+
)}
);
diff --git a/src/components/insights/recommendation-card.tsx b/src/components/insights/recommendation-card.tsx
index 822835157..19c1ad164 100644
--- a/src/components/insights/recommendation-card.tsx
+++ b/src/components/insights/recommendation-card.tsx
@@ -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) ? (
-
+
) : isComplianceMetric(metricType) ? null : chartTypes.length > 0 ? (
) : null}
diff --git a/src/components/insights/trends-row.tsx b/src/components/insights/trends-row.tsx
index 54ea18b55..558ce2cc9 100644
--- a/src/components/insights/trends-row.tsx
+++ b/src/components/insights/trends-row.tsx
@@ -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}
/>
-
+
{
}
const { GET } = await import("@/app/api/dashboard/summary/route");
- const res = await GET(
+ // The handler is typed `() => Promise`, 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;
+ const res = await handler(
new Request("http://localhost/api/dashboard/summary", {
method: "GET",
- }) as Parameters[0],
+ }),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {