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