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} - -