diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index c17109db5..271389d2b 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -68,6 +68,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { requireAssistantSurface } from "@/lib/feature-flags"; import { invalidateUserInsights } from "@/lib/cache/invalidate"; import { annotate } from "@/lib/logging/context"; +import { toProfileSex } from "@/lib/profile/sex"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; import { normalizeLocale } from "@/lib/insights/status-shared"; import { hasUsableStatusProvider } from "@/lib/insights/status-provider"; @@ -597,10 +598,7 @@ export const POST = apiHandler((request: NextRequest) => // v1.10.0 — fold a notable derived wellness signal (readiness / recovery // shift, confidence-gated) into the briefing as a SYSTEM CONTEXT block. // Reads the one derived contract; null (the common case) costs nothing. - const derivedSex = - dbUser?.gender === "MALE" || dbUser?.gender === "FEMALE" - ? (dbUser.gender as "MALE" | "FEMALE") - : null; + const derivedSex = toProfileSex(dbUser?.gender); const derivedBriefing = await detectDerivedBriefingSignals(userId, { ageYears: getAgeFromDateOfBirth(dbUser?.dateOfBirth ?? null), sex: derivedSex, diff --git a/src/components/onboarding/baseline-form.tsx b/src/components/onboarding/baseline-form.tsx index b1ba7f94d..f905c1ca1 100644 --- a/src/components/onboarding/baseline-form.tsx +++ b/src/components/onboarding/baseline-form.tsx @@ -29,6 +29,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { apiGet, apiPost, apiPut } from "@/lib/api/api-fetch"; import { localizedApiError } from "@/lib/api/localized-error"; import { queryKeys } from "@/lib/query-keys"; +import { toProfileSex } from "@/lib/profile/sex"; import { AnamnesisCard, buildAnamnesisAboutMeBody, @@ -139,13 +140,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" || - form.gender === "OTHER" - ) { - profileBody.gender = form.gender; - } + // Every value the profile stores travels; the guard drops only the + // "prefer not to say" placeholder. It used to list two of the three + // by hand, so a third-value answer vanished on submit without a word. + const gender = toProfileSex(form.gender); + if (gender) profileBody.gender = gender; if (Object.keys(profileBody).length > 0) { await apiPut("/api/auth/profile", profileBody); } diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index ae160c7f2..f6cbe5fcc 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -53,6 +53,7 @@ import { resolveModuleMap, type ModuleKey } from "@/lib/modules/gate"; import { MODULE_SCOPED_SOURCES } from "@/lib/modules/measurement-scope"; import { SCHEDULE_COMPLIANCE_SELECT } from "@/lib/analytics/compliance"; import type { BaselineProfile } from "@/lib/insights/derived"; +import { toProfileSex } from "@/lib/profile/sex"; import { CLUSTER_PRIORITY, clusterSourcesFromPrefs, @@ -691,10 +692,7 @@ async function buildCoachSnapshotImpl( const derivedCtx = features.context; const derivedProfile: BaselineProfile = { ageYears: derivedCtx?.ageYears ?? null, - sex: - derivedCtx?.gender === "MALE" || derivedCtx?.gender === "FEMALE" - ? (derivedCtx.gender as "MALE" | "FEMALE") - : null, + sex: toProfileSex(derivedCtx?.gender), heightCm: derivedCtx?.heightCm ?? null, }; // v1.18.0 — the derived block + WHOOP-native dayStrain + trajectory are diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index 19ef63e60..288e2c39f 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -1276,3 +1276,32 @@ describe("buildDashboardSnapshot — scoreRings wire", () => { expect(snap.tiles.summaries.WEIGHT).toBeDefined(); }); }); + +describe("buildDashboardSnapshot — the wire carries the stored gender", () => { + beforeEach(() => { + // Prime only what the user block needs; this suite must not inherit + // another test's mock state. + probeRollupCoverage.mockResolvedValue(new Map()); + isFullyCovered.mockReturnValue(false); + computeBpInTargetFastPath.mockResolvedValue(null); + }); + + it("emits OTHER rather than flattening it to no answer", async () => { + // Narrowing to two values on the wire made an account that answered + // OTHER indistinguishable from one that answered nothing, on every + // client reading this payload. + const snap = await buildDashboardSnapshot( + fakePrisma, + baseUser({ gender: "OTHER" }), + ); + expect(snap.user.gender).toBe("OTHER"); + }); + + it("still reports no answer as null", async () => { + const snap = await buildDashboardSnapshot( + fakePrisma, + baseUser({ gender: null }), + ); + expect(snap.user.gender).toBeNull(); + }); +}); diff --git a/src/lib/doctor-report-pdf/__tests__/header-profile-gender.test.ts b/src/lib/doctor-report-pdf/__tests__/header-profile-gender.test.ts new file mode 100644 index 000000000..ce126d2b5 --- /dev/null +++ b/src/lib/doctor-report-pdf/__tests__/header-profile-gender.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, vi } from "vitest"; +import { buildHeaderProfileSection } from "../header-profile-section"; +import type { + DoctorReportPdfRenderContext, + DoctorReportPdfCursorState, +} from "../render-context"; + +/** + * A line on a clinical cover sheet is a positive claim about the patient. + * The gender row used to label ANY unrecognised stored string as the third + * value, so a legacy or malformed value asserted something the account + * never chose. Absence has to read as absence. + */ +function renderPatientLines(gender: string | null): string[] { + const lines: string[] = []; + const doc = { + setFontSize: vi.fn(), + setFont: vi.fn(), + setTextColor: vi.fn(), + setDrawColor: vi.fn(), + setLineWidth: vi.fn(), + line: vi.fn(), + addPage: vi.fn(), + getNumberOfPages: () => 1, + splitTextToSize: (s: string) => [s], + text: (line: string) => { + lines.push(line); + }, + internal: { pageSize: { getWidth: () => 210, getHeight: () => 297 } }, + }; + const context = { + doc, + data: { + patient: { + username: "testuser", + fullName: null, + dateOfBirth: null, + gender, + heightCm: null, + insurerName: null, + insurerIkNumber: null, + }, + period: { + since: "2026-06-01T00:00:00.000Z", + start: null, + end: null, + days: 30, + }, + practiceName: null, + measurements: {}, + stats: {}, + compliance: {}, + }, + // The translator echoes the key so an assertion can name the label it + // expects instead of a locale string. + t: (key: string) => key, + num: (value: number) => String(value), + fmtDate: (iso: string) => iso.slice(0, 10), + now: new Date("2026-07-26T00:00:00.000Z"), + insuranceNumber: null, + margin: 15, + pageWidth: 210, + contentMaxY: 280, + ensureSpace: (current: number) => current, + } as unknown as DoctorReportPdfRenderContext; + buildHeaderProfileSection(context, { + y: 20, + } as unknown as DoctorReportPdfCursorState); + return lines; +} + +describe("doctor-report cover — gender row", () => { + it("labels each of the three stored values", () => { + expect(renderPatientLines("MALE")).toContain( + "doctorReport.gender: doctorReport.genderMale", + ); + expect(renderPatientLines("FEMALE")).toContain( + "doctorReport.gender: doctorReport.genderFemale", + ); + expect(renderPatientLines("OTHER")).toContain( + "doctorReport.gender: doctorReport.genderOther", + ); + }); + + it("omits the row entirely for no answer or an unrecognised value", () => { + for (const stored of [null, "", "diverse", "male"]) { + const lines = renderPatientLines(stored); + expect( + lines.some((line) => line.startsWith("doctorReport.gender:")), + ).toBe(false); + } + }); +}); diff --git a/src/lib/profile/__tests__/sex.test.ts b/src/lib/profile/__tests__/sex.test.ts new file mode 100644 index 000000000..bd0d8dad9 --- /dev/null +++ b/src/lib/profile/__tests__/sex.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from "vitest"; +import { toProfileSex, binaryReferenceSex } from "@/lib/profile/sex"; + +/** + * The column holds three values. Two separate defects shipped from code + * that could only hold two: the FHIR export mapped the third to `female`, + * and the Strain model scored it on the male coefficients. These two + * functions are the only sanctioned way to move between the widths. + */ +describe("toProfileSex", () => { + it("carries every stored value through", () => { + expect(toProfileSex("MALE")).toBe("MALE"); + expect(toProfileSex("FEMALE")).toBe("FEMALE"); + expect(toProfileSex("OTHER")).toBe("OTHER"); + }); + + it("reports no answer as no answer, and never guesses one", () => { + expect(toProfileSex(null)).toBeNull(); + expect(toProfileSex(undefined)).toBeNull(); + expect(toProfileSex("")).toBeNull(); + expect(toProfileSex("male")).toBeNull(); + expect(toProfileSex("diverse")).toBeNull(); + }); +}); + +describe("binaryReferenceSex", () => { + it("keeps the two values a sex-split reference table has rows for", () => { + expect(binaryReferenceSex("MALE")).toBe("MALE"); + expect(binaryReferenceSex("FEMALE")).toBe("FEMALE"); + }); + + it("resolves the third value to null rather than to one of the two", () => { + expect(binaryReferenceSex("OTHER")).toBeNull(); + expect(binaryReferenceSex(null)).toBeNull(); + expect(binaryReferenceSex(undefined)).toBeNull(); + expect(binaryReferenceSex("")).toBeNull(); + }); +});