feat(score): say which yardstick graded the weight row

The weight pillar has been graded against something for two years and
never said what. The score card's weight row now carries one line: the
user's own target band when they set one, or a plain statement that the
row scored the trend with no target at all. The band rides the analytics
DTO in canonical kg and the page formats it into the reader's display
unit, so an imperial account reads pounds.

The weight sub-page shades the same yardstick: its chart bands follow an
explicit target when one exists and fall back to the height-derived band
only when it does not.
This commit is contained in:
Marc-André Bombeck committed 2026-07-27 23:00:26 +02:00
1 parent b1b76ab68a
commit b0862c0902
11 files changed
+181 -39

No files matched your search

+4
View File
@@ -2234,6 +2234,10 @@
"componentWeight": "Gewicht",
"componentMood": "Stimmung",
"componentCompliance": "Therapietreue",
"weightTarget": {
"user": "Gemessen an deinem Ziel, {range}.",
"none": "Nur am Verlauf gemessen, kein Ziel gesetzt."
},
"deltaExplainer": {
"title": "So wird die Veränderung berechnet",
"description": "Veränderung gegenüber letzter Woche: {delta}",
+4
View File
@@ -2234,6 +2234,10 @@
"componentWeight": "Weight",
"componentMood": "Mood",
"componentCompliance": "Adherence",
"weightTarget": {
"user": "Graded against your target, {range}.",
"none": "Graded on the trend alone, no target set."
},
"deltaExplainer": {
"title": "How the delta is computed",
"description": "Change vs last week: {delta}",
+4
View File
@@ -2234,6 +2234,10 @@
"componentWeight": "Peso",
"componentMood": "Estado de ánimo",
"componentCompliance": "Cumplimiento",
"weightTarget": {
"user": "Evaluado con tu objetivo, {range}.",
"none": "Evaluado solo por la tendencia, sin objetivo definido."
},
"deltaExplainer": {
"title": "Cómo se calcula el cambio",
"description": "Cambio respecto a la semana pasada: {delta}",
+4
View File
@@ -2234,6 +2234,10 @@
"componentWeight": "Poids",
"componentMood": "Humeur",
"componentCompliance": "Observance",
"weightTarget": {
"user": "Évalué par rapport à ton objectif, {range}.",
"none": "Évalué sur la tendance seule, aucun objectif défini."
},
"deltaExplainer": {
"title": "Comment l'écart est calculé",
"description": "Écart par rapport à la semaine dernière : {delta}",
+4
View File
@@ -2234,6 +2234,10 @@
"componentWeight": "Peso",
"componentMood": "Umore",
"componentCompliance": "Aderenza",
"weightTarget": {
"user": "Valutato rispetto al tuo obiettivo, {range}.",
"none": "Valutato solo sull'andamento, nessun obiettivo impostato."
},
"deltaExplainer": {
"title": "Come si calcola la variazione",
"description": "Variazione rispetto alla settimana scorsa: {delta}",
+4
View File
@@ -2234,6 +2234,10 @@
"componentWeight": "Waga",
"componentMood": "Nastrój",
"componentCompliance": "Przestrzeganie",
"weightTarget": {
"user": "Oceniane względem Twojego celu, {range}.",
"none": "Oceniane tylko na podstawie trendu, brak ustawionego celu."
},
"deltaExplainer": {
"title": "Jak liczona jest ta zmiana",
"description": "Zmiana względem ubiegłego tygodnia: {delta}",
+24
View File
@@ -7,6 +7,7 @@ import Link from "next/link";
import { SlidersHorizontal, TrendingUp } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
@@ -344,6 +345,27 @@ export default function InsightsPageClient() {
const heroTension = snapshotHealthScore?.tension ?? null;
const heroReturnToBand = snapshotHealthScore?.returnToBand ?? null;
// v1.34 — the weight pillar's yardstick, disclosed on the score card. The
// server sends the band in canonical kg; the reader's display unit is a
// client fact, so the page formats the label and the card renders the line.
// A payload without `targetSource` (an older cached body) discloses nothing
// rather than guessing at one.
const unitDisplay = useUnitDisplay();
const weightComponent = analytics?.healthScore?.components.weight;
const weightTargetSource = weightComponent?.targetSource ?? null;
const weightTargetBand = weightComponent?.target ?? null;
const heroWeightYardstick = weightTargetSource
? {
source: weightTargetSource,
targetLabel: weightTargetBand
? `${unitDisplay.toDisplay("WEIGHT", weightTargetBand.min)}–${unitDisplay.toDisplay(
"WEIGHT",
weightTargetBand.max,
)} ${unitDisplay.unitFor("WEIGHT")}`
: null,
}
: null;
// v1.12.6 — the page owns the ONE overview derived batch. The wellness
// strip (above the briefing) and the vitals grid (below it) both read this
// single query instance, so the wellness lift adds no second request.
@@ -572,6 +594,8 @@ export default function InsightsPageClient() {
// forwards them to the score card; null leaves the card quiet.
tension={heroTension}
returnToBand={heroReturnToBand}
// v1.34 — name the yardstick the weight pillar was graded against.
weightYardstick={heroWeightYardstick}
/>
{/* v1.15.18 — the inline "Anpassen" toggle was removed. Customising the
+49 -9
View File
@@ -1,9 +1,12 @@
"use client";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { Scale } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
import { useTranslations } from "@/lib/i18n/context";
@@ -19,16 +22,22 @@ import { MetricCorrelationCard } from "@/components/insights/metric-correlation-
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { buildWeightBandsFromHeight } from "@/lib/analytics/value-bands";
import {
buildBandsFromTrafficRange,
buildWeightBandsFromHeight,
type TrafficRange,
} from "@/lib/analytics/value-bands";
/**
* v1.4.25 W4 — `/insights/weight`.
*
* Routed Weight sub-page. Renders the weight chart with the user's
* height-derived green/orange/red bands plus the per-section AI
* assessment. The chart-cog (`chartKey="weight"`) lets the user toggle
* trend lines + comparison overlay independently from the dashboard
* weight card.
* Routed Weight sub-page. Renders the weight chart with green/orange/red
* bands plus the per-section AI assessment. The chart-cog
* (`chartKey="weight"`) lets the user toggle trend lines + comparison
* overlay independently from the dashboard weight card.
*
* v1.34 — the bands follow the user's own weight target when one is set,
* and fall back to the height-derived WHO band only when it is not.
*
* v1.4.28 R3d (BK-F-H1 + BK-F-M1) — analytics fetch + empty-state
* branch now consume `useInsightsAnalytics()` + `<MetricEmptyState>`.
@@ -42,6 +51,22 @@ export default function InsightsGewichtPage() {
const { data: analytics, isEmpty } = useInsightsAnalytics("WEIGHT");
const weightSummary = analytics?.summaries?.WEIGHT ?? null;
// v1.34 — the user's own weight target, read through the shared
// `queryKeys.userThresholds()` cell the targets editor also subscribes to, so
// editing the target repaints this chart's band without a reload.
const { data: thresholds } = useQuery({
queryKey: queryKeys.userThresholds(),
queryFn: async () => {
return apiGet<{
effective: Record<
string,
{ range: TrafficRange | null; isOverride: boolean }
>;
}>("/api/user/thresholds");
},
enabled: isAuthenticated,
});
// v1.32.26 — resolve the weight display unit + scale from the user's
// preference (kg for metric, lb for imperial). This page inlines the shell
// rather than riding the scaffold, so it converts the stat strip + chart
@@ -106,12 +131,27 @@ export default function InsightsGewichtPage() {
);
}
const weightBands = user?.heightCm
? buildWeightBandsFromHeight(user.heightCm, {
// v1.34 — the shaded zone follows the user's OWN target when they set one.
// The page used to shade the height-derived WHO band unconditionally, so
// someone who had entered a target on `/targets` kept reading a chart that
// ignored the answer. `/api/user/thresholds` already resolves the override
// (with its orange wings) through `getEffectiveRange`, so the page reads the
// resolved band rather than re-deriving one. No target and no height → no
// bands, unchanged.
const overrideRange = thresholds?.effective?.WEIGHT?.isOverride
? (thresholds.effective.WEIGHT.range ?? null)
: null;
const weightBands = overrideRange
? buildBandsFromTrafficRange(overrideRange, {
lowerBound: 30,
upperBound: 250,
})
: undefined;
: user?.heightCm
? buildWeightBandsFromHeight(user.heightCm, {
lowerBound: 30,
upperBound: 250,
})
: undefined;
// The bands are kg bounds → scale into the display unit so the shaded zone
// tracks the converted line (factor-only; weight carries no offset).
const displayBands =
+66 -30
View File
@@ -22,7 +22,8 @@ import { HealthScoreDeltaExplainer } from "./health-score-delta-explainer";
* and stacks below the title block on `<lg`. Surfaces:
* - the composite 0..100 score with a band-coloured number
* - "vs last week" delta line with arrow + percentage
* - 4 component rows (BP / Weight / Mood / Compliance) with sub-bars
* - 4 component rows (BP / Weight / Mood / Compliance) with sub-bars,
* the weight row naming the yardstick it was graded against
* - "Indicative — not a clinical assessment" disclaimer
* - "Ask the Coach" button that opens the B2 drawer with a prefill
* ("Why is my health score X out of 100?") so the user can drill
@@ -128,6 +129,20 @@ export interface HealthScoreCardProps {
/** Days the metric has now sat back inside its range. */
daysInside: number;
} | null;
/**
* v1.34 — the weight row's yardstick, disclosed.
*
* `"user"` renders "vs your target 64-70 kg" (the parent formats
* `targetLabel` in the reader's display unit); `"none"` renders the
* trend-only line. The score used to grade weight against a BMI-22 band
* the user never set, and no surface said so — that silence is what this
* line ends. Omitted only by callers that render no weight pillar.
*/
weightYardstick?: {
source: "user" | "none";
/** Already unit-formatted band, e.g. "64-70 kg". Null when source is "none". */
targetLabel: string | null;
} | null;
}
type ComponentKey = keyof HealthScoreCardProps["components"];
@@ -212,6 +227,7 @@ export function HealthScoreCard({
restModeActive = false,
tension = null,
returnToBand = null,
weightYardstick = null,
}: HealthScoreCardProps) {
const { t, locale } = useTranslations();
// The asOf timestamps render under the source pill as a tooltip
@@ -539,38 +555,58 @@ export function HealthScoreCard({
key={key}
data-slot="health-score-card-component-row"
data-component={key}
className="flex items-center gap-2 text-xs"
className="text-xs"
>
{/* v1.4.25 W3 — widened label column from w-16 (64px) to
w-24 (96px) so the longest German label
("Einnahmetreue" — 13 chars at 11px) sits inside the
column without spilling into the bar/value chip. */}
<span className="text-muted-foreground w-24 shrink-0 truncate">
{label}
</span>
<div
className="bg-muted/50 h-1.5 flex-1 overflow-hidden rounded-full"
aria-hidden="true"
>
<div className="flex items-center gap-2">
{/* v1.4.25 W3 — widened label column from w-16 (64px) to
w-24 (96px) so the longest German label
("Einnahmetreue" — 13 chars at 11px) sits inside the
column without spilling into the bar/value chip. */}
<span className="text-muted-foreground w-24 shrink-0 truncate">
{label}
</span>
<div
className={cn(
"h-full",
value === null ? "bg-muted" : BAND_PROGRESS_CLASS[band],
)}
style={{
width:
value === null
? "0%"
: `${Math.max(0, Math.min(100, value))}%`,
}}
/>
className="bg-muted/50 h-1.5 flex-1 overflow-hidden rounded-full"
aria-hidden="true"
>
<div
className={cn(
"h-full",
value === null ? "bg-muted" : BAND_PROGRESS_CLASS[band],
)}
style={{
width:
value === null
? "0%"
: `${Math.max(0, Math.min(100, value))}%`,
}}
/>
</div>
<span
data-slot="health-score-card-component-value"
className="text-foreground w-8 shrink-0 text-right tabular-nums"
>
{value === null ? "—" : Math.round(value)}
</span>
</div>
<span
data-slot="health-score-card-component-value"
className="text-foreground w-8 shrink-0 text-right tabular-nums"
>
{value === null ? "—" : Math.round(value)}
</span>
{/* v1.34 — the weight row names its yardstick. Either the user's
own target band or an explicit "trend only, no target set".
Indented to the label column so it reads as that row's
caption rather than a new row. */}
{key === "weight" && weightYardstick && value !== null && (
<p
data-slot="health-score-card-weight-yardstick"
data-target-source={weightYardstick.source}
className="text-muted-foreground mt-1 pl-[6.5rem] leading-snug"
>
{weightYardstick.source === "user" &&
weightYardstick.targetLabel
? t("insights.healthScore.weightTarget.user", {
range: weightYardstick.targetLabel,
})
: t("insights.healthScore.weightTarget.none")}
</p>
)}
</li>
))}
</ul>
+8
View File
@@ -95,6 +95,12 @@ interface HeroStripProps {
*/
restMode?: { active: boolean } | null;
} | null;
/**
* v1.34 — the weight pillar's yardstick, already resolved into the reader's
* display unit by the page (the hero carries no unit context of its own).
* Forwarded verbatim to the score card, which renders the disclosure line.
*/
weightYardstick?: HealthScoreCardProps["weightYardstick"];
/**
* v1.21.2 (A5) — Tension Verdict, server-resolved + locale-agnostic. `band`
* is the readiness composite's band; `positive` / `negative` carry the
@@ -188,6 +194,7 @@ export function HeroStrip({
noProviderStale = false,
tension = null,
returnToBand = null,
weightYardstick = null,
}: HeroStripProps) {
const { t } = useTranslations();
const fmt = useFormatters();
@@ -412,6 +419,7 @@ export function HeroStrip({
restModeActive={healthScore.restMode?.active ?? false}
tension={tensionProp}
returnToBand={returnToBandProp}
weightYardstick={weightYardstick}
/>
)}
</div>
+10
View File
@@ -151,6 +151,16 @@ export interface InsightsAnalyticsData {
weight: number;
source?: "manual" | "withings" | "appleHealth" | "mixed" | "none";
asOf?: string;
/**
* v1.34 — which yardstick graded this row. `"user"` when the account
* has set an explicit weight target (`target` then carries that band
* in kg), `"none"` when the pillar scored the bare trend against no
* target at all. The card renders one disclosure line off this pair;
* until it existed the score graded weight against a BMI-22 band
* nobody chose and no surface named.
*/
targetSource?: "user" | "none";
target?: { min: number; max: number } | null;
};
mood: {
value: number | null;