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
This commit is contained in:
Marc-André Bombeck committed 2026-09-24 08:15:16 +02:00
1 parent 3170a622d1
commit 16ef75e729
4 files changed
+104 -6

No files matched your search

+10 -1
View File
@@ -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:
@@ -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("<HealthChart> 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(
<I18nProvider initialLocale="en">
<HealthChart types={["ACTIVITY_STEPS"]} title="Steps" unit="" />
</I18nProvider>,
);
expect(lastKey).toBeDefined();
expect(lastKey).toContain("Asia/Kolkata");
expect(lastKey).not.toContain("Europe/Berlin");
});
});
+10 -5
View File
@@ -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
@@ -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();