From f74efc44aa06e19461c9a3fa4532f92b03c6d7c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 26 Jul 2026 05:41:40 +0200 Subject: [PATCH] fix(profile): let the third gender value through every surface that handles it The profile validator has accepted MALE, FEMALE and OTHER for a long time, but the published contract documented two, and a client written against that contract had no way to know the third existed. The profile PATCH is one write, so a rejected gender took every sibling field with it - a time-format change lost to a field the person never touched. Gender now clears on an empty string, the same contract every other profile field documents, and an unsupported value is refused with a message that names the field and its accepted values instead of the validator's own prose. Onboarding validated against a narrower enum than the profile schema and 422'd the whole submission; its form could not record the third value at all. The rest is the same defect in other clothes. The Strain model scored an unanswered or OTHER account on the male Banister coefficients; it now uses the midpoint of both published models, matching every other sex-split formula here. The dashboard snapshot emitted null for an account that answered OTHER, which is indistinguishable from no answer. Three casts asserted a two-value type over a three-value column. All of them now carry the stored value and narrow once, at the boundary of a reference table that genuinely has no third row, through one named helper in src/lib/profile/sex.ts. Refs #645 --- docs/api/openapi.yaml | 5 + messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/pl.json | 1 + .../complete/__tests__/route.test.ts | 93 +++++++++++++++++++ src/app/api/onboarding/complete/route.ts | 6 +- .../api/user/profile/__tests__/route.test.ts | 80 ++++++++++++++++ src/app/page-client.tsx | 3 +- src/app/privacy/page.tsx | 5 +- src/components/onboarding/baseline-form.tsx | 9 +- .../dashboard/__tests__/target-bands.test.ts | 23 +++++ src/lib/dashboard/bands.ts | 10 +- src/lib/dashboard/snapshot.ts | 11 ++- .../header-profile-section.ts | 11 ++- .../insights/__tests__/strain-score.test.ts | 19 ++++ src/lib/insights/derived/baseline.ts | 14 ++- src/lib/insights/derived/norms.ts | 5 +- src/lib/insights/pulse-status.ts | 3 +- src/lib/insights/strain-score.ts | 30 ++++-- src/lib/openapi/routes/insights/schemas.ts | 2 +- src/lib/openapi/routes/profile.ts | 12 ++- src/lib/profile/sex.ts | 39 ++++++++ src/lib/targets/build-response.ts | 3 +- src/lib/targets/vitals-builder.ts | 20 +++- src/lib/validations/auth.ts | 18 +++- 28 files changed, 388 insertions(+), 39 deletions(-) create mode 100644 src/app/api/onboarding/complete/__tests__/route.test.ts create mode 100644 src/lib/profile/sex.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f0aa76bef..75d1d22bc 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -13272,11 +13272,13 @@ components: - type: string - type: "null" gender: + description: Stored gender. Null or an empty string clears it; an omitted field is left untouched. anyOf: - type: string enum: - MALE - FEMALE + - OTHER - type: "null" displayName: anyOf: @@ -24028,6 +24030,7 @@ components: enum: - MALE - FEMALE + - OTHER - type: "null" heightCm: anyOf: @@ -24139,6 +24142,7 @@ components: enum: - MALE - FEMALE + - OTHER - type: "null" heightCm: anyOf: @@ -26037,6 +26041,7 @@ components: enum: - MALE - FEMALE + - OTHER - type: "null" glucoseUnit: anyOf: diff --git a/messages/de.json b/messages/de.json index 55d67f5f4..31f362748 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4145,6 +4145,7 @@ "genderNone": "Keine Angabe", "genderMale": "Männlich", "genderFemale": "Weiblich", + "genderOther": "Divers", "dateOfBirthLabel": "Geburtsdatum", "dateOfBirthHint": "Wird ausschließlich für altersbezogene Auswertungen genutzt.", "saveCta": "Speichern und weiter" diff --git a/messages/en.json b/messages/en.json index bfd7535dd..3fcc3b6cb 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4145,6 +4145,7 @@ "genderNone": "Prefer not to say", "genderMale": "Male", "genderFemale": "Female", + "genderOther": "Other", "dateOfBirthLabel": "Date of birth", "dateOfBirthHint": "Used only for age-aware analytics.", "saveCta": "Save and continue" diff --git a/messages/es.json b/messages/es.json index f150bd1d9..ce0c60e5e 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4145,6 +4145,7 @@ "genderNone": "Prefiero no decirlo", "genderMale": "Hombre", "genderFemale": "Mujer", + "genderOther": "Otro", "dateOfBirthLabel": "Fecha de nacimiento", "dateOfBirthHint": "Se utiliza solo para análisis sensibles a la edad.", "saveCta": "Guardar y continuar" diff --git a/messages/fr.json b/messages/fr.json index 50fdd82da..cea4d2355 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4145,6 +4145,7 @@ "genderNone": "Préfère ne pas le préciser", "genderMale": "Homme", "genderFemale": "Femme", + "genderOther": "Autre", "dateOfBirthLabel": "Date de naissance", "dateOfBirthHint": "Utilisée uniquement pour les analyses qui tiennent compte de l'âge.", "saveCta": "Enregistrer et continuer" diff --git a/messages/it.json b/messages/it.json index aed593603..3edd4d0cc 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4145,6 +4145,7 @@ "genderNone": "Preferisco non specificare", "genderMale": "Maschile", "genderFemale": "Femminile", + "genderOther": "Altro", "dateOfBirthLabel": "Data di nascita", "dateOfBirthHint": "Usata solo per le analisi che tengono conto dell'età.", "saveCta": "Salva e continua" diff --git a/messages/pl.json b/messages/pl.json index d959fa615..fbb86f532 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4145,6 +4145,7 @@ "genderNone": "Wolę nie podawać", "genderMale": "Mężczyzna", "genderFemale": "Kobieta", + "genderOther": "Inna", "dateOfBirthLabel": "Data urodzenia", "dateOfBirthHint": "Używana wyłącznie do analiz zależnych od wieku.", "saveCta": "Zapisz i kontynuuj" diff --git a/src/app/api/onboarding/complete/__tests__/route.test.ts b/src/app/api/onboarding/complete/__tests__/route.test.ts new file mode 100644 index 000000000..667d16d7e --- /dev/null +++ b/src/app/api/onboarding/complete/__tests__/route.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { update: vi.fn() }, + auditLog: { create: vi.fn() }, + }, +})); + +vi.mock("@/lib/auth/session", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getSession: vi.fn(), + setOnboardingPendingCookie: vi.fn(async () => {}), + }; +}); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/context", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, annotate: vi.fn() }; +}); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { POST } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "testuser", role: "USER" as const }, +}; + +function req(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/onboarding/complete", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); +}); + +describe("POST /api/onboarding/complete", () => { + it("returns 401 when unauthenticated", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await POST(req({})); + expect(res.status).toBe(401); + }); + + /** + * Onboarding validated gender against a narrower enum than the profile + * schema, so a value the account is allowed to hold rejected the whole + * submission — height, date of birth and display name included. + */ + it("accepts every gender the profile stores, including OTHER", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await POST(req({ gender: "OTHER", heightCm: 180 })); + expect(res.status).toBe(200); + expect(prisma.user.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: "user-1" }, + data: expect.objectContaining({ gender: "OTHER", heightCm: 180 }), + }), + ); + }); + + it("still refuses an unsupported gender", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await POST(req({ gender: "diverse" })); + expect(res.status).toBe(422); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/onboarding/complete/route.ts b/src/app/api/onboarding/complete/route.ts index 9c7230491..cd7eebdc7 100644 --- a/src/app/api/onboarding/complete/route.ts +++ b/src/app/api/onboarding/complete/route.ts @@ -10,7 +10,11 @@ const onboardingSchema = z.object({ displayName: z.string().trim().min(1).max(50).optional(), heightCm: z.number().min(50).max(300).optional(), dateOfBirth: z.string().optional(), - gender: z.enum(["MALE", "FEMALE"]).optional(), + // Same three values the profile schema stores. A narrower enum here + // rejected the whole onboarding submission over a field the account + // is allowed to hold, and left the value unrecordable until the person + // found the setting again afterwards. + gender: z.enum(["MALE", "FEMALE", "OTHER"]).optional(), }); /** diff --git a/src/app/api/user/profile/__tests__/route.test.ts b/src/app/api/user/profile/__tests__/route.test.ts index 8a8654e13..b628b0550 100644 --- a/src/app/api/user/profile/__tests__/route.test.ts +++ b/src/app/api/user/profile/__tests__/route.test.ts @@ -297,4 +297,84 @@ describe("PATCH /api/user/profile", () => { expect(res.status).toBe(422); expect(prisma.user.update).not.toHaveBeenCalled(); }); + /** + * The whole profile PATCH is one write, so a rejected gender took every + * sibling change down with it — someone switching their time format lost + * the save over a field they never touched. + */ + it("clears gender on an empty string and still persists the sibling change", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue(null); + vi.mocked(prisma.user.update).mockResolvedValue({ + id: "user-1", + username: "testuser", + displayName: null, + email: null, + role: "USER", + heightCm: null, + dateOfBirth: null, + gender: null, + timezone: "Europe/Berlin", + locale: null, + timeFormat: "H24", + dateFormat: "AUTO", + moodReminderEnabled: false, + fullName: null, + insurerName: null, + insurerIkNumber: null, + insuranceNumberEncrypted: null, + } as never); + + const res = await PATCH(req({ timeFormat: "H24", gender: "" })); + expect(res.status).toBe(200); + expect(prisma.user.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ timeFormat: "H24", gender: null }), + }), + ); + }); + + it("accepts OTHER and writes it through", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue(null); + vi.mocked(prisma.user.update).mockResolvedValue({ + id: "user-1", + username: "testuser", + displayName: null, + email: null, + role: "USER", + heightCm: null, + dateOfBirth: null, + gender: "OTHER", + timezone: "Europe/Berlin", + locale: null, + timeFormat: "AUTO", + dateFormat: "AUTO", + moodReminderEnabled: false, + fullName: null, + insurerName: null, + insurerIkNumber: null, + insuranceNumberEncrypted: null, + } as never); + + const res = await PATCH(req({ gender: "OTHER" })); + expect(res.status).toBe(200); + expect(prisma.user.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ gender: "OTHER" }), + }), + ); + const body = (await res.json()) as { data: { gender: string | null } }; + expect(body.data.gender).toBe("OTHER"); + }); + + it("still refuses an unsupported gender, with a message that names the field", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await PATCH(req({ timeFormat: "H24", gender: "diverse" })); + expect(res.status).toBe(422); + expect(prisma.user.update).not.toHaveBeenCalled(); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("Gender must be MALE, FEMALE or OTHER"); + expect(body.error).not.toContain("Invalid option"); + }); }); diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index f4c67f5b3..584ada4d2 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -154,6 +154,7 @@ import type { DataSummary } from "@/lib/analytics/trends"; import { mergeSlimAndThickAnalytics } from "@/lib/analytics/merge-slim-thick"; import { isWindowSufficient } from "@/lib/analytics/window-confidence"; import { buildDashboardBands } from "@/lib/dashboard/bands"; +import { toProfileSex } from "@/lib/profile/sex"; import { apiGet } from "@/lib/api/api-fetch"; // v1.18.6 — the pure first-paint / skeleton-reservation gates moved to @@ -718,7 +719,7 @@ export default function DashboardPageClient({ () => buildDashboardBands({ dateOfBirth: user?.dateOfBirth ? new Date(user.dateOfBirth) : null, - gender: (user?.gender as "MALE" | "FEMALE" | null | undefined) ?? null, + gender: toProfileSex(user?.gender), heightCm: user?.heightCm ?? null, }), [user], diff --git a/src/app/privacy/page.tsx b/src/app/privacy/page.tsx index d15b07946..2db962bff 100644 --- a/src/app/privacy/page.tsx +++ b/src/app/privacy/page.tsx @@ -754,8 +754,9 @@ export default function PrivacyPage() {
  • Profile metadata: locale, timezone, date of birth (optional), - biological sex (optional), height (optional) — used to compute - age-adjusted target ranges and BMI. + gender (optional, one of male, female or other), height + (optional) — used to compute age-adjusted target ranges and + BMI.
  • diff --git a/src/components/onboarding/baseline-form.tsx b/src/components/onboarding/baseline-form.tsx index 86916172e..b1ba7f94d 100644 --- a/src/components/onboarding/baseline-form.tsx +++ b/src/components/onboarding/baseline-form.tsx @@ -139,7 +139,11 @@ export function BaselineForm() { const heightCm = heightAdapter.toCanonicalCm(form.height); if (heightCm !== null) profileBody.heightCm = heightCm; if (form.dateOfBirth) profileBody.dateOfBirth = form.dateOfBirth; - if (form.gender === "MALE" || form.gender === "FEMALE") { + if ( + form.gender === "MALE" || + form.gender === "FEMALE" || + form.gender === "OTHER" + ) { profileBody.gender = form.gender; } if (Object.keys(profileBody).length > 0) { @@ -241,6 +245,9 @@ export function BaselineForm() { {t("onboarding.baseline.genderFemale")} + + {t("onboarding.baseline.genderOther")} + diff --git a/src/lib/dashboard/__tests__/target-bands.test.ts b/src/lib/dashboard/__tests__/target-bands.test.ts index 7f76f903a..5e798d75b 100644 --- a/src/lib/dashboard/__tests__/target-bands.test.ts +++ b/src/lib/dashboard/__tests__/target-bands.test.ts @@ -134,6 +134,29 @@ describe("buildTargetBands — server/client parity", () => { }); } + it("gives OTHER the neutral bands, identical to an unanswered profile", () => { + // OTHER has no row in the percentile / body-fat tables. It must land on + // the same neutral fallback an unanswered profile gets, never on one of + // the two sides — and the type must be able to carry it here at all. + const other = buildTargetBands({ + dateOfBirth: new Date("1985-06-15T00:00:00.000Z"), + gender: "OTHER", + heightCm: 180, + }); + const unanswered = buildTargetBands({ + dateOfBirth: new Date("1985-06-15T00:00:00.000Z"), + gender: null, + heightCm: 180, + }); + expect(other).toEqual(unanswered); + const male = buildTargetBands({ + dateOfBirth: new Date("1985-06-15T00:00:00.000Z"), + gender: "MALE", + heightCm: 180, + }); + expect(other.bodyFatBands).not.toEqual(male.bodyFatBands); + }); + it("nulls the profile-derived bands when no DOB / height", () => { const bands = buildTargetBands({ dateOfBirth: null, diff --git a/src/lib/dashboard/bands.ts b/src/lib/dashboard/bands.ts index 381ed55aa..d27dacd53 100644 --- a/src/lib/dashboard/bands.ts +++ b/src/lib/dashboard/bands.ts @@ -29,6 +29,7 @@ import { getAgeFromDateOfBirth, getPersonalizedPulseTarget, } from "@/lib/analytics/pulse-targets"; +import { binaryReferenceSex, type ProfileSex } from "@/lib/profile/sex"; export interface DashboardTargetBands { /** Personalised BP target numbers (null when no DOB). */ @@ -63,15 +64,18 @@ export interface DashboardTargetBands { */ export function buildDashboardBands(profile: { dateOfBirth: Date | null; - gender: "MALE" | "FEMALE" | null; + gender: ProfileSex; heightCm: number | null; }): DashboardTargetBands { const bpTargets = profile.dateOfBirth ? getBpTargets(profile.dateOfBirth) : null; const pulseAge = getAgeFromDateOfBirth(profile.dateOfBirth); - const pulseTarget = getPersonalizedPulseTarget(pulseAge, profile.gender); - const bodyFatRange = getBodyFatTargetRange(profile.gender); + // The percentile and body-fat tables carry two sexes and no third row; + // both helpers answer a null with a neutral midpoint rather than a side. + const referenceSex = binaryReferenceSex(profile.gender); + const pulseTarget = getPersonalizedPulseTarget(pulseAge, referenceSex); + const bodyFatRange = getBodyFatTargetRange(referenceSex); const weightRange = profile.heightCm ? buildWeightRangeFromHeight(profile.heightCm) : null; diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index 82b63d4c2..159ba501b 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -101,6 +101,7 @@ import { import { getServerTranslator } from "@/lib/i18n/server-translator"; import type { Locale } from "@/lib/i18n/config"; import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets"; +import { toProfileSex, type ProfileSex } from "@/lib/profile/sex"; /** Briefing freshness window — mirrors the 24 h TTL on the advisor cache. */ const BRIEFING_TTL_MS = 24 * 60 * 60 * 1000; @@ -203,7 +204,12 @@ export interface DashboardSnapshotUser { timezone: string; heightCm: number | null; dateOfBirth: string | null; - gender: "MALE" | "FEMALE" | null; + /** + * Sex as stored, `OTHER` included. Emitting `null` for an account that + * answered `OTHER` made the value indistinguishable from no answer at + * all on every client reading this payload. + */ + gender: ProfileSex; glucoseUnit: string | null; onboardingTourCompleted: boolean; /** @@ -1277,8 +1283,7 @@ export async function buildDashboardSnapshot( ]); const briefingMemory = mapBriefingMemory(coachMemoryBlock, locale); - const gender = - user.gender === "MALE" || user.gender === "FEMALE" ? user.gender : null; + const gender = toProfileSex(user.gender); return { user: { diff --git a/src/lib/doctor-report-pdf/header-profile-section.ts b/src/lib/doctor-report-pdf/header-profile-section.ts index c425240ed..ac9683a83 100644 --- a/src/lib/doctor-report-pdf/header-profile-section.ts +++ b/src/lib/doctor-report-pdf/header-profile-section.ts @@ -74,13 +74,18 @@ export function buildHeaderProfileSection( ); } if (data.patient.gender) { + // Only the three stored values get a label. A row on a clinical + // document is a positive claim, so an unrecognised string leaves the + // line out rather than asserting a third value the account never chose. const genderKeys: Record = { MALE: "doctorReport.genderMale", FEMALE: "doctorReport.genderFemale", + OTHER: "doctorReport.genderOther", }; - const genderKey = - genderKeys[data.patient.gender] ?? "doctorReport.genderOther"; - patientInfo.push(`${t("doctorReport.gender")}: ${t(genderKey)}`); + const genderKey = genderKeys[data.patient.gender]; + if (genderKey) { + patientInfo.push(`${t("doctorReport.gender")}: ${t(genderKey)}`); + } } if (data.patient.heightCm) { patientInfo.push( diff --git a/src/lib/insights/__tests__/strain-score.test.ts b/src/lib/insights/__tests__/strain-score.test.ts index c6a896f42..1e47ca9f2 100644 --- a/src/lib/insights/__tests__/strain-score.test.ts +++ b/src/lib/insights/__tests__/strain-score.test.ts @@ -66,6 +66,25 @@ describe("banisterTrimp", () => { ).toBe(0); }); + it("scores an unanswered or OTHER sex on the midpoint of both models, not on the male one", () => { + // The published Banister weights cover two groups. Defaulting the rest + // to the male pair scored people on an assumption they never made; the + // neutral answer is the mean of the two models, matching every other + // sex-split formula in the repo. + const samples = [ + { t: "2026-06-02T08:00:00.000Z", hr: 150 }, + { t: "2026-06-02T09:00:00.000Z", hr: 150 }, + ]; + const male = banisterTrimp(samples, hrRest, hrMax, "MALE"); + const female = banisterTrimp(samples, hrRest, hrMax, "FEMALE"); + const other = banisterTrimp(samples, hrRest, hrMax, "OTHER"); + const unanswered = banisterTrimp(samples, hrRest, hrMax, null); + expect(male).not.toBeCloseTo(female, 6); + expect(other).toBeCloseTo((male + female) / 2, 10); + expect(unanswered).toBeCloseTo(other, 10); + expect(other).not.toBeCloseTo(male, 6); + }); + it("accumulates a positive TRIMP for a sustained elevated series", () => { const samples = [ { t: "2026-06-02T08:00:00.000Z", hr: 150 }, diff --git a/src/lib/insights/derived/baseline.ts b/src/lib/insights/derived/baseline.ts index 456a83718..83c090e8f 100644 --- a/src/lib/insights/derived/baseline.ts +++ b/src/lib/insights/derived/baseline.ts @@ -31,6 +31,8 @@ import type { MeasurementType, PrismaClient } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets"; +import { toProfileSex } from "@/lib/profile/sex"; +import type { ProfileSex } from "@/lib/profile/sex"; import { probeRollupCoverage, type RollupCoverageMap, @@ -82,7 +84,14 @@ export interface VitalsBaselineValue { /** Caller-supplied profile (read once per request, never re-fetched here). */ export interface BaselineProfile { ageYears: number | null; - sex: "MALE" | "FEMALE" | null; + /** + * Sex as stored, `OTHER` included. The sex-split reference tables have + * no `OTHER` row, so `resolveSexRows` falls through to the sex-agnostic + * band or reports none — an honest omission. Narrowing the value away + * HERE instead would hand every consumer a `null` it cannot tell apart + * from "no answer". + */ + sex: ProfileSex; /** * Height in cm from `User.heightCm`, when set. Consumed by the BMI * metric (weight ÷ height²); `null` when the profile has no height. @@ -108,8 +117,7 @@ export async function loadBaselineProfile( where: { id: userId }, select: { dateOfBirth: true, gender: true, heightCm: true }, }); - const sex = - user?.gender === "MALE" || user?.gender === "FEMALE" ? user.gender : null; + const sex = toProfileSex(user?.gender); return { ageYears: getAgeFromDateOfBirth(user?.dateOfBirth ?? null), sex, diff --git a/src/lib/insights/derived/norms.ts b/src/lib/insights/derived/norms.ts index 5e5c0f0d2..a82ce39eb 100644 --- a/src/lib/insights/derived/norms.ts +++ b/src/lib/insights/derived/norms.ts @@ -49,9 +49,10 @@ * Client-safe — pure data + a pure lookup, no server imports. */ import type { MetricStatusMetricId } from "@/lib/insights/metric-status-registry"; +import type { ProfileSex } from "@/lib/profile/sex"; -/** Profile sex as stored on `User.gender` ("MALE" | "FEMALE" | null). */ -export type NormSex = "MALE" | "FEMALE" | null | undefined; +/** Profile sex as a reference-table lookup key; `undefined` = unknown. */ +export type NormSex = ProfileSex | undefined; /** A reference band — same shape as `MetricNormalRange`. */ export interface NormRange { diff --git a/src/lib/insights/pulse-status.ts b/src/lib/insights/pulse-status.ts index 0c17a12f8..66965d70a 100644 --- a/src/lib/insights/pulse-status.ts +++ b/src/lib/insights/pulse-status.ts @@ -50,6 +50,7 @@ import { type StatusCardResult, } from "@/lib/insights/status-card-generation"; import { annotate } from "@/lib/logging/context"; +import { binaryReferenceSex } from "@/lib/profile/sex"; import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver"; /** @@ -263,7 +264,7 @@ export async function preparePulseStatusForUser( const pulseAge = getAgeFromDateOfBirth(user?.dateOfBirth ?? null); const pulseTarget = getPersonalizedPulseTarget( pulseAge, - (user?.gender as "MALE" | "FEMALE" | null | undefined) ?? null, + binaryReferenceSex(user?.gender), ); // v1.15.12 A2 — resting-target in-target % over the RESTING series diff --git a/src/lib/insights/strain-score.ts b/src/lib/insights/strain-score.ts index 4334fd9d3..00c2bc400 100644 --- a/src/lib/insights/strain-score.ts +++ b/src/lib/insights/strain-score.ts @@ -64,6 +64,7 @@ */ import type { MeasurementType, PrismaClient } from "@/generated/prisma/client"; import { loadBaselineProfile } from "@/lib/insights/derived/baseline"; +import type { ProfileSex } from "@/lib/profile/sex"; import { scoreDayKey, scoreExternalId, @@ -187,14 +188,24 @@ export function banisterTrimp( samples: readonly HrSample[], hrRest: number, hrMax: number, - sex: "MALE" | "FEMALE" | null, + sex: ProfileSex, ): number { if (hrMax <= hrRest) return 0; - // Morton/Fitz-Clarke/Banister gender weights; the unisex default uses the - // male coefficients (the more common watch-cohort assumption) when sex is - // unknown. - const a = sex === "FEMALE" ? 0.86 : 0.64; - const b = sex === "FEMALE" ? 1.67 : 1.92; + // Morton/Fitz-Clarke/Banister weights. The published pair covers two + // groups and nothing else, so an account that is neither (OTHER, or no + // answer at all) is scored on the MIDPOINT of the two models rather than + // silently on the male one — the same neutral fallback every other + // sex-split formula in the repo uses (`value-bands`, `classifications`, + // `pulse-targets`). Picking a side for someone who did not pick one is a + // guess dressed as a measurement. + const MALE_WEIGHTS = { a: 0.64, b: 1.92 } as const; + const FEMALE_WEIGHTS = { a: 0.86, b: 1.67 } as const; + const weights = + sex === "MALE" + ? [MALE_WEIGHTS] + : sex === "FEMALE" + ? [FEMALE_WEIGHTS] + : [MALE_WEIGHTS, FEMALE_WEIGHTS]; const usable = samples .filter((s) => typeof s.hr === "number" && Number.isFinite(s.hr)) @@ -213,7 +224,10 @@ export function banisterTrimp( if (!Number.isFinite(hrr)) continue; if (hrr <= 0) continue; // below resting → no training impulse. if (hrr > 1) hrr = 1; // clamp a spurious over-max sample. - trimp += dtMin * hrr * a * Math.exp(b * hrr); + // One model for a known sex; the mean of both when there is none. + let impulse = 0; + for (const { a, b } of weights) impulse += a * Math.exp(b * hrr); + trimp += (dtMin * hrr * impulse) / weights.length; } return trimp; } @@ -359,7 +373,7 @@ export interface StrainComputeResult { export interface StrainProfile { ageYears: number | null; - sex: "MALE" | "FEMALE" | null; + sex: ProfileSex; } /** diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index f9e1ce047..08cf58d45 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -800,7 +800,7 @@ export const dashboardSnapshotResponse = z timezone: z.string(), heightCm: z.number().nullable(), dateOfBirth: z.string().nullable(), - gender: z.enum(["MALE", "FEMALE"]).nullable(), + gender: z.enum(["MALE", "FEMALE", "OTHER"]).nullable(), glucoseUnit: z.string().nullable(), onboardingTourCompleted: z.boolean(), greetingHour: z.number().int(), diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts index 1dba5d131..0b9c2b671 100644 --- a/src/lib/openapi/routes/profile.ts +++ b/src/lib/openapi/routes/profile.ts @@ -211,7 +211,13 @@ const profileUpdateRequest = z email: z.email().nullable().optional(), heightCm: z.number().min(50).max(300).nullable().optional(), dateOfBirth: z.string().nullable().optional(), - gender: z.enum(["MALE", "FEMALE"]).nullable().optional(), + gender: z + .enum(["MALE", "FEMALE", "OTHER"]) + .nullable() + .optional() + .describe( + "Stored gender. Null or an empty string clears it; an omitted field is left untouched.", + ), displayName: z.string().min(1).max(80).nullable().optional(), locale: z.enum(["de", "en"]).nullable().optional(), timezone: z.string().min(1).max(64).optional(), @@ -255,7 +261,7 @@ const profileResponse = z displayName: z.string().nullable().optional(), email: z.string().nullable(), dateOfBirth: z.iso.datetime({ offset: true }).nullable(), - gender: z.enum(["MALE", "FEMALE"]).nullable(), + gender: z.enum(["MALE", "FEMALE", "OTHER"]).nullable(), heightCm: z.number().nullable(), locale: z.string().nullable(), timezone: z.string(), @@ -299,7 +305,7 @@ const profileUpdateResponse = z displayName: z.string().nullable().optional(), email: z.string().nullable(), dateOfBirth: z.iso.datetime({ offset: true }).nullable(), - gender: z.enum(["MALE", "FEMALE"]).nullable(), + gender: z.enum(["MALE", "FEMALE", "OTHER"]).nullable(), heightCm: z.number().nullable(), locale: z.string().nullable(), timezone: z.string(), diff --git a/src/lib/profile/sex.ts b/src/lib/profile/sex.ts new file mode 100644 index 000000000..c9c9e96b0 --- /dev/null +++ b/src/lib/profile/sex.ts @@ -0,0 +1,39 @@ +/** + * The one owner of the profile sex value: what the column holds, and the + * single place where it is allowed to shrink. + * + * `User.gender` stores three values plus "no answer". Several surfaces used + * to type it as two, either by narrowing early or by an `as` cast, and the + * third value then travelled through code that could not represent it. That + * is how it ends up defaulting into whichever branch happens to be last: + * the FHIR export mapped it to `female`, and the Strain model scored it on + * the male coefficients. Both were the same defect wearing different clothes. + * + * So: carry the stored value as far as it can honestly go, and narrow only + * at the boundary of a reference table that genuinely has no third row — + * through `binaryReferenceSex`, which is named so the shrink is visible in + * a diff instead of hiding inside a ternary. + * + * Client-safe — a type and a pure function, no imports. + */ + +/** Profile sex exactly as stored; `null` = the account gave no answer. */ +export type ProfileSex = "MALE" | "FEMALE" | "OTHER" | null; + +/** + * Narrow a stored sex to the two values a sex-split reference table has + * rows for. `OTHER` and "no answer" both resolve to `null`, and every + * consumer of that `null` answers with a neutral value or with nothing — + * never with one of the two sides. Accepts a loose `string` because a few + * callers read the column straight off Prisma, where it is `String?`. + */ +export function binaryReferenceSex( + sex: string | null | undefined, +): "MALE" | "FEMALE" | null { + return sex === "MALE" || sex === "FEMALE" ? sex : null; +} + +/** Narrow an arbitrary stored string to the three values the column holds. */ +export function toProfileSex(sex: string | null | undefined): ProfileSex { + return sex === "MALE" || sex === "FEMALE" || sex === "OTHER" ? sex : null; +} diff --git a/src/lib/targets/build-response.ts b/src/lib/targets/build-response.ts index a669e8d89..43efa954d 100644 --- a/src/lib/targets/build-response.ts +++ b/src/lib/targets/build-response.ts @@ -18,6 +18,7 @@ import pLimit from "p-limit"; import type { MeasurementType } from "@/generated/prisma/client"; import type { ThresholdOverridesJson } from "@/lib/analytics/effective-range"; import { resolveGlucoseUnit } from "@/lib/glucose"; +import { toProfileSex } from "@/lib/profile/sex"; import { ensureUserMoodRollupsFresh, readMoodDayRollups, @@ -234,7 +235,7 @@ export async function buildTargetsResponse(user: AuthedUser) { const now = new Date(); const age = dbUser?.dateOfBirth ? getAge(new Date(dbUser.dateOfBirth)) : null; - const gender = (dbUser?.gender as "MALE" | "FEMALE" | null) ?? null; + const gender = toProfileSex(dbUser?.gender); const heightCm = dbUser?.heightCm ?? null; const latestByType: TargetValueByType = {}; const average30ByType: TargetValueByType = {}; diff --git a/src/lib/targets/vitals-builder.ts b/src/lib/targets/vitals-builder.ts index 9517aa01f..8dc1feb06 100644 --- a/src/lib/targets/vitals-builder.ts +++ b/src/lib/targets/vitals-builder.ts @@ -15,6 +15,7 @@ import { } from "@/lib/analytics/pulse-targets"; import { resolveRestingPulseSeries } from "@/lib/analytics/resting-pulse"; import { getBodyFatTargetRange } from "@/lib/analytics/value-bands"; +import { binaryReferenceSex, type ProfileSex } from "@/lib/profile/sex"; import { userDayKey } from "@/lib/tz/resolver"; import { makeRangeClassifier, @@ -42,7 +43,8 @@ interface VitalTargetsInput { average30ByType: TargetValueByType; heightCm: number | null; age: number | null; - gender: "MALE" | "FEMALE" | null; + /** Sex as stored, `OTHER` included; narrowed per formula below. */ + gender: ProfileSex; timezone: string; now: Date; } @@ -141,6 +143,10 @@ export function buildVitalTargets({ }: VitalTargetsInput): VitalTargetsResult { const targets: TargetItem[] = []; const consistencyClock = { timezone, now }; + // The BP / pulse / body-fat tables are published for two groups and have + // no third row. Narrow once, here, where that is true — each helper + // answers a null with a neutral value, never with one of the two sides. + const referenceSex = binaryReferenceSex(gender); const weightRange = heightCm ? getWeightRange(heightCm) : null; let weightClassification: TargetItem["classification"] = null; @@ -173,7 +179,7 @@ export function buildVitalTargets({ }), }); - const bpRange = age != null ? getBpTargetsByAge(age, gender) : null; + const bpRange = age != null ? getBpTargetsByAge(age, referenceSex) : null; const bpPairsByDay = buildBpPairsByDay(recentMeasurements, bpRange, timezone); let bpClassification: TargetItem["classification"] = null; if ( @@ -254,7 +260,7 @@ export function buildVitalTargets({ }); } - const pulseTarget = getPersonalizedPulseTarget(age, gender); + const pulseTarget = getPersonalizedPulseTarget(age, referenceSex); const restingResolved = resolveRestingPulseSeries({ restingSamples: recentMeasurements .filter((measurement) => measurement.type === "RESTING_HEART_RATE") @@ -385,13 +391,17 @@ export function buildVitalTargets({ let bodyFatClassification: TargetItem["classification"] = null; if (latestByType.BODY_FAT != null && age != null) { - const classification = classifyBodyFat(latestByType.BODY_FAT, gender, age); + const classification = classifyBodyFat( + latestByType.BODY_FAT, + referenceSex, + age, + ); bodyFatClassification = { category: classification.category, color: classification.color, }; } - const bodyFatRange = age != null ? getBodyFatTargetRange(gender) : null; + const bodyFatRange = age != null ? getBodyFatTargetRange(referenceSex) : null; targets.push({ type: "BODY_FAT", label: "Body fat", diff --git a/src/lib/validations/auth.ts b/src/lib/validations/auth.ts index 329cd2878..1d4021c7f 100644 --- a/src/lib/validations/auth.ts +++ b/src/lib/validations/auth.ts @@ -45,7 +45,23 @@ export const profileSchema = z.object({ email: z.email("Invalid email address").nullable().optional(), heightCm: z.number().min(50).max(300).nullable().optional(), dateOfBirth: z.string().nullable().optional(), // ISO date string - gender: z.enum(["MALE", "FEMALE", "OTHER"]).nullable().optional(), + // Three stored values, and the same clearing contract every other + // profile field honours: null OR an empty string clears it. A client + // that renders "no answer" as "" was otherwise 422'd on a field the + // person never touched, and because the whole profile PATCH is one + // transaction that rejection took every sibling change with it. The + // message names the field and the accepted values — a person should + // never read the validator's own prose off a form. + gender: z.preprocess( + (v) => (v === "" ? null : v), + z + .enum(["MALE", "FEMALE", "OTHER"], { + error: + "Gender must be MALE, FEMALE or OTHER. Send null or an empty string to clear it.", + }) + .nullable() + .optional(), + ), // v1.7.0 — optional patient-identity fields for the health-record // export cover + FHIR Patient resource. All optional, never required. fullName: z.string().trim().max(120).nullable().optional(),