diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index b33064a19..c06e8cf65 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -19,7 +19,11 @@ import { import { computeUserHealthScoreFastPath } from "@/lib/analytics/health-score-fast-path"; import { isModuleEnabled } from "@/lib/modules/gate"; import { resolveRestMode } from "@/lib/illness/rest-mode"; -import { buildHealthScoreBpInputs } from "@/lib/analytics/health-score-inputs"; +import { + buildHealthScoreBpInputs, + buildHealthScoreWeightTarget, +} from "@/lib/analytics/health-score-inputs"; +import type { ThresholdOverridesJson } from "@/lib/analytics/effective-range"; import { deriveBpWindow90 } from "@/lib/analytics/window-confidence"; import { computeCorrelationHypothesesFastPath } from "@/lib/analytics/correlations-fast-path"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; @@ -453,10 +457,24 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) { // Mode annotation resolves against the SAME window the score covers, not a // fresh "now" that could fall outside it. const scoredAt = new Date(); + // v1.34 — the weight yardstick comes from the ONE shared + // `buildHealthScoreWeightTarget` builder the dashboard snapshot also uses, + // so the ring and the card grade weight against the identical band. It hands + // over a target ONLY when the account carries an explicit override; every + // other account scores the bare trend. The fabricated BMI-22 band that stood + // in here is gone. + const weightTargetInputs = buildHealthScoreWeightTarget( + { + heightCm: user.heightCm ?? null, + dateOfBirth: user.dateOfBirth, + gender: user.gender, + }, + (user.thresholdsJson ?? null) as ThresholdOverridesJson | null, + ); const healthScore = await computeUserHealthScoreFastPath({ userId: user.id, ...bpInputs, - heightCm: user.heightCm ?? null, + ...weightTargetInputs, now: scoredAt, coverage, moodEnabled, diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index 584ada4d2..9efb468b7 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 { resolveWeightTargetOverride } from "@/lib/analytics/effective-range"; import { toProfileSex } from "@/lib/profile/sex"; import { apiGet } from "@/lib/api/api-fetch"; @@ -377,6 +378,21 @@ export default function DashboardPageClient({ analyticsThickQuery.data, ]); + // v1.34 — the user's own weight target, for the legacy (snapshot-disabled) + // band fallback only. In snapshot mode the server resolves the same override + // into `targetBands` and this query never fires; the fallback would otherwise + // keep shading the height-derived band for someone who has set a target. + const { data: thresholdsData } = useQuery({ + queryKey: queryKeys.userThresholds(), + queryFn: async () => { + return apiGet<{ + overrides: Record; + }>("/api/user/thresholds"); + }, + enabled: !snapshotEnabled && isAuthenticated, + ...DASHBOARD_QUERY_OPTS, + }); + const { data: layoutDataLegacy } = useQuery({ queryKey: queryKeys.dashboardWidgets(), queryFn: async () => { @@ -715,14 +731,21 @@ export default function DashboardPageClient({ // in the snapshot steady state) before this. Keyed on the `user` object // identity so the React Compiler can preserve the memo (a narrower // property list trips `preserve-manual-memoization`). + // v1.34 — the weight target rides the same memo. In snapshot mode + // `thresholdsData` is never fetched, so the client copy falls back to the + // height-derived band for the frames before `serverBands` lands — exactly + // as it already did for every other profile-derived number. const clientBands = useMemo( () => buildDashboardBands({ dateOfBirth: user?.dateOfBirth ? new Date(user.dateOfBirth) : null, gender: toProfileSex(user?.gender), heightCm: user?.heightCm ?? null, + weightTargetOverride: resolveWeightTargetOverride( + thresholdsData?.overrides ?? null, + ), }), - [user], + [user, thresholdsData], ); const bands = serverBands ?? clientBands; const bpTargets = bands.bpTargets; diff --git a/src/lib/analytics/effective-range.ts b/src/lib/analytics/effective-range.ts index f7be20816..eb2b8798b 100644 --- a/src/lib/analytics/effective-range.ts +++ b/src/lib/analytics/effective-range.ts @@ -310,6 +310,28 @@ export function getEffectiveRange( }; } +/** + * Narrow a raw `User.thresholdsJson` blob down to the user's WEIGHT override. + * + * The column is untyped JSON, so every consumer that wants the weight target + * has to validate it. v1.34 gave weight targets three new consumers at once + * (the Health Score, the dashboard bands, the PR direction) and one shared + * narrowing beats three hand-rolled casts. Returns `null` for anything that + * is not a well-formed, ordered pair of finite numbers — a malformed blob + * reads as "no target set", never as a target of `NaN`. + */ +export function resolveWeightTargetOverride( + thresholdsJson: unknown, +): { min: number; max: number } | null { + if (!thresholdsJson || typeof thresholdsJson !== "object") return null; + const weight = (thresholdsJson as Record).WEIGHT; + if (!weight || typeof weight !== "object") return null; + const { min, max } = weight as { min?: unknown; max?: unknown }; + if (typeof min !== "number" || typeof max !== "number") return null; + if (!Number.isFinite(min) || !Number.isFinite(max) || max <= min) return null; + return { min, max }; +} + /** * Convenience: resolve every supported metric at once. Useful for /targets * and the insight generator. diff --git a/src/lib/analytics/health-score-fast-path.ts b/src/lib/analytics/health-score-fast-path.ts index 2a85fada3..13bc067bd 100644 --- a/src/lib/analytics/health-score-fast-path.ts +++ b/src/lib/analytics/health-score-fast-path.ts @@ -55,10 +55,10 @@ import { import { resolveUserTimezone } from "@/lib/tz/resolver"; import { computeHealthScore, - defaultWeightTargetFromHeight, type ContributingSource, type HealthScoreInput, type HealthScoreResult, + type WeightTargetSource, } from "./health-score"; import type { MeasurementSource } from "@/generated/prisma/client"; @@ -129,7 +129,17 @@ export interface HealthScoreFastPathInput { * delta), mirroring the `bpInTargetPctPriorWeek` fallback. */ bpGradedScorePriorWeek?: number | null; - heightCm: number | null; + /** + * v1.34 — the weight yardstick, resolved by the caller through + * `buildHealthScoreWeightTarget`. The band the user set, or `null` + * when they set none. Required, not defaulted: a forgotten caller must + * fail to compile rather than silently fall back to a number nobody + * chose — which is exactly how `22 × height²` graded every account for + * months. + */ + weightTarget: { min: number; max: number } | null; + /** Which yardstick `weightTarget` came from. Required for the same reason. */ + weightTargetSource: WeightTargetSource; now: Date; /** * Per-type rollup coverage map. The caller (analytics route) probes @@ -145,11 +155,15 @@ export interface HealthScoreFastPathInput { * `null` and the existing proportional null-redistribution re-scales * the surviving pillars to sum to 100. Without this a disabled-mood * account is silently penalised (or rewarded) by a pillar it can no - * longer see anywhere else in the product. Omit (or pass `true`) to - * keep the pre-v1.18.0 always-weighted behaviour — the field is - * additive so legacy callers are unchanged. + * longer see anywhere else in the product. + * + * v1.34 — required. The field used to default to `true`, and the + * dashboard snapshot never passed it: a mood-off account got a mood + * pillar in the hero ring and none in the Insights card, the same + * score composed two different ways on two surfaces. A required flag + * makes the compiler name every caller. */ - moodEnabled?: boolean; + moodEnabled: boolean; } /** @@ -163,11 +177,14 @@ export async function computeUserHealthScoreFastPath( input: HealthScoreFastPathInput, ): Promise { const DAY_MS = 24 * 60 * 60 * 1000; - const { userId, bpInTargetPct, heightCm, now } = input; - // v1.18.0 R4 — default-on so omitting the flag preserves the - // pre-v1.18.0 always-weighted mood pillar. Only an explicit `false` - // (the caller resolved `mood` as a disabled module) drops the pillar. - const moodEnabled = input.moodEnabled !== false; + const { + userId, + bpInTargetPct, + now, + moodEnabled, + weightTarget, + weightTargetSource, + } = input; // v1.4.38 — prior-week BP fallback. When the caller omits the field // we pin to the current value (pre-v1.4.38 behaviour) so the helper // stays a drop-in replacement for legacy callers; when the caller @@ -448,8 +465,6 @@ export async function computeUserHealthScoreFastPath( }); } - const fallbackTarget = defaultWeightTargetFromHeight(heightCm); - const moodSeriesLast30d = moodRows .filter((r) => r.moodLoggedAt >= since30d) .map((r) => ({ @@ -489,7 +504,8 @@ export async function computeUserHealthScoreFastPath( // (the binary rate above only gates presence + is the secondary stat). bpGradedScore: input.bpGradedScore ?? null, weightSeriesLast30d, - weightTargetKg: fallbackTarget, + weightTarget, + weightTargetSource, moodEntriesLast30d: moodSeriesLast30d, medicationCompliance30, attribution: { @@ -524,7 +540,8 @@ export async function computeUserHealthScoreFastPath( ? (input.bpGradedScore ?? null) : input.bpGradedScorePriorWeek, weightSeriesLast30d: weightSeriesPrev30d, - weightTargetKg: fallbackTarget, + weightTarget, + weightTargetSource, moodEntriesLast30d: moodSeriesPrev30d, medicationCompliance30: medicationCompliance30Previous, }; diff --git a/src/lib/analytics/health-score-inputs.ts b/src/lib/analytics/health-score-inputs.ts index 7a0f4d89d..60526b730 100644 --- a/src/lib/analytics/health-score-inputs.ts +++ b/src/lib/analytics/health-score-inputs.ts @@ -20,11 +20,26 @@ * the two surfaces again. Both callers pass the identical * `BpInTargetEnvelope` pair through here and consume the result verbatim. * + * v1.34 — the WEIGHT yardstick joins the file for the same reason. The score + * used to grade weight against `22 × height²` ± 2 kg: a target no user ever + * set and no surface ever named. `buildHealthScoreWeightTarget` replaces it + * with the one honest source, `getEffectiveRange("WEIGHT", …)`, and hands the + * band over ONLY when it is a real user override. Both callers route through + * it so the ring and the card can never grade weight against two different + * yardsticks. + * * Pure & deterministic — no I/O. The callers own the * `computeBpInTargetFastPath` reads (current + prior-week) and hand the two - * envelopes in. + * envelopes in, and own the `User` row the weight profile / overrides come + * from. */ import type { BpInTargetEnvelope } from "./bp-in-target-fast-path"; +import { + getEffectiveRange, + type ThresholdOverridesJson, + type UserProfileForRange, +} from "./effective-range"; +import type { WeightTargetSource } from "./health-score"; import { isWindowSufficient } from "./window-confidence"; /** @@ -105,3 +120,42 @@ export function buildHealthScoreBpInputs( : null, }; } + +/** + * The weight-pillar slice of `HealthScoreFastPathInput`. Both Health-Score + * callers spread this into their `computeUserHealthScoreFastPath` call so the + * pillar is graded against the identical yardstick regardless of surface. + */ +export interface HealthScoreWeightTargetInputs { + weightTarget: { min: number; max: number } | null; + weightTargetSource: WeightTargetSource; +} + +/** + * Resolve the weight yardstick for the Health Score. + * + * The score grades weight against the user's OWN target or against nothing. + * `getEffectiveRange` layers an evidence-based default under the override, and + * that default is exactly what must NOT reach the score: it is a reference + * band the person never chose and no score surface ever named. So the green + * band is handed over only when `isOverride` is true; every other case resolves + * to `null`, which routes the pillar through the honest trend-only path. + * + * Pure. `overrides` is the parsed `User.thresholdsJson` value (may be null). + */ +export function buildHealthScoreWeightTarget( + profile: UserProfileForRange, + overrides: ThresholdOverridesJson | null, +): HealthScoreWeightTargetInputs { + const effective = getEffectiveRange("WEIGHT", profile, overrides); + if (!effective.isOverride || !effective.range) { + return { weightTarget: null, weightTargetSource: "none" }; + } + return { + weightTarget: { + min: effective.range.greenMin, + max: effective.range.greenMax, + }, + weightTargetSource: "user", + }; +} diff --git a/src/lib/analytics/health-score.ts b/src/lib/analytics/health-score.ts index b369cd2be..606455746 100644 --- a/src/lib/analytics/health-score.ts +++ b/src/lib/analytics/health-score.ts @@ -18,10 +18,20 @@ * + 0.30 * complianceRate // mean of medication compliance30 * ) * - * When a component is null (insufficient data, no medications, no - * weight target, etc.) the remaining weights are scaled proportionally - * so they still sum to 100 — the score never lies about what was - * actually measured. + * When a component is null (insufficient data, no medications, etc.) + * the remaining weights are scaled proportionally so they still sum to + * 100 — the score never lies about what was actually measured. + * + * Weight yardstick: the pillar grades against the user's OWN target + * band or against nothing at all. There is no population default and no + * derived stand-in — a band the user never set is not a band. Callers + * resolve the target through `buildHealthScoreWeightTarget` + * (`./health-score-inputs`) and pass `weightTarget: null` + + * `weightTargetSource: "none"` when the account carries no override; the + * trend is then scored on its own via `bareWeightTrendScore`. The + * resolved yardstick rides the result on + * `components.weight.targetSource` so every surface can say which + * yardstick graded the row. * * Bands: * green >= 75 @@ -55,6 +65,19 @@ export type HealthScoreBand = "green" | "yellow" | "red"; */ export type ContributingSource = "manual" | "withings" | "appleHealth"; +/** + * Which yardstick graded the weight pillar. + * + * - `"user"` — an explicit target the user set on the targets surface + * (`User.thresholdsJson`, resolved through `getEffectiveRange`). + * - `"none"` — no target on file; the pillar scores the bare trend and + * the surfaces say so. + * + * There is deliberately no third token: a score must never grade a + * person against a band nobody set and nothing discloses. + */ +export type WeightTargetSource = "user" | "none"; + export type HealthScoreComponentSource = ContributingSource | "mixed" | "none"; /** @@ -105,8 +128,22 @@ export interface HealthScoreInput { bpGradedScore?: number | null; /** Weight readings over the last 30 days, ascending or unsorted (we sort). */ weightSeriesLast30d: Array<{ date: string; kg: number }>; - /** User's stored target weight in kg, if any. */ - weightTargetKg: number | null; + /** + * The user's own target weight band in kg, or `null` when they have + * not set one. The band arrives whole — the scorer neither widens nor + * narrows it, and it never synthesises one from height, BMI, or the + * user's own median. `null` routes the pillar through + * `bareWeightTrendScore`. + */ + weightTarget: { min: number; max: number } | null; + /** + * Which yardstick the weight pillar is graded against. `"user"` means + * `weightTarget` carries an explicit override the user set on the + * targets surface; `"none"` means no target exists and the pillar + * scores the bare trend. Required so a caller cannot pass a band + * without declaring where it came from. + */ + weightTargetSource: WeightTargetSource; /** Mood entries over the last 30 days. `score` 1..5 in HealthLog. */ moodEntriesLast30d: Array<{ date: string; score: number }>; /** Per-active-medication 30-day compliance %. Empty array → null. */ @@ -138,13 +175,29 @@ export interface HealthScoreComponentDetail { asOf: string; } +/** + * The weight pillar carries its yardstick alongside the value, so every + * surface can name what the row was graded against instead of leaving + * the reader to assume. The disclosure is the point: a target that grades + * silently is the defect this field closes. + */ +export interface HealthScoreWeightComponentDetail extends HealthScoreComponentDetail { + /** `"user"` when an explicit target graded the pillar, else `"none"`. */ + targetSource: WeightTargetSource; + /** + * The band the pillar was graded against, in kg. Null exactly when + * `targetSource` is `"none"` — the pillar then scored the bare trend. + */ + target: { min: number; max: number } | null; +} + export interface HealthScoreResult { /** 0..100, integer. Always present (defaults to 0 with all-null input). */ score: number; band: HealthScoreBand; components: { bp: HealthScoreComponentDetail; - weight: HealthScoreComponentDetail; + weight: HealthScoreWeightComponentDetail; mood: HealthScoreComponentDetail; compliance: HealthScoreComponentDetail; }; @@ -232,12 +285,10 @@ export function coefficientOfVariation(values: number[]): number | null { /** * Score the recent weight trend on its own, with no target band. * - * Used when the user has weight readings but no derivable target (their - * profile has no `heightCm`, so `defaultWeightTargetFromHeight` returns - * null). Without a goal we can't say "closing the gap", but a sustained - * upward weight trend is the one direction that is broadly an adverse - * signal independent of any individual target, so we score on that - * asymmetry: + * Used whenever the user has weight readings but has not set a target. + * Without a goal we can't say "closing the gap", but a sustained upward + * weight trend is the one direction that is broadly an adverse signal + * independent of any individual target, so we score on that asymmetry: * * - Flat / stable (slope ≈ 0) → 75 (maintenance is a solid result). * - Gentle-to-steep decline (slope<0) → 75 → 100 (favourable; saturating). @@ -281,13 +332,13 @@ function bareWeightTrendScore( * doesn't read more positive than a 0.05 kg/day drop. * - Below the band → an upward slope is closing. * - * No target (no stored height → no BMI-22 fallback band) but ≥ 2 - * readings → fall back to scoring the **bare trend** on its own, so a - * user with weight data still gets a populated, weighted pillar instead - * of an empty `{value:null, weight:0, source:"none"}`. See - * `bareWeightTrendScore` for the curve and its rationale. We do NOT - * fabricate a target from the user's own median and pretend it's a goal - * — the trend is scored honestly, without a target. + * No target set but ≥ 2 readings → fall back to scoring the **bare + * trend** on its own, so a user with weight data still gets a populated, + * weighted pillar instead of an empty `{value:null, weight:0, + * source:"none"}`. See `bareWeightTrendScore` for the curve and its + * rationale. We do NOT fabricate a target — not from height, not from + * the user's own median — and pretend it's a goal; the trend is scored + * honestly, without one. * * Insufficient data (< 2 readings) → null regardless of target. */ @@ -383,8 +434,13 @@ export function computeHealthScore( ? clampToHundred(input.bpGradedScore) : clampToHundred(input.bpInTargetRate); - const target = deriveWeightTarget(input.weightTargetKg); - const weightValue = weightTrendAlignment(input.weightSeriesLast30d, target); + // The band arrives whole from the caller's `getEffectiveRange` read — + // no expansion, no fabrication. `null` scores the bare trend. + const weightTarget = input.weightTarget; + const weightValue = weightTrendAlignment( + input.weightSeriesLast30d, + weightTarget, + ); const moodValue = moodStability(input.moodEntriesLast30d); @@ -418,6 +474,10 @@ export function computeHealthScore( }, }, windowEndAt, + { + source: input.weightTargetSource, + target: input.weightTargetSource === "user" ? weightTarget : null, + }, ); let raw = 0; @@ -482,6 +542,10 @@ function redistribute( } >, windowEndAt: string, + weightYardstick: { + source: WeightTargetSource; + target: { min: number; max: number } | null; + }, ): HealthScoreResult["components"] { const present: Array = []; for (const key of ["bp", "weight", "mood", "compliance"] as const) { @@ -511,7 +575,11 @@ function redistribute( }; return { bp: detailFor("bp"), - weight: detailFor("weight"), + weight: { + ...detailFor("weight"), + targetSource: weightYardstick.source, + target: weightYardstick.target, + }, mood: detailFor("mood"), compliance: detailFor("compliance"), }; @@ -542,28 +610,3 @@ function deriveWindowEndAt(input: HealthScoreInput): string { if (latest === -Infinity) return new Date(0).toISOString(); return new Date(latest).toISOString(); } - -/** - * The `weightTargetKg` input is a single number; we expand it to a - * narrow ±2 kg band so the alignment helper has a "target zone" to - * test inclusion against. Null target → null band → null component. - */ -function deriveWeightTarget( - targetKg: number | null, -): { min: number; max: number } | null { - if (targetKg === null || !Number.isFinite(targetKg)) return null; - return { min: targetKg - 2, max: targetKg + 2 }; -} - -/** - * Helper for callers that have only height + want a BMI-22 fallback - * target. Surfaces the same ±2 kg band semantics as the in-house - * derivation so the math is consistent across callers. - */ -export function defaultWeightTargetFromHeight( - heightCm: number | null, -): number | null { - if (heightCm === null || heightCm <= 0) return null; - const heightM = heightCm / 100; - return Math.round(22 * heightM * heightM * 10) / 10; -} diff --git a/src/lib/analytics/value-bands.ts b/src/lib/analytics/value-bands.ts index 38fe9fa2b..7b1d24d6d 100644 --- a/src/lib/analytics/value-bands.ts +++ b/src/lib/analytics/value-bands.ts @@ -89,11 +89,19 @@ export function buildWeightRangeFromHeight(heightCm: number): TrafficRange { }; } -export function buildWeightBandsFromHeight( - heightCm: number, +/** + * Chart bands straight off an already-resolved traffic range. + * + * v1.34 — the weight chart shades either the height-derived WHO band or the + * user's OWN target band (`User.thresholdsJson`, resolved through + * `getEffectiveRange`, which computes its own orange wings). Both go through + * this one builder so the two yardsticks paint with identical geometry and + * only the numbers differ. + */ +export function buildBandsFromTrafficRange( + range: TrafficRange, options: { lowerBound?: number; upperBound?: number } = {}, ): ValueBand[] { - const range = buildWeightRangeFromHeight(heightCm); const lowerBound = options.lowerBound ?? 30; const upperBound = options.upperBound ?? 250; @@ -131,6 +139,16 @@ export function buildWeightBandsFromHeight( ].filter((band) => band.max > band.min); } +export function buildWeightBandsFromHeight( + heightCm: number, + options: { lowerBound?: number; upperBound?: number } = {}, +): ValueBand[] { + return buildBandsFromTrafficRange( + buildWeightRangeFromHeight(heightCm), + options, + ); +} + /** * Body-fat target range — the "green" band shown on the dashboard / * targets page / chart value-bands. diff --git a/src/lib/dashboard/bands.ts b/src/lib/dashboard/bands.ts index d27dacd53..8ed706262 100644 --- a/src/lib/dashboard/bands.ts +++ b/src/lib/dashboard/bands.ts @@ -17,9 +17,9 @@ */ import { getBpTargets, type BpTargets } from "@/lib/analytics/bp-targets"; import { + buildBandsFromTrafficRange, buildTrafficLightBands, buildTrafficRange, - buildWeightBandsFromHeight, buildWeightRangeFromHeight, getBodyFatTargetRange, type ValueBand, @@ -30,6 +30,7 @@ import { getPersonalizedPulseTarget, } from "@/lib/analytics/pulse-targets"; import { binaryReferenceSex, type ProfileSex } from "@/lib/profile/sex"; +import { getEffectiveRange } from "@/lib/analytics/effective-range"; export interface DashboardTargetBands { /** Personalised BP target numbers (null when no DOB). */ @@ -51,9 +52,12 @@ export interface DashboardTargetBands { bodyFatRange: { min: number; max: number }; /** Body-fat chart bands (always present). */ bodyFatBands: ValueBand[]; - /** Weight traffic range (null when no height). */ + /** + * Weight traffic range — the user's own target band when they set one, + * else the height-derived WHO band, else null (no height, no target). + */ weightRange: TrafficRange | null; - /** Weight chart bands (null when no height). */ + /** Weight chart bands, from the same range. Null when the range is null. */ weightBands: ValueBand[] | null; } @@ -61,11 +65,19 @@ export interface DashboardTargetBands { * Resolve the band / target math from a user's profile facts. Pure; no * DB read. The snapshot builder and the page fallback both call this so * they produce byte-identical numbers. + * + * v1.34 — `weightTargetOverride` is the user's OWN weight band as stored in + * `User.thresholdsJson.WEIGHT`, in kg. When present it replaces the + * height-derived WHO band on both the weight range and the weight chart bands: + * a user who has answered "this is my target" must not keep reading a shaded + * zone that ignores the answer. `null` keeps the height-derived band. Required + * rather than optional so every call site is named by the compiler. */ export function buildDashboardBands(profile: { dateOfBirth: Date | null; gender: ProfileSex; heightCm: number | null; + weightTargetOverride: { min: number; max: number } | null; }): DashboardTargetBands { const bpTargets = profile.dateOfBirth ? getBpTargets(profile.dateOfBirth) @@ -76,11 +88,28 @@ export function buildDashboardBands(profile: { const referenceSex = binaryReferenceSex(profile.gender); const pulseTarget = getPersonalizedPulseTarget(pulseAge, referenceSex); const bodyFatRange = getBodyFatTargetRange(referenceSex); - const weightRange = profile.heightCm - ? buildWeightRangeFromHeight(profile.heightCm) - : null; - const weightBands = profile.heightCm - ? buildWeightBandsFromHeight(profile.heightCm, { + // v1.34 — an explicit target outranks the height-derived WHO band. Without + // this the dashboard kept shading a band the user had already answered, and + // the answer lived on `/targets` where nothing else could see it. The + // override is routed through `getEffectiveRange` rather than widened here, + // so the orange wings match the `/targets` page and the doctor report + // exactly instead of inventing a second convention. No height and no target + // → no band at all, unchanged. + const weightRange = profile.weightTargetOverride + ? getEffectiveRange( + "WEIGHT", + { + heightCm: profile.heightCm, + dateOfBirth: profile.dateOfBirth, + gender: profile.gender, + }, + { WEIGHT: profile.weightTargetOverride }, + ).range + : profile.heightCm + ? buildWeightRangeFromHeight(profile.heightCm) + : null; + const weightBands = weightRange + ? buildBandsFromTrafficRange(weightRange, { lowerBound: 30, upperBound: 250, }) diff --git a/src/lib/dashboard/snapshot-read.ts b/src/lib/dashboard/snapshot-read.ts index 3570ad691..4acd25431 100644 --- a/src/lib/dashboard/snapshot-read.ts +++ b/src/lib/dashboard/snapshot-read.ts @@ -67,6 +67,7 @@ export async function readDashboardSnapshotCached( insightsCachedText: user.insightsCachedText, insightsCachedAt: user.insightsCachedAt, dashboardWidgetsJson: user.dashboardWidgetsJson, + thresholdsJson: user.thresholdsJson, }; // v1.21.2 (A4) — the briefing recall + forward-look is locale-specific diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index 1dfd43431..9639bf65f 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -53,7 +53,14 @@ import { computeBpInTargetFastPath, type BpInTargetEnvelope, } from "@/lib/analytics/bp-in-target-fast-path"; -import { buildHealthScoreBpInputs } from "@/lib/analytics/health-score-inputs"; +import { + buildHealthScoreBpInputs, + buildHealthScoreWeightTarget, +} from "@/lib/analytics/health-score-inputs"; +import { + resolveWeightTargetOverride, + type ThresholdOverridesJson, +} from "@/lib/analytics/effective-range"; import { deriveBpWindow90 } from "@/lib/analytics/window-confidence"; import { probeRollupCoverage, @@ -472,6 +479,14 @@ export interface SnapshotUserInput { insightsCachedText: string | null; insightsCachedAt: Date | null; dashboardWidgetsJson: unknown; + /** + * v1.34 — the raw `User.thresholdsJson` blob. The hero ring's weight + * pillar and the dashboard's weight bands both grade against the user's + * OWN target when one is set, so the builder needs the overrides the + * `/targets` editor writes. Parsed at the point of use through + * `ThresholdOverridesJson`. + */ + thresholdsJson: unknown; } /** Wall-clock hour in `tz`, used for the server-side greeting. */ @@ -582,6 +597,11 @@ async function buildNutrientWaterBlock( * windows already computed here (`gradedScore` + `last30Days.pct`) and * the same coverage map, so the score's weight pillar also stays on * the rollup branch. + * + * v1.34 — takes the resolved module map. The hero ring used to score a + * mood pillar for accounts that had switched the mood module off, while + * the Insights card (which does resolve the module) dropped it: one + * score, two compositions, two surfaces. */ async function buildExtras( prisma: PrismaClient, @@ -590,6 +610,7 @@ async function buildExtras( coverage: RollupCoverageMap, now: Date, time: (label: string, fn: () => Promise) => Promise, + modules: Record, ): Promise<{ extras: DashboardSnapshotExtras; healthScore: DashboardSnapshotHealthScore | null; @@ -666,13 +687,28 @@ async function buildExtras( // 90-day window via W1d, same all-time fallback, same graded score, same // prior-week delta values). Closes the dashboard-vs-insights divergence. const bpInputs = buildHealthScoreBpInputs(bpEnvelope, bpEnvelopePriorWeek); + // v1.34 — the weight yardstick, resolved through the same shared builder the + // analytics route uses. A target reaches the pillar only when the account + // set one; otherwise the pillar scores the bare trend on both surfaces. + const weightTargetInputs = buildHealthScoreWeightTarget( + { + heightCm: user.heightCm, + dateOfBirth: user.dateOfBirth, + gender: user.gender, + }, + (user.thresholdsJson ?? null) as ThresholdOverridesJson | null, + ); const scoreResult = await time("healthScore", () => computeUserHealthScoreFastPath({ userId: user.id, ...bpInputs, - heightCm: user.heightCm, + ...weightTargetInputs, now, coverage, + // v1.34 — the mood module gates the pillar here exactly as it does on + // `/api/analytics`. Before this the ring scored a pillar the account + // had switched off everywhere else. + moodEnabled: modules.mood !== false, }), ); // v1.18.1 — resolve the value-free Rest Mode annotation alongside the score @@ -1165,9 +1201,21 @@ export async function buildDashboardSnapshot( // doesn't re-run the identical `probeRollupCoverage` query. time("summaries", () => computeSummariesSlice(user.id, coverage)), time("mood", () => buildMoodBlock(prisma, user.id)), + // v1.34 — the thick slice awaits the shared `modulesPromise` (the same + // pattern the score-ring task uses) so the hero ring's health score can + // gate its mood pillar on the resolved module map without a second + // resolver call or a serialised phase. warm - ? time("extras", () => - buildExtras(prisma, user, userTz, coverage, now, time), + ? time("extras", async () => + buildExtras( + prisma, + user, + userTz, + coverage, + now, + time, + await modulesPromise, + ), ) : Promise.resolve(null), time("flags", () => getAssistantFlags()), @@ -1306,6 +1354,10 @@ export async function buildDashboardSnapshot( dateOfBirth: user.dateOfBirth, gender, heightCm: user.heightCm ?? null, + // v1.34 — the user's own weight target, when they set one, replaces the + // height-derived band on the tile + the chart. Resolved off the same + // `thresholdsJson` blob the health score reads. + weightTargetOverride: resolveWeightTargetOverride(user.thresholdsJson), }), tiles: { summaries: slim.summaries, diff --git a/src/lib/dashboard/verdict.ts b/src/lib/dashboard/verdict.ts index dbeb9946e..94c3c0fb1 100644 --- a/src/lib/dashboard/verdict.ts +++ b/src/lib/dashboard/verdict.ts @@ -31,7 +31,6 @@ import { COACH_NUDGE_SLEEP_DEFICIT_MARGIN_H, } from "@/lib/jobs/coach-nudge-thresholds"; import { BP_SYS_CRITICAL, BP_DIA_CRITICAL } from "@/lib/clinical-floors"; -import { buildWeightRangeFromHeight } from "@/lib/analytics/value-bands"; import { userDayKey } from "@/lib/tz/format"; import type { DashboardSnapshot } from "@/lib/dashboard/snapshot"; @@ -187,19 +186,20 @@ export function resolveDashboardVerdict( } // ── 4 · weightDrift ──────────────────────────────────────────────── - // Distance-to-green-range drift: the 7-day mean sits further from - // the BMI-derived green band than the 30-day mean by at least the - // coach-nudge drift threshold. No stored height → no range → skip. + // Distance-to-green-range drift: the 7-day mean sits further from the + // green band than the 30-day mean by at least the coach-nudge drift + // threshold. + // + // v1.34 — the band comes off the snapshot's resolved `targetBands`, which + // honours the user's own weight target when they set one. The rung used to + // recompute the height-derived BMI band inline, so it nudged about drifting + // away from a band the user had explicitly replaced. No band on the snapshot + // (no height and no target) → skip. const weight = summaries.WEIGHT; - if ( - snapshot.user.heightCm !== null && - weight && - weight.avg7 !== null && - weight.avg30 !== null - ) { - const range = buildWeightRangeFromHeight(snapshot.user.heightCm); + const weightRange = snapshot.targetBands.weightRange; + if (weightRange && weight && weight.avg7 !== null && weight.avg30 !== null) { const dist = (x: number): number => - Math.max(0, range.greenMin - x, x - range.greenMax); + Math.max(0, weightRange.greenMin - x, x - weightRange.greenMax); if (dist(weight.avg7) - dist(weight.avg30) >= COACH_NUDGE_WEIGHT_DRIFT_KG) { return { variant: "weightDrift", diff --git a/src/lib/personal-records/pr-detection-worker.ts b/src/lib/personal-records/pr-detection-worker.ts index 644215d0c..107fcc704 100644 --- a/src/lib/personal-records/pr-detection-worker.ts +++ b/src/lib/personal-records/pr-detection-worker.ts @@ -43,7 +43,13 @@ import { dayKeyForUserTz } from "@/lib/measurements/consolidation-tz"; import { metricKeyForType } from "@/lib/measurements/cumulative-day-sum"; import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority"; import { measurementTypeEnum } from "@/lib/validations/measurement"; -import { getPRDirection, isPRTrackable } from "./pr-direction"; +import { + getPRDirection, + isPRTrackable, + resolveWeightGoalDirection, + type PRDirectionContext, +} from "./pr-direction"; +import { resolveWeightTargetOverride } from "@/lib/analytics/effective-range"; import { Prisma, PersonalRecordDirection, @@ -223,7 +229,14 @@ export async function detectPersonalRecordsForUser( // stays intact for tests. const userRow = await prisma.user.findUnique({ where: { id: userId }, - select: { timezone: true, sourcePriorityJson: true }, + select: { + timezone: true, + sourcePriorityJson: true, + // v1.34 — the weight target the user set on `/targets`. Weight and BMI + // have no intrinsic record direction; they have the user's own once a + // target exists, and this is the read the v1.5.5 comment deferred. + thresholdsJson: true, + }, }); const tz = userRow?.timezone && userRow.timezone.length > 0 @@ -231,13 +244,31 @@ export async function detectPersonalRecordsForUser( : "Europe/Berlin"; const priorityJson = userRow?.sourcePriorityJson ?? null; + // v1.34 — resolve the weight goal once per run: the user's own target band + // plus their latest reading decide which end of the scale is the record. + // The latest-weight read only fires when a target actually exists, so an + // account without one pays nothing. + const weightTarget = resolveWeightTargetOverride(userRow?.thresholdsJson); + let latestWeightKg: number | null = null; + if (weightTarget) { + const latestWeight = await prisma.measurement.findFirst({ + where: { userId, type: "WEIGHT", deletedAt: null }, + orderBy: { measuredAt: "desc" }, + select: { value: true }, + }); + latestWeightKg = latestWeight?.value ?? null; + } + const directionContext: PRDirectionContext = { + weightGoal: resolveWeightGoalDirection(weightTarget, latestWeightKg), + }; + // ── Measurement-driven PRs (metricSlot = null) ───────────── for (const type of measurementTypeEnum.options) { const metricType = type as MeasurementType; - if (!isPRTrackable(metricType)) continue; + if (!isPRTrackable(metricType, directionContext)) continue; scanned += 1; - const direction = getPRDirection(metricType); + const direction = getPRDirection(metricType, directionContext); if (direction === null) continue; // Warm-up gate. Cheap count first so we don't pull a 30k-row diff --git a/src/lib/personal-records/pr-direction.ts b/src/lib/personal-records/pr-direction.ts index ddf3b86ae..deb6568c9 100644 --- a/src/lib/personal-records/pr-direction.ts +++ b/src/lib/personal-records/pr-direction.ts @@ -27,12 +27,19 @@ * * Blood pressure (high reading isn't an achievement; low can * also be clinically bad), blood glucose (extremes both ways), * body & skin temperature (homeostatic, not a goal), pulse - * (spot-reading), oxygen saturation (homeostatic), weight - * (direction is user-goal-dependent — to defer until the - * v1.4.26 worker can read User.thresholdsJson), sleep duration + * (spot-reading), oxygen saturation (homeostatic), sleep duration * (longer is not strictly better), fat-free mass when treated * as a derived (weight − fat) value with the same ambiguity. * + * user-goal direction (v1.34): + * * Weight and BMI have no intrinsic direction, which is why they + * returned `null` from v1.5.5 until now. They do have the user's + * OWN direction once a weight target exists in + * `User.thresholdsJson`, so the resolver takes a context carrying + * it and answers MIN / MAX accordingly. No target, or a reading + * already inside the band, still returns `null` — the direction is + * read off the person's stated goal, never guessed from height. + * * Adding a new MeasurementType: extend the switch below. The * drift-guard test in `__tests__/pr-direction.test.ts` asserts every * canonical enum value lands in exactly one branch. @@ -40,8 +47,48 @@ import type { MeasurementType } from "@/generated/prisma/client"; import { PersonalRecordDirection } from "@/generated/prisma/client"; +/** + * v1.34 — the user-goal facts the resolver needs for the goal-dependent + * metrics. Everything else in the switch is goal-independent and ignores it. + */ +export interface PRDirectionContext { + /** + * Which way the user's OWN weight target points, resolved by + * `resolveWeightGoalDirection` from `User.thresholdsJson` plus their latest + * reading. `null` when no target is set, the reading is missing, or the + * reading already sits inside the band — in all three cases weight and BMI + * keep the historical "no record direction" answer. + */ + weightGoal: "lower" | "higher" | null; +} + +/** + * Resolve the weight goal direction from the user's own target band and their + * latest weight. + * + * Above the band → the goal is downward → the record is the lowest reading. + * Below the band → upward. Inside the band → no direction: the person is where + * they said they wanted to be, and neither end of the scale is an achievement + * from there. No target → no direction, because inferring one from height or + * population norms is the fabrication this release removes. + * + * Pure; the worker owns the reads. + */ +export function resolveWeightGoalDirection( + target: { min: number; max: number } | null, + latestWeightKg: number | null, +): "lower" | "higher" | null { + if (!target || latestWeightKg === null || !Number.isFinite(latestWeightKg)) { + return null; + } + if (latestWeightKg > target.max) return "lower"; + if (latestWeightKg < target.min) return "higher"; + return null; +} + export function getPRDirection( metricType: MeasurementType, + context: PRDirectionContext, ): PersonalRecordDirection | null { switch (metricType) { // MAX direction — higher value is the record. @@ -107,6 +154,20 @@ export function getPRDirection( case "SLEEP_DISTURBANCE_COUNT": return PersonalRecordDirection.MIN; + // v1.34 — weight + BMI are goal-dependent, and the goal finally has a + // source the worker can read: the target the user set on `/targets` + // (`User.thresholdsJson`). With one on file the record direction is the + // direction of their OWN goal — lowest reading when they are working + // down, highest when they are working up. Without a target, or while + // they sit inside their band, both stay `null` exactly as they have + // since v1.5.5: a record direction is not something to guess at from + // height or a population norm. + case "WEIGHT": + case "BODY_MASS_INDEX": + if (context.weightGoal === "lower") return PersonalRecordDirection.MIN; + if (context.weightGoal === "higher") return PersonalRecordDirection.MAX; + return null; + // Explicitly no PersonalRecord — see comment block above. case "BLOOD_PRESSURE_SYS": case "BLOOD_PRESSURE_DIA": @@ -115,7 +176,6 @@ export function getPRDirection( case "SKIN_TEMPERATURE": case "PULSE": case "OXYGEN_SATURATION": - case "WEIGHT": case "SLEEP_DURATION": case "FAT_FREE_MASS": // v1.4.30 — audio-exposure events fire on a threshold-cross, not @@ -124,13 +184,11 @@ export function getPRDirection( // PR direction. Defer. case "AUDIO_EXPOSURE_EVENT": // v1.5.5 — homeostatic / display-only metrics. Respiratory rate - // is goal-neutral (homeostatic), walking HR average is fitness + // is goal-neutral (homeostatic) and walking HR average is fitness // dependent without a clean direction (a higher value can be - // either ill or harder-effort), and BMI direction is user-goal - // dependent — defer until the worker can read User.thresholdsJson. + // either ill or harder-effort). case "RESPIRATORY_RATE": case "WALKING_HEART_RATE_AVERAGE": - case "BODY_MASS_INDEX": // v1.5.5 follow-up — walking step length is a state metric: // taller users have longer strides regardless of fitness, and // there is no clean "higher is better" axis (very long strides @@ -217,10 +275,13 @@ export function getPRDirection( } /** - * Convenience predicate for the future detection worker: - * `if (!isPRTrackable(type)) return;` keeps the worker from writing + * Convenience predicate for the detection worker: + * `if (!isPRTrackable(type, ctx)) return;` keeps the worker from writing * rows that wouldn't have a defined direction anyway. */ -export function isPRTrackable(metricType: MeasurementType): boolean { - return getPRDirection(metricType) !== null; +export function isPRTrackable( + metricType: MeasurementType, + context: PRDirectionContext, +): boolean { + return getPRDirection(metricType, context) !== null; }