diff --git a/src/components/insights/health-status-card.tsx b/src/components/insights/health-status-card.tsx
index 3d7161432..dea25694c 100644
--- a/src/components/insights/health-status-card.tsx
+++ b/src/components/insights/health-status-card.tsx
@@ -3,8 +3,9 @@
import { useQuery } from "@tanstack/react-query";
import { Activity, ArrowDownRight, ArrowUpRight } from "lucide-react";
-import { useTranslations } from "@/lib/i18n/context";
+import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
+import { metricFractionDigits } from "@/lib/measurements/value-domain";
import { cn } from "@/lib/utils";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
@@ -44,6 +45,7 @@ export function HealthStatusCard({
className,
}: HealthStatusCardProps) {
const { t } = useTranslations();
+ const fmt = useFormatters();
const { isAuthenticated } = useAuth();
const { data } = useQuery({
@@ -60,6 +62,14 @@ export function HealthStatusCard({
return key ? t(key) : type;
};
+ // The engines hand over raw doubles. Interpolating one straight into the
+ // sentence prints whatever floating-point produced — a pulse mean reads
+ // "68.33333333333333" — and reads in the wrong number convention for every
+ // locale that does not use a dot. Render each figure at the metric's own
+ // decimal precision, through the same formatter the rest of the app uses.
+ const metricValue = (type: string, value: number): string =>
+ fmt.number(value, metricFractionDigits(type));
+
return (
{t("insights.healthStatus.rangeContext", {
- value: d.value,
- low: d.low,
- high: d.high,
+ value: metricValue(d.type, d.value),
+ low: metricValue(d.type, d.low),
+ high: metricValue(d.type, d.high),
})}
@@ -137,8 +147,8 @@ export function HealthStatusCard({
{t("insights.healthStatus.shiftContext", {
- before: s.beforeMean,
- after: s.afterMean,
+ before: metricValue(s.metric, s.beforeMean),
+ after: metricValue(s.metric, s.afterMean),
})}
diff --git a/src/lib/insights/derived/__tests__/baseline-plausibility.test.ts b/src/lib/insights/derived/__tests__/baseline-plausibility.test.ts
new file mode 100644
index 000000000..7a83f5b61
--- /dev/null
+++ b/src/lib/insights/derived/__tests__/baseline-plausibility.test.ts
@@ -0,0 +1,115 @@
+/**
+ * A stored value the application declares impossible is not a measurement.
+ *
+ * The personal band ("your usual range") and the deviation line that reads
+ * against it are both means over stored `Measurement.value`. Neither end
+ * checked the value against the metric's own plausibility domain, so a single
+ * impossible pulse reading — the shape a provider unit-decode slip or a writer
+ * without the range gate produces — moved the day mean, the median carried it
+ * into the band, and the card said "your pulse is above your usual range,
+ * currently 111,287,531.01 instead of the usual 36,016.75".
+ *
+ * The numbers below are shaped like the real thing: a resting-pulse series in
+ * the 60s and 70s, one impossible sample dropped into it.
+ */
+import { describe, expect, it } from "vitest";
+
+import { buildBaselineBand, dayMeansFromRows } from "../baseline";
+import { latestDayMeanFromRows } from "../coincident-deviation";
+import { isPlausibleMetricValue } from "@/lib/measurements/value-domain";
+
+const TZ = "Europe/Berlin";
+
+/** A real-shaped resting-pulse day: four readings in the 60s/70s. */
+function pulseDay(day: string, values: number[]) {
+ return values.map((value, i) => ({
+ value,
+ measuredAt: new Date(`${day}T0${6 + i}:30:00.000Z`),
+ }));
+}
+
+describe("metric plausibility domain", () => {
+ it("admits a real pulse and refuses one no heart produces", () => {
+ expect(isPlausibleMetricValue("PULSE", 68)).toBe(true);
+ expect(isPlausibleMetricValue("PULSE", 20)).toBe(true);
+ expect(isPlausibleMetricValue("PULSE", 300)).toBe(true);
+ expect(isPlausibleMetricValue("PULSE", 111287531.01)).toBe(false);
+ expect(isPlausibleMetricValue("PULSE", 36016.75)).toBe(false);
+ expect(isPlausibleMetricValue("PULSE", 19)).toBe(false);
+ });
+
+ it("passes a metric with no declared range and refuses a non-finite value", () => {
+ expect(isPlausibleMetricValue("SOMETHING_UNDECLARED", 12345)).toBe(true);
+ expect(isPlausibleMetricValue("PULSE", Number.NaN)).toBe(false);
+ expect(isPlausibleMetricValue("PULSE", Number.POSITIVE_INFINITY)).toBe(
+ false,
+ );
+ });
+});
+
+describe("per-day means behind the personal band", () => {
+ it("keeps the day mean on the real readings when one sample is impossible", () => {
+ const rows = pulseDay("2026-08-01", [66, 70, 111287531.01, 68]);
+
+ const [point] = dayMeansFromRows(rows, "PULSE");
+
+ expect(point.day).toBe("2026-08-01");
+ expect(point.mean).toBeCloseTo(68, 6);
+ });
+
+ it("drops a day whose every reading is impossible rather than inventing one", () => {
+ const rows = [
+ ...pulseDay("2026-08-01", [66, 70, 68]),
+ ...pulseDay("2026-08-02", [36016.75, 111287531.01]),
+ ];
+
+ const points = dayMeansFromRows(rows, "PULSE");
+
+ expect(points.map((p) => p.day)).toEqual(["2026-08-01"]);
+ });
+
+ it("holds the band inside the metric's own domain across a poisoned month", () => {
+ const rows: { value: number; measuredAt: Date }[] = [];
+ for (let d = 1; d <= 28; d += 1) {
+ const day = `2026-08-${String(d).padStart(2, "0")}`;
+ // A steady resting pulse with a normal day-to-day wobble.
+ rows.push(...pulseDay(day, [64 + (d % 5), 70 + (d % 3), 67]));
+ }
+ // Three days the provider decoded wrong.
+ rows.push(...pulseDay("2026-08-05", [36016.75]));
+ rows.push(...pulseDay("2026-08-17", [111287531.01]));
+ rows.push(...pulseDay("2026-08-26", [98211.5]));
+
+ const band = buildBaselineBand(
+ dayMeansFromRows(rows, "PULSE").map((p) => p.mean),
+ "PULSE",
+ );
+
+ expect(band).not.toBeNull();
+ expect(band!.center).toBeGreaterThan(40);
+ expect(band!.center).toBeLessThan(120);
+ expect(band!.low).toBeGreaterThan(20);
+ expect(band!.high).toBeLessThan(300);
+ });
+});
+
+describe("the latest-day mean the deviation line reads", () => {
+ it("means only the real readings of the most recent day", () => {
+ const rows = [
+ ...pulseDay("2026-08-01", [66, 70, 68]),
+ ...pulseDay("2026-08-02", [72, 111287531.01, 74]),
+ ];
+
+ const latest = latestDayMeanFromRows(rows, "PULSE", TZ);
+
+ expect(latest).not.toBeNull();
+ expect(latest!.day).toBe("2026-08-02");
+ expect(latest!.value).toBeCloseTo(73, 6);
+ });
+
+ it("reports nothing when the latest day holds no plausible reading", () => {
+ const rows = pulseDay("2026-08-02", [36016.75, 111287531.01]);
+
+ expect(latestDayMeanFromRows(rows, "PULSE", TZ)).toBeNull();
+ });
+});
diff --git a/src/lib/insights/derived/baseline.ts b/src/lib/insights/derived/baseline.ts
index 8ae06c775..881ae202d 100644
--- a/src/lib/insights/derived/baseline.ts
+++ b/src/lib/insights/derived/baseline.ts
@@ -32,7 +32,10 @@ import type { MeasurementType, PrismaClient } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets";
import { toProfileSex } from "@/lib/profile/sex";
-import { clampDerivedLowerBound } from "@/lib/measurements/value-domain";
+import {
+ clampDerivedLowerBound,
+ isPlausibleMetricValue,
+} from "@/lib/measurements/value-domain";
import type { ProfileSex } from "@/lib/profile/sex";
import {
probeRollupCoverage,
@@ -209,6 +212,39 @@ export function buildBaselineBand(
};
}
+/**
+ * Group raw readings into per-day means, keeping only the values that sit
+ * inside the metric's own plausibility domain.
+ *
+ * Pure, and exported because this is the arithmetic the band rests on: a
+ * single stored value the application itself declares impossible drags the
+ * day's mean with it, the median then carries it into the band, and the
+ * personal "usual range" reads in the tens of thousands. Filtering here rather
+ * than at the edge of the card keeps the number wrong in exactly one place —
+ * nowhere — instead of right on one surface and wrong on the next.
+ *
+ * A day whose every reading is implausible produces no point at all. That is
+ * the honest outcome: the day contributed no measurement, and an absent day is
+ * something the coverage floor already knows how to report.
+ */
+export function dayMeansFromRows(
+ rows: readonly { value: number; measuredAt: Date }[],
+ type: MeasurementType,
+): DayMeanPoint[] {
+ const byDay = new Map();
+ for (const row of rows) {
+ if (!isPlausibleMetricValue(type, row.value)) continue;
+ const day = row.measuredAt.toISOString().slice(0, 10);
+ const acc = byDay.get(day) ?? { sum: 0, count: 0 };
+ acc.sum += row.value;
+ acc.count += 1;
+ byDay.set(day, acc);
+ }
+ return [...byDay.entries()]
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
+ .map(([day, acc]) => ({ day, mean: acc.sum / acc.count }));
+}
+
// ── reads ────────────────────────────────────────────────────────────
/**
@@ -237,11 +273,19 @@ export async function readDayMeanSeries(
resolved.granularity === "DAY" &&
resolved.rows.length > 0
) {
- const points = resolved.rows.map((row) => ({
- day: row.bucketStart.toISOString().slice(0, 10),
- mean: row.mean,
- }));
- return { points, source: "DAY" };
+ // A bucket mean is only as good as the rows it averaged. The rollup
+ // writer aggregates whatever is stored, so a reading outside the
+ // metric's plausibility domain rides straight through into the bucket
+ // and from there into the band. Drop those buckets on the same rule the
+ // live read applies to its rows, so the two paths agree about what
+ // counts as a measurement.
+ const points = resolved.rows
+ .filter((row) => isPlausibleMetricValue(type, row.mean))
+ .map((row) => ({
+ day: row.bucketStart.toISOString().slice(0, 10),
+ mean: row.mean,
+ }));
+ if (points.length > 0) return { points, source: "DAY" };
}
// Coverage probe said "has buckets" but the window resolved to a
// coarser tier or zero DAY rows → fall through to the live read so
@@ -264,17 +308,10 @@ export async function readDayMeanSeries(
if (rows.length === 0) {
return { points: [], source: "none" };
}
- const byDay = new Map();
- for (const row of rows) {
- const day = row.measuredAt.toISOString().slice(0, 10);
- const acc = byDay.get(day) ?? { sum: 0, count: 0 };
- acc.sum += row.value;
- acc.count += 1;
- byDay.set(day, acc);
+ const points = dayMeansFromRows(rows, type);
+ if (points.length === 0) {
+ return { points: [], source: "none" };
}
- const points: DayMeanPoint[] = [...byDay.entries()]
- .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
- .map(([day, acc]) => ({ day, mean: acc.sum / acc.count }));
return { points, source: "live" };
}
diff --git a/src/lib/insights/derived/coincident-deviation.ts b/src/lib/insights/derived/coincident-deviation.ts
index 8b1bfdc18..6217971ab 100644
--- a/src/lib/insights/derived/coincident-deviation.ts
+++ b/src/lib/insights/derived/coincident-deviation.ts
@@ -40,6 +40,7 @@ import { computeVitalsBaseline, type BaselineProfile } from "./baseline";
import { VITALS_BASELINE_TYPES } from "./registry";
import type { Derived, DerivedProvenanceSource } from "./types";
import { resolveRestMode } from "@/lib/illness/rest-mode";
+import { isPlausibleMetricValue } from "@/lib/measurements/value-domain";
import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
@@ -154,15 +155,36 @@ async function readLatestDayMean(
take: MAX_LATEST_DAY_ROWS,
select: { value: true, measuredAt: true },
});
- if (rows.length === 0) return null;
+ return latestDayMeanFromRows(rows, type, tz);
+}
+
+/**
+ * The pure half of the read above: pick the most recent day present in the
+ * rows and mean that day's readings.
+ *
+ * Readings outside the metric's declared plausibility domain are not means
+ * material. This side of the comparison had no gate at all, so one impossible
+ * stored value became "your pulse today" and was then held against a band the
+ * same value had already inflated — the two numbers in the sentence were both
+ * wrong, and wrong by different amounts, which is why the line read as
+ * nonsense rather than as an exaggeration. A day left with no plausible
+ * reading contributes nothing and the vital drops out of the comparison.
+ */
+export function latestDayMeanFromRows(
+ rows: readonly { value: number; measuredAt: Date }[],
+ type: MeasurementType,
+ tz: string,
+): { value: number; day: string } | null {
+ const usable = rows.filter((r) => isPlausibleMetricValue(type, r.value));
+ if (usable.length === 0) return null;
// Derive the most-recent day defensively (do not assume the DB ordering)
// so the "today" reading is always the genuine latest, then mean its rows.
let day = "";
- for (const r of rows) {
+ for (const r of usable) {
const d = userDayKey(r.measuredAt, tz);
if (d > day) day = d;
}
- const sameDay = rows.filter((r) => userDayKey(r.measuredAt, tz) === day);
+ const sameDay = usable.filter((r) => userDayKey(r.measuredAt, tz) === day);
return {
value: sameDay.reduce((s, r) => s + r.value, 0) / sameDay.length,
day,
diff --git a/src/lib/measurements/value-domain.ts b/src/lib/measurements/value-domain.ts
index 33e913f27..fdfad09c4 100644
--- a/src/lib/measurements/value-domain.ts
+++ b/src/lib/measurements/value-domain.ts
@@ -81,6 +81,36 @@ export function isNonNegativeMetric(type: string | null | undefined): boolean {
return range !== undefined && range.min >= 0;
}
+/**
+ * True when the value sits inside the metric's declared plausibility domain —
+ * the same `VALUE_RANGES` band every interactive, import, MCP and Telegram
+ * write path validates against before storing a reading.
+ *
+ * The derivation layer needs the question answered too, and for the opposite
+ * direction. A stored value outside the domain is one the application has
+ * already declared impossible, so it is not a reading: it is a provider glitch,
+ * a unit-decode slip, or a row from a writer that never had the gate. Folding
+ * it into a mean or a median makes the statistic say something the data never
+ * did — a personal pulse band in the tens of thousands, and a "your pulse is
+ * above your usual range" line built on a number no heart has ever produced.
+ * A statistic that quietly digests an impossible input is worse than one that
+ * declines to, because it looks like an answer.
+ *
+ * Unknown types and non-finite values answer `false`-safe in opposite ways on
+ * purpose: an absent range is an absent fact, so the value passes; a NaN is
+ * never an input to anything.
+ */
+export function isPlausibleMetricValue(
+ type: string | null | undefined,
+ value: number,
+): boolean {
+ if (!Number.isFinite(value)) return false;
+ if (!type) return true;
+ const range = VALUE_RANGES[type];
+ if (range === undefined) return true;
+ return value >= range.min && value <= range.max;
+}
+
/**
* True when EVERY named metric forbids negative values (and at least one was
* named). Used by the chart, which can paint several series in one axis: a
diff --git a/tests/integration/health-status-implausible-values.test.ts b/tests/integration/health-status-implausible-values.test.ts
new file mode 100644
index 000000000..fdf4d45e6
--- /dev/null
+++ b/tests/integration/health-status-implausible-values.test.ts
@@ -0,0 +1,160 @@
+/**
+ * The Insights baseline-drift read, driven as a route against real Postgres.
+ *
+ * The unit suite pins the arithmetic; this pins the pipe. A stored pulse value
+ * the application declares impossible used to reach the card twice over — once
+ * as "currently", once through the band it inflated — and the line read
+ * "your pulse is above your usual range, currently 111,287,531.01 instead of
+ * the usual 36,016.75". The shipped `GET` export answers here, so the readers
+ * really have to apply the domain rather than a test's copy of it.
+ */
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { NextRequest } from "next/server";
+
+import { cookieJar, headerJar } from "./mock-next-headers";
+import { getPrismaClient, truncateAllTables } from "./setup";
+
+vi.mock("next/headers", async () => {
+ const { cookieJar, headerJar } = await import("./mock-next-headers");
+ return {
+ headers: vi.fn(async () => ({
+ get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
+ })),
+ cookies: vi.fn(async () => ({
+ get: (name: string) => {
+ const value = cookieJar.get(name);
+ return value ? { name, value } : undefined;
+ },
+ set: (name: string, value: string) => {
+ cookieJar.set(name, value);
+ },
+ delete: (name: string) => {
+ cookieJar.delete(name);
+ },
+ })),
+ };
+});
+
+vi.mock("@/lib/db-compat", () => ({
+ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
+}));
+
+const DAY_MS = 24 * 60 * 60 * 1000;
+/** The two numbers off the report, kept verbatim so the case stays legible. */
+const IMPOSSIBLE_PULSE = 111287531.01;
+/** A raised but real pulse, so the card has a genuine deviation to report. */
+const REAL_RAISED_PULSE = 112;
+
+let counter = 0;
+
+async function makeUser() {
+ const suffix = `hs-${counter++}`;
+ return getPrismaClient().user.create({
+ data: {
+ username: `hs-${suffix}`,
+ email: `hs-${suffix}@example.test`,
+ role: "USER",
+ timezone: "Europe/Berlin",
+ },
+ });
+}
+
+async function signIn(userId: string) {
+ const session = await getPrismaClient().session.create({
+ data: { userId, expiresAt: new Date(Date.now() + 60_000) },
+ });
+ cookieJar.set("healthlog_session", session.id);
+}
+
+/** A fortnight of ordinary readings for one metric, one per day. */
+async function seedDaily(
+ userId: string,
+ type: "PULSE" | "WEIGHT",
+ valueFor: (dayIndex: number) => number,
+) {
+ const rows = [];
+ for (let d = 14; d >= 1; d -= 1) {
+ rows.push({
+ userId,
+ type,
+ value: valueFor(d),
+ unit: type === "PULSE" ? "bpm" : "kg",
+ measuredAt: new Date(Date.now() - d * DAY_MS),
+ source: "MANUAL" as const,
+ });
+ }
+ await getPrismaClient().measurement.createMany({ data: rows });
+}
+
+async function readHealthStatus() {
+ const { GET } = await import("@/app/api/insights/health-status/route");
+ const response = await (GET as (request: NextRequest) => Promise)(
+ new NextRequest("http://localhost/api/insights/health-status", {
+ method: "GET",
+ }),
+ );
+ expect(response.status).toBe(200);
+ const body = (await response.json()) as {
+ data: {
+ present: boolean;
+ deviations: {
+ type: string;
+ value: number;
+ low: number;
+ high: number;
+ }[];
+ };
+ };
+ return body.data;
+}
+
+beforeEach(async () => {
+ await truncateAllTables(getPrismaClient());
+ cookieJar.clear();
+ headerJar.clear();
+});
+
+describe("GET /api/insights/health-status — implausible stored values", () => {
+ it("reports today's real pulse, not the impossible row beside it", async () => {
+ const user = await makeUser();
+ await signIn(user.id);
+ // Two banded vitals — the coincident engine needs a pair before it reports.
+ await seedDaily(user.id, "PULSE", (d) => 64 + (d % 5));
+ await seedDaily(user.id, "WEIGHT", (d) => 80 + (d % 3) * 0.2);
+ // Today: a genuinely raised pulse — so the card has something real to say —
+ // and beside it one impossible reading, the shape a provider unit-decode
+ // slip leaves behind.
+ await getPrismaClient().measurement.createMany({
+ data: [
+ {
+ userId: user.id,
+ type: "PULSE",
+ value: REAL_RAISED_PULSE,
+ unit: "bpm",
+ measuredAt: new Date(Date.now() - 120_000),
+ source: "MANUAL",
+ },
+ {
+ userId: user.id,
+ type: "PULSE",
+ value: IMPOSSIBLE_PULSE,
+ unit: "bpm",
+ measuredAt: new Date(Date.now() - 60_000),
+ source: "MANUAL",
+ },
+ ],
+ });
+
+ const data = await readHealthStatus();
+
+ const pulse = data.deviations.find((d) => d.type === "PULSE");
+ // The deviation has to be there — otherwise this asserts nothing.
+ expect(pulse).toBeDefined();
+ expect(pulse!.value).toBeCloseTo(REAL_RAISED_PULSE, 6);
+ expect(pulse!.high).toBeLessThan(300);
+ expect(pulse!.low).toBeGreaterThanOrEqual(20);
+ // And the impossible number itself never reaches the wire.
+ expect(JSON.stringify(data)).not.toContain(String(IMPOSSIBLE_PULSE));
+ });
+});