diff --git a/src/app/api/nutrients/daily/route.ts b/src/app/api/nutrients/daily/route.ts index dd0bfd4c6..382200b4b 100644 --- a/src/app/api/nutrients/daily/route.ts +++ b/src/app/api/nutrients/daily/route.ts @@ -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({ diff --git a/src/components/insights/nutrients/micronutrients-card.tsx b/src/components/insights/nutrients/micronutrients-card.tsx index 7822d1811..998b15c91 100644 --- a/src/components/insights/nutrients/micronutrients-card.tsx +++ b/src/components/insights/nutrients/micronutrients-card.tsx @@ -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 diff --git a/src/lib/ai/insight-interpretation.ts b/src/lib/ai/insight-interpretation.ts index 71ad3909e..1dbf420b0 100644 --- a/src/lib/ai/insight-interpretation.ts +++ b/src/lib/ai/insight-interpretation.ts @@ -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; /** * 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 = { */ 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) { diff --git a/src/lib/analytics/effective-range.ts b/src/lib/analytics/effective-range.ts index 2b78bcf06..f7be20816 100644 --- a/src/lib/analytics/effective-range.ts +++ b/src/lib/analytics/effective-range.ts @@ -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) { diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index 159ba501b..1dfd43431 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -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, diff --git a/src/lib/insights/metric-status.ts b/src/lib/insights/metric-status.ts index 393563889..e1e085585 100644 --- a/src/lib/insights/metric-status.ts +++ b/src/lib/insights/metric-status.ts @@ -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 diff --git a/src/lib/mcp/nutrients-read.ts b/src/lib/mcp/nutrients-read.ts index 4c84f4010..6d4b457ac 100644 --- a/src/lib/mcp/nutrients-read.ts +++ b/src/lib/mcp/nutrients-read.ts @@ -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 { @@ -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); diff --git a/src/lib/nutrients/catalog.ts b/src/lib/nutrients/catalog.ts index dedf9dc0d..d69956529 100644 --- a/src/lib/nutrients/catalog.ts +++ b/src/lib/nutrients/catalog.ts @@ -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 = diff --git a/src/lib/profile/sex.ts b/src/lib/profile/sex.ts index c9c9e96b0..b9b95ab3b 100644 --- a/src/lib/profile/sex.ts +++ b/src/lib/profile/sex.ts @@ -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; }