merge: s4-yardstick into release/v1.34.0

This commit is contained in:
Marc-André Bombeck committed 2026-07-27 23:41:33 +02:00
commit b402be221d
39 files changed
+1605 -215

No files matched your search

+4
View File
@@ -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}",
+4
View File
@@ -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}",
+4
View File
@@ -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}",
+4
View File
@@ -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}",
+4
View File
@@ -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}",
+4
View File
@@ -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}",
+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
View File
@@ -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
+49 -9
View File
@@ -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()` + `<MetricEmptyState>`.
@@ -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 =
+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;
@@ -359,3 +359,75 @@ describe("<HealthScoreCard>", () => {
expect(progressMatch?.[0]).toContain('aria-valuemax="100"');
});
});
/**
* v1.34 — the weight row names its yardstick. Before this the score graded
* weight against a band the user never set and the card said nothing at all,
* which is why the defect survived for months.
*/
describe("<HealthScoreCard> — weight yardstick disclosure", () => {
it("names the user's own target band", () => {
const html = ssr(
<HealthScoreCard
score={80}
band="green"
components={baseComponents}
delta={null}
weightYardstick={{ source: "user", targetLabel: "64-70 kg" }}
/>,
);
const line = html.match(
/<p[^>]*data-slot="health-score-card-weight-yardstick"[^>]*>([\s\S]*?)<\/p>/,
);
expect(line).not.toBeNull();
expect(line?.[0]).toContain('data-target-source="user"');
expect(line?.[1]).toContain("64-70 kg");
});
it("says plainly when no target graded the row", () => {
const html = ssr(
<HealthScoreCard
score={80}
band="green"
components={baseComponents}
delta={null}
weightYardstick={{ source: "none", targetLabel: null }}
/>,
);
const line = html.match(
/<p[^>]*data-slot="health-score-card-weight-yardstick"[^>]*>([\s\S]*?)<\/p>/,
);
expect(line).not.toBeNull();
expect(line?.[0]).toContain('data-target-source="none"');
expect(line?.[1]).toMatch(/trend/i);
});
it("discloses nothing when the payload carries no yardstick", () => {
// An older cached body has no `targetSource`; silence beats a guess.
const html = ssr(
<HealthScoreCard
score={80}
band="green"
components={baseComponents}
delta={null}
/>,
);
expect(html).not.toContain("health-score-card-weight-yardstick");
});
it("stays quiet when the weight pillar itself has no value", () => {
const html = ssr(
<HealthScoreCard
score={80}
band="green"
components={{
...baseComponents,
weight: { value: null, weight: 0 },
}}
delta={null}
weightYardstick={{ source: "none", targetLabel: null }}
/>,
);
expect(html).not.toContain("health-score-card-weight-yardstick");
});
});
+66 -30
View File
@@ -22,7 +22,8 @@ import { HealthScoreDeltaExplainer } from "./health-score-delta-explainer";
* and stacks below the title block on `<lg`. Surfaces:
* - the composite 0..100 score with a band-coloured number
* - "vs last week" delta line with arrow + percentage
* - 4 component rows (BP / Weight / Mood / Compliance) with sub-bars
* - 4 component rows (BP / Weight / Mood / Compliance) with sub-bars,
* the weight row naming the yardstick it was graded against
* - "Indicative — not a clinical assessment" disclaimer
* - "Ask the Coach" button that opens the B2 drawer with a prefill
* ("Why is my health score X out of 100?") so the user can drill
@@ -128,6 +129,20 @@ export interface HealthScoreCardProps {
/** Days the metric has now sat back inside its range. */
daysInside: number;
} | null;
/**
* v1.34 — the weight row's yardstick, disclosed.
*
* `"user"` renders "vs your target 64-70 kg" (the parent formats
* `targetLabel` in the reader's display unit); `"none"` renders the
* trend-only line. The score used to grade weight against a BMI-22 band
* the user never set, and no surface said so — that silence is what this
* line ends. Omitted only by callers that render no weight pillar.
*/
weightYardstick?: {
source: "user" | "none";
/** Already unit-formatted band, e.g. "64-70 kg". Null when source is "none". */
targetLabel: string | null;
} | null;
}
type ComponentKey = keyof HealthScoreCardProps["components"];
@@ -212,6 +227,7 @@ export function HealthScoreCard({
restModeActive = false,
tension = null,
returnToBand = null,
weightYardstick = null,
}: HealthScoreCardProps) {
const { t, locale } = useTranslations();
// The asOf timestamps render under the source pill as a tooltip
@@ -539,38 +555,58 @@ export function HealthScoreCard({
key={key}
data-slot="health-score-card-component-row"
data-component={key}
className="flex items-center gap-2 text-xs"
className="text-xs"
>
{/* 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. */}
<span className="text-muted-foreground w-24 shrink-0 truncate">
{label}
</span>
<div
className="bg-muted/50 h-1.5 flex-1 overflow-hidden rounded-full"
aria-hidden="true"
>
<div className="flex items-center gap-2">
{/* 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. */}
<span className="text-muted-foreground w-24 shrink-0 truncate">
{label}
</span>
<div
className={cn(
"h-full",
value === null ? "bg-muted" : BAND_PROGRESS_CLASS[band],
)}
style={{
width:
value === null
? "0%"
: `${Math.max(0, Math.min(100, value))}%`,
}}
/>
className="bg-muted/50 h-1.5 flex-1 overflow-hidden rounded-full"
aria-hidden="true"
>
<div
className={cn(
"h-full",
value === null ? "bg-muted" : BAND_PROGRESS_CLASS[band],
)}
style={{
width:
value === null
? "0%"
: `${Math.max(0, Math.min(100, value))}%`,
}}
/>
</div>
<span
data-slot="health-score-card-component-value"
className="text-foreground w-8 shrink-0 text-right tabular-nums"
>
{value === null ? "—" : Math.round(value)}
</span>
</div>
<span
data-slot="health-score-card-component-value"
className="text-foreground w-8 shrink-0 text-right tabular-nums"
>
{value === null ? "—" : Math.round(value)}
</span>
{/* v1.34 — the weight row names its yardstick. Either the user's
own target band or an explicit "trend only, no target set".
Indented to the label column so it reads as that row's
caption rather than a new row. */}
{key === "weight" && weightYardstick && value !== null && (
<p
data-slot="health-score-card-weight-yardstick"
data-target-source={weightYardstick.source}
className="text-muted-foreground mt-1 pl-[6.5rem] leading-snug"
>
{weightYardstick.source === "user" &&
weightYardstick.targetLabel
? t("insights.healthScore.weightTarget.user", {
range: weightYardstick.targetLabel,
})
: t("insights.healthScore.weightTarget.none")}
</p>
)}
</li>
))}
</ul>
+8
View File
@@ -95,6 +95,12 @@ interface HeroStripProps {
*/
restMode?: { active: boolean } | null;
} | null;
/**
* v1.34 — the weight pillar's yardstick, already resolved into the reader's
* display unit by the page (the hero carries no unit context of its own).
* Forwarded verbatim to the score card, which renders the disclosure line.
*/
weightYardstick?: HealthScoreCardProps["weightYardstick"];
/**
* v1.21.2 (A5) — Tension Verdict, server-resolved + locale-agnostic. `band`
* is the readiness composite's band; `positive` / `negative` carry the
@@ -188,6 +194,7 @@ export function HeroStrip({
noProviderStale = false,
tension = null,
returnToBand = null,
weightYardstick = null,
}: HeroStripProps) {
const { t } = useTranslations();
const fmt = useFormatters();
@@ -412,6 +419,7 @@ export function HeroStrip({
restModeActive={healthScore.restMode?.active ?? false}
tension={tensionProp}
returnToBand={returnToBandProp}
weightYardstick={weightYardstick}
/>
)}
</div>
@@ -3,6 +3,7 @@ import {
getEffectiveRange,
getAllEffectiveRanges,
METRIC_BOUNDS,
resolveWeightTargetOverride,
type UserProfileForRange,
type ThresholdOverridesJson,
} from "../effective-range";
@@ -224,3 +225,48 @@ describe("OXYGEN_SATURATION thresholds", () => {
});
});
});
/**
* v1.34 — `User.thresholdsJson` is untyped JSON and v1.34 gave the weight
* target three new consumers at once (the Health Score, the dashboard bands,
* the PR direction). One narrowing beats three hand-rolled casts, and a
* malformed blob must read as "no target", never as a target of NaN.
*/
describe("resolveWeightTargetOverride", () => {
it("reads a well-formed weight override", () => {
expect(
resolveWeightTargetOverride({ WEIGHT: { min: 74, max: 78 } }),
).toEqual({ min: 74, max: 78 });
});
it("returns null for a blob with no weight entry", () => {
expect(resolveWeightTargetOverride(null)).toBeNull();
expect(resolveWeightTargetOverride({})).toBeNull();
expect(
resolveWeightTargetOverride({ BLOOD_PRESSURE_SYS: { min: 1, max: 2 } }),
).toBeNull();
});
it("rejects a malformed pair instead of passing it through", () => {
expect(resolveWeightTargetOverride({ WEIGHT: { min: 74 } })).toBeNull();
expect(
resolveWeightTargetOverride({ WEIGHT: { min: "74", max: "78" } }),
).toBeNull();
expect(
resolveWeightTargetOverride({ WEIGHT: { min: NaN, max: 78 } }),
).toBeNull();
// An inverted or collapsed band is not a band.
expect(
resolveWeightTargetOverride({ WEIGHT: { min: 80, max: 70 } }),
).toBeNull();
expect(
resolveWeightTargetOverride({ WEIGHT: { min: 75, max: 75 } }),
).toBeNull();
});
it("survives a non-object blob", () => {
expect(resolveWeightTargetOverride("nope")).toBeNull();
expect(resolveWeightTargetOverride(42)).toBeNull();
expect(resolveWeightTargetOverride(undefined)).toBeNull();
});
});
@@ -132,9 +132,11 @@ describe("computeUserHealthScoreFastPath", () => {
const result = await computeUserHealthScoreFastPath({
userId: "user-rollup",
bpInTargetPct: 85,
heightCm: 178,
weightTarget: null,
weightTargetSource: "none",
now,
coverage,
moodEnabled: true,
});
expect(result).not.toBeNull();
@@ -225,9 +227,11 @@ describe("computeUserHealthScoreFastPath", () => {
await computeUserHealthScoreFastPath({
userId: "user-longwindow",
bpInTargetPct: 85,
heightCm: 178,
weightTarget: null,
weightTargetSource: "none",
now,
coverage,
moodEnabled: true,
});
const calls = ANNOTATE.mock.calls.map((c) => c[0]);
@@ -276,9 +280,11 @@ describe("computeUserHealthScoreFastPath", () => {
await computeUserHealthScoreFastPath({
userId: "user-no-longwindow",
bpInTargetPct: 85,
heightCm: 178,
weightTarget: null,
weightTargetSource: "none",
now,
coverage,
moodEnabled: true,
});
const calls = ANNOTATE.mock.calls.map((c) => c[0]);
@@ -306,9 +312,11 @@ describe("computeUserHealthScoreFastPath", () => {
await computeUserHealthScoreFastPath({
userId: "user-live-nowmy",
bpInTargetPct: 80,
heightCm: 178,
weightTarget: null,
weightTargetSource: "none",
now,
coverage,
moodEnabled: true,
});
// The live branch never issues the long-window probe — guards
@@ -348,9 +356,11 @@ describe("computeUserHealthScoreFastPath", () => {
const result = await computeUserHealthScoreFastPath({
userId: "user-live",
bpInTargetPct: 75,
heightCm: 178,
weightTarget: null,
weightTargetSource: "none",
now,
coverage,
moodEnabled: true,
});
expect(result).not.toBeNull();
@@ -402,9 +412,11 @@ describe("computeUserHealthScoreFastPath", () => {
const result = await computeUserHealthScoreFastPath({
userId: "user-no-height",
bpInTargetPct: null,
heightCm: null,
weightTarget: null,
weightTargetSource: "none",
now,
coverage,
moodEnabled: true,
});
expect(result).not.toBeNull();
@@ -430,9 +442,11 @@ describe("computeUserHealthScoreFastPath", () => {
const result = await computeUserHealthScoreFastPath({
userId: "user-empty",
bpInTargetPct: null,
heightCm: null,
weightTarget: null,
weightTargetSource: "none",
now: new Date("2026-05-17T12:00:00.000Z"),
coverage,
moodEnabled: true,
});
expect(result).toBeNull();
@@ -468,9 +482,11 @@ describe("computeUserHealthScoreFastPath", () => {
const result = await computeUserHealthScoreFastPath({
userId: "user-one-mood-entry",
bpInTargetPct: null,
heightCm: null,
weightTarget: null,
weightTargetSource: "none",
now,
coverage,
moodEnabled: true,
});
expect(result).toBeNull();
@@ -500,9 +516,11 @@ describe("computeUserHealthScoreFastPath", () => {
const result = await computeUserHealthScoreFastPath({
userId: "user-one-weight-reading",
bpInTargetPct: null,
heightCm: null,
weightTarget: null,
weightTargetSource: "none",
now,
coverage,
moodEnabled: true,
});
expect(result).toBeNull();
@@ -534,9 +552,11 @@ describe("computeUserHealthScoreFastPath", () => {
const result = await computeUserHealthScoreFastPath({
userId: "user-multi-pillar-ineligible",
bpInTargetPct: null,
heightCm: null,
weightTarget: null,
weightTargetSource: "none",
now,
coverage,
moodEnabled: true,
});
expect(result).toBeNull();
@@ -559,9 +579,11 @@ describe("computeUserHealthScoreFastPath", () => {
userId: "user-legit-zero",
bpInTargetPct: 0,
bpGradedScore: 0,
heightCm: null,
weightTarget: null,
weightTargetSource: "none",
now,
coverage,
moodEnabled: true,
});
expect(result).not.toBeNull();
@@ -580,8 +602,10 @@ describe("computeUserHealthScoreFastPath", () => {
await computeUserHealthScoreFastPath({
userId: "user-fresh",
bpInTargetPct: null,
heightCm: null,
weightTarget: null,
weightTargetSource: "none",
now: new Date("2026-05-17T12:00:00.000Z"),
moodEnabled: true,
});
expect(PROBE).toHaveBeenCalledWith("user-fresh");
@@ -603,9 +627,11 @@ describe("computeUserHealthScoreFastPath", () => {
userId: "user-bpdelta",
bpInTargetPct: 90,
bpInTargetPctPriorWeek: 70,
heightCm: null,
weightTarget: null,
weightTargetSource: "none",
now: new Date("2026-05-17T12:00:00.000Z"),
coverage,
moodEnabled: true,
});
// With only BP data the helper still returns a score. The delta
@@ -634,9 +660,11 @@ describe("computeUserHealthScoreFastPath", () => {
userId: "user-bplegacy",
bpInTargetPct: 80,
// bpInTargetPctPriorWeek omitted — should fall back to 80.
heightCm: null,
weightTarget: null,
weightTargetSource: "none",
now: new Date("2026-05-17T12:00:00.000Z"),
coverage,
moodEnabled: true,
});
expect(result).not.toBeNull();
@@ -715,9 +743,11 @@ describe("computeUserHealthScoreFastPath", () => {
const result = await computeUserHealthScoreFastPath({
userId: "user-weekly-med",
bpInTargetPct: 80,
heightCm: 178,
weightTarget: null,
weightTargetSource: "none",
now,
coverage,
moodEnabled: true,
});
expect(result).not.toBeNull();
@@ -778,9 +808,11 @@ describe("computeUserHealthScoreFastPath", () => {
const result = await computeUserHealthScoreFastPath({
userId: "user-daily-med",
bpInTargetPct: 80,
heightCm: 178,
weightTarget: null,
weightTargetSource: "none",
now,
coverage,
moodEnabled: true,
});
expect(result).not.toBeNull();
@@ -799,6 +831,10 @@ describe("computeUserHealthScoreFastPath", () => {
* (or inflated) by a pillar the user can no longer see. Golden fixture:
* an identical profile with strong, stable mood scored mood-on vs
* mood-off must produce a different, correctly re-weighted score.
*
* v1.34 — the flag is required. It used to default to on, and the
* dashboard snapshot never passed it, so the hero ring scored a pillar
* the account had switched off while the Insights card dropped it.
*/
describe("module-aware mood pillar (R4)", () => {
// A profile with BP + steady high mood (low variance → high
@@ -811,7 +847,7 @@ describe("computeUserHealthScoreFastPath", () => {
}));
}
it("weights the mood pillar when moodEnabled is omitted (pre-v1.18.0 default)", async () => {
it("weights the mood pillar when moodEnabled is true", async () => {
const coverage = new Map<string, boolean>();
FULLY_COVERED.mockReturnValue(false);
PROBE.mockResolvedValue(coverage);
@@ -823,9 +859,11 @@ describe("computeUserHealthScoreFastPath", () => {
userId: "user-mood-on",
bpInTargetPct: 60,
bpGradedScore: 60,
heightCm: null,
weightTarget: null,
weightTargetSource: "none",
now,
coverage,
moodEnabled: true,
});
expect(result).not.toBeNull();
@@ -849,7 +887,8 @@ describe("computeUserHealthScoreFastPath", () => {
userId: "user-mood-off",
bpInTargetPct: 60,
bpGradedScore: 60,
heightCm: null,
weightTarget: null,
weightTargetSource: "none",
now,
coverage,
moodEnabled: false,
@@ -888,9 +927,11 @@ describe("computeUserHealthScoreFastPath", () => {
userId: "user-golden-on",
bpInTargetPct: 60,
bpGradedScore: 60,
heightCm: null,
weightTarget: null,
weightTargetSource: "none",
now,
coverage,
moodEnabled: true,
});
// Mood-OFF: only BP=60 survives → score 60.
@@ -899,7 +940,8 @@ describe("computeUserHealthScoreFastPath", () => {
userId: "user-golden-off",
bpInTargetPct: 60,
bpGradedScore: 60,
heightCm: null,
weightTarget: null,
weightTargetSource: "none",
now,
coverage,
moodEnabled: false,
@@ -936,9 +978,11 @@ describe("computeUserHealthScoreFastPath", () => {
bpInTargetPct: 16,
// …but the graded clinical-proximity score says borderline.
bpGradedScore: 55,
heightCm: null,
weightTarget: null,
weightTargetSource: "none",
now,
coverage,
moodEnabled: true,
});
expect(result).not.toBeNull();
@@ -968,9 +1012,11 @@ describe("computeUserHealthScoreFastPath", () => {
// stray graded score were supplied.
bpInTargetPct: null,
bpGradedScore: 80,
heightCm: null,
weightTarget: null,
weightTargetSource: "none",
now,
coverage,
moodEnabled: true,
});
expect(result).not.toBeNull();
@@ -1,5 +1,8 @@
import { describe, it, expect } from "vitest";
import { buildHealthScoreBpInputs } from "../health-score-inputs";
import {
buildHealthScoreBpInputs,
buildHealthScoreWeightTarget,
} from "../health-score-inputs";
import type { BpInTargetEnvelope } from "../bp-in-target-fast-path";
function env(partial: Partial<BpInTargetEnvelope>): BpInTargetEnvelope {
@@ -134,3 +137,60 @@ describe("buildHealthScoreBpInputs — single Health-Score BP input builder", ()
expect(a.bpInTargetPct).not.toBeNull();
});
});
/**
* v1.34 — the weight yardstick builder. The score used to grade weight against
* `22 x height^2` +/- 2 kg, a band no user set and no surface named. The
* builder is the one place the two Health-Score callers resolve it, and its
* whole job is to refuse to hand over anything but a real user target.
*/
describe("buildHealthScoreWeightTarget — the one weight-yardstick resolver", () => {
const profile = {
heightCm: 178,
dateOfBirth: new Date("1985-06-15T00:00:00.000Z"),
gender: "MALE",
};
it("hands over the user's own band when they set one", () => {
const result = buildHealthScoreWeightTarget(profile, {
WEIGHT: { min: 74, max: 78 },
});
expect(result.weightTargetSource).toBe("user");
expect(result.weightTarget).toEqual({ min: 74, max: 78 });
});
it("refuses the evidence-based default that sits under the override", () => {
// `getEffectiveRange` resolves a height-derived WHO band for this profile.
// It is a reference band, not a goal the person chose, so it must never
// reach the score — that substitution IS the defect.
const result = buildHealthScoreWeightTarget(profile, null);
expect(result.weightTargetSource).toBe("none");
expect(result.weightTarget).toBeNull();
});
it("ignores overrides on other metrics", () => {
const result = buildHealthScoreWeightTarget(profile, {
BLOOD_PRESSURE_SYS: { min: 110, max: 130 },
});
expect(result.weightTargetSource).toBe("none");
expect(result.weightTarget).toBeNull();
});
it("returns no target for a profile with no height and no override", () => {
const result = buildHealthScoreWeightTarget(
{ heightCm: null, dateOfBirth: null, gender: null },
null,
);
expect(result.weightTargetSource).toBe("none");
expect(result.weightTarget).toBeNull();
});
it("honours an override even when the profile carries no height", () => {
const result = buildHealthScoreWeightTarget(
{ heightCm: null, dateOfBirth: null, gender: null },
{ WEIGHT: { min: 60, max: 65 } },
);
expect(result.weightTargetSource).toBe("user");
expect(result.weightTarget).toEqual({ min: 60, max: 65 });
});
});
@@ -0,0 +1,259 @@
/**
* F1 — rollup-vs-live golden for the Health Score.
*
* `computeUserHealthScoreFastPath` reads the weight series two ways: per-day
* MEAN buckets off `measurement_rollups` when the WEIGHT type is covered, raw
* `measurements` rows otherwise. The scoring math cannot diverge — there is one
* scorer and both branches call it — but the INPUT SERIES can, and nothing in
* the suite asserted the two branches land on the same number for the same
* underlying readings. The existing fast-path tests exercise each branch alone.
*
* This file encodes one series both ways and pins the equivalence, plus the one
* documented case where the two legitimately differ: with several readings on a
* day, the rollup branch regresses over the day's mean while the live branch
* regresses over every reading. That is a collapse, not a bug — so the second
* suite pins that the rollup branch equals the live branch fed those same daily
* means, which is the exact claim the fast-path's own comment makes.
*/
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
measurement: { findMany: vi.fn() },
measurementRollup: { findMany: vi.fn() },
user: { findUnique: vi.fn() },
moodEntry: { findMany: vi.fn() },
medication: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
},
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
vi.mock("@/lib/rollups/measurement-coverage", () => ({
isFullyCovered: vi.fn(),
probeRollupCoverage: vi.fn(),
}));
import { prisma } from "@/lib/db";
import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage";
import { computeUserHealthScoreFastPath } from "../health-score-fast-path";
const MEASUREMENT_FIND_MANY = prisma.measurement
.findMany as unknown as ReturnType<typeof vi.fn>;
const ROLLUP_FIND_MANY = prisma.measurementRollup
.findMany as unknown as ReturnType<typeof vi.fn>;
const MOOD_FIND_MANY = prisma.moodEntry.findMany as unknown as ReturnType<
typeof vi.fn
>;
const MEDICATION_FIND_MANY = prisma.medication
.findMany as unknown as ReturnType<typeof vi.fn>;
const PROBE = probeRollupCoverage as unknown as ReturnType<typeof vi.fn>;
const NOW = new Date("2026-05-20T12:00:00.000Z");
/** A weight reading in the shared fixture vocabulary. */
interface Reading {
/** Days before `NOW` the reading was taken. */
daysAgo: number;
/** Hour of that day, so several readings can share a day. */
hour: number;
kg: number;
}
function measuredAt(r: Reading): Date {
const d = new Date(NOW.getTime() - r.daysAgo * 24 * 60 * 60 * 1000);
d.setUTCHours(r.hour, 0, 0, 0);
return d;
}
/** UTC midnight of a reading's day — the DAY bucket key. */
function bucketStart(r: Reading): Date {
const d = measuredAt(r);
return new Date(
Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()),
);
}
/** Collapse readings into the per-day MEAN buckets the rollup tier stores. */
function toDayBuckets(readings: Reading[]) {
const byDay = new Map<number, { sum: number; count: number }>();
for (const r of readings) {
const key = bucketStart(r).getTime();
const slot = byDay.get(key) ?? { sum: 0, count: 0 };
slot.sum += r.kg;
slot.count += 1;
byDay.set(key, slot);
}
return [...byDay.entries()]
.sort((a, b) => a[0] - b[0])
.map(([ms, slot]) => ({
bucketStart: new Date(ms),
count: slot.count,
mean: slot.sum / slot.count,
minValue: slot.sum / slot.count,
maxValue: slot.sum / slot.count,
sd: null,
slope: null,
r2: null,
computedAt: NOW,
}));
}
beforeEach(() => {
MEASUREMENT_FIND_MANY.mockReset();
ROLLUP_FIND_MANY.mockReset();
MOOD_FIND_MANY.mockReset();
MEDICATION_FIND_MANY.mockReset();
PROBE.mockReset();
MOOD_FIND_MANY.mockResolvedValue([]);
MEDICATION_FIND_MANY.mockResolvedValue([]);
});
afterEach(() => {
vi.restoreAllMocks();
});
/** Score the readings through the ROLLUP branch (WEIGHT covered). */
async function scoreViaRollup(readings: Reading[]) {
const coverage = new Map<string, boolean>([["WEIGHT", true]]);
PROBE.mockResolvedValue(coverage);
const buckets = toDayBuckets(readings);
// The DAY read and the long-window (WEEK/MONTH/YEAR) read fire in parallel,
// so key the mock on the requested granularity rather than call order.
ROLLUP_FIND_MANY.mockImplementation(
async ({ where }: { where: { granularity: string } }) =>
where.granularity === "DAY" ? buckets : [],
);
// Source attribution still comes off the raw rows (2-column projection),
// then the BP-SYS attribution read.
MEASUREMENT_FIND_MANY.mockResolvedValueOnce(
readings.map((r) => ({ measuredAt: measuredAt(r), source: "MANUAL" })),
).mockResolvedValueOnce([]);
return computeUserHealthScoreFastPath({
userId: "user-rollup",
bpInTargetPct: null,
weightTarget: null,
weightTargetSource: "none",
now: NOW,
coverage,
moodEnabled: true,
});
}
/** Score the readings through the LIVE branch (WEIGHT uncovered). */
async function scoreViaLive(readings: Reading[]) {
const coverage = new Map<string, boolean>([["WEIGHT", false]]);
PROBE.mockResolvedValue(coverage);
MEASUREMENT_FIND_MANY.mockResolvedValueOnce(
readings.map((r) => ({
measuredAt: measuredAt(r),
value: r.kg,
source: "MANUAL",
})),
).mockResolvedValueOnce([]);
return computeUserHealthScoreFastPath({
userId: "user-live",
bpInTargetPct: null,
weightTarget: null,
weightTargetSource: "none",
now: NOW,
coverage,
moodEnabled: true,
});
}
describe("Health Score — rollup / live parity on one weigh-in per day", () => {
// The canonical cadence: one weigh-in a day, the pattern the fast path's own
// comment says the equivalence rests on.
const dailyDecline: Reading[] = [
{ daysAgo: 24, hour: 7, kg: 84.0 },
{ daysAgo: 20, hour: 7, kg: 83.6 },
{ daysAgo: 16, hour: 7, kg: 83.1 },
{ daysAgo: 12, hour: 7, kg: 82.7 },
{ daysAgo: 8, hour: 7, kg: 82.2 },
{ daysAgo: 4, hour: 7, kg: 81.8 },
{ daysAgo: 1, hour: 7, kg: 81.5 },
];
it("produces the same score, band and weight pillar on both branches", async () => {
const rollup = await scoreViaRollup(dailyDecline);
const live = await scoreViaLive(dailyDecline);
expect(rollup).not.toBeNull();
expect(live).not.toBeNull();
expect(rollup?.score).toBe(live?.score);
expect(rollup?.band).toBe(live?.band);
expect(rollup?.components.weight.value).toBe(live?.components.weight.value);
// The weight pillar is the whole score here (no BP, mood or medications),
// so an equal score is only meaningful if the pillar actually resolved.
expect(rollup?.components.weight.value).not.toBeNull();
});
it("agrees on the week-over-week delta too", async () => {
// The delta re-runs the scorer over a 7-day-shifted window, so it exercises
// the branch's series construction a second time on a different slice.
const rollup = await scoreViaRollup(dailyDecline);
const live = await scoreViaLive(dailyDecline);
expect(rollup?.delta).toBe(live?.delta);
});
it("agrees on a rising series as well as a falling one", async () => {
const rise = dailyDecline.map((r) => ({ ...r, kg: 160 - r.kg }));
const rollup = await scoreViaRollup(rise);
const live = await scoreViaLive(rise);
expect(rollup?.score).toBe(live?.score);
expect(rollup?.components.weight.value).toBe(live?.components.weight.value);
});
});
describe("Health Score — rollup / live on an uneven weigh-in cadence", () => {
// Three weigh-ins on the older day, one on the recent day. Per-reading
// regression weights that older day three times over; per-day-mean regression
// gives it one point. This is the shape where the two branches genuinely part
// company, so it is the shape worth pinning.
const uneven: Reading[] = [
{ daysAgo: 20, hour: 6, kg: 85.0 },
{ daysAgo: 20, hour: 12, kg: 84.0 },
{ daysAgo: 20, hour: 20, kg: 83.0 },
{ daysAgo: 2, hour: 7, kg: 83.6 },
];
/** The same readings collapsed to one per day at their daily mean. */
const collapsed: Reading[] = toDayBuckets(uneven).map((b) => ({
daysAgo: Math.round(
(NOW.getTime() - b.bucketStart.getTime()) / (24 * 60 * 60 * 1000),
),
hour: 0,
kg: b.mean,
}));
it("the two branches do differ here — the fixture is not a no-op", async () => {
// Guard the guard: if raw and collapsed ever scored the same for this
// series, the equality below would prove nothing.
const liveOnRaw = await scoreViaLive(uneven);
const liveOnMeans = await scoreViaLive(collapsed);
expect(liveOnRaw?.components.weight.value).not.toBe(
liveOnMeans?.components.weight.value,
);
});
it("the rollup branch equals the live branch fed the same daily means", async () => {
// The whole difference between the branches is the mean-collapse and
// nothing else: replay the collapsed series through the LIVE branch and the
// two agree again. That is the claim the fast path's own comment makes,
// stated as a test rather than left as prose.
const rollup = await scoreViaRollup(uneven);
const liveOnMeans = await scoreViaLive(collapsed);
expect(rollup?.score).toBe(liveOnMeans?.score);
expect(rollup?.components.weight.value).toBe(
liveOnMeans?.components.weight.value,
);
});
});
@@ -3,7 +3,6 @@ import {
coefficientOfVariation,
complianceRate,
computeHealthScore,
defaultWeightTargetFromHeight,
linearRegressionSlope,
moodStability,
weightTrendAlignment,
@@ -170,16 +169,6 @@ describe("complianceRate", () => {
});
});
describe("defaultWeightTargetFromHeight", () => {
it("returns null when height is null", () => {
expect(defaultWeightTargetFromHeight(null)).toBeNull();
});
it("approximates BMI-22 midpoint for 178 cm", () => {
expect(defaultWeightTargetFromHeight(178)).toBeCloseTo(69.7, 1);
});
});
// ── Composite scenarios ──────────────────────────────────────────────
describe("computeHealthScore — strong positive case", () => {
@@ -187,7 +176,8 @@ describe("computeHealthScore — strong positive case", () => {
const input: HealthScoreInput = {
bpInTargetRate: 90,
weightSeriesLast30d: weightSeries([80, 79.5, 79, 78.5, 78]),
weightTargetKg: 78,
weightTarget: { min: 76, max: 80 },
weightTargetSource: "user",
moodEntriesLast30d: moodEntries([4, 4, 5, 4, 5, 4, 5, 4, 4]),
medicationCompliance30: [95, 100, 90],
};
@@ -205,7 +195,8 @@ describe("computeHealthScore — mixed case", () => {
bpInTargetRate: 60,
// Above the band, drifting up — alignment <50.
weightSeriesLast30d: weightSeries([85, 86, 87, 88]),
weightTargetKg: 80,
weightTarget: { min: 78, max: 82 },
weightTargetSource: "user",
moodEntriesLast30d: moodEntries([2, 3, 5, 1, 4, 3]),
medicationCompliance30: [100, 100],
};
@@ -221,7 +212,8 @@ describe("computeHealthScore — poor case", () => {
const input: HealthScoreInput = {
bpInTargetRate: 20,
weightSeriesLast30d: weightSeries([95, 96, 97, 98, 99]),
weightTargetKg: 80,
weightTarget: { min: 78, max: 82 },
weightTargetSource: "user",
moodEntriesLast30d: moodEntries([1, 5, 1, 5, 1, 5]),
medicationCompliance30: [40, 30, 20],
};
@@ -236,7 +228,8 @@ describe("computeHealthScore — null component redistribution", () => {
const input: HealthScoreInput = {
bpInTargetRate: null,
weightSeriesLast30d: weightSeries([80, 80, 80, 80]),
weightTargetKg: 80,
weightTarget: { min: 78, max: 82 },
weightTargetSource: "user",
moodEntriesLast30d: moodEntries([5, 5, 5, 5, 5]),
medicationCompliance30: [100, 100],
};
@@ -254,7 +247,8 @@ describe("computeHealthScore — null component redistribution", () => {
const input: HealthScoreInput = {
bpInTargetRate: null,
weightSeriesLast30d: weightSeries([80, 80, 80]),
weightTargetKg: 80,
weightTarget: { min: 78, max: 82 },
weightTargetSource: "user",
// <5 mood entries → moodStability returns null.
moodEntriesLast30d: moodEntries([4, 4]),
medicationCompliance30: [80],
@@ -273,7 +267,8 @@ describe("computeHealthScore — null component redistribution", () => {
const input: HealthScoreInput = {
bpInTargetRate: null,
weightSeriesLast30d: [],
weightTargetKg: null,
weightTarget: null,
weightTargetSource: "none",
moodEntriesLast30d: [],
medicationCompliance30: [73],
};
@@ -287,7 +282,8 @@ describe("computeHealthScore — null component redistribution", () => {
const input: HealthScoreInput = {
bpInTargetRate: null,
weightSeriesLast30d: [],
weightTargetKg: null,
weightTarget: null,
weightTargetSource: "none",
moodEntriesLast30d: [],
medicationCompliance30: [],
};
@@ -305,7 +301,8 @@ describe("computeHealthScore — determinism", () => {
const input: HealthScoreInput = {
bpInTargetRate: 75,
weightSeriesLast30d: weightSeries([82, 81, 80, 79]),
weightTargetKg: 78,
weightTarget: { min: 76, max: 80 },
weightTargetSource: "user",
moodEntriesLast30d: moodEntries([4, 5, 3, 4, 5, 4]),
medicationCompliance30: [85, 90, 80],
};
@@ -335,7 +332,8 @@ describe("computeHealthScore — delta vs previous week", () => {
const baseInput: HealthScoreInput = {
bpInTargetRate: 80,
weightSeriesLast30d: weightSeries([82, 81, 80]),
weightTargetKg: 80,
weightTarget: { min: 78, max: 82 },
weightTargetSource: "user",
moodEntriesLast30d: moodEntries([5, 4, 5, 4, 5]),
medicationCompliance30: [100, 100],
};
@@ -377,7 +375,8 @@ describe("computeHealthScore — source attribution", () => {
return {
bpInTargetRate: 90,
weightSeriesLast30d: weightSeries([80, 80, 80]),
weightTargetKg: 80,
weightTarget: { min: 78, max: 82 },
weightTargetSource: "user",
moodEntriesLast30d: moodEntries([5, 4, 5, 4, 5]),
medicationCompliance30: [95, 100],
attribution,
@@ -433,7 +432,8 @@ describe("computeHealthScore — source attribution", () => {
const input: HealthScoreInput = {
bpInTargetRate: null,
weightSeriesLast30d: [],
weightTargetKg: null,
weightTarget: null,
weightTargetSource: "none",
// <5 mood entries → moodStability returns null.
moodEntriesLast30d: moodEntries([4, 4]),
medicationCompliance30: [],
@@ -461,7 +461,8 @@ describe("computeHealthScore — source attribution", () => {
const result = computeHealthScore({
bpInTargetRate: 80,
weightSeriesLast30d: weightSeries([80, 80, 80]),
weightTargetKg: 80,
weightTarget: { min: 78, max: 82 },
weightTargetSource: "user",
moodEntriesLast30d: moodEntries([5, 5, 5, 5, 5]),
medicationCompliance30: [100],
});
@@ -480,7 +481,8 @@ describe("computeHealthScore — source attribution", () => {
const result = computeHealthScore({
bpInTargetRate: 90,
weightSeriesLast30d: weightSeries([80, 80, 80]),
weightTargetKg: 80,
weightTarget: { min: 78, max: 82 },
weightTargetSource: "user",
moodEntriesLast30d: moodEntries([5, 5, 5, 5, 5]),
medicationCompliance30: [100],
attribution: {
@@ -494,3 +496,75 @@ describe("computeHealthScore — source attribution", () => {
expect(result.components.bp.asOf).toBe(WINDOW_END);
});
});
/**
* v1.34 — the weight yardstick.
*
* Until this release the pillar graded every account with a height on file
* against `22 x height^2` +/- 2 kg: a band nobody set and no surface named.
* These pin the replacement contract — the band arrives whole from the caller,
* a missing band scores the bare trend, and the result says which of the two
* happened.
*/
describe("computeHealthScore — weight yardstick", () => {
const stable = weightSeries([80, 80, 80, 80]);
function scoreWeightOnly(
series: Array<{ date: string; kg: number }>,
target: { min: number; max: number } | null,
) {
return computeHealthScore({
bpInTargetRate: null,
weightSeriesLast30d: series,
weightTarget: target,
weightTargetSource: target ? "user" : "none",
moodEntriesLast30d: [],
medicationCompliance30: [],
});
}
it("takes the band whole — no expansion of the caller's bounds", () => {
// 80 kg sits OUTSIDE 76-79. A +/- 2 kg widening would pull it in and
// score 100; the band must be used exactly as handed over.
expect(
scoreWeightOnly(stable, { min: 76, max: 79 }).components.weight.value,
).toBe(50);
// The same reading INSIDE the band scores the in-band 100.
expect(
scoreWeightOnly(stable, { min: 78, max: 82 }).components.weight.value,
).toBe(100);
});
it("scores the bare trend when no target is set", () => {
// Stable weight, no target: the trend-only anchor, not the target-aware
// "neither" midpoint of 50, and never a fabricated band.
const result = scoreWeightOnly(stable, null);
expect(result.components.weight.value).toBe(75);
expect(result.components.weight.weight).toBeGreaterThan(0);
});
it("carries the resolved yardstick on the weight component", () => {
const withTarget = scoreWeightOnly(stable, { min: 78, max: 82 });
expect(withTarget.components.weight.targetSource).toBe("user");
expect(withTarget.components.weight.target).toEqual({ min: 78, max: 82 });
const without = scoreWeightOnly(stable, null);
expect(without.components.weight.targetSource).toBe("none");
expect(without.components.weight.target).toBeNull();
});
it("never reports a band when the source is `none`", () => {
// Defensive: a caller that passes a band while declaring no source must
// not have that band surface as though the user had set it.
const result = computeHealthScore({
bpInTargetRate: null,
weightSeriesLast30d: stable,
weightTarget: { min: 78, max: 82 },
weightTargetSource: "none",
moodEntriesLast30d: [],
medicationCompliance30: [],
});
expect(result.components.weight.target).toBeNull();
expect(result.components.weight.targetSource).toBe("none");
});
});
+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.
+42 -2
View File
@@ -146,6 +146,7 @@ function baseUser(
insightsCachedText: null,
insightsCachedAt: null,
dashboardWidgetsJson: null,
thresholdsJson: null,
...overrides,
};
}
@@ -471,7 +472,9 @@ describe("buildDashboardSnapshot — healthScore (warm phase only)", () => {
userId: string;
bpInTargetPct: number | null;
bpGradedScore: number | null;
heightCm: number | null;
weightTarget: { min: number; max: number } | null;
weightTargetSource: string;
moodEnabled: boolean;
coverage: unknown;
};
expect(arg.userId).toBe("user-1");
@@ -480,7 +483,10 @@ describe("buildDashboardSnapshot — healthScore (warm phase only)", () => {
// number. Previously this was last30Days (80).
expect(arg.bpInTargetPct).toBe(77);
expect(arg.bpGradedScore).toBe(88);
expect(arg.heightCm).toBe(180);
// v1.34 — no weight target on this fixture, so the pillar scores the bare
// trend. The BMI-22 band that used to be derived from `heightCm` is gone.
expect(arg.weightTarget).toBeNull();
expect(arg.weightTargetSource).toBe("none");
expect(arg.coverage).toBe(coverageMap);
});
@@ -998,6 +1004,40 @@ describe("buildDashboardSnapshot — module gating (v1.18.0)", () => {
expect(snap.tiles.mood.summary).toBeNull();
// Core tile unaffected.
expect(widget(snap, "weight")!.visible).toBe(true);
// v1.34 — the hero ring's health score drops the mood pillar too. Before
// this the snapshot never resolved the module, so a mood-off account was
// scored on a pillar the Insights card had already dropped.
const scoreCall = computeUserHealthScoreFastPath.mock.calls.at(-1)?.[0] as
{ moodEnabled: boolean } | undefined;
expect(scoreCall?.moodEnabled).toBe(false);
});
it("mood enabled: the health score keeps its mood pillar", async () => {
await buildDashboardSnapshot(fakePrisma, baseUser(), {
modules: () => Promise.resolve(moduleMap()),
});
const scoreCall = computeUserHealthScoreFastPath.mock.calls.at(-1)?.[0] as
{ moodEnabled: boolean } | undefined;
expect(scoreCall?.moodEnabled).toBe(true);
});
it("an explicit weight target reaches the score and the bands", async () => {
const snap = await buildDashboardSnapshot(
fakePrisma,
baseUser({ thresholdsJson: { WEIGHT: { min: 74, max: 78 } } }),
{ modules: () => Promise.resolve(moduleMap()) },
);
const scoreCall = computeUserHealthScoreFastPath.mock.calls.at(-1)?.[0] as
| {
weightTarget: { min: number; max: number } | null;
weightTargetSource: string;
}
| undefined;
expect(scoreCall?.weightTarget).toEqual({ min: 74, max: 78 });
expect(scoreCall?.weightTargetSource).toBe("user");
// The tile / chart band follows the same target.
expect(snap.targetBands.weightRange?.greenMin).toBe(74);
expect(snap.targetBands.weightRange?.greenMax).toBe(78);
});
it("sleep disabled: tile hidden + SLEEP_DURATION stripped from summaries", async () => {
@@ -105,24 +105,28 @@ describe("buildTargetBands — server/client parity", () => {
dateOfBirth: Date | null;
gender: "MALE" | "FEMALE" | null;
heightCm: number | null;
weightTargetOverride: { min: number; max: number } | null;
}> = [
{
name: "male 180cm with DOB",
dateOfBirth: new Date("1985-06-15T00:00:00.000Z"),
gender: "MALE",
heightCm: 180,
weightTargetOverride: null,
},
{
name: "female 165cm with DOB",
dateOfBirth: new Date("1970-01-01T00:00:00.000Z"),
gender: "FEMALE",
heightCm: 165,
weightTargetOverride: null,
},
{
name: "no-profile user (null DOB / gender / height)",
dateOfBirth: null,
gender: null,
heightCm: null,
weightTargetOverride: null,
},
];
@@ -142,17 +146,20 @@ describe("buildTargetBands — server/client parity", () => {
dateOfBirth: new Date("1985-06-15T00:00:00.000Z"),
gender: "OTHER",
heightCm: 180,
weightTargetOverride: null,
});
const unanswered = buildTargetBands({
dateOfBirth: new Date("1985-06-15T00:00:00.000Z"),
gender: null,
heightCm: 180,
weightTargetOverride: null,
});
expect(other).toEqual(unanswered);
const male = buildTargetBands({
dateOfBirth: new Date("1985-06-15T00:00:00.000Z"),
gender: "MALE",
heightCm: 180,
weightTargetOverride: null,
});
expect(other.bodyFatBands).not.toEqual(male.bodyFatBands);
});
@@ -162,6 +169,7 @@ describe("buildTargetBands — server/client parity", () => {
dateOfBirth: null,
gender: null,
heightCm: null,
weightTargetOverride: null,
});
expect(bands.bpTargets).toBeNull();
expect(bands.bpSysRange).toBeNull();
@@ -173,3 +181,63 @@ describe("buildTargetBands — server/client parity", () => {
expect(bands.bodyFatBands.length).toBeGreaterThan(0);
});
});
/**
* v1.34 — an explicit weight target outranks the height-derived WHO band.
* The dashboard used to shade the BMI band for someone who had already
* answered the question on `/targets`, so the answer stayed invisible on
* every surface that mattered.
*/
describe("buildTargetBands — user weight target", () => {
const profile = {
dateOfBirth: new Date("1985-06-15T00:00:00.000Z"),
gender: "MALE" as const,
heightCm: 180,
};
it("replaces the height-derived band with the user's own", () => {
const withTarget = buildTargetBands({
...profile,
weightTargetOverride: { min: 74, max: 78 },
});
expect(withTarget.weightRange?.greenMin).toBe(74);
expect(withTarget.weightRange?.greenMax).toBe(78);
const heightDerived = buildTargetBands({
...profile,
weightTargetOverride: null,
});
expect(heightDerived.weightRange).not.toEqual(withTarget.weightRange);
// The chart bands follow the same range, so tile and chart agree.
expect(withTarget.weightBands).not.toEqual(heightDerived.weightBands);
const green = withTarget.weightBands?.find(
(b) => b.color === "var(--success)",
);
expect(green).toEqual(expect.objectContaining({ min: 74, max: 78 }));
});
it("honours a target even for a profile with no height on file", () => {
const bands = buildTargetBands({
dateOfBirth: null,
gender: null,
heightCm: null,
weightTargetOverride: { min: 60, max: 65 },
});
expect(bands.weightRange?.greenMin).toBe(60);
expect(bands.weightBands?.length).toBeGreaterThan(0);
});
it("leaves every non-weight band untouched", () => {
const withTarget = buildTargetBands({
...profile,
weightTargetOverride: { min: 74, max: 78 },
});
const without = buildTargetBands({
...profile,
weightTargetOverride: null,
});
expect(withTarget.bpTargets).toEqual(without.bpTargets);
expect(withTarget.pulseBands).toEqual(without.pulseBands);
expect(withTarget.bodyFatBands).toEqual(without.bodyFatBands);
});
});
+63 -1
View File
@@ -79,6 +79,7 @@ function baseSnapshot(
dateOfBirth: null,
gender: null,
heightCm: null,
weightTargetOverride: null,
}),
tiles: {
summaries: {},
@@ -395,6 +396,7 @@ describe("rung 4 — weightDrift", () => {
avg7: number | null,
avg30: number | null,
heightCm: number | null = 180,
weightTargetOverride: { min: number; max: number } | null = null,
): DashboardSnapshot {
const snap = baseSnapshot({
tiles: {
@@ -408,6 +410,14 @@ describe("rung 4 — weightDrift", () => {
},
});
snap.user.heightCm = heightCm;
// v1.34 — the rung reads the snapshot's RESOLVED band, so the fixture
// resolves it the same way the builder does.
snap.targetBands = buildTargetBands({
dateOfBirth: null,
gender: null,
heightCm,
weightTargetOverride,
});
return snap;
}
@@ -429,7 +439,7 @@ describe("rung 4 — weightDrift", () => {
expect(verdict.variant).toBe("allQuiet");
});
it("skips entirely when no height is stored (no derivable range)", () => {
it("skips entirely when no height and no target (no band at all)", () => {
const verdict = resolveDashboardVerdict(
weightSnapshot(GREEN_MAX_180 + 5, GREEN_MAX_180 - 1, null),
NOW,
@@ -729,3 +739,55 @@ describe("rung 9 — allQuiet + full-ladder precedence", () => {
expect(verdict.values).toEqual({ points: 15 });
});
});
/**
* v1.34 — the weight-drift rung measures against the band the user actually
* reads. It used to recompute the height-derived BMI band inline, so it kept
* nudging about drift away from a band an explicit target had replaced.
*/
describe("rung 4 — weightDrift honours an explicit target", () => {
function driftSnapshot(
avg7: number,
avg30: number,
weightTargetOverride: { min: number; max: number } | null,
): DashboardSnapshot {
const snap = baseSnapshot({
tiles: {
summaries: {
WEIGHT: summary({ latest: avg7, avg7, avg30, count: 20 }),
},
lastSeenByType: {
WEIGHT: { lastSeenAt: isoHoursAgo(2), daysAgo: 0 },
},
mood: { summary: null, entries: [] },
},
});
snap.user.heightCm = 180;
snap.targetBands = buildTargetBands({
dateOfBirth: null,
gender: null,
heightCm: 180,
weightTargetOverride,
});
return snap;
}
it("stays quiet inside the user's own band even when the BMI band would fire", () => {
// 82 / 81 kg is above the height-derived green max (80.68) and drifting
// away from it, but sits inside a self-set 78-85 kg band.
expect(
resolveDashboardVerdict(driftSnapshot(82, 81, null), NOW).variant,
).toBe("weightDrift");
expect(
resolveDashboardVerdict(driftSnapshot(82, 81, { min: 78, max: 85 }), NOW)
.variant,
).toBe("allQuiet");
});
it("fires on drift away from the user's own band", () => {
expect(
resolveDashboardVerdict(driftSnapshot(72, 74, { min: 78, max: 85 }), NOW)
.variant,
).toBe("weightDrift");
});
});
+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",
@@ -18,7 +18,17 @@ import {
__test__,
detectPersonalRecordsForUser,
} from "../pr-detection-worker";
import { getPRDirection, isPRTrackable } from "../pr-direction";
import {
getPRDirection,
isPRTrackable,
type PRDirectionContext,
} from "../pr-direction";
/**
* v1.34 — the fixtures below carry no weight target, so the resolver answers
* for an account that has set no goal (the historical behaviour).
*/
const NO_GOAL: PRDirectionContext = { weightGoal: null };
import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping";
import { measurementTypeEnum } from "@/lib/validations/measurement";
import {
@@ -147,6 +157,9 @@ function makeFakePrisma(state: {
// the existing day-sum assertions stay valid.
timezone: "UTC",
sourcePriorityJson: null,
// v1.34 — no weight target on file, so weight / BMI keep their
// historical null PR direction throughout this file's fixtures.
thresholdsJson: null,
})),
},
measurement: {
@@ -688,7 +701,7 @@ describe("detectPersonalRecordsForUser — flags + drift guard", () => {
const result = await detectPersonalRecordsForUser(USER, { prisma });
const trackable = measurementTypeEnum.options.filter((t) =>
isPRTrackable(t as MeasurementType),
isPRTrackable(t as MeasurementType, NO_GOAL),
);
expect(result.scanned).toBeGreaterThanOrEqual(trackable.length);
});
@@ -783,7 +796,7 @@ describe("detectPersonalRecordsForUser — REG-9 cumulative day-sum (v1.4.46)",
// direction lookup short-circuits null-direction types upstream;
// we filter to the PR-trackable subset for the loop.
const trackableCumulative = Array.from(CUMULATIVE_HK_TYPES).filter((t) =>
isPRTrackable(t),
isPRTrackable(t, NO_GOAL),
);
expect(trackableCumulative.length).toBeGreaterThan(0);
@@ -843,7 +856,9 @@ describe("detectPersonalRecordsForUser — REG-9 cumulative day-sum (v1.4.46)",
// falls is the record) the winning day is the smallest day-sum, so
// the seed days at 100 hold the record over the 500 day.
const expectedValue =
getPRDirection(type) === PersonalRecordDirection.MIN ? 100 : 500;
getPRDirection(type, NO_GOAL) === PersonalRecordDirection.MIN
? 100
: 500;
expect(pr?.value, `${type} day-sum`).toBe(expectedValue);
}
});
@@ -1,18 +1,30 @@
import { describe, expect, it } from "vitest";
import { getPRDirection, isPRTrackable } from "../pr-direction";
import {
getPRDirection,
isPRTrackable,
resolveWeightGoalDirection,
type PRDirectionContext,
} from "../pr-direction";
import { measurementTypeEnum } from "@/lib/validations/measurement";
import {
PersonalRecordDirection,
type MeasurementType,
} from "@/generated/prisma/client";
/**
* v1.34 — the resolver takes the user-goal context. `NO_GOAL` is the account
* with no weight target on file, which is the historical behaviour every
* assertion below was written against.
*/
const NO_GOAL: PRDirectionContext = { weightGoal: null };
describe("getPRDirection", () => {
// Drift guard — every canonical MeasurementType MUST land in exactly
// one branch of the switch so the future detection worker never
// encounters an "I have a sample but no direction" race.
it("returns either MAX, MIN, or null for every MeasurementType", () => {
for (const type of measurementTypeEnum.options) {
const result = getPRDirection(type as MeasurementType);
const result = getPRDirection(type as MeasurementType, NO_GOAL);
expect(
result === null ||
result === PersonalRecordDirection.MAX ||
@@ -36,7 +48,7 @@ describe("getPRDirection", () => {
"TIME_IN_DAYLIGHT",
];
for (const t of maxMetrics) {
expect(getPRDirection(t)).toBe(PersonalRecordDirection.MAX);
expect(getPRDirection(t, NO_GOAL)).toBe(PersonalRecordDirection.MAX);
}
});
@@ -52,11 +64,11 @@ describe("getPRDirection", () => {
"AUDIO_EXPOSURE_HEADPHONE",
];
for (const t of minMetrics) {
expect(getPRDirection(t)).toBe(PersonalRecordDirection.MIN);
expect(getPRDirection(t, NO_GOAL)).toBe(PersonalRecordDirection.MIN);
}
});
it("returns null for BP, glucose, weight, sleep, and homeostatic vitals", () => {
it("returns null for BP, glucose, sleep, and homeostatic vitals", () => {
const noPRMetrics: MeasurementType[] = [
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
@@ -70,7 +82,7 @@ describe("getPRDirection", () => {
"FAT_FREE_MASS",
];
for (const t of noPRMetrics) {
expect(getPRDirection(t)).toBeNull();
expect(getPRDirection(t, NO_GOAL)).toBeNull();
}
});
});
@@ -79,7 +91,74 @@ describe("isPRTrackable", () => {
it("returns true exactly when getPRDirection is non-null", () => {
for (const type of measurementTypeEnum.options) {
const t = type as MeasurementType;
expect(isPRTrackable(t)).toBe(getPRDirection(t) !== null);
expect(isPRTrackable(t, NO_GOAL)).toBe(
getPRDirection(t, NO_GOAL) !== null,
);
}
});
});
/**
* v1.34 — weight + BMI stopped deferring on `User.thresholdsJson`. With no
* target they answer `null` exactly as they have since v1.5.5; with one they
* answer the direction the user's own goal points in.
*/
describe("getPRDirection — user weight goal", () => {
const goalMetrics: MeasurementType[] = ["WEIGHT", "BODY_MASS_INDEX"];
it("stays null when no weight target is set", () => {
for (const t of goalMetrics) {
expect(getPRDirection(t, { weightGoal: null })).toBeNull();
expect(isPRTrackable(t, { weightGoal: null })).toBe(false);
}
});
it("reads MIN when the user is working down toward their target", () => {
for (const t of goalMetrics) {
expect(getPRDirection(t, { weightGoal: "lower" })).toBe(
PersonalRecordDirection.MIN,
);
}
});
it("reads MAX when the user is working up toward their target", () => {
for (const t of goalMetrics) {
expect(getPRDirection(t, { weightGoal: "higher" })).toBe(
PersonalRecordDirection.MAX,
);
}
});
it("leaves every other metric untouched by the goal context", () => {
for (const type of measurementTypeEnum.options) {
const t = type as MeasurementType;
if (goalMetrics.includes(t)) continue;
expect(getPRDirection(t, { weightGoal: "lower" })).toBe(
getPRDirection(t, { weightGoal: null }),
);
}
});
});
describe("resolveWeightGoalDirection", () => {
const band = { min: 70, max: 75 };
it("returns null without a target — a goal is never inferred", () => {
expect(resolveWeightGoalDirection(null, 90)).toBeNull();
});
it("returns null without a reading to place against the band", () => {
expect(resolveWeightGoalDirection(band, null)).toBeNull();
});
it("returns null inside the band — the goal is already met", () => {
expect(resolveWeightGoalDirection(band, 70)).toBeNull();
expect(resolveWeightGoalDirection(band, 72.5)).toBeNull();
expect(resolveWeightGoalDirection(band, 75)).toBeNull();
});
it("points down above the band and up below it", () => {
expect(resolveWeightGoalDirection(band, 82)).toBe("lower");
expect(resolveWeightGoalDirection(band, 64)).toBe("higher");
});
});
@@ -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;
}
@@ -20,6 +20,7 @@ describe("target section builders", () => {
gender: null,
timezone: TZ,
now: NOW,
weightTargetOverride: null,
});
expect(result.targets.map((target) => target.type)).toEqual([
@@ -163,3 +164,39 @@ describe("target section builders", () => {
});
});
});
/**
* v1.34 — the weight card grades against the user's own target when one is set,
* so the `/targets` reference panel and the weight chart above it name the same
* band. The panel used to show the WHO BMI band unconditionally.
*/
describe("buildVitalTargets — user weight target", () => {
const base = {
recentMeasurements: [],
latestByType: {},
average30ByType: {},
heightCm: 178,
age: 40,
gender: null,
timezone: TZ,
now: NOW,
};
it("shows the WHO BMI band when no target is set", () => {
const weight = buildVitalTargets({
...base,
weightTargetOverride: null,
}).targets.find((t) => t.type === "WEIGHT");
expect(weight?.source).toBe("WHO BMI");
expect(weight?.range).not.toEqual({ min: 74, max: 78 });
});
it("shows the user's own band, labelled as theirs, when one is set", () => {
const weight = buildVitalTargets({
...base,
weightTargetOverride: { min: 74, max: 78 },
}).targets.find((t) => t.type === "WEIGHT");
expect(weight?.range).toEqual({ min: 74, max: 78 });
expect(weight?.source).toBe("Custom");
});
});
+8 -1
View File
@@ -16,7 +16,10 @@ import { SCHEDULE_COMPLIANCE_SELECT } from "@/lib/analytics/compliance";
import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
import pLimit from "p-limit";
import type { MeasurementType } from "@/generated/prisma/client";
import type { ThresholdOverridesJson } from "@/lib/analytics/effective-range";
import {
resolveWeightTargetOverride,
type ThresholdOverridesJson,
} from "@/lib/analytics/effective-range";
import { resolveGlucoseUnit } from "@/lib/glucose";
import { toProfileSex } from "@/lib/profile/sex";
import {
@@ -264,6 +267,10 @@ export async function buildTargetsResponse(user: AuthedUser) {
gender,
timezone,
now,
// v1.34 — the weight card grades against the user's OWN target when they
// set one, so the reference panel and the weight chart above it name the
// same band.
weightTargetOverride: resolveWeightTargetOverride(dbUser?.thresholdsJson),
});
const sleepTarget = buildSleepTarget({
sleepStageRows,
+14 -2
View File
@@ -47,6 +47,13 @@ interface VitalTargetsInput {
gender: ProfileSex;
timezone: string;
now: Date;
/**
* v1.34 — the user's own weight band from `User.thresholdsJson`, in kg.
* When present it replaces the WHO BMI band on the weight card and the
* card's source reads "Custom", matching how the glucose builder has
* always treated a user override. `null` keeps the height-derived band.
*/
weightTargetOverride: { min: number; max: number } | null;
}
export interface VitalTargetsResult {
@@ -140,6 +147,7 @@ export function buildVitalTargets({
gender,
timezone,
now,
weightTargetOverride,
}: VitalTargetsInput): VitalTargetsResult {
const targets: TargetItem[] = [];
const consistencyClock = { timezone, now };
@@ -148,7 +156,11 @@ export function buildVitalTargets({
// answers a null with a neutral value, never with one of the two sides.
const referenceSex = binaryReferenceSex(gender);
const weightRange = heightCm ? getWeightRange(heightCm) : null;
// v1.34 — a user-set target outranks the WHO BMI band. The BMI
// classification below stays WHO-derived either way: it names which BMI
// category a reading falls in, which is a reference fact and not a goal.
const weightRange =
weightTargetOverride ?? (heightCm ? getWeightRange(heightCm) : null);
let weightClassification: TargetItem["classification"] = null;
if (latestByType.WEIGHT != null && heightCm) {
const heightM = heightCm / 100;
@@ -169,7 +181,7 @@ export function buildVitalTargets({
unit: "kg",
range: weightRange,
classification: weightClassification,
source: "WHO BMI",
source: weightTargetOverride ? "Custom" : "WHO BMI",
...rollupConsistency({
events: recentMeasurements.filter(
(measurement) => measurement.type === "WEIGHT",
+10
View File
@@ -151,6 +151,16 @@ export interface InsightsAnalyticsData {
weight: number;
source?: "manual" | "withings" | "appleHealth" | "mixed" | "none";
asOf?: string;
/**
* v1.34 — which yardstick graded this row. `"user"` when the account
* has set an explicit weight target (`target` then carries that band
* in kg), `"none"` when the pillar scored the bare trend against no
* target at all. The card renders one disclosure line off this pair;
* until it existed the score graded weight against a BMI-22 band
* nobody chose and no surface named.
*/
targetSource?: "user" | "none";
target?: { min: number; max: number } | null;
};
mood: {
value: number | null;
@@ -180,8 +180,10 @@ describe("PR detection — drift guard (real DB)", () => {
// either the worker or the suppress branch via `getPRDirection`).
const result = await detectPersonalRecordsForUser(TEST_USER_ID);
// v1.34 — the seeded user carries no weight target, so weight / BMI keep
// their null direction and stay out of the trackable set.
const trackable = measurementTypeEnum.options.filter((t) =>
isPRTrackable(t as MeasurementType),
isPRTrackable(t as MeasurementType, { weightGoal: null }),
);
expect(result.scanned).toBeGreaterThanOrEqual(trackable.length);
});