diff --git a/src/app/api/onboarding/complete/__tests__/route.test.ts b/src/app/api/onboarding/complete/__tests__/route.test.ts index c33b8a690..1c1f36b18 100644 --- a/src/app/api/onboarding/complete/__tests__/route.test.ts +++ b/src/app/api/onboarding/complete/__tests__/route.test.ts @@ -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" })); diff --git a/src/app/api/onboarding/complete/route.ts b/src/app/api/onboarding/complete/route.ts index f51f93d22..42e5c788b 100644 --- a/src/app/api/onboarding/complete/route.ts +++ b/src/app/api/onboarding/complete/route.ts @@ -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) { diff --git a/src/components/onboarding/baseline-form.tsx b/src/components/onboarding/baseline-form.tsx index b73fdf595..c59b5f886 100644 --- a/src/components/onboarding/baseline-form.tsx +++ b/src/components/onboarding/baseline-form.tsx @@ -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(() => ({ 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); diff --git a/src/components/onboarding/confirm-screen.tsx b/src/components/onboarding/confirm-screen.tsx index 26442c5e9..0de9c2ab1 100644 --- a/src/components/onboarding/confirm-screen.tsx +++ b/src/components/onboarding/confirm-screen.tsx @@ -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({ ) : ( { ); }); +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, { diff --git a/src/lib/auth/profile-update.ts b/src/lib/auth/profile-update.ts index 532fc382e..7762aa62d 100644 --- a/src/lib/auth/profile-update.ts +++ b/src/lib/auth/profile-update.ts @@ -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, +): Promise { + 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) { diff --git a/src/lib/doctor-report-types.ts b/src/lib/doctor-report-types.ts index 2b7a73697..e5e2c4026 100644 --- a/src/lib/doctor-report-types.ts +++ b/src/lib/doctor-report-types.ts @@ -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; diff --git a/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts b/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts index 6650e83a6..ef2da928b 100644 --- a/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts +++ b/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts @@ -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( diff --git a/src/lib/doctor-report/collect.ts b/src/lib/doctor-report/collect.ts index f55d2f66c..7e91ed12e 100644 --- a/src/lib/doctor-report/collect.ts +++ b/src/lib/doctor-report/collect.ts @@ -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, diff --git a/src/lib/targets/build-response.ts b/src/lib/targets/build-response.ts index b236b894a..04928a600 100644 --- a/src/lib/targets/build-response.ts +++ b/src/lib/targets/build-response.ts @@ -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 = {}; diff --git a/src/lib/tz/__tests__/date-only.test.ts b/src/lib/tz/__tests__/date-only.test.ts index 3aca0df8c..ad4a85bcd 100644 --- a/src/lib/tz/__tests__/date-only.test.ts +++ b/src/lib/tz/__tests__/date-only.test.ts @@ -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({ diff --git a/src/lib/tz/date-only.ts b/src/lib/tz/date-only.ts index 4a94de2cd..9edccd10c 100644 --- a/src/lib/tz/date-only.ts +++ b/src/lib/tz/date-only.ts @@ -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