diff --git a/messages/de.json b/messages/de.json index 7975bedc1..c2c58573e 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2665,6 +2665,10 @@ "SIX_MINUTE_WALK_BAND": { "method": "Die von deinem Gerät GESCHÄTZTE 6-Minuten-Gehstrecke, eingeordnet gegen die Referenz von Enright & Sherrill für Alter, Größe, Gewicht und Geschlecht, als Prozent des Vorhersagewerts. Die Strecke ist die Schätzung des Geräts — hier nie neu berechnet. Ohne Alter, Größe, Gewicht und Geschlecht wird der Prozentwert ausgeblendet und nur Strecke und Trend gezeigt.", "caveat": "Eine gegen eine veröffentlichte Referenz neu eingeordnete Schätzung — ein Hinweis zur funktionellen Kapazität, kein klinischer 6-Minuten-Gehtest und keine Diagnose. Die Referenz wurde für Alter 40–80 erstellt; außerhalb dieses Bereichs ist der Vorhersagewert eine Extrapolation." + }, + "TRAJECTORY": { + "method": "Eine Ausgleichsgerade (kleinste Quadrate), an deine jüngsten Tageswerte angepasst und über einen kurzen Zeitraum fortgeschrieben — mit dem klassischen Vorhersageintervall, das umso breiter wird, je weiter es reicht. Wird nur berechnet, wenn der jüngste Trend deutlich genug und deine Datenreihe lang und aktuell genug ist; sonst wird keine Linie gezeichnet.", + "caveat": "Eine Fortschreibung deines eigenen jüngsten Trends — keine Vorhersage, kein Ziel und keine klinische Prognose. Sie gilt nur, solange das jüngste Muster anhält; das schattierte Band zeigt, wie schnell diese Sicherheit nachlässt." } }, "scores": { @@ -2685,6 +2689,13 @@ "firedHeadline": "{count} deiner Vitalwerte liegen heute außerhalb ihres persönlichen Bereichs.", "factors": "Mögliche Faktoren — nie eine Ursache: ein hartes Training, schlechter Schlaf, Alkohol, Höhe, Stress oder Krankheit. Das ist ein Hinweis aus deinen eigenen Baselines, keine Diagnose.", "building": "Deine persönlichen Baselines entstehen — erfasse ein paar Tage mehr, dann erscheint dies." + }, + "trajectory": { + "cardTitle": "Kurzfrist-Projektion", + "headline": "Wenn dieses Muster anhält — die nächsten {days} Tage", + "range": "Projiziert auf rund {low}–{high} {unit} in {days} Tagen.", + "caveat": "Eine Fortschreibung deines eigenen jüngsten Trends, keine Vorhersage. Das schattierte Band wird breiter und zeigt, wie schnell die Unsicherheit wächst.", + "insufficient": "Noch kein klar genuger Trend für eine Projektion — erfasse weiter, dann erscheint eine Projektion, sobald das jüngste Muster deutlich genug ist." } }, "cardioFitness": { diff --git a/messages/en.json b/messages/en.json index ad4fa6bc3..ed168d31f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2665,6 +2665,10 @@ "SIX_MINUTE_WALK_BAND": { "method": "Your device's ESTIMATED six-minute-walk distance placed against the Enright & Sherrill reference for your age, height, weight and sex, as a percent of predicted. The distance is the device's estimate — never recomputed here. Without your age, height, weight and sex the percent is hidden and only the distance and trend are shown.", "caveat": "An estimate re-framed against a published reference — a functional-capacity awareness signal, not a clinical 6-minute-walk test or a diagnosis. The reference was established for ages 40–80; outside that range the predicted value is an extrapolation." + }, + "TRAJECTORY": { + "method": "A least-squares line fitted to your recent daily values and projected forward over a short horizon, with the textbook prediction interval that widens the further it reaches. Computed only when the recent trend is strong enough and your history is long and current enough; otherwise no line is drawn.", + "caveat": "A projection of your own recent trend — not a prediction, a target, or a clinical forecast. It holds only if the recent pattern continues; the shaded band shows how quickly that certainty fades." } }, "scores": { @@ -2685,6 +2689,13 @@ "firedHeadline": "{count} of your vitals are outside their personal range today.", "factors": "Possible factors — never a cause: a hard workout, poor sleep, alcohol, altitude, stress, or illness. This is awareness from your own baselines, not a diagnosis.", "building": "Building your personal baselines — track a few more days and this will appear." + }, + "trajectory": { + "cardTitle": "Short-horizon projection", + "headline": "If this pattern continues — the next {days} days", + "range": "Projected around {low}–{high} {unit} in {days} days.", + "caveat": "A projection of your own recent trend, not a prediction. The shaded band widens to show how quickly the uncertainty grows.", + "insufficient": "Not enough of a clear trend to project yet — keep tracking and a projection will appear once the recent pattern is strong enough." } }, "cardioFitness": { diff --git a/messages/es.json b/messages/es.json index e6c610842..92cef9481 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2665,6 +2665,10 @@ "SIX_MINUTE_WALK_BAND": { "method": "La distancia ESTIMADA de marcha de seis minutos de tu dispositivo situada frente a la referencia de Enright y Sherrill para tu edad, estatura, peso y sexo, como porcentaje de lo previsto. La distancia es la estimación del dispositivo: nunca se recalcula aquí. Sin tu edad, estatura, peso y sexo se oculta el porcentaje y solo se muestran la distancia y la tendencia.", "caveat": "Una estimación reformulada frente a una referencia publicada: una señal de capacidad funcional, no una prueba clínica de marcha de seis minutos ni un diagnóstico. La referencia se estableció para edades de 40 a 80 años; fuera de ese rango el valor previsto es una extrapolación." + }, + "TRAJECTORY": { + "method": "Una recta de mínimos cuadrados ajustada a tus valores diarios recientes y proyectada hacia delante en un horizonte corto, con el intervalo de predicción clásico que se ensancha cuanto más lejos llega. Solo se calcula cuando la tendencia reciente es lo bastante marcada y tu historial es lo bastante largo y actual; de lo contrario no se traza ninguna línea.", + "caveat": "Una proyección de tu propia tendencia reciente, no una predicción, un objetivo ni un pronóstico clínico. Solo se cumple si el patrón reciente continúa; la banda sombreada muestra con qué rapidez se desvanece esa certeza." } }, "scores": { @@ -2685,6 +2689,13 @@ "firedHeadline": "Hoy, {count} de tus constantes están fuera de su rango personal.", "factors": "Posibles factores, nunca una causa: un entrenamiento intenso, dormir mal, alcohol, altitud, estrés o una enfermedad. Es un aviso a partir de tus propias bases, no un diagnóstico.", "building": "Construyendo tus bases personales: registra unos días más y esto aparecerá." + }, + "trajectory": { + "cardTitle": "Proyección a corto plazo", + "headline": "Si este patrón continúa: los próximos {days} días", + "range": "Proyectado en torno a {low}–{high} {unit} en {days} días.", + "caveat": "Una proyección de tu propia tendencia reciente, no una predicción. La banda sombreada se ensancha para mostrar la rapidez con que crece la incertidumbre.", + "insufficient": "Aún no hay una tendencia lo bastante clara para proyectar: sigue registrando y aparecerá una proyección cuando el patrón reciente sea lo bastante marcado." } }, "cardioFitness": { diff --git a/messages/fr.json b/messages/fr.json index 9a9197ad4..398c018c2 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2665,6 +2665,10 @@ "SIX_MINUTE_WALK_BAND": { "method": "La distance de marche de six minutes ESTIMÉE par votre appareil, située par rapport à la référence d’Enright et Sherrill pour votre âge, votre taille, votre poids et votre sexe, en pourcentage de la valeur prédite. La distance est l’estimation de l’appareil — jamais recalculée ici. Sans votre âge, taille, poids et sexe, le pourcentage est masqué et seules la distance et la tendance sont affichées.", "caveat": "Une estimation reformulée par rapport à une référence publiée — un indicateur de capacité fonctionnelle, ni un test de marche de six minutes clinique ni un diagnostic. La référence a été établie pour les âges de 40 à 80 ans ; en dehors de cette plage, la valeur prédite est une extrapolation." + }, + "TRAJECTORY": { + "method": "Une droite des moindres carrés ajustée à vos valeurs journalières récentes et projetée sur un court horizon, avec l’intervalle de prédiction classique qui s’élargit à mesure qu’il s’éloigne. Calculée uniquement lorsque la tendance récente est suffisamment marquée et que votre historique est assez long et récent ; sinon aucune ligne n’est tracée.", + "caveat": "Une projection de votre propre tendance récente — ni une prédiction, ni un objectif, ni un pronostic clinique. Elle ne tient que si le schéma récent se poursuit ; la bande ombrée montre la vitesse à laquelle cette certitude s’estompe." } }, "scores": { @@ -2685,6 +2689,13 @@ "firedHeadline": "Aujourd’hui, {count} de vos constantes sont hors de leur plage personnelle.", "factors": "Facteurs possibles — jamais une cause : un entraînement intense, un mauvais sommeil, l’alcool, l’altitude, le stress ou une maladie. C’est un repère issu de vos propres références, pas un diagnostic.", "building": "Construction de vos références personnelles — enregistrez quelques jours de plus et cela apparaîtra." + }, + "trajectory": { + "cardTitle": "Projection à court terme", + "headline": "Si ce schéma se poursuit — les {days} prochains jours", + "range": "Projeté autour de {low}–{high} {unit} dans {days} jours.", + "caveat": "Une projection de votre propre tendance récente, pas une prédiction. La bande ombrée s’élargit pour montrer la vitesse à laquelle l’incertitude augmente.", + "insufficient": "Pas encore de tendance assez nette pour projeter — continuez à enregistrer et une projection apparaîtra dès que le schéma récent sera suffisamment marqué." } }, "cardioFitness": { diff --git a/messages/it.json b/messages/it.json index b1914e763..c0d4ac0b1 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2665,6 +2665,10 @@ "SIX_MINUTE_WALK_BAND": { "method": "La distanza di cammino di sei minuti STIMATA dal tuo dispositivo, collocata rispetto al riferimento di Enright e Sherrill per la tua età, altezza, peso e sesso, come percentuale del previsto. La distanza è la stima del dispositivo — mai ricalcolata qui. Senza età, altezza, peso e sesso la percentuale è nascosta e vengono mostrate solo distanza e tendenza.", "caveat": "Una stima riformulata rispetto a un riferimento pubblicato — un segnale di capacità funzionale, non un test del cammino di sei minuti clinico né una diagnosi. Il riferimento è stato stabilito per le età 40–80; al di fuori di questo intervallo il valore previsto è un’estrapolazione." + }, + "TRAJECTORY": { + "method": "Una retta ai minimi quadrati adattata ai tuoi valori giornalieri recenti e proiettata in avanti su un breve orizzonte, con l’intervallo di previsione classico che si allarga man mano che si estende. Calcolata solo quando la tendenza recente è abbastanza marcata e la tua cronologia è abbastanza lunga e aggiornata; altrimenti non viene tracciata alcuna linea.", + "caveat": "Una proiezione della tua tendenza recente — non una previsione, un obiettivo o una prognosi clinica. Vale solo se lo schema recente continua; la banda ombreggiata mostra quanto rapidamente quella certezza svanisce." } }, "scores": { @@ -2685,6 +2689,13 @@ "firedHeadline": "Oggi {count} dei tuoi parametri vitali sono fuori dal loro intervallo personale.", "factors": "Possibili fattori — mai una causa: un allenamento intenso, poco sonno, alcol, altitudine, stress o una malattia. È un avviso basato sui tuoi parametri, non una diagnosi.", "building": "Stiamo costruendo i tuoi riferimenti personali — registra ancora qualche giorno e comparirà." + }, + "trajectory": { + "cardTitle": "Proiezione a breve termine", + "headline": "Se questo schema continua — i prossimi {days} giorni", + "range": "Proiettato intorno a {low}–{high} {unit} tra {days} giorni.", + "caveat": "Una proiezione della tua tendenza recente, non una previsione. La banda ombreggiata si allarga per mostrare quanto rapidamente cresce l’incertezza.", + "insufficient": "Non c’è ancora una tendenza abbastanza chiara da proiettare — continua a registrare e comparirà una proiezione quando lo schema recente sarà abbastanza marcato." } }, "cardioFitness": { diff --git a/messages/pl.json b/messages/pl.json index 0f6cdac16..7e461159e 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2665,6 +2665,10 @@ "SIX_MINUTE_WALK_BAND": { "method": "SZACOWANY przez Twoje urządzenie dystans sześciominutowego marszu odniesiony do wzorca Enrighta i Sherrilla dla Twojego wieku, wzrostu, wagi i płci, jako procent wartości przewidywanej. Dystans to szacunek urządzenia — nigdy nie jest tu przeliczany. Bez Twojego wieku, wzrostu, wagi i płci procent jest ukryty i pokazywane są tylko dystans i trend.", "caveat": "Szacunek odniesiony do opublikowanego wzorca — sygnał wydolności funkcjonalnej, nie kliniczny test sześciominutowego marszu ani diagnoza. Wzorzec opracowano dla wieku 40–80 lat; poza tym zakresem wartość przewidywana jest ekstrapolacją." + }, + "TRAJECTORY": { + "method": "Prosta dopasowana metodą najmniejszych kwadratów do Twoich ostatnich wartości dziennych i rzutowana w przód na krótki horyzont, z klasycznym przedziałem predykcji, który poszerza się tym bardziej, im dalej sięga. Obliczana tylko wtedy, gdy ostatni trend jest dość wyraźny, a Twoja historia wystarczająco długa i aktualna; w przeciwnym razie linia nie jest rysowana.", + "caveat": "Rzut Twojego własnego ostatniego trendu — nie prognoza, cel ani kliniczne przewidywanie. Sprawdza się tylko, jeśli ostatni wzorzec się utrzyma; zacieniony pas pokazuje, jak szybko ta pewność maleje." } }, "scores": { @@ -2685,6 +2689,13 @@ "firedHeadline": "Dziś {count} Twoich parametrów życiowych jest poza osobistym zakresem.", "factors": "Możliwe czynniki — nigdy przyczyna: intensywny trening, słaby sen, alkohol, wysokość, stres lub choroba. To wskazówka z Twoich własnych baz, nie diagnoza.", "building": "Budujemy Twoje osobiste bazy — zarejestruj jeszcze kilka dni, a to się pojawi." + }, + "trajectory": { + "cardTitle": "Krótkoterminowy rzut", + "headline": "Jeśli ten wzorzec się utrzyma — następne {days} dni", + "range": "Rzutowane na około {low}–{high} {unit} za {days} dni.", + "caveat": "Rzut Twojego własnego ostatniego trendu, nie prognoza. Zacieniony pas poszerza się, pokazując, jak szybko rośnie niepewność.", + "insufficient": "Jeszcze brak wystarczająco wyraźnego trendu do rzutowania — rejestruj dalej, a rzut pojawi się, gdy ostatni wzorzec będzie dość wyraźny." } }, "cardioFitness": { diff --git a/src/app/insights/weight/page.tsx b/src/app/insights/weight/page.tsx index de2a9b6a3..47c765cbf 100644 --- a/src/app/insights/weight/page.tsx +++ b/src/app/insights/weight/page.tsx @@ -17,6 +17,7 @@ import { MeasurementDiversityNudge } from "@/components/insights/measurement-div import { MetricRangeControls } from "@/components/insights/metric-range-controls"; import { MetricTargetSummary } from "@/components/insights/metric-target-summary"; import { SubPageShell } from "@/components/insights/sub-page-shell"; +import { TrajectoryForecastCard } from "@/components/insights/derived/trajectory-forecast-card"; import { buildWeightBandsFromHeight } from "@/lib/analytics/value-bands"; /** @@ -103,6 +104,13 @@ export default function InsightsGewichtPage() { + + } diff --git a/src/components/insights/derived/standards.ts b/src/components/insights/derived/standards.ts index 1649f82ce..17bea3d38 100644 --- a/src/components/insights/derived/standards.ts +++ b/src/components/insights/derived/standards.ts @@ -164,4 +164,13 @@ export const METRIC_PROVENANCE: Record = }, caveatKey: "insights.derived.composite.SIX_MINUTE_WALK_BAND.caveat", }, + TRAJECTORY: { + methodKey: "insights.derived.composite.TRAJECTORY.method", + standard: { + // Ordinary least squares + the textbook OLS prediction interval. + name: "Montgomery, Peck & Vining 2012, Intro. to Linear Regression Analysis", + url: "https://www.wiley.com/en-us/Introduction+to+Linear+Regression+Analysis%2C+5th+Edition-p-9780470542811", + }, + caveatKey: "insights.derived.composite.TRAJECTORY.caveat", + }, }; diff --git a/src/components/insights/derived/trajectory-forecast-card.tsx b/src/components/insights/derived/trajectory-forecast-card.tsx new file mode 100644 index 000000000..bff5eb41b --- /dev/null +++ b/src/components/insights/derived/trajectory-forecast-card.tsx @@ -0,0 +1,300 @@ +"use client"; + +import { useMemo } from "react"; +import { + ResponsiveContainer, + ComposedChart, + Area, + Line, + XAxis, + YAxis, + ReferenceLine, +} from "recharts"; +import { TrendingUp, TrendingDown, Minus } from "lucide-react"; + +import { useTranslations } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; +import { CoverageMeter } from "@/components/insights/derived/coverage-meter"; +import { ProvenanceExplainer } from "@/components/insights/derived/provenance-explainer"; +import { METRIC_PROVENANCE } from "@/components/insights/derived/standards"; +import { useDerivedMetric } from "@/components/insights/derived/use-derived-metric"; +// Type-only — the compute payload never drags the server graph into the +// client bundle (the v1.9.0 lesson, mirrored across the derived surfaces). +import type { TrajectoryValue } from "@/lib/insights/derived/trajectory"; +import type { DerivedProvenance } from "@/lib/insights/derived/types"; + +/** + * v1.11.0 (Epic B, Pillar 3) — the short-horizon projection card on the + * metric detail page. + * + * Surfaces the deterministic `TRAJECTORY` derived metric as a small band + * chart: the recent observed line, then the projected line (dashed) with a + * widening prediction-interval fan. The fan VISIBLY widens with the horizon + * — that is the uncertainty communication, no words needed. The number is + * computed server-side; this card only draws it. It is explicitly labelled + * a PROJECTION ("if this pattern continues"), never a prediction or a dated + * event, and the cited method (OLS + prediction interval) rides the ⓘ + * provenance affordance. + * + * Below the engine's R² / history / staleness floor the engine returns + * `insufficient`, and the card renders the same calm "not enough of a trend + * to project yet" + coverage state every derived surface uses — never a weak + * line, never a blank. + * + * The card mounts as a sibling beneath the main metric chart (mirroring + * `MetricStatusCard`), so the main chart's visual identity is untouched. + */ + +const TRAJECTORY_METRIC = "TRAJECTORY"; + +/** A single value-scale used to render stored values in the display unit. */ +function scaled(value: number, scale: number): number { + return value * scale; +} + +interface TrajectoryForecastCardProps { + /** The MeasurementType to project (the page's canonical type). */ + type: string; + /** Display unit suffix (e.g. "kg"). */ + unit?: string; + /** Display scale folded into every rendered value (defaults to 1). */ + valueScale?: number; + /** Canonical chart colour for the metric's line. */ + color?: string; + /** Gate the underlying derived read (e.g. on the auth/empty flag). */ + enabled?: boolean; + className?: string; +} + +/** The provenance ⓘ explainer for the projection, wired from the map. */ +function TrajectoryProvenance({ + provenance, +}: { + provenance: DerivedProvenance; +}) { + const { t } = useTranslations(); + const meta = METRIC_PROVENANCE.TRAJECTORY; + const method = ( + <> + {meta.caveatKey ? ( + + {t(meta.caveatKey)} + + ) : null} + {t(meta.methodKey)} + + ); + return ( + + ); +} + +/** Card shell — uppercase label + provenance affordance + body children. */ +function CardShell({ + provenance, + children, +}: { + provenance?: DerivedProvenance; + children: React.ReactNode; +}) { + const { t } = useTranslations(); + return ( +
+
+ + {t("insights.derived.trajectory.cardTitle")} + + {provenance ? : null} +
+ {children} +
+ ); +} + +/** CLS-safe skeleton matching the resolved card geometry. */ +function CardSkeleton() { + return ( +