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(),