refactor(profile): route every binary-sex narrow through the one owner

`binaryReferenceSex` was already the declared owner of the shrink from the
three stored values to the two a sex-split reference table has rows for, but
eight sites still narrowed inline — including a byte-for-byte private copy in
the effective-range resolver and a ternary 37 lines above a `toProfileSex`
call in the same file. Each of those is a place the fallback can drift.

Delete the copy, replace the ternaries, and name the pair once as
`BinaryReferenceSex` so a signature that needs it stops re-listing the two
literals. No behaviour changes: every site already resolved OTHER and "no
answer" to the same neutral or absent value, and the existing suites pass
untouched.
This commit is contained in:
Marc-André Bombeck committed 2026-07-27 14:47:54 +02:00
1 parent eba9f15539
commit 36f525c465
9 files changed
+36 -26

No files matched your search

+2 -2
View File
@@ -22,6 +22,7 @@ import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { binaryReferenceSex } from "@/lib/profile/sex";
import { requireModuleEnabled } from "@/lib/modules/gate";
import {
NUTRIENT_CATALOG,
@@ -72,8 +73,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
daySeries.push({ day: key, amount: sumByDay.get(key) ?? 0 });
}
const sex =
user.gender === "MALE" || user.gender === "FEMALE" ? user.gender : null;
const sex = binaryReferenceSex(user.gender);
const reference = resolveNutrientReference(nutrient, sex);
annotate({
@@ -12,6 +12,7 @@ import { useAuth } from "@/hooks/use-auth";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import { binaryReferenceSex } from "@/lib/profile/sex";
import {
NUTRIENT_CODES,
isNutrientCode,
@@ -51,8 +52,7 @@ export function MicronutrientsCard() {
const { t } = useTranslations();
const fmt = useFormatters();
const { user } = useAuth();
const sex =
user?.gender === "MALE" || user?.gender === "FEMALE" ? user.gender : null;
const sex = binaryReferenceSex(user?.gender);
// A failed read left `data` undefined, `rows` empty, and the card rendered
// its "no micronutrient data" EmptyState — the same output as an account that
+8 -5
View File
@@ -27,6 +27,8 @@
* The band position is computed HERE, server-side — never left to the model.
*/
import { binaryReferenceSex, type BinaryReferenceSex } from "@/lib/profile/sex";
/** How a band reads against health: the four-way valence the prose leans on. */
export type BandValence = "favourable" | "neutral" | "caution" | "unfavourable";
@@ -54,7 +56,7 @@ export interface MetricInterpretation {
/** Ascending bands when the metric is not sex-split. */
bands?: InterpretationBand[];
/** Sex-split bands; used only when the profile carries the sex. */
sexBands?: Record<"MALE" | "FEMALE", InterpretationBand[]>;
sexBands?: Record<BinaryReferenceSex, InterpretationBand[]>;
/**
* Optional honesty caveat surfaced in the prompt block — used where the KB
* hedges the edges (consumer rating scales, proxy measurements).
@@ -300,7 +302,7 @@ const INTERPRETATION_REGISTRY: Record<string, MetricInterpretation> = {
*/
export function resolveInterpretation(
metricKey: string,
sex: "MALE" | "FEMALE" | null | undefined,
sex: BinaryReferenceSex | null | undefined,
): {
unit: string;
directionOfGood: DirectionOfGood;
@@ -312,8 +314,9 @@ export function resolveInterpretation(
if (!entry) return null;
let bands: InterpretationBand[] | undefined = entry.bands;
if (entry.sexBands) {
if (sex !== "MALE" && sex !== "FEMALE") return null;
bands = entry.sexBands[sex];
const referenceSex = binaryReferenceSex(sex);
if (!referenceSex) return null;
bands = entry.sexBands[referenceSex];
}
if (!bands || bands.length === 0) return null;
return {
@@ -398,7 +401,7 @@ export function classifyBandPosition(
*/
export function interpretationBandEdges(
metricKey: string,
sex?: "MALE" | "FEMALE" | null,
sex?: BinaryReferenceSex | null,
): number[] {
const resolved = resolveInterpretation(metricKey, sex ?? null);
if (!resolved) {
+2 -6
View File
@@ -15,6 +15,7 @@
* consistently.
*/
import { binaryReferenceSex } from "@/lib/profile/sex";
import { getBpTargets } from "./bp-targets";
import {
getAgeFromDateOfBirth,
@@ -120,11 +121,6 @@ const GLUCOSE_DEFAULTS: Record<
BEDTIME: { min: 90, max: 150 },
};
function normalizedGender(value: string | null): "MALE" | "FEMALE" | null {
if (value === "MALE" || value === "FEMALE") return value;
return null;
}
function toDate(value: Date | string | null): Date | null {
if (!value) return null;
return value instanceof Date ? value : new Date(value);
@@ -135,7 +131,7 @@ function defaultRange(
profile: UserProfileForRange,
): TrafficRange | null {
const age = getAgeFromDateOfBirth(profile.dateOfBirth ?? null);
const gender = normalizedGender(profile.gender ?? null);
const gender = binaryReferenceSex(profile.gender);
const dateOfBirth = toDate(profile.dateOfBirth);
switch (metric) {
+6 -3
View File
@@ -101,7 +101,11 @@ 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";
import {
binaryReferenceSex,
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;
@@ -1246,8 +1250,7 @@ export async function buildDashboardSnapshot(
// never sinks the snapshot. The narrative block rides the warm phase's
// healthScore; memory rides the briefing card. The locale defaults to English.
const locale = options.locale ?? "en";
const narrativeSex: "MALE" | "FEMALE" | null =
user.gender === "MALE" || user.gender === "FEMALE" ? user.gender : null;
const narrativeSex = binaryReferenceSex(user.gender);
const narrativeProfile = {
ageYears: getAgeFromDateOfBirth(user.dateOfBirth),
sex: narrativeSex,
+2 -2
View File
@@ -83,6 +83,7 @@ import {
import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash";
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
import { annotate } from "@/lib/logging/context";
import { binaryReferenceSex } from "@/lib/profile/sex";
export interface MetricStatusResult {
hasProvider: boolean;
@@ -335,8 +336,7 @@ export async function generateMetricStatus(args: {
: null;
const ageYears = getAgeFromDateOfBirth(user?.dateOfBirth ?? null);
const sex =
user?.gender === "MALE" || user?.gender === "FEMALE" ? user.gender : null;
const sex = binaryReferenceSex(user?.gender);
// v1.10.0 F1 — sharpen the coarse registry `normalRange` with the
// age/sex reference-range enabler when one is available for this
+3 -4
View File
@@ -20,6 +20,7 @@
*/
import { prisma } from "@/lib/db";
import { isModuleEnabled } from "@/lib/modules/gate";
import { binaryReferenceSex, type BinaryReferenceSex } from "@/lib/profile/sex";
import {
NUTRIENT_CATALOG,
NUTRIENT_CODES,
@@ -195,7 +196,7 @@ async function readOverview(
async function readDaily(
userId: string,
userTz: string,
sex: "MALE" | "FEMALE" | null,
sex: BinaryReferenceSex | null,
nutrient: NutrientCode,
days: number,
): Promise<NutrientsDailyResult> {
@@ -268,9 +269,7 @@ export async function getNutrients(
select: { timezone: true, gender: true },
});
const userTz = user?.timezone || DEFAULT_TIMEZONE;
const genderValue = user?.gender ?? null;
const sex =
genderValue === "MALE" || genderValue === "FEMALE" ? genderValue : null;
const sex = binaryReferenceSex(user?.gender);
if (!args.nutrient) {
const days = clampDays(args.days, OVERVIEW_DEFAULT_DAYS, OVERVIEW_MAX_DAYS);
+3 -1
View File
@@ -36,6 +36,8 @@
* its source string.
*/
import type { BinaryReferenceSex } from "@/lib/profile/sex";
export const NUTRIENT_CODES = [
// 13 vitamins
"vitamin_a",
@@ -331,7 +333,7 @@ export interface ResolvedNutrientReference {
*/
export function resolveNutrientReference(
code: NutrientCode,
sex: "MALE" | "FEMALE" | null,
sex: BinaryReferenceSex | null,
): ResolvedNutrientReference | null {
const ref = NUTRIENT_CATALOG[code].reference;
const value =
+8 -1
View File
@@ -20,6 +20,13 @@
/** Profile sex exactly as stored; `null` = the account gave no answer. */
export type ProfileSex = "MALE" | "FEMALE" | "OTHER" | null;
/**
* The two values a sex-split reference table actually has rows for. Named so
* a table's signature can say what it needs without re-listing the pair, and
* so the list has exactly one place to change.
*/
export type BinaryReferenceSex = "MALE" | "FEMALE";
/**
* 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
@@ -29,7 +36,7 @@ export type ProfileSex = "MALE" | "FEMALE" | "OTHER" | null;
*/
export function binaryReferenceSex(
sex: string | null | undefined,
): "MALE" | "FEMALE" | null {
): BinaryReferenceSex | null {
return sex === "MALE" || sex === "FEMALE" ? sex : null;
}