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) {
+10 -2
View File
@@ -9,6 +9,8 @@ import {
type BaselineFieldErrors,
} from "@/components/onboarding/baseline-fields";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { dateOfBirthKey } from "@/lib/tz/date-only";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import {
resolveHeightUnitAdapter,
type HeightDraft,
@@ -59,6 +61,8 @@ interface BaselineInitialValues {
heightCm: number | null;
dateOfBirth: string | null;
gender: string | null;
/** The owner's zone, which a date of birth from the iOS app is read in. */
timezone?: string;
}
interface BaselineFormState {
@@ -133,8 +137,12 @@ export function BaselineForm({
const [form, setForm] = useState<BaselineFormState>(() => ({
displayName: "",
height: heightAdapter.toDraft(initial.heightCm),
// The payload's ISO instant, as the date field's YYYY-MM-DD.
dateOfBirth: initial.dateOfBirth ? initial.dateOfBirth.slice(0, 10) : "",
dateOfBirth: initial.dateOfBirth
? dateOfBirthKey(
new Date(initial.dateOfBirth),
initial.timezone ?? DEFAULT_TIMEZONE,
)
: "",
gender: initial.gender ?? "",
}));
const [saving, setSaving] = useState(false);
+3 -5
View File
@@ -35,6 +35,8 @@ import {
questionsToPassBeforeConfirm,
} from "@/lib/onboarding/wizard-steps";
const NO_BASELINE = { heightCm: null, dateOfBirth: null, gender: null };
/**
* v1.39 — the confirm screen.
*
@@ -334,11 +336,7 @@ export function ConfirmScreen({
) : (
<BaselineForm
backHref={back ? screenHref(back) : undefined}
initial={{
heightCm: user?.heightCm ?? null,
dateOfBirth: user?.dateOfBirth ?? null,
gender: user?.gender ?? null,
}}
initial={user ?? NO_BASELINE}
onConfirmed={finish}
confirming={finishing}
demoMode={demoMode}
@@ -52,7 +52,7 @@ import {
} from "./account-section-utils";
import { AvatarSection } from "./avatar-section";
import { SetupCard } from "./setup-card";
import { dateOnlyKey } from "@/lib/tz/date-only";
import { dateOfBirthKey } from "@/lib/tz/date-only";
import { translateCodeOrNull } from "@/lib/i18n/translate-code";
export { resolveInitialTimezone } from "./account-section-utils";
@@ -149,7 +149,7 @@ export function AccountSection() {
displayName: user.displayName ?? "",
height: heightAdapter.toDraft(user.heightCm),
dateOfBirth: user.dateOfBirth
? dateOnlyKey(new Date(user.dateOfBirth))
? dateOfBirthKey(new Date(user.dateOfBirth), user.timezone)
: "",
gender: user.gender ?? "",
fullName: user.fullName ?? "",
@@ -71,6 +71,55 @@ describe("applyProfileUpdate locale", () => {
);
});
describe("applyProfileUpdate date of birth", () => {
const writtenDateOfBirth = () =>
(
vi.mocked(prisma.user.update).mock.calls[0]![0]!.data as {
dateOfBirth?: Date | null;
}
).dateOfBirth;
it("stores a typed date as that calendar date", async () => {
await applyProfileUpdate(USER_ID, { dateOfBirth: "2012-05-14" });
expect(writtenDateOfBirth()?.toISOString()).toBe(
"2012-05-14T12:00:00.000Z",
);
});
it("stores an instant from the iOS app as its day in the zone the request sets", async () => {
// 14 May picked at 20:00 in Honolulu (UTC-10).
await applyProfileUpdate(USER_ID, {
dateOfBirth: "2012-05-15T06:00:00Z",
timezone: "Pacific/Honolulu",
});
expect(writtenDateOfBirth()?.toISOString()).toBe(
"2012-05-14T12:00:00.000Z",
);
});
it("reads an instant in the stored zone when the request sets none", async () => {
// 14 May picked at 09:30 in Nuku'alofa (UTC+13). A user id of its own:
// the zone resolver caches per account.
vi.mocked(prisma.user.findUnique).mockResolvedValue({
timezone: "Pacific/Tongatapu",
} as never);
await applyProfileUpdate("user-tonga", {
dateOfBirth: "2012-05-13T20:30:00Z",
});
expect(writtenDateOfBirth()?.toISOString()).toBe(
"2012-05-14T12:00:00.000Z",
);
});
it("clears on null and leaves the column alone for a string that names no date", async () => {
await applyProfileUpdate(USER_ID, { dateOfBirth: null });
expect(writtenDateOfBirth()).toBeNull();
vi.mocked(prisma.user.update).mockClear();
await applyProfileUpdate(USER_ID, { dateOfBirth: "2012-02-30" });
expect(writtenDateOfBirth()).toBeUndefined();
});
});
describe("applyProfileUpdate timezone validation", () => {
it("accepts a valid IANA zone", async () => {
const result = await applyProfileUpdate(USER_ID, {
+18 -1
View File
@@ -7,7 +7,9 @@ import { optionalDisplayNameSchema } from "@/lib/validations/display-name";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { profileSchema } from "@/lib/validations/auth";
import { dateOfBirthForStorage } from "@/lib/tz/date-only";
import { isValidTimezone } from "@/lib/tz/format";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { encrypt } from "@/lib/crypto";
import {
invalidateUserMedications,
@@ -187,6 +189,21 @@ export interface ApplyProfileError {
issues?: SanitisedZodIssue[];
}
/**
* The date of birth to store: the owner's calendar day (the iOS app sends an
* instant, read in the zone this request sets or the stored one), null to
* clear, and undefined, which leaves the column alone, for a string that
* names no date.
*/
async function storedDateOfBirth(
userId: string,
data: Partial<ExtendedProfileInput>,
): Promise<Date | null | undefined> {
if (!data.dateOfBirth) return null;
const timezone = data.timezone ?? (await resolveUserTimezone(userId));
return dateOfBirthForStorage(data.dateOfBirth, timezone) ?? undefined;
}
/**
* Validate and persist profile updates for `userId`. Returns either an
* `ApplyProfileResult` on success or `ApplyProfileError` with a status
@@ -434,7 +451,7 @@ export async function applyProfileUpdate(
if (data.email !== undefined) updates.email = normalizedEmail;
if (data.heightCm !== undefined) updates.heightCm = data.heightCm ?? null;
if (data.dateOfBirth !== undefined) {
updates.dateOfBirth = data.dateOfBirth ? new Date(data.dateOfBirth) : null;
updates.dateOfBirth = await storedDateOfBirth(userId, data);
}
if (data.gender !== undefined) updates.gender = data.gender;
if (data.displayName !== undefined) {
+1
View File
@@ -86,6 +86,7 @@ export interface DoctorReportData {
* login, never a managed record's generated handle. Null prints no line.
*/
name: string | null;
/** The calendar date, `YYYY-MM-DD` (`dateOfBirthKey`). */
dateOfBirth: string | null;
gender: string | null;
heightCm: number | null;
@@ -500,6 +500,35 @@ describe("the name the report prints", () => {
});
});
describe("the date of birth the report prints", () => {
const identity = selectionFromLeaves(["PATIENT_IDENTITY"]);
const born = async (dateOfBirth: Date, timezone: string) => {
rows.userUnique = [
{ ...(rows.userUnique[0] as object), dateOfBirth, timezone },
];
const payload = await collectDoctorReportData("u1", RANGE, identity, {
moduleMap: moduleMap(),
});
return payload.patient.dateOfBirth;
};
it("prints a date stored at UTC midnight as that date in every zone", async () => {
const stored = new Date("1960-05-14T00:00:00.000Z");
expect(await born(stored, "Pacific/Honolulu")).toBe("1960-05-14");
expect(await born(stored, "Pacific/Tongatapu")).toBe("1960-05-14");
});
it("prints an instant from the iOS app as its date in the owner's zone", async () => {
// 14 May entered at 00:30 in Berlin, and at 20:00 in Honolulu.
expect(
await born(new Date("1960-05-13T23:30:00.000Z"), "Europe/Berlin"),
).toBe("1960-05-14");
expect(
await born(new Date("1960-05-15T06:00:00.000Z"), "Pacific/Honolulu"),
).toBe("1960-05-14");
});
});
describe("fail-closed exclusion", () => {
it("excludes a measurement type the catalogue does not know", () => {
const gate = resolveReportGate(
+2 -2
View File
@@ -83,7 +83,7 @@ import {
loadSurgicalHistory,
loadLabResults,
} from "./clinical-records";
import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
import { dateOfBirthKey, dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
import { PATIENT_NAME_SELECT, patientName } from "@/lib/account-name";
import { readingsMean } from "@/lib/measurements/day-mean";
import { loadScreenerUnvalidatedTranslations } from "./screener-translations";
@@ -723,7 +723,7 @@ export async function collectDoctorReportData(
name: identityOn && userProfile ? patientName(userProfile) : null,
dateOfBirth:
identityOn && userProfile?.dateOfBirth
? userProfile.dateOfBirth.toISOString()
? dateOfBirthKey(userProfile.dateOfBirth, reportTz)
: null,
gender: identityOn ? (userProfile?.gender ?? null) : null,
heightCm: identityOn ? (userProfile?.heightCm ?? null) : null,
+2 -14
View File
@@ -18,6 +18,7 @@ import {
COURSES_COMPLIANCE_SELECT,
SCHEDULE_COMPLIANCE_SELECT,
} from "@/lib/analytics/compliance";
import { ageInYears } from "@/lib/tz/date-only";
import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
import pLimit from "p-limit";
import type { MeasurementType } from "@/generated/prisma/client";
@@ -56,19 +57,6 @@ import {
import type { User } from "@/generated/prisma/client";
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
function getAge(dateOfBirth: Date): number {
const today = new Date();
let age = today.getFullYear() - dateOfBirth.getFullYear();
const monthDiff = today.getMonth() - dateOfBirth.getMonth();
if (
monthDiff < 0 ||
(monthDiff === 0 && today.getDate() < dateOfBirth.getDate())
) {
age--;
}
return age;
}
/**
* The authenticated-user shape the builder reads directly. The profile
* fields (height / dob / gender / glucose unit / thresholds) are re-read
@@ -256,7 +244,7 @@ export async function buildTargetsResponse(user: AuthedUser) {
const now = new Date();
const age = dbUser?.dateOfBirth ? getAge(new Date(dbUser.dateOfBirth)) : null;
const age = ageInYears(dbUser?.dateOfBirth ?? null, timezone, now);
const gender = toProfileSex(dbUser?.gender);
const heightCm = dbUser?.heightCm ?? null;
const latestByType: TargetValueByType = {};
+83
View File
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import {
ageInYears,
dateOfBirthForStorage,
dateOfBirthKey,
isNoonUtcAnchor,
dateOnlyAtNoonUtc,
dateOnlyKey,
@@ -62,6 +65,86 @@ describe("isNoonUtcAnchor", () => {
});
});
describe("date of birth", () => {
// UTC-10 and UTC+13 the year round.
const WEST = "Pacific/Honolulu";
const EAST = "Pacific/Tongatapu";
it("reads a date-only anchor by its date in every zone", () => {
for (const zone of [WEST, EAST, "Europe/Berlin"]) {
expect(dateOfBirthKey(new Date("2012-05-14T00:00:00.000Z"), zone)).toBe(
"2012-05-14",
);
expect(dateOfBirthKey(new Date("2012-05-14T12:00:00.000Z"), zone)).toBe(
"2012-05-14",
);
}
});
it("reads an instant from the iOS app as its date in the owner's zone", () => {
// 14 May picked at 20:00 in Honolulu, and at 09:30 in Nuku'alofa.
expect(dateOfBirthKey(new Date("2012-05-15T06:00:00.000Z"), WEST)).toBe(
"2012-05-14",
);
expect(dateOfBirthKey(new Date("2012-05-13T20:30:00.000Z"), EAST)).toBe(
"2012-05-14",
);
});
it("stores a typed date and an iOS instant as the same calendar date", () => {
const noon = "2012-05-14T12:00:00.000Z";
expect(dateOfBirthForStorage("2012-05-14", WEST)?.toISOString()).toBe(noon);
expect(
dateOfBirthForStorage("2012-05-15T06:00:00.000Z", WEST)?.toISOString(),
).toBe(noon);
expect(
dateOfBirthForStorage("2012-05-13T20:30:00+00:00", EAST)?.toISOString(),
).toBe(noon);
});
it("keeps a stored value that comes back unchanged", () => {
for (const echoed of [
"2012-05-14T00:00:00.000Z",
"2012-05-14T12:00:00.000Z",
]) {
expect(dateOfBirthForStorage(echoed, WEST)?.toISOString()).toBe(
"2012-05-14T12:00:00.000Z",
);
}
});
it("stores nothing for a string that names no date", () => {
expect(dateOfBirthForStorage("2012-02-30", WEST)).toBeNull();
expect(dateOfBirthForStorage("not a date", WEST)).toBeNull();
});
it("counts the age from the birthday on the owner's calendar", () => {
const iosEntry = new Date("2012-05-13T20:30:00.000Z"); // 14 May in EAST
expect(
ageInYears(iosEntry, EAST, new Date("2026-05-13T10:59:00.000Z")),
).toBe(13);
// 00:00 on 14 May in Nuku'alofa.
expect(
ageInYears(iosEntry, EAST, new Date("2026-05-13T11:00:00.000Z")),
).toBe(14);
expect(
ageInYears(
new Date("2012-05-14T00:00:00.000Z"),
WEST,
new Date("2026-05-14T09:59:00.000Z"),
),
).toBe(13);
// 00:00 on 14 May in Honolulu.
expect(
ageInYears(
new Date("2012-05-14T00:00:00.000Z"),
WEST,
new Date("2026-05-14T10:00:00.000Z"),
),
).toBe(14);
});
});
describe("dateOnlyWindow", () => {
it("spans UTC midnight to the next UTC midnight, both days included", () => {
expect(dateOnlyWindow("2026-11-11")).toEqual({
+54
View File
@@ -14,6 +14,8 @@
* migration.
*/
import { userDayKey } from "./format";
const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/u;
/** True for a strict `YYYY-MM-DD` string. */
@@ -85,6 +87,58 @@ export function dateOnlyKey(value: Date): string {
return value.toISOString().slice(0, 10);
}
/**
* The calendar date a stored date of birth names, in its owner's `timezone`.
*
* The web and the managed profiles store the date as a date-only anchor (UTC
* midnight, or noon), read by its date. The iOS app sent the instant its date
* picker returned, which names the day it falls on in the owner's zone: a
* Berlin entry of 14 May made at half past midnight is 23:30 UTC on 13 May,
* and read by its UTC date it printed the day before.
*/
export function dateOfBirthKey(value: Date, timezone: string): string {
const anchored =
value.getUTCHours() % 12 === 0 &&
value.getUTCMinutes() === 0 &&
value.getUTCSeconds() === 0 &&
value.getUTCMilliseconds() === 0;
return anchored ? dateOnlyKey(value) : userDayKey(value, timezone);
}
/**
* A date of birth as a client sends it (`YYYY-MM-DD`, or an instant from the
* iOS app) as it is stored: its calendar date, read the way
* {@link dateOfBirthKey} reads a stored value, at noon UTC. Noon keeps the
* date wherever a client reads the stored instant back in its own zone from
* UTC-11 to UTC+11, where UTC midnight is the evening before everywhere
* west of UTC. Null when the string names no date.
*/
export function dateOfBirthForStorage(
raw: string,
timezone: string,
): Date | null {
if (isDateOnlyKey(raw)) {
return isCalendarDateKey(raw) ? dateOnlyAtNoonUtc(raw) : null;
}
const instant = new Date(raw);
if (Number.isNaN(instant.getTime())) return null;
return dateOnlyAtNoonUtc(dateOfBirthKey(instant, timezone));
}
/** Whole years of age on `now` in the owner's `timezone`; null when unknown. */
export function ageInYears(
dateOfBirth: Date | null,
timezone: string,
now: Date = new Date(),
): number | null {
if (!dateOfBirth) return null;
const [by, bm, bd] = dateOfBirthKey(dateOfBirth, timezone)
.split("-")
.map(Number);
const [ty, tm, td] = userDayKey(now, timezone).split("-").map(Number);
return ty - by - (tm < bm || (tm === bm && td < bd) ? 1 : 0);
}
/**
* Calendar date `key` as UTC midnight. A LABEL, never the instant the day
* began for anyone: it is how a `@db.Date` column holds a date (Prisma reads