mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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
This commit is contained in:
1 parent
0ce510fa68
commit
f74efc44aa
28 files changed
+388
-39
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<typeof import("@/lib/auth/session")>();
|
||||
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<typeof import("@/lib/logging/context")>();
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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],
|
||||
|
||||
@@ -754,8 +754,9 @@ export default function PrivacyPage() {
|
||||
</li>
|
||||
<li>
|
||||
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.
|
||||
</li>
|
||||
</ul>
|
||||
<h3 className="text-foreground pt-2 text-base font-semibold">
|
||||
|
||||
@@ -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() {
|
||||
<SelectItem value="FEMALE">
|
||||
{t("onboarding.baseline.genderFemale")}
|
||||
</SelectItem>
|
||||
<SelectItem value="OTHER">
|
||||
{t("onboarding.baseline.genderOther")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FieldGroup>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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(
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 = {};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user