mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
merge: s4-yardstick into release/v1.34.0
This commit is contained in:
commit
b402be221d
39 files changed
+1605
-215
No files matched your search
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user