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)); + }); +});