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 (
+
+ );
+}
+
+export function TrajectoryForecastCard({
+ type,
+ unit,
+ valueScale = 1,
+ color = "var(--chart-1)",
+ enabled = true,
+ className,
+}: TrajectoryForecastCardProps) {
+ const { t } = useTranslations();
+ const { data } = useDerivedMetric(TRAJECTORY_METRIC, {
+ type,
+ enabled,
+ });
+
+ // Assemble the band chart series: the projected fan only (the engine
+ // returns the projection points; the recent observed value anchors it as
+ // the dashed line's left edge). Each projection point carries the band
+ // `low`/`base` (= bandLow) + `range` (= bandHigh − bandLow) so a stacked
+ // Area renders the fan, plus the projected line.
+ const chart = useMemo(() => {
+ if (!data || data.status !== "ok" || !data.value) return null;
+ const v = data.value;
+ const points = v.projection.map((p) => ({
+ date: p.date,
+ projected: scaled(p.projected, valueScale),
+ base: scaled(p.bandLow, valueScale),
+ range: scaled(p.bandHigh - p.bandLow, valueScale),
+ }));
+ // Anchor the fan at the last observed value so the dashed line departs
+ // from "today" rather than floating.
+ const anchor = {
+ date: "now",
+ projected: scaled(v.lastValue, valueScale),
+ base: scaled(v.lastValue, valueScale),
+ range: 0,
+ };
+ return { points: [anchor, ...points], lastValue: scaled(v.lastValue, valueScale) };
+ }, [data, valueScale]);
+
+ if (!data) {
+ return (
+
+
+
+ );
+ }
+
+ // Below the fit / history / staleness floor — calm "no trend to project"
+ // state + coverage, never a weak line.
+ if (data.status === "insufficient" || !chart) {
+ return (
+
+
+ {/* The fan: a stacked transparent base + a shaded range Area, then
+ the dashed projected line. The shaded band visibly widens toward
+ the horizon — the uncertainty signal. */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* The mandatory conditional caveat — never a certainty, never a
+ dated clinical event. */}
+
+ {t("insights.derived.trajectory.caveat")}
+
+
+
+ );
+}
diff --git a/src/components/insights/healthkit-metric-page.tsx b/src/components/insights/healthkit-metric-page.tsx
index 019390999..1d0e63b54 100644
--- a/src/components/insights/healthkit-metric-page.tsx
+++ b/src/components/insights/healthkit-metric-page.tsx
@@ -15,6 +15,8 @@ import type { ChartOverlayKey } from "@/lib/dashboard-layout";
import { Button } from "@/components/ui/button";
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { MetricStatusCard } from "@/components/insights/metric-status-card";
+import { TrajectoryForecastCard } from "@/components/insights/derived/trajectory-forecast-card";
+import { isTrajectoryType } from "@/lib/insights/derived/registry";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
@@ -118,6 +120,17 @@ export interface HealthKitMetricPageProps {
* is a compile error, not a silent 422.
*/
statusMetric?: MetricStatusMetricId;
+ /**
+ * v1.11.0 (Epic B, Pillar 3) — when `true`, mounts the short-horizon
+ * `` beneath the chart for this metric (the page's
+ * `measurementType`). Opt-in: a page sets it only for a slow daily
+ * physiological series where a conservative 7–14-day OLS projection reads
+ * honestly. The card is only rendered on the data-bearing branch and only
+ * when the type is a supported trajectory metric; the engine still gates
+ * on R² / history / staleness, so a flat or noisy series shows the calm
+ * "no trend to project yet" state rather than a line.
+ */
+ forecast?: boolean;
}
export function HealthKitMetricPage({
@@ -136,6 +149,7 @@ export function HealthKitMetricPage({
explainerMetric,
targetSummarySlug,
statusMetric,
+ forecast = false,
}: HealthKitMetricPageProps) {
const { user, isAuthenticated } = useAuth();
const { t } = useTranslations();
@@ -236,6 +250,15 @@ export function HealthKitMetricPage({
enabled={!isEmpty}
/>
) : null}
+ {forecast && isTrajectoryType(measurementType) ? (
+
+ ) : null}
);
}
diff --git a/src/lib/insights/derived/__tests__/dispatch.test.ts b/src/lib/insights/derived/__tests__/dispatch.test.ts
index 7c0bc7d4c..f23ef2450 100644
--- a/src/lib/insights/derived/__tests__/dispatch.test.ts
+++ b/src/lib/insights/derived/__tests__/dispatch.test.ts
@@ -52,6 +52,7 @@ describe("registry", () => {
getDerivedMetricMeta("STAIR_DESCENT_SPEED_BASELINE")?.implemented,
).toBe(true);
expect(getDerivedMetricMeta("SIX_MINUTE_WALK_BAND")?.implemented).toBe(true);
+ expect(getDerivedMetricMeta("TRAJECTORY")?.implemented).toBe(true);
});
it("isDerivedMetricId rejects unknown ids", () => {
@@ -76,7 +77,8 @@ describe("registry", () => {
expect(DERIVED_METRIC_IDS).toContain("STAIR_ASCENT_SPEED_BASELINE");
expect(DERIVED_METRIC_IDS).toContain("STAIR_DESCENT_SPEED_BASELINE");
expect(DERIVED_METRIC_IDS).toContain("SIX_MINUTE_WALK_BAND");
- expect(DERIVED_METRIC_IDS.length).toBe(15);
+ expect(DERIVED_METRIC_IDS).toContain("TRAJECTORY");
+ expect(DERIVED_METRIC_IDS.length).toBe(16);
});
});
@@ -181,6 +183,34 @@ describe("computeDerivedMetric dispatch", () => {
}
});
+ it("routes TRAJECTORY to its engine (no data → insufficient, not not_implemented)", async () => {
+ const result = await computeDerivedMetric({
+ metric: "TRAJECTORY",
+ userId: "u1",
+ profile: PROFILE,
+ type: "WEIGHT",
+ now: NOW,
+ });
+ expect(result.status).toBe("insufficient");
+ if (result.status === "insufficient") {
+ expect(result.reason).toBe("no_readings_in_window");
+ }
+ });
+
+ it("returns unsupported_trajectory_type for a bad TRAJECTORY type", async () => {
+ const result = await computeDerivedMetric({
+ metric: "TRAJECTORY",
+ userId: "u1",
+ profile: PROFILE,
+ type: "STEPS",
+ now: NOW,
+ });
+ expect(result.status).toBe("insufficient");
+ if (result.status === "insufficient") {
+ expect(result.reason).toBe("unsupported_trajectory_type");
+ }
+ });
+
it("returns unsupported_baseline_type for a bad VITALS_BASELINE type", async () => {
const result = await computeDerivedMetric({
metric: "VITALS_BASELINE",
diff --git a/src/lib/insights/derived/__tests__/trajectory.test.ts b/src/lib/insights/derived/__tests__/trajectory.test.ts
new file mode 100644
index 000000000..705cfb00a
--- /dev/null
+++ b/src/lib/insights/derived/__tests__/trajectory.test.ts
@@ -0,0 +1,224 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+vi.mock("@/lib/db", () => ({
+ prisma: { measurement: { findMany: vi.fn() } },
+}));
+vi.mock("@/lib/rollups/measurement-coverage", () => ({
+ probeRollupCoverage: vi.fn(),
+}));
+vi.mock("@/lib/rollups/measurement-read-wmy", () => ({
+ readBestGranularityRollups: vi.fn(),
+}));
+
+import {
+ fitOls,
+ predictionIntervalHalfWidth,
+ computeTrajectory,
+ TRAJECTORY_MIN_HISTORY_DAYS,
+} from "../trajectory";
+import { prisma } from "@/lib/db";
+import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage";
+import { readBestGranularityRollups } from "@/lib/rollups/measurement-read-wmy";
+
+const PROFILE = { ageYears: 40, sex: "MALE" as const };
+// `now` sits just after the last seeded day so the window-anchored reader
+// keeps every seeded day in-window (the staleness gate stays satisfied).
+const NOW = new Date("2026-06-02T07:00:00Z");
+const TYPE = "WEIGHT";
+
+function dayRow(day: string, mean: number) {
+ return {
+ bucketStart: new Date(`${day}T00:00:00Z`),
+ count: 1,
+ mean,
+ sd: null,
+ slope: null,
+ r2: null,
+ sumValue: null,
+ minValue: mean,
+ maxValue: mean,
+ };
+}
+
+/** Build `count` consecutive DAY rows ending at `2026-06-01`, mean = f(i). */
+function seedDays(count: number, f: (i: number) => number) {
+ const end = new Date("2026-06-01T00:00:00Z").getTime();
+ const dayMs = 24 * 60 * 60 * 1000;
+ const rows = [];
+ for (let i = 0; i < count; i++) {
+ const day = new Date(end - (count - 1 - i) * dayMs)
+ .toISOString()
+ .slice(0, 10);
+ rows.push(dayRow(day, f(i)));
+ }
+ return rows;
+}
+
+beforeEach(() => {
+ vi.resetAllMocks();
+ vi.mocked(probeRollupCoverage).mockResolvedValue(new Map([[TYPE, true]]));
+});
+
+describe("pure OLS + prediction interval", () => {
+ it("recovers the slope + intercept of a clean line (R² = 1)", () => {
+ // y = 80 + 0.5x over x = 0..19.
+ const xs = Array.from({ length: 20 }, (_, i) => i);
+ const ys = xs.map((x) => 80 + 0.5 * x);
+ const fit = fitOls(xs, ys);
+ expect(fit).not.toBeNull();
+ expect(fit!.slope).toBeCloseTo(0.5, 6);
+ expect(fit!.intercept).toBeCloseTo(80, 6);
+ expect(fit!.r2).toBeCloseTo(1, 6);
+ // A perfect fit still carries a non-negative (here ~0) residual SE.
+ expect(fit!.residualStdError).toBeCloseTo(0, 6);
+ });
+
+ it("returns null on a degenerate fit (< 3 points or zero variance in x)", () => {
+ expect(fitOls([0, 1], [1, 2])).toBeNull();
+ expect(fitOls([5, 5, 5], [1, 2, 3])).toBeNull();
+ });
+
+ it("prediction band widens monotonically as x leaves the data centre", () => {
+ // Noisy enough to carry a positive residual SE so the band has width.
+ const xs = Array.from({ length: 20 }, (_, i) => i);
+ const ys = xs.map((x) => 80 + 0.5 * x + (x % 2 === 0 ? 1 : -1));
+ const fit = fitOls(xs, ys)!;
+ const near = predictionIntervalHalfWidth(fit, fit.meanX); // at the centre
+ const mid = predictionIntervalHalfWidth(fit, 25);
+ const far = predictionIntervalHalfWidth(fit, 35);
+ expect(near).toBeGreaterThan(0);
+ expect(mid).toBeGreaterThan(near);
+ expect(far).toBeGreaterThan(mid);
+ });
+});
+
+describe("computeTrajectory — ok path", () => {
+ it("projects a seeded upward trend with a widening band", async () => {
+ // 20 days of a clean +0.2/day trend with a touch of noise so the fit is
+ // strong (R² well over the floor) but the residual SE is non-zero.
+ const rows = seedDays(20, (i) => 80 + 0.2 * i + (i % 2 === 0 ? 0.1 : -0.1));
+ vi.mocked(readBestGranularityRollups).mockResolvedValue({
+ granularity: "DAY",
+ rows,
+ });
+
+ const result = await computeTrajectory("u1", PROFILE, {
+ type: TYPE,
+ horizonDays: 14,
+ now: NOW,
+ });
+
+ expect(result.status).toBe("ok");
+ if (result.status !== "ok") return;
+ const v = result.value;
+ expect(v.direction).toBe("up");
+ expect(v.slopePerDay).toBeCloseTo(0.2, 1);
+ expect(v.r2).toBeGreaterThan(0.9);
+ expect(v.method).toBe("ols");
+ expect(v.horizonDays).toBe(14);
+ expect(v.projection).toHaveLength(14);
+ expect(result.provenance.source).toBe("DAY");
+
+ // The projected line keeps rising over the horizon.
+ const first = v.projection[0];
+ const last = v.projection[v.projection.length - 1];
+ expect(last.projected).toBeGreaterThan(first.projected);
+
+ // The honesty signal: the band fans out — the last horizon point is
+ // strictly wider than the first.
+ const firstWidth = first.bandHigh - first.bandLow;
+ const lastWidth = last.bandHigh - last.bandLow;
+ expect(firstWidth).toBeGreaterThan(0);
+ expect(lastWidth).toBeGreaterThan(firstWidth);
+
+ // The band brackets the projected value at every point.
+ for (const p of v.projection) {
+ expect(p.bandLow).toBeLessThan(p.projected);
+ expect(p.bandHigh).toBeGreaterThan(p.projected);
+ }
+ });
+
+ it("clamps the horizon to the 14-day ceiling", async () => {
+ const rows = seedDays(20, (i) => 80 + 0.2 * i + (i % 2 === 0 ? 0.1 : -0.1));
+ vi.mocked(readBestGranularityRollups).mockResolvedValue({
+ granularity: "DAY",
+ rows,
+ });
+ const result = await computeTrajectory("u1", PROFILE, {
+ type: TYPE,
+ horizonDays: 90,
+ now: NOW,
+ });
+ expect(result.status).toBe("ok");
+ if (result.status === "ok") {
+ expect(result.value.horizonDays).toBe(14);
+ expect(result.value.projection).toHaveLength(14);
+ }
+ });
+});
+
+describe("computeTrajectory — insufficient gates (no weak line)", () => {
+ it("no data in window → insufficient (source none)", async () => {
+ vi.mocked(probeRollupCoverage).mockResolvedValue(new Map());
+ vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
+ const result = await computeTrajectory("u1", PROFILE, {
+ type: TYPE,
+ now: NOW,
+ });
+ expect(result.status).toBe("insufficient");
+ if (result.status === "insufficient") {
+ expect(result.reason).toBe("no_readings_in_window");
+ expect(result.provenance.source).toBe("none");
+ }
+ });
+
+ it("too few history days → insufficient, never a line", async () => {
+ // One under the floor → gated.
+ const rows = seedDays(TRAJECTORY_MIN_HISTORY_DAYS - 1, (i) => 80 + 0.2 * i);
+ vi.mocked(readBestGranularityRollups).mockResolvedValue({
+ granularity: "DAY",
+ rows,
+ });
+ const result = await computeTrajectory("u1", PROFILE, {
+ type: TYPE,
+ now: NOW,
+ });
+ expect(result.status).toBe("insufficient");
+ if (result.status === "insufficient") {
+ expect(result.reason).toBe("insufficient_history_for_projection");
+ }
+ });
+
+ it("weak fit (R² below the floor) → insufficient, never a line", async () => {
+ // A flat-with-alternating-noise series: no real trend → low R².
+ const rows = seedDays(20, (i) => 80 + (i % 2 === 0 ? 3 : -3));
+ vi.mocked(readBestGranularityRollups).mockResolvedValue({
+ granularity: "DAY",
+ rows,
+ });
+ const result = await computeTrajectory("u1", PROFILE, {
+ type: TYPE,
+ now: NOW,
+ });
+ expect(result.status).toBe("insufficient");
+ if (result.status === "insufficient") {
+ expect(result.reason).toBe("insufficient_fit_for_projection");
+ }
+ });
+
+ it("stale series (all points outside the now-anchored window) → insufficient", async () => {
+ // Coverage misses → live read; rows are months old, so the
+ // window-anchored live read returns nothing in-window.
+ vi.mocked(probeRollupCoverage).mockResolvedValue(new Map([[TYPE, false]]));
+ vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
+ const result = await computeTrajectory("u1", PROFILE, {
+ type: TYPE,
+ windowDays: 30,
+ now: NOW,
+ });
+ expect(result.status).toBe("insufficient");
+ if (result.status === "insufficient") {
+ expect(result.reason).toBe("no_readings_in_window");
+ }
+ });
+});
diff --git a/src/lib/insights/derived/dispatch.ts b/src/lib/insights/derived/dispatch.ts
index 1e2f226f0..23e3d4777 100644
--- a/src/lib/insights/derived/dispatch.ts
+++ b/src/lib/insights/derived/dispatch.ts
@@ -16,6 +16,7 @@ import { buildInsufficient, nowProvenanceTimestamp } from "./coverage";
import {
getDerivedMetricMeta,
isVitalsBaselineType,
+ isTrajectoryType,
type DerivedMetricId,
} from "./registry";
import {
@@ -30,6 +31,7 @@ import { computeBmi } from "./bmi";
import { computeSleepScore } from "./sleep-score";
import { computeReadiness } from "./readiness";
import { computeCoincidentDeviation } from "./coincident-deviation";
+import { computeTrajectory } from "./trajectory";
import {
computeWellnessScore,
type WellnessScoreType,
@@ -177,6 +179,35 @@ export async function computeDerivedMetric(
windowDays: args.windowDays,
now,
}) as Promise>;
+ case "TRAJECTORY": {
+ // Short-horizon OLS projection over a single chosen metric; defaults
+ // to RESTING_HEART_RATE (the catalogue's canonical example) when the
+ // caller omits one. Validated against the supported projection set —
+ // any other type 422s the same way VITALS_BASELINE does.
+ const requested = args.type ?? "RESTING_HEART_RATE";
+ if (!isTrajectoryType(requested)) {
+ return buildInsufficient({
+ coverage: {
+ requiredInputs: 1,
+ presentInputs: 0,
+ historyDays: 0,
+ missing: [requested],
+ },
+ provenance: {
+ inputs: [requested],
+ source: "none",
+ windowDays: 0,
+ computedAt: nowProvenanceTimestamp(now),
+ },
+ reason: "unsupported_trajectory_type",
+ });
+ }
+ return computeTrajectory(args.userId, args.profile, {
+ type: requested as MeasurementType,
+ windowDays: args.windowDays,
+ now,
+ }) as Promise>;
+ }
case "RECOVERY_SCORE":
case "STRESS_SCORE":
case "STRAIN_SCORE":
diff --git a/src/lib/insights/derived/index.ts b/src/lib/insights/derived/index.ts
index 2b699085c..4a18038ec 100644
--- a/src/lib/insights/derived/index.ts
+++ b/src/lib/insights/derived/index.ts
@@ -39,9 +39,11 @@ export type { DeriveCoverageArgs } from "./coverage";
export {
DERIVED_METRIC_IDS,
VITALS_BASELINE_TYPES,
+ TRAJECTORY_TYPES,
isDerivedMetricId,
getDerivedMetricMeta,
isVitalsBaselineType,
+ isTrajectoryType,
} from "./registry";
export type {
DerivedMetricId,
@@ -157,3 +159,18 @@ export type {
WellnessScoreType,
WellnessScoreOpts,
} from "./wellness-scores";
+
+// ── v1.11.0 (Epic B, Pillar 3) forecasting engine (server-only) ──────
+export {
+ computeTrajectory,
+ fitOls,
+ predictionIntervalHalfWidth,
+ TRAJECTORY_MIN_R2,
+ TRAJECTORY_MIN_HISTORY_DAYS,
+} from "./trajectory";
+export type {
+ TrajectoryValue,
+ TrajectoryPoint,
+ TrajectoryOpts,
+ OlsFit,
+} from "./trajectory";
diff --git a/src/lib/insights/derived/registry.ts b/src/lib/insights/derived/registry.ts
index 4e27b4193..9f7ddb804 100644
--- a/src/lib/insights/derived/registry.ts
+++ b/src/lib/insights/derived/registry.ts
@@ -54,7 +54,9 @@ export type DerivedMetricId =
/** v1.10.3: stair-descent-speed personal trend band (baseline engine). */
| "STAIR_DESCENT_SPEED_BASELINE"
/** v1.10.3: estimated 6-minute-walk distance vs Enright-predicted (passthrough re-frame). */
- | "SIX_MINUTE_WALK_BAND";
+ | "SIX_MINUTE_WALK_BAND"
+ /** v1.11.0 (Epic B): short-horizon OLS projection with a widening prediction band. */
+ | "TRAJECTORY";
// Documented-as-omitted (v1.10.3): two additive HealthKit signals stay
// trend-only with NO derived band, on purpose —
@@ -266,6 +268,20 @@ const REGISTRY: Record = {
minInputs: 1,
implemented: true,
},
+ TRAJECTORY: {
+ id: "TRAJECTORY",
+ // Short-horizon OLS projection over a single chosen metric. The caller
+ // selects the projected type via the route's `type` opt (validated
+ // against `TRAJECTORY_TYPES`); below the R²/history/staleness floor the
+ // engine returns `insufficient`, never a weak line — so the
+ // `minHistoryDays` here documents the engine's own 14-day fit floor.
+ displayName: "Short-horizon projection",
+ archetype: "any-user-baseline",
+ inputs: VITALS_BASELINE_TYPES,
+ minHistoryDays: 14,
+ minInputs: 1,
+ implemented: true,
+ },
};
/** Closed set of ids the generic route accepts (Zod enum source). */
@@ -287,3 +303,16 @@ export function getDerivedMetricMeta(
export function isVitalsBaselineType(type: string): boolean {
return (VITALS_BASELINE_TYPES as string[]).includes(type);
}
+
+/**
+ * The metrics the short-horizon `TRAJECTORY` engine supports. Reuses the
+ * vitals baseline set — slow, daily physiological day-series for which a
+ * conservative 7–14-day OLS projection is honest; the engine gates anything
+ * without a real trend out to `insufficient` regardless.
+ */
+export const TRAJECTORY_TYPES: MeasurementType[] = VITALS_BASELINE_TYPES;
+
+/** `true` when the type is a metric the trajectory engine supports. */
+export function isTrajectoryType(type: string): boolean {
+ return (TRAJECTORY_TYPES as string[]).includes(type);
+}
diff --git a/src/lib/insights/derived/trajectory.ts b/src/lib/insights/derived/trajectory.ts
new file mode 100644
index 000000000..6eb417d3c
--- /dev/null
+++ b/src/lib/insights/derived/trajectory.ts
@@ -0,0 +1,341 @@
+/**
+ * v1.11.0 (Epic B, Pillar 3) — the `TRAJECTORY` forecasting derived metric.
+ *
+ * `computeTrajectory(userId, profile, opts)` projects a single metric's
+ * recent trend a SHORT horizon forward with an HONEST, widening
+ * prediction-interval band. It is the deterministic-compute extension of
+ * the existing `trendSlope` (`src/lib/analytics/trends.ts:29`) which
+ * already returns `slope` + `direction` + R² (as `confidence`):
+ *
+ * - **fit** = ordinary least squares on the DAY-native per-day mean
+ * series (x = days since the window start, y = the per-day mean). The
+ * series is read exactly like the baseline engine
+ * (`readDayMeanSeries`) — rollup DAY buckets (`mean` composes) with a
+ * per-type bounded live-SQL fallback. NEVER from a composed WEEK/MONTH
+ * `sd`/`slope`: the rollup invariant forbids it (`baseline.ts:14-21`),
+ * so the fit + residual spread are computed at DAY granularity only.
+ * - **projection** = the fitted line evaluated at the next
+ * `horizonDays` day-offsets past the last observation.
+ * - **band** = the textbook OLS prediction interval
+ * `ŷ ± t·s·sqrt(1 + 1/n + (x−x̄)²/Sxx)`, which VISIBLY WIDENS the
+ * further the horizon strays from the data centre `x̄`. NOT a flat ±.
+ * The fanning band IS the uncertainty communication.
+ *
+ * Honesty gates (overclaiming a forecast is the top Epic-B risk). A
+ * projection is produced ONLY when all hold; otherwise `insufficient`
+ * (never a weak line, never extrapolated noise):
+ * - R² ≥ `TRAJECTORY_MIN_R2` (a weak-but-real trend, not noise).
+ * - `sampleDays ≥ TRAJECTORY_MIN_HISTORY_DAYS` (enough fit support).
+ * - the series is not stale: the window is anchored on `now` (like
+ * `trendSlope`) so a series with no recent points yields too few
+ * in-window days and gates out the same way the dashboard tile hides a
+ * stale average.
+ *
+ * The displayed confidence is server-computed from n + fit + recency
+ * (`deriveCoverage`), never the model's self-confidence — the LLM never
+ * invents the number; it only narrates a band that is already here.
+ *
+ * Server-only — reads the rollup tier + raw rows via the baseline reader.
+ * The pure OLS + prediction-interval helpers are exported so the unit
+ * test asserts the projection on a seeded trend and the band's widening.
+ */
+import type { MeasurementType } from "@/generated/prisma/client";
+import {
+ probeRollupCoverage,
+ type RollupCoverageMap,
+} from "@/lib/rollups/measurement-coverage";
+import {
+ buildInsufficient,
+ buildOk,
+ deriveCoverage,
+ nowProvenanceTimestamp,
+} from "./coverage";
+import { readDayMeanSeries, type BaselineProfile } from "./baseline";
+import type { Derived, DerivedProvenanceSource } from "./types";
+
+/** Trailing window the fit reads (days). Matches the baseline engine. */
+const DEFAULT_WINDOW_DAYS = 30;
+/** Default short projection horizon (days). Deliberately conservative. */
+const DEFAULT_HORIZON_DAYS = 14;
+/** Hard ceiling on the horizon — the literature warns off long horizons. */
+const MAX_HORIZON_DAYS = 14;
+/** Minimum distinct in-window days before a projection is produced. */
+export const TRAJECTORY_MIN_HISTORY_DAYS = 14;
+/** Minimum R² (fit quality) before a projection is produced. */
+export const TRAJECTORY_MIN_R2 = 0.3;
+/**
+ * Two-sided t critical value at 95% used for the prediction band. A fixed
+ * conservative constant (≈ the large-n z) rather than a per-n t-table
+ * lookup: the band is an honesty signal, not an inferential claim, and a
+ * constant keeps the math pure + dependency-free. With the ≥14-day floor
+ * the true t is within ~5% of this, and erring slightly wide is the safe
+ * direction for a forecast band.
+ */
+const T_CRITICAL_95 = 1.96;
+
+/** One projected day on the forecast fan. */
+export interface TrajectoryPoint {
+ /** Days past the last observation (1..horizonDays). */
+ dayOffset: number;
+ /** ISO date (YYYY-MM-DD) the offset lands on. */
+ date: string;
+ /** Fitted projection ŷ at this offset. */
+ projected: number;
+ /** Prediction-interval lower edge at this offset. */
+ bandLow: number;
+ /** Prediction-interval upper edge at this offset. */
+ bandHigh: number;
+}
+
+/** The successful `value` payload for a trajectory projection. */
+export interface TrajectoryValue {
+ /** The metric this projection describes. */
+ type: MeasurementType;
+ /** OLS slope (units per day). */
+ slopePerDay: number;
+ /** Trend direction, mirroring `trendSlope`. */
+ direction: "up" | "down" | "stable";
+ /** Horizon the projection covers (days). */
+ horizonDays: number;
+ /** R² of the fit (0..1) — the confidence the band rides. */
+ r2: number;
+ /** Residual standard error of the fit (same units as the metric). */
+ residualStdError: number;
+ /** Distinct in-window days that backed the fit. */
+ sampleDays: number;
+ /** Last observed per-day mean (the fan's anchor). */
+ lastValue: number;
+ /** Projected fan (oldest → newest offset), each with a widening band. */
+ projection: TrajectoryPoint[];
+ /** Method tag — always "ols" for v1. */
+ method: "ols";
+}
+
+export interface TrajectoryOpts {
+ /** Which metric to project. */
+ type: MeasurementType;
+ /** Trailing fit window in days. Defaults to 30. */
+ windowDays?: number;
+ /** Projection horizon in days. Clamped to [1, MAX_HORIZON_DAYS]. */
+ horizonDays?: number;
+ /** Compute time (injected for deterministic tests). */
+ now?: Date;
+ /** Pre-probed coverage map (one probe per request). */
+ coverage?: RollupCoverageMap;
+}
+
+// ── pure OLS + prediction interval (exported for the unit test) ───────
+
+/** A fitted OLS line plus the spread terms the band needs. */
+export interface OlsFit {
+ slope: number;
+ intercept: number;
+ r2: number;
+ /** Number of points. */
+ n: number;
+ /** Mean of x (the data centre the band widens away from). */
+ meanX: number;
+ /** Σ(x − x̄)² — the denominator in the prediction-interval term. */
+ sxx: number;
+ /** Residual standard error s = sqrt(SSres / (n − 2)). */
+ residualStdError: number;
+}
+
+/**
+ * Fit an OLS line `y = intercept + slope·x` and return the spread terms
+ * the prediction interval needs. Pure. Returns `null` when the fit is
+ * degenerate (< 3 points, or all x identical → Sxx = 0). Three points is
+ * the floor for a meaningful residual standard error (n − 2 ≥ 1).
+ */
+export function fitOls(
+ xs: number[],
+ ys: number[],
+): OlsFit | null {
+ const n = xs.length;
+ if (n < 3 || ys.length !== n) return null;
+
+ const sumX = xs.reduce((s, x) => s + x, 0);
+ const sumY = ys.reduce((s, y) => s + y, 0);
+ const meanX = sumX / n;
+ const meanY = sumY / n;
+
+ let sxx = 0;
+ let sxy = 0;
+ for (let i = 0; i < n; i++) {
+ const dx = xs[i] - meanX;
+ sxx += dx * dx;
+ sxy += dx * (ys[i] - meanY);
+ }
+ if (sxx === 0) return null; // all x identical → no slope
+
+ const slope = sxy / sxx;
+ const intercept = meanY - slope * meanX;
+
+ let ssRes = 0;
+ let ssTot = 0;
+ for (let i = 0; i < n; i++) {
+ const fitted = intercept + slope * xs[i];
+ ssRes += (ys[i] - fitted) ** 2;
+ ssTot += (ys[i] - meanY) ** 2;
+ }
+ const r2 = ssTot === 0 ? 0 : 1 - ssRes / ssTot;
+ // n − 2 degrees of freedom; n ≥ 3 guarantees a positive denominator.
+ const residualStdError = Math.sqrt(ssRes / (n - 2));
+
+ return { slope, intercept, r2, n, meanX, sxx, residualStdError };
+}
+
+/**
+ * The half-width of the OLS prediction interval at a single x:
+ * t·s·sqrt(1 + 1/n + (x − x̄)²/Sxx)
+ * Pure. This is the term that makes the band FAN OUT — the `(x − x̄)²/Sxx`
+ * summand grows quadratically as x leaves the data centre, so a point far
+ * into the horizon carries a visibly wider band than one near the data.
+ */
+export function predictionIntervalHalfWidth(
+ fit: OlsFit,
+ x: number,
+): number {
+ const { n, meanX, sxx, residualStdError } = fit;
+ const leverage = 1 + 1 / n + (x - meanX) ** 2 / sxx;
+ return T_CRITICAL_95 * residualStdError * Math.sqrt(leverage);
+}
+
+// ── compute ───────────────────────────────────────────────────────────
+
+/**
+ * Short-horizon OLS trajectory with a widening prediction band, gated on
+ * fit quality + history + staleness. Reads the DAY-native per-day mean
+ * series (rollup tier + bounded live fallback) — never a composed
+ * WEEK/MONTH slope. Below any gate it returns `insufficient` (never a
+ * weak line, never noise extrapolated).
+ */
+export async function computeTrajectory(
+ userId: string,
+ _profile: BaselineProfile,
+ opts: TrajectoryOpts,
+): Promise> {
+ const windowDays = opts.windowDays ?? DEFAULT_WINDOW_DAYS;
+ const horizonDays = Math.max(
+ 1,
+ Math.min(MAX_HORIZON_DAYS, opts.horizonDays ?? DEFAULT_HORIZON_DAYS),
+ );
+ const now = opts.now ?? new Date();
+ const type = opts.type;
+ const computedAt = nowProvenanceTimestamp(now);
+
+ const coverage = opts.coverage ?? (await probeRollupCoverage(userId));
+ const { points, source } = await readDayMeanSeries(
+ userId,
+ type,
+ windowDays,
+ now,
+ coverage,
+ );
+
+ const sampleDays = points.length;
+
+ const insufficient = (
+ reason: string,
+ src: DerivedProvenanceSource,
+ presentInputs: number,
+ ): Derived => {
+ const { coverage: cov } = deriveCoverage({
+ requiredInputs: 1,
+ presentInputs,
+ historyDays: sampleDays,
+ missing: presentInputs === 0 ? [String(type)] : [],
+ fullHistoryDays: windowDays,
+ });
+ return buildInsufficient({
+ coverage: cov,
+ provenance: { inputs: [String(type)], source: src, windowDays, computedAt },
+ reason,
+ });
+ };
+
+ // No data at all — insufficient, source "none".
+ if (sampleDays === 0) {
+ return insufficient("no_readings_in_window", "none", 0);
+ }
+
+ // Too little history for an honest fit. The window is anchored on `now`
+ // (via the reader), so a STALE series (no recent points) lands here too
+ // — the same staleness gate `trendSlope` enforces.
+ if (sampleDays < TRAJECTORY_MIN_HISTORY_DAYS) {
+ return insufficient("insufficient_history_for_projection", source, 1);
+ }
+
+ // x = days since the FIRST in-window day; y = the per-day mean.
+ const dayMs = 24 * 60 * 60 * 1000;
+ const startMs = new Date(`${points[0].day}T00:00:00Z`).getTime();
+ const xs = points.map(
+ (p) => (new Date(`${p.day}T00:00:00Z`).getTime() - startMs) / dayMs,
+ );
+ const ys = points.map((p) => p.mean);
+
+ const fit = fitOls(xs, ys);
+ if (!fit) {
+ return insufficient("fit_computation_failed", source, 1);
+ }
+
+ // Gate on fit quality — a weak-but-real trend, never noise.
+ if (fit.r2 < TRAJECTORY_MIN_R2) {
+ return insufficient("insufficient_fit_for_projection", source, 1);
+ }
+
+ // Project from the last observed day-offset forward over the horizon,
+ // each point carrying the widening prediction band.
+ const lastX = xs[xs.length - 1];
+ const lastDayMs = new Date(
+ `${points[points.length - 1].day}T00:00:00Z`,
+ ).getTime();
+ const projection: TrajectoryPoint[] = [];
+ for (let h = 1; h <= horizonDays; h++) {
+ const x = lastX + h;
+ const projected = fit.intercept + fit.slope * x;
+ const half = predictionIntervalHalfWidth(fit, x);
+ projection.push({
+ dayOffset: h,
+ date: new Date(lastDayMs + h * dayMs).toISOString().slice(0, 10),
+ projected,
+ bandLow: projected - half,
+ bandHigh: projected + half,
+ });
+ }
+
+ const slopePerDay = fit.slope;
+ const threshold = 0.01;
+ const direction: "up" | "down" | "stable" =
+ Math.abs(slopePerDay) < threshold
+ ? "stable"
+ : slopePerDay > 0
+ ? "up"
+ : "down";
+
+ const { coverage: cov, confidence } = deriveCoverage({
+ requiredInputs: 1,
+ presentInputs: 1,
+ historyDays: sampleDays,
+ missing: [],
+ fullHistoryDays: windowDays,
+ });
+
+ return buildOk({
+ value: {
+ type,
+ slopePerDay,
+ direction,
+ horizonDays,
+ r2: fit.r2,
+ residualStdError: fit.residualStdError,
+ sampleDays,
+ lastValue: ys[ys.length - 1],
+ projection,
+ method: "ols",
+ },
+ coverage: cov,
+ confidence,
+ provenance: { inputs: [String(type)], source, windowDays, computedAt },
+ });
+}