fix(profile): read and store the date of birth as a calendar day

The iOS app sends the date of birth as the instant its date picker
returned, and the profile writers stored that instant as sent. Since the
report cover and the share page read the date part of the stored value,
an entry made just after midnight in Berlin printed the day before, and a
Californian entry made in the evening printed the day after.

Both profile writers now store the calendar day the value names in the
owner's zone, at noon UTC like other date-only values, so an iOS client
reading the instant back in its own zone keeps the date. Older rows keep
their instant: dateOfBirthKey reads a date-only anchor (UTC midnight or
noon) by its date and any other instant in the owner's zone. The report
collector, the targets age, and the web forms that send the date back
read it through that helper.
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 22:37:57 +01:00
1 parent 82125815d5
commit 17dbb225d4
13 files changed
+275 -30

No files matched your search

@@ -90,6 +90,20 @@ describe("POST /api/onboarding/complete", () => {
);
});
it("stores a date of birth from the iOS app as its day in the owner's zone", async () => {
vi.mocked(getSession).mockResolvedValue({
...SESSION_OK,
user: { ...SESSION_OK.user, timezone: "Europe/Berlin" },
} as never);
// 14 May picked at 00:30 in Berlin.
const res = await POST(req({ dateOfBirth: "1960-05-13T23:30:00Z" }));
expect(res.status).toBe(200);
const data = vi.mocked(prisma.user.update).mock.calls[0]![0]!.data as {
dateOfBirth: Date;
};
expect(data.dateOfBirth.toISOString()).toBe("1960-05-14T12:00:00.000Z");
});
it("still refuses an unsupported gender", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await POST(req({ gender: "diverse" }));
+8 -4
View File
@@ -29,6 +29,8 @@ import { readManagedProfileForGuardian } from "@/lib/managed-profiles/lifecycle"
import { writeRecordModulePreferences } from "@/lib/record-settings/modules";
import { NextRequest } from "next/server";
import { onboardingCompleteSchema } from "@/lib/validations/onboarding";
import { dateOfBirthForStorage } from "@/lib/tz/date-only";
import { resolveStoredTimezone } from "@/lib/tz/resolver";
/**
* Complete the onboarding flow. Saves optional profile data and marks
@@ -84,10 +86,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
if (result.data.dateOfBirth) {
const dob = new Date(result.data.dateOfBirth);
if (!isNaN(dob.getTime())) {
data.dateOfBirth = dob;
}
// The calendar day in the owner's zone: the iOS app sends an instant.
const dob = dateOfBirthForStorage(
result.data.dateOfBirth,
await resolveStoredTimezone(user.timezone),
);
if (dob) data.dateOfBirth = dob;
}
if (result.data.gender) {