fix(score): grade weight against the user's own target, never a fabricated one

The Health Score graded the weight pillar against 22 x height squared,
expanded by 2 kg either side: a target nobody set, nothing disclosed,
and the only producer of the pillar's band. For anyone with a height on
file it pre-empted both the honest trend-only path the module header
promised and the null-redistribution behind it, while the target the
user had actually entered sat unread in User.thresholdsJson.

computeHealthScore now takes the band whole plus the yardstick it came
from. Both callers resolve it through one shared builder next to
buildHealthScoreBpInputs, which hands over a band only when
getEffectiveRange reports a real user override; every other account
scores the bare trend. defaultWeightTargetFromHeight is gone.

Same pass, same defect class elsewhere: the dashboard bands, the
weight-drift verdict rung and the weight/BMI record direction all
ignored an explicit target too, and the snapshot never resolved the
mood module, so a mood-off account got a mood pillar in the hero ring
and none in the Insights card. moodEnabled and the weight yardstick are
required parameters now, so a caller that forgets one fails to compile
instead of quietly scoring against a default.
This commit is contained in:
Marc-André Bombeck committed 2026-07-27 22:54:48 +02:00
1 parent 60c30e1e3a
commit b1b76ab68a
13 files changed
+479 -110

No files matched your search

+20 -2
View File
@@ -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,
+24 -1
View File
@@ -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<string, { min: number; max: number }>;
}>("/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;
+22
View File
@@ -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<string, unknown>).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.
+32 -15
View File
@@ -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<HealthScoreResult | null> {
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,
};
+55 -1
View File
@@ -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",
};
}
+91 -48
View File
@@ -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<keyof typeof BASE_WEIGHTS> = [];
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;
}
+21 -3
View File
@@ -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.
+37 -8
View File
@@ -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,
})
+1
View File
@@ -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
+56 -4
View File
@@ -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: <T>(label: string, fn: () => Promise<T>) => Promise<T>,
modules: Record<ModuleKey, boolean>,
): 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,
+12 -12
View File
@@ -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",
@@ -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
+73 -12
View File
@@ -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;
}