From b1b76ab68a3bc90c6c1e020999ed5b3d16596881 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 27 Jul 2026 22:54:48 +0200 Subject: [PATCH 1/4] 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. --- src/app/api/analytics/route.ts | 22 ++- src/app/page-client.tsx | 25 +++- src/lib/analytics/effective-range.ts | 22 +++ src/lib/analytics/health-score-fast-path.ts | 47 ++++-- src/lib/analytics/health-score-inputs.ts | 56 ++++++- src/lib/analytics/health-score.ts | 139 ++++++++++++------ src/lib/analytics/value-bands.ts | 24 ++- src/lib/dashboard/bands.ts | 45 +++++- src/lib/dashboard/snapshot-read.ts | 1 + src/lib/dashboard/snapshot.ts | 60 +++++++- src/lib/dashboard/verdict.ts | 24 +-- .../personal-records/pr-detection-worker.ts | 39 ++++- src/lib/personal-records/pr-direction.ts | 85 +++++++++-- 13 files changed, 479 insertions(+), 110 deletions(-) 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; } From b0862c0902a3a56d5f096de303ab4b6f445ab6cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 27 Jul 2026 23:00:26 +0200 Subject: [PATCH 2/4] feat(score): say which yardstick graded the weight row The weight pillar has been graded against something for two years and never said what. The score card's weight row now carries one line: the user's own target band when they set one, or a plain statement that the row scored the trend with no target at all. The band rides the analytics DTO in canonical kg and the page formats it into the reader's display unit, so an imperial account reads pounds. The weight sub-page shades the same yardstick: its chart bands follow an explicit target when one exists and fall back to the height-derived band only when it does not. --- messages/de.json | 4 + messages/en.json | 4 + messages/es.json | 4 + messages/fr.json | 4 + messages/it.json | 4 + messages/pl.json | 4 + src/app/insights/page-client.tsx | 24 +++++ src/app/insights/weight/page.tsx | 58 +++++++++-- src/components/insights/health-score-card.tsx | 96 +++++++++++++------ src/components/insights/hero-strip.tsx | 8 ++ src/types/analytics.ts | 10 ++ 11 files changed, 181 insertions(+), 39 deletions(-) diff --git a/messages/de.json b/messages/de.json index e633cfc99..2beeb545b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2234,6 +2234,10 @@ "componentWeight": "Gewicht", "componentMood": "Stimmung", "componentCompliance": "Therapietreue", + "weightTarget": { + "user": "Gemessen an deinem Ziel, {range}.", + "none": "Nur am Verlauf gemessen, kein Ziel gesetzt." + }, "deltaExplainer": { "title": "So wird die Veränderung berechnet", "description": "Veränderung gegenüber letzter Woche: {delta}", diff --git a/messages/en.json b/messages/en.json index d7183b5c9..0eb760774 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2234,6 +2234,10 @@ "componentWeight": "Weight", "componentMood": "Mood", "componentCompliance": "Adherence", + "weightTarget": { + "user": "Graded against your target, {range}.", + "none": "Graded on the trend alone, no target set." + }, "deltaExplainer": { "title": "How the delta is computed", "description": "Change vs last week: {delta}", diff --git a/messages/es.json b/messages/es.json index 642747c38..6f994a265 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2234,6 +2234,10 @@ "componentWeight": "Peso", "componentMood": "Estado de ánimo", "componentCompliance": "Cumplimiento", + "weightTarget": { + "user": "Evaluado con tu objetivo, {range}.", + "none": "Evaluado solo por la tendencia, sin objetivo definido." + }, "deltaExplainer": { "title": "Cómo se calcula el cambio", "description": "Cambio respecto a la semana pasada: {delta}", diff --git a/messages/fr.json b/messages/fr.json index 604b61659..54b420b78 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2234,6 +2234,10 @@ "componentWeight": "Poids", "componentMood": "Humeur", "componentCompliance": "Observance", + "weightTarget": { + "user": "Évalué par rapport à ton objectif, {range}.", + "none": "Évalué sur la tendance seule, aucun objectif défini." + }, "deltaExplainer": { "title": "Comment l'écart est calculé", "description": "Écart par rapport à la semaine dernière : {delta}", diff --git a/messages/it.json b/messages/it.json index 715a74ce6..16019bc1c 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2234,6 +2234,10 @@ "componentWeight": "Peso", "componentMood": "Umore", "componentCompliance": "Aderenza", + "weightTarget": { + "user": "Valutato rispetto al tuo obiettivo, {range}.", + "none": "Valutato solo sull'andamento, nessun obiettivo impostato." + }, "deltaExplainer": { "title": "Come si calcola la variazione", "description": "Variazione rispetto alla settimana scorsa: {delta}", diff --git a/messages/pl.json b/messages/pl.json index 927e9b7e5..312dd9147 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2234,6 +2234,10 @@ "componentWeight": "Waga", "componentMood": "Nastrój", "componentCompliance": "Przestrzeganie", + "weightTarget": { + "user": "Oceniane względem Twojego celu, {range}.", + "none": "Oceniane tylko na podstawie trendu, brak ustawionego celu." + }, "deltaExplainer": { "title": "Jak liczona jest ta zmiana", "description": "Zmiana względem ubiegłego tygodnia: {delta}", diff --git a/src/app/insights/page-client.tsx b/src/app/insights/page-client.tsx index 439522754..7e22bb727 100644 --- a/src/app/insights/page-client.tsx +++ b/src/app/insights/page-client.tsx @@ -7,6 +7,7 @@ import Link from "next/link"; import { SlidersHorizontal, TrendingUp } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; +import { useUnitDisplay } from "@/hooks/use-unit-display"; import { queryKeys } from "@/lib/query-keys"; import { apiGet } from "@/lib/api/api-fetch"; import { useFeatureFlags } from "@/hooks/use-feature-flags"; @@ -344,6 +345,27 @@ export default function InsightsPageClient() { const heroTension = snapshotHealthScore?.tension ?? null; const heroReturnToBand = snapshotHealthScore?.returnToBand ?? null; + // v1.34 — the weight pillar's yardstick, disclosed on the score card. The + // server sends the band in canonical kg; the reader's display unit is a + // client fact, so the page formats the label and the card renders the line. + // A payload without `targetSource` (an older cached body) discloses nothing + // rather than guessing at one. + const unitDisplay = useUnitDisplay(); + const weightComponent = analytics?.healthScore?.components.weight; + const weightTargetSource = weightComponent?.targetSource ?? null; + const weightTargetBand = weightComponent?.target ?? null; + const heroWeightYardstick = weightTargetSource + ? { + source: weightTargetSource, + targetLabel: weightTargetBand + ? `${unitDisplay.toDisplay("WEIGHT", weightTargetBand.min)}–${unitDisplay.toDisplay( + "WEIGHT", + weightTargetBand.max, + )} ${unitDisplay.unitFor("WEIGHT")}` + : null, + } + : null; + // v1.12.6 — the page owns the ONE overview derived batch. The wellness // strip (above the briefing) and the vitals grid (below it) both read this // single query instance, so the wellness lift adds no second request. @@ -572,6 +594,8 @@ export default function InsightsPageClient() { // forwards them to the score card; null leaves the card quiet. tension={heroTension} returnToBand={heroReturnToBand} + // v1.34 — name the yardstick the weight pillar was graded against. + weightYardstick={heroWeightYardstick} /> {/* v1.15.18 — the inline "Anpassen" toggle was removed. Customising the diff --git a/src/app/insights/weight/page.tsx b/src/app/insights/weight/page.tsx index cad7f711c..00ad2e0ca 100644 --- a/src/app/insights/weight/page.tsx +++ b/src/app/insights/weight/page.tsx @@ -1,9 +1,12 @@ "use client"; import Link from "next/link"; +import { useQuery } from "@tanstack/react-query"; import { Scale } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; +import { queryKeys } from "@/lib/query-keys"; +import { apiGet } from "@/lib/api/api-fetch"; import { useUnitDisplay } from "@/hooks/use-unit-display"; import { useInsightsAnalytics } from "@/hooks/use-insights-analytics"; import { useTranslations } from "@/lib/i18n/context"; @@ -19,16 +22,22 @@ import { MetricCorrelationCard } from "@/components/insights/metric-correlation- import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge"; import { MetricTargetSummary } from "@/components/insights/metric-target-summary"; import { SubPageShell } from "@/components/insights/sub-page-shell"; -import { buildWeightBandsFromHeight } from "@/lib/analytics/value-bands"; +import { + buildBandsFromTrafficRange, + buildWeightBandsFromHeight, + type TrafficRange, +} from "@/lib/analytics/value-bands"; /** * v1.4.25 W4 — `/insights/weight`. * - * Routed Weight sub-page. Renders the weight chart with the user's - * height-derived green/orange/red bands plus the per-section AI - * assessment. The chart-cog (`chartKey="weight"`) lets the user toggle - * trend lines + comparison overlay independently from the dashboard - * weight card. + * Routed Weight sub-page. Renders the weight chart with green/orange/red + * bands plus the per-section AI assessment. The chart-cog + * (`chartKey="weight"`) lets the user toggle trend lines + comparison + * overlay independently from the dashboard weight card. + * + * v1.34 — the bands follow the user's own weight target when one is set, + * and fall back to the height-derived WHO band only when it is not. * * v1.4.28 R3d (BK-F-H1 + BK-F-M1) — analytics fetch + empty-state * branch now consume `useInsightsAnalytics()` + ``. @@ -42,6 +51,22 @@ export default function InsightsGewichtPage() { const { data: analytics, isEmpty } = useInsightsAnalytics("WEIGHT"); const weightSummary = analytics?.summaries?.WEIGHT ?? null; + // v1.34 — the user's own weight target, read through the shared + // `queryKeys.userThresholds()` cell the targets editor also subscribes to, so + // editing the target repaints this chart's band without a reload. + const { data: thresholds } = useQuery({ + queryKey: queryKeys.userThresholds(), + queryFn: async () => { + return apiGet<{ + effective: Record< + string, + { range: TrafficRange | null; isOverride: boolean } + >; + }>("/api/user/thresholds"); + }, + enabled: isAuthenticated, + }); + // v1.32.26 — resolve the weight display unit + scale from the user's // preference (kg for metric, lb for imperial). This page inlines the shell // rather than riding the scaffold, so it converts the stat strip + chart @@ -106,12 +131,27 @@ export default function InsightsGewichtPage() { ); } - const weightBands = user?.heightCm - ? buildWeightBandsFromHeight(user.heightCm, { + // v1.34 — the shaded zone follows the user's OWN target when they set one. + // The page used to shade the height-derived WHO band unconditionally, so + // someone who had entered a target on `/targets` kept reading a chart that + // ignored the answer. `/api/user/thresholds` already resolves the override + // (with its orange wings) through `getEffectiveRange`, so the page reads the + // resolved band rather than re-deriving one. No target and no height → no + // bands, unchanged. + const overrideRange = thresholds?.effective?.WEIGHT?.isOverride + ? (thresholds.effective.WEIGHT.range ?? null) + : null; + const weightBands = overrideRange + ? buildBandsFromTrafficRange(overrideRange, { lowerBound: 30, upperBound: 250, }) - : undefined; + : user?.heightCm + ? buildWeightBandsFromHeight(user.heightCm, { + lowerBound: 30, + upperBound: 250, + }) + : undefined; // The bands are kg bounds → scale into the display unit so the shaded zone // tracks the converted line (factor-only; weight carries no offset). const displayBands = diff --git a/src/components/insights/health-score-card.tsx b/src/components/insights/health-score-card.tsx index 3c8e41dca..d3e8b70ba 100644 --- a/src/components/insights/health-score-card.tsx +++ b/src/components/insights/health-score-card.tsx @@ -22,7 +22,8 @@ import { HealthScoreDeltaExplainer } from "./health-score-delta-explainer"; * and stacks below the title block on ` - {/* v1.4.25 W3 — widened label column from w-16 (64px) to - w-24 (96px) so the longest German label - ("Einnahmetreue" — 13 chars at 11px) sits inside the - column without spilling into the bar/value chip. */} - - {label} - -