From 16ef75e729cb915c0becf0c0e8641322bddbbfa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 08:15:16 +0200 Subject: [PATCH] fix(charts): cut days in the account's zone when a mount passes none Several chart mounts (the medications page, the coach chat, the recovery section) pass no zone, and the chart then fell back to a fixed Europe/Berlin, so those charts cut and labelled days in Berlin for every user whatever their profile said. The chart now falls back to the signed-in account's zone, and only before that has loaded to the shared DEFAULT_TIMEZONE the server's own resolver ends on. /api/auth/me reports the zone the server resolves: the stored one, or the instance default when the stored one is unusable, so the client cuts days where the server does. Refs #1026 --- src/app/api/auth/me/route.ts | 11 +++- .../__tests__/health-chart-timezone.test.tsx | 53 +++++++++++++++++++ src/components/charts/health-chart.tsx | 15 ++++-- tests/integration/timezone-per-user.test.ts | 31 +++++++++++ 4 files changed, 104 insertions(+), 6 deletions(-) create mode 100644 src/components/charts/__tests__/health-chart-timezone.test.tsx diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index 52dcfc7cf..1bd2b7f13 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -49,6 +49,10 @@ import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment"; import { buildAvatarUrl } from "@/lib/avatar"; import { decrypt } from "@/lib/crypto"; import { prisma } from "@/lib/db"; +import { + isValidTimezone, + resolveServerDefaultTimezone, +} from "@/lib/tz/resolver"; import { isCycleEnabled } from "@/lib/cycle/gate"; import { resolveModuleMap, @@ -238,7 +242,12 @@ export const GET = apiHandler(async () => { heightCm: user.heightCm, dateOfBirth: user.dateOfBirth, gender: user.gender, - timezone: user.timezone, + // The zone the server cuts this account's days in: the stored zone, or + // the instance default when the stored one is unusable. Clients bucket + // by this value, so it must be the resolved one, not the raw column. + timezone: isValidTimezone(user.timezone) + ? user.timezone + : await resolveServerDefaultTimezone(), onboardingCompletedAt: user.onboardingCompletedAt, onboardingTourCompleted: user.onboardingTourCompleted, // v1.39 (C1) — the needs-based setup flow, published additively: diff --git a/src/components/charts/__tests__/health-chart-timezone.test.tsx b/src/components/charts/__tests__/health-chart-timezone.test.tsx new file mode 100644 index 000000000..4dd513ae7 --- /dev/null +++ b/src/components/charts/__tests__/health-chart-timezone.test.tsx @@ -0,0 +1,53 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +/** + * A chart mount that passes no `userTimezone` must still cut days in the + * signed-in user's zone. Several mounts (the medications page, the coach + * chat, the recovery section) pass none, and the component used to fall + * back to a fixed Europe/Berlin, so a user in Asia/Kolkata saw those charts + * bucketed and labelled in Berlin days while every other chart used their + * own. The zone now comes from the account, which the server resolves. + */ + +let lastKey: unknown[] | undefined; + +vi.mock("@tanstack/react-query", () => ({ + keepPreviousData: (previous: unknown) => previous, + useQuery: ({ queryKey }: { queryKey: unknown[] }) => { + lastKey = queryKey; + return { data: [], isLoading: false }; + }, + useQueryClient: () => ({ + cancelQueries: () => Promise.resolve(), + getQueryData: () => undefined, + setQueryData: () => undefined, + invalidateQueries: () => Promise.resolve(), + }), + useMutation: () => ({ mutate: () => undefined, isPending: false }), +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + isAuthenticated: true, + user: { id: "u1", timezone: "Asia/Kolkata" }, + isLoading: false, + }), +})); + +describe(" timezone", () => { + it("uses the account's zone when the mount passes none", async () => { + const { I18nProvider } = await import("@/lib/i18n/context"); + const { HealthChart } = await import("../health-chart"); + + renderToStaticMarkup( + + + , + ); + + expect(lastKey).toBeDefined(); + expect(lastKey).toContain("Asia/Kolkata"); + expect(lastKey).not.toContain("Europe/Berlin"); + }); +}); diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index 23f75dc76..d2d766ce8 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -1,6 +1,7 @@ "use client"; import { chartSeriesParams } from "@/components/charts/chart-series-request"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { useAuth } from "@/hooks/use-auth"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; @@ -214,10 +215,11 @@ interface HealthChartProps { /** * 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. + * per-day bucket keys both render in the user's zone. When a mount + * passes none, the chart uses the signed-in account's zone (which + * `/api/auth/me` reports already resolved against the server default), + * and only before that has loaded the shared `DEFAULT_TIMEZONE` the + * server itself falls back to. */ userTimezone?: string; /** @@ -614,7 +616,7 @@ export function HealthChart({ chartKey, annotations, verticalMarkers, - userTimezone = "Europe/Berlin", + userTimezone: userTimezoneProp, valueScale = 1, valueOffset = 0, onVisibleStats, @@ -625,6 +627,9 @@ export function HealthChart({ showDataTable = false, }: HealthChartProps) { const { isAuthenticated, user } = useAuth(); + // A mount without the prop used to pin Europe/Berlin, so those charts + // cut days in Berlin for every user whatever their profile said. + const userTimezone = userTimezoneProp || user?.timezone || DEFAULT_TIMEZONE; const { t, locale } = useTranslations(); const fmt = useFormatters(); // Issue #490 — x-axis tick + tooltip date labels. Every timestamp this diff --git a/tests/integration/timezone-per-user.test.ts b/tests/integration/timezone-per-user.test.ts index 117fb1742..c064e7f9c 100644 --- a/tests/integration/timezone-per-user.test.ts +++ b/tests/integration/timezone-per-user.test.ts @@ -28,6 +28,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; process.env.ENCRYPTION_KEY ??= "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +import { invalidateServerDefaultTimezone } from "@/lib/tz/resolver"; import { cookieJar, headerJar } from "./mock-next-headers"; import { getPrismaClient, truncateAllTables } from "./setup"; @@ -166,6 +167,36 @@ describe("per-user timezone — Pacific/Auckland end-to-end", () => { expect(body.data?.timezone).toBe("Pacific/Auckland"); }); + // A stored zone the server cannot use falls back to the instance + // default for every server-side day cut. `/api/auth/me` reports that + // same resolved zone, so a client (the charts' day buckets among them) + // never cuts days in a different zone than the server did. + it("GET /api/auth/me reports the resolved zone when the stored one is unusable", async () => { + const user = await seedAucklandUser(); + const prisma = getPrismaClient(); + await prisma.user.update({ + where: { id: user.id }, + data: { timezone: "" }, + }); + await prisma.appSettings.upsert({ + where: { id: "singleton" }, + create: { id: "singleton", defaultUserTimezone: "Asia/Kolkata" }, + update: { defaultUserTimezone: "Asia/Kolkata" }, + }); + invalidateServerDefaultTimezone(); + try { + const { GET } = await import("@/app/api/auth/me/route"); + const res = await GET(); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { timezone?: string } | null; + }; + expect(body.data?.timezone).toBe("Asia/Kolkata"); + } finally { + invalidateServerDefaultTimezone(); + } + }); + it("PUT /api/auth/me/timezone rejects an invalid IANA zone with 422", async () => { await seedAucklandUser();