diff --git a/scripts/diagnose-manual-sleep-hours.ts b/scripts/diagnose-manual-sleep-hours.ts
new file mode 100644
index 000000000..38a4b51a9
--- /dev/null
+++ b/scripts/diagnose-manual-sleep-hours.ts
@@ -0,0 +1,103 @@
+/**
+ * Read-only diagnostic for hand-entered sleep rows stored in the wrong unit.
+ *
+ * `SLEEP_DURATION` is stored in minutes. The manual entry field asked for
+ * hours and sent the typed number through unconverted, so a night entered as
+ * 7.5 was filed as seven and a half MINUTES. Nothing objected: a single sleep
+ * STAGE really can be that short, so the value sits inside the column's
+ * plausibility band and looks like an ordinary row.
+ *
+ * That ambiguity is why this reports and does not repair. A stage segment of
+ * eight minutes and a night typed as eight hours are the same number in the
+ * same column, and only the person who entered it knows which it was. What CAN
+ * be narrowed is the population: a MANUAL, whole-night row (no sleep stage) is
+ * one somebody typed into the field, and a typed night shorter than the
+ * longest plausible hours entry is almost certainly hours that were read as
+ * minutes.
+ *
+ * The output lists the candidates with their date, stored value and the night
+ * they would have been if the number was hours, so the maintainer can decide
+ * per row. Nothing is written.
+ *
+ * Run (inside the app container, which has DATABASE_URL):
+ * pnpm dlx tsx scripts/diagnose-manual-sleep-hours.ts
+ */
+import "dotenv/config";
+
+import { prisma } from "@/lib/db";
+import { MINUTES_PER_HOUR } from "@/lib/measurements/entry-units";
+
+/**
+ * Longest night anybody types into an hours field. A stored value at or below
+ * this reads as hours; above it, the number can only ever have been minutes.
+ */
+const MAX_PLAUSIBLE_HOURS = 24;
+
+async function main(): Promise {
+ const suspects = await prisma.measurement.findMany({
+ where: {
+ type: "SLEEP_DURATION",
+ source: "MANUAL",
+ deletedAt: null,
+ // Whole-night rows only — a staged row comes from a sync source, never
+ // from the entry field.
+ sleepStage: null,
+ value: { gt: 0, lte: MAX_PLAUSIBLE_HOURS },
+ },
+ orderBy: [{ userId: "asc" }, { measuredAt: "asc" }],
+ select: {
+ id: true,
+ userId: true,
+ value: true,
+ measuredAt: true,
+ createdAt: true,
+ },
+ });
+
+ if (suspects.length === 0) {
+ console.log(
+ "No hand-entered sleep rows in the hours-shaped band. Nothing to review.",
+ );
+ return;
+ }
+
+ const byUser = new Map();
+ for (const row of suspects) {
+ const bucket = byUser.get(row.userId);
+ if (bucket) bucket.push(row);
+ else byUser.set(row.userId, [row]);
+ }
+
+ console.log(
+ `${suspects.length} hand-entered sleep row(s) across ${byUser.size} record(s) ` +
+ `store a value of ${MAX_PLAUSIBLE_HOURS} or less.\n` +
+ `Each is either a very short recorded sleep, or hours that were read as minutes.\n`,
+ );
+
+ for (const [userId, rows] of byUser) {
+ console.log(`record ${userId} — ${rows.length} row(s)`);
+ for (const row of rows) {
+ const asHours = row.value * MINUTES_PER_HOUR;
+ console.log(
+ ` ${row.measuredAt.toISOString().slice(0, 10)} id=${row.id} ` +
+ `stored=${row.value} min if it was hours=${asHours} min ` +
+ `(${(asHours / MINUTES_PER_HOUR).toFixed(2)} h) ` +
+ `entered=${row.createdAt.toISOString().slice(0, 10)}`,
+ );
+ }
+ console.log("");
+ }
+
+ console.log(
+ "Nothing was changed. Correct a row from the record list, or delete and re-enter it.",
+ );
+}
+
+main()
+ .catch((error) => {
+ console.error(error);
+ process.exitCode = 1;
+ })
+ .finally(() => {
+ void prisma.$disconnect();
+ });
diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts
index 4a05ff73a..0164660c2 100644
--- a/src/app/api/medications/[id]/intake/route.ts
+++ b/src/app/api/medications/[id]/intake/route.ts
@@ -314,6 +314,53 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
// A band decision (slot or ad-hoc) is an AUTO binding; it also resets a
// stale USER_PIN when this write converges onto a previously-pinned row.
attributionSource = "AUTO";
+
+ // The client can NAME the dose it is recording, and when it does, that
+ // answer outranks the band. The Verlauf ledger's "Genommen" posts the
+ // displayed slot's own anchor, and the medication card posts its
+ // display-due slot; both mean "record THIS dose". Band attribution answers
+ // a different question — which slot the take's clock time falls into — so
+ // backfilling a slot from an earlier day matched no band, fell through to
+ // the standalone insert, and left the named slot unserved: the entry
+ // reverted to missed on the next read and the dose the user recorded was
+ // filed on the wrong day.
+ //
+ // Only a REAL slot of this medication is honoured (the same band-anchor
+ // validation the "diesem Slot zuordnen" pin runs), so an arbitrary client
+ // instant still records ad-hoc rather than parking a live row on a
+ // fabricated anchor. And only a slot that does not sit ahead of the take,
+ // by the same forward guard the convergence probe below uses — a
+ // late-morning dose must never consume the evening slot.
+ if (
+ scheduledFor !== undefined &&
+ mayConvergeOntoSuppliedSlot({
+ skipped,
+ takenAt: resolvedTakenAt,
+ suppliedSlot: incomingScheduledFor,
+ })
+ ) {
+ const namedSlot = await resolveForcedSlotForWrite({
+ userId: user.id,
+ medicationId: id,
+ userTz: user.timezone,
+ slotInstant: incomingScheduledFor,
+ });
+ // Only when the naming actually decides something the clock did not.
+ // A dose taken inside its own window resolves to the same anchor either
+ // way and stays an AUTO binding, so an ordinary on-time take is
+ // unchanged. When the two disagree the binding rests on the user's
+ // choice, which is what USER_PIN records — and the read ledger binds a
+ // USER_PIN take by its stored anchor rather than by the clock, so a
+ // backfilled dose reads under the slot it was recorded for instead of
+ // falling back to ad-hoc.
+ if (
+ namedSlot !== null &&
+ namedSlot.getTime() !== canonicalSlot?.getTime()
+ ) {
+ canonicalSlot = namedSlot;
+ attributionSource = "USER_PIN";
+ }
+ }
}
// Idempotency check (explicit key or server-side dedup window)
diff --git a/src/components/insights/__tests__/coincident-deviation-card.test.tsx b/src/components/insights/__tests__/coincident-deviation-card.test.tsx
index 17ed6ec87..7e123c875 100644
--- a/src/components/insights/__tests__/coincident-deviation-card.test.tsx
+++ b/src/components/insights/__tests__/coincident-deviation-card.test.tsx
@@ -37,6 +37,7 @@ function deviation(
high: 62,
outside,
direction: outside ? "above" : "in",
+ daysAgo: 0,
};
}
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 (
diff --git a/src/components/measurements/measurement-form.tsx b/src/components/measurements/measurement-form.tsx
index 11e814478..93d1da4f5 100644
--- a/src/components/measurements/measurement-form.tsx
+++ b/src/components/measurements/measurement-form.tsx
@@ -26,6 +26,10 @@ import { Loader2, MoreHorizontal, Plus, RotateCcw } from "lucide-react";
import { toast } from "sonner";
import { useTranslations } from "@/lib/i18n/context";
import { useUnitDisplay } from "@/hooks/use-unit-display";
+import {
+ entryValueToCanonical,
+ parseDecimalEntry,
+} from "@/lib/measurements/entry-units";
import {
invalidateKeys,
measurementDependentKeys,
@@ -343,7 +347,17 @@ export function MeasurementForm({
// identity path — `fromDisplay` returns the typed number unchanged, so
// the payload is byte-identical to before. Round only the converted
// imperial value so 210 lb stores as 95.25 kg, not 95.25439770….
- const typed = parseFloat(value);
+ //
+ // A second boundary sits beside it: a few types are stored in a unit
+ // the form does not ask for. Sleep is entered in hours and stored in
+ // minutes, and sending the typed number through unconverted filed a
+ // 7.5-hour night as seven and a half minutes — inside the column's
+ // plausibility band, so nothing objected.
+ const typed = parseDecimalEntry(value);
+ if (typed === null) {
+ setError(t("measurements.saveError"));
+ return;
+ }
let canonicalValue = typed;
if (unitDisplay.isTransformed(type)) {
const inverted = unitDisplay.fromDisplay(type, typed);
@@ -352,6 +366,7 @@ export function MeasurementForm({
? Math.round(inverted * 100) / 100
: inverted;
}
+ canonicalValue = entryValueToCanonical(type, canonicalValue);
await apiPost("/api/measurements", {
type,
value: canonicalValue,
@@ -533,9 +548,13 @@ export function MeasurementForm({
>
setValue(e.target.value)}
placeholder={
diff --git a/src/lib/ai/coach/snapshot-freshness.ts b/src/lib/ai/coach/snapshot-freshness.ts
new file mode 100644
index 000000000..670d365be
--- /dev/null
+++ b/src/lib/ai/coach/snapshot-freshness.ts
@@ -0,0 +1,87 @@
+/**
+ * Stamp every metric block in the Coach snapshot with the age of its freshest
+ * reading.
+ *
+ * The narrated surfaces — the dashboard hero line, the daily briefing, the
+ * Coach reply — are written from this snapshot. Each metric block carried its
+ * aggregates and a dated timeline, and nothing else: no statement of when the
+ * series actually ends. Reading it, the newest row looks like now, and the
+ * prose came out as "your pulse is well above your 30-day average today" on a
+ * day whose pulse had not been measured for the better part of a week.
+ *
+ * The age was already in the block, buried one level down in the coverage
+ * object as `newestDaysAgo`, where it read as a statistics footnote rather
+ * than as the fact that governs the tense of every sentence about the metric.
+ * This lifts it to the block's own surface and states the consequence
+ * directly, so the answer arrives with the data instead of depending on the
+ * reader working it out.
+ *
+ * Pure and in-place — the snapshot is a plain record by the time it gets here.
+ */
+import {
+ TODAY_CLAIM_MAX_AGE_DAYS,
+ isCurrentForTodayClaim,
+} from "@/lib/insights/measurement-freshness";
+
+/** When a metric block's series actually ends, and what may be said about it. */
+export interface SnapshotAsOf {
+ /** Whole days between the freshest reading and today. `0` is today. */
+ daysAgo: number;
+ /** True when the freshest reading is from today. */
+ isToday: boolean;
+ /**
+ * False when the freshest reading is too old to back a present-tense
+ * sentence. The block's numbers are still true — they are just true about
+ * the day they were taken on, and must be stated with that day.
+ */
+ currentForTodayClaims: boolean;
+}
+
+function readNewestDaysAgo(block: unknown): number | null {
+ if (typeof block !== "object" || block === null) return null;
+ const aggregate = (block as { aggregate?: unknown }).aggregate;
+ if (typeof aggregate !== "object" || aggregate === null) return null;
+ const coverage = (aggregate as { coverage?: unknown }).coverage;
+ if (typeof coverage !== "object" || coverage === null) return null;
+ const newestDaysAgo = (coverage as { newestDaysAgo?: unknown }).newestDaysAgo;
+ return typeof newestDaysAgo === "number" && Number.isFinite(newestDaysAgo)
+ ? newestDaysAgo
+ : null;
+}
+
+/**
+ * Build the `asOf` stamp for one reading age. Exported so the rule is testable
+ * without assembling a snapshot.
+ */
+export function asOfFromDaysAgo(daysAgo: number): SnapshotAsOf {
+ return {
+ daysAgo,
+ isToday: daysAgo === 0,
+ currentForTodayClaims: isCurrentForTodayClaim(daysAgo),
+ };
+}
+
+/**
+ * Attach `asOf` to every snapshot block whose aggregate reports a freshest
+ * reading. Blocks with no coverage (narrative memory, plans, the reference
+ * grounding table) are untouched — they carry no measurement to date.
+ *
+ * Returns the metric keys that were stamped stale, so the caller can annotate
+ * them: a briefing narrated off a week-old series is worth seeing in the wide
+ * event, not just in the reader's confusion.
+ */
+export function annotateSnapshotFreshness(
+ snapshot: Record,
+): string[] {
+ const stale: string[] = [];
+ for (const [key, block] of Object.entries(snapshot)) {
+ const daysAgo = readNewestDaysAgo(block);
+ if (daysAgo === null) continue;
+ const asOf = asOfFromDaysAgo(daysAgo);
+ (block as Record).asOf = asOf;
+ if (!asOf.currentForTodayClaims) stale.push(key);
+ }
+ return stale;
+}
+
+export { TODAY_CLAIM_MAX_AGE_DAYS };
diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts
index ca34a5df1..1b4473dfc 100644
--- a/src/lib/ai/coach/snapshot.ts
+++ b/src/lib/ai/coach/snapshot.ts
@@ -29,6 +29,7 @@ import type { SleepStageRow } from "@/lib/analytics/sleep-night";
import { compactSections } from "@/lib/ai/prompts/compact-sections";
import { annotate } from "@/lib/logging/context";
import { memoizePerRequest } from "@/lib/request-cache";
+import { annotateSnapshotFreshness } from "./snapshot-freshness";
import { buildGlp1SnapshotBlock } from "./glp1-snapshot";
import { buildDerivedSnapshotBlock } from "./derived-snapshot";
import { buildCorrelationsSnapshotBlock } from "./correlations-snapshot";
@@ -1209,6 +1210,19 @@ async function buildCoachSnapshotImpl(
// side of the prompt.
const compactSnapshot = compactSections(snapshot);
+ // Date the end of every metric series before anything narrates it. Without
+ // this each block reads as if its newest number were taken just now, and the
+ // hero line said "today" about a metric last measured five days earlier. The
+ // stamp sits on the block itself so the age travels with the numbers into
+ // every surface the snapshot feeds — hero, briefing, Coach reply, tools.
+ const staleBlocks = annotateSnapshotFreshness(compactSnapshot);
+ if (staleBlocks.length > 0) {
+ annotate({
+ action: { name: "coach.snapshot.stale_blocks" },
+ meta: { blocks: staleBlocks.sort() },
+ });
+ }
+
// v1.7.0 — assembled-snapshot soft cap. Enabling every cluster at a
// long window can balloon the prompt; degrade progressively by
// reverse cluster priority until the serialised size fits
diff --git a/src/lib/insights/__tests__/health-status.test.ts b/src/lib/insights/__tests__/health-status.test.ts
index f0750a2bc..a932d476b 100644
--- a/src/lib/insights/__tests__/health-status.test.ts
+++ b/src/lib/insights/__tests__/health-status.test.ts
@@ -7,6 +7,7 @@ import type { ChangepointSignal } from "@/lib/insights/derived/changepoint";
function vital(
outside: boolean,
direction: VitalDeviation["direction"],
+ daysAgo = 0,
): VitalDeviation {
return {
type: "RESTING_HEART_RATE",
@@ -16,6 +17,7 @@ function vital(
high: 65,
outside,
direction,
+ daysAgo,
};
}
diff --git a/src/lib/insights/__tests__/measurement-freshness.test.ts b/src/lib/insights/__tests__/measurement-freshness.test.ts
new file mode 100644
index 000000000..4d77ad0d8
--- /dev/null
+++ b/src/lib/insights/__tests__/measurement-freshness.test.ts
@@ -0,0 +1,138 @@
+/**
+ * A reading from five days ago must not produce a sentence about today.
+ *
+ * Two surfaces said it out loud — the day-signals card ("one of your vitals is
+ * outside its usual range today") and the baseline-drift card ("your pulse is
+ * above your usual range") — and the narrated hero line said it in prose. One
+ * class: the freshest stored reading reached each of them with no notion of
+ * when it was taken, so every one of them assumed now.
+ *
+ * These cover the shared rule and the two deterministic consumers. The
+ * narrated surfaces are covered by the snapshot stamp beside them.
+ */
+import { describe, expect, it } from "vitest";
+
+import {
+ TODAY_CLAIM_MAX_AGE_DAYS,
+ dayKeyAgeInDays,
+ isCurrentForTodayClaim,
+} from "../measurement-freshness";
+import { summariseHealthStatus } from "../health-status";
+import { classifyDeviation } from "../derived/coincident-deviation";
+import {
+ annotateSnapshotFreshness,
+ asOfFromDaysAgo,
+} from "@/lib/ai/coach/snapshot-freshness";
+
+describe("the today-claim window", () => {
+ it("admits today and yesterday and refuses anything older", () => {
+ expect(isCurrentForTodayClaim(0)).toBe(true);
+ expect(isCurrentForTodayClaim(TODAY_CLAIM_MAX_AGE_DAYS)).toBe(true);
+ expect(isCurrentForTodayClaim(TODAY_CLAIM_MAX_AGE_DAYS + 1)).toBe(false);
+ expect(isCurrentForTodayClaim(5)).toBe(false);
+ });
+
+ it("treats an unknown age as not current", () => {
+ expect(isCurrentForTodayClaim(null)).toBe(false);
+ expect(isCurrentForTodayClaim(undefined)).toBe(false);
+ expect(isCurrentForTodayClaim(Number.NaN)).toBe(false);
+ expect(isCurrentForTodayClaim(Number.POSITIVE_INFINITY)).toBe(false);
+ expect(isCurrentForTodayClaim(-1)).toBe(false);
+ });
+
+ it("counts whole calendar days between two local day keys", () => {
+ expect(dayKeyAgeInDays("2026-08-07", "2026-08-07")).toBe(0);
+ expect(dayKeyAgeInDays("2026-08-06", "2026-08-07")).toBe(1);
+ expect(dayKeyAgeInDays("2026-08-02", "2026-08-07")).toBe(5);
+ // Across a month end and across a DST change in the reader's zone.
+ expect(dayKeyAgeInDays("2026-07-31", "2026-08-07")).toBe(7);
+ expect(dayKeyAgeInDays("2026-10-24", "2026-10-26")).toBe(2);
+ expect(dayKeyAgeInDays("not-a-day", "2026-08-07")).toBeNull();
+ });
+});
+
+describe("the baseline-drift card's deviations", () => {
+ /** A pulse well above its band, taken `daysAgo` days ago. */
+ const raisedPulse = (daysAgo: number) =>
+ classifyDeviation("PULSE", 96, 58, 74, 66, daysAgo);
+
+ it("carries a deviation measured today", () => {
+ const summary = summariseHealthStatus([raisedPulse(0)], []);
+
+ expect(summary.present).toBe(true);
+ expect(summary.deviations.map((d) => d.type)).toEqual(["PULSE"]);
+ });
+
+ it("says nothing about a vital last measured five days ago", () => {
+ const summary = summariseHealthStatus([raisedPulse(5)], []);
+
+ expect(summary.deviations).toEqual([]);
+ expect(summary.present).toBe(false);
+ });
+
+ it("keeps a dated level shift, which was never a today claim", () => {
+ const summary = summariseHealthStatus(
+ [raisedPulse(5)],
+ [
+ {
+ metric: "RESTING_HEART_RATE",
+ breakDate: "2026-07-20",
+ beforeMean: 54,
+ afterMean: 61,
+ direction: "up",
+ magnitude: 2.1,
+ },
+ ],
+ );
+
+ expect(summary.deviations).toEqual([]);
+ expect(summary.shifts).toHaveLength(1);
+ expect(summary.present).toBe(true);
+ });
+});
+
+describe("the Coach snapshot stamp", () => {
+ it("states the age and the consequence on the block itself", () => {
+ expect(asOfFromDaysAgo(0)).toEqual({
+ daysAgo: 0,
+ isToday: true,
+ currentForTodayClaims: true,
+ });
+ expect(asOfFromDaysAgo(5)).toEqual({
+ daysAgo: 5,
+ isToday: false,
+ currentForTodayClaims: false,
+ });
+ });
+
+ it("stamps every metric block and names the stale ones", () => {
+ const snapshot: Record = {
+ pulse: {
+ aggregate: { avg30: 66, coverage: { count: 40, newestDaysAgo: 5 } },
+ timeline: { recent: [] },
+ },
+ weight: {
+ aggregate: { latest: 81.4, coverage: { count: 30, newestDaysAgo: 0 } },
+ },
+ // No measurement to date — untouched.
+ memory: { headline: "steady month" },
+ scope: { sources: ["pulse", "weight"] },
+ };
+
+ const stale = annotateSnapshotFreshness(snapshot);
+
+ expect(stale).toEqual(["pulse"]);
+ expect((snapshot.pulse as { asOf: unknown }).asOf).toEqual({
+ daysAgo: 5,
+ isToday: false,
+ currentForTodayClaims: false,
+ });
+ expect((snapshot.weight as { asOf: unknown }).asOf).toEqual({
+ daysAgo: 0,
+ isToday: true,
+ currentForTodayClaims: true,
+ });
+ expect(snapshot.memory).toEqual({ headline: "steady month" });
+ expect(snapshot.scope).toEqual({ sources: ["pulse", "weight"] });
+ });
+});
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..152c4401f 100644
--- a/src/lib/insights/derived/coincident-deviation.ts
+++ b/src/lib/insights/derived/coincident-deviation.ts
@@ -40,6 +40,11 @@ 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 {
+ dayKeyAgeInDays,
+ isCurrentForTodayClaim,
+} from "@/lib/insights/measurement-freshness";
import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
@@ -69,6 +74,16 @@ export interface VitalDeviation {
outside: boolean;
/** "above" / "below" the band, or "in" when inside. */
direction: "above" | "below" | "in";
+ /**
+ * Whole days between the reading this standing was computed from and the
+ * caller's own local today. `0` is today, `1` yesterday.
+ *
+ * The value used to be presented with no age at all, so a vital last taken
+ * five days ago was narrated as "outside its range today" on a day that
+ * carried no reading. Consumers making a present-tense claim check this;
+ * `contributing` below already does.
+ */
+ daysAgo: number;
}
export interface CoincidentDeviationValue {
@@ -101,6 +116,7 @@ export function classifyDeviation(
low: number,
high: number,
center: number,
+ daysAgo: number = 0,
): VitalDeviation {
const above = value > high;
const below = value < low;
@@ -112,6 +128,7 @@ export function classifyDeviation(
high,
outside: above || below,
direction: above ? "above" : below ? "below" : "in",
+ daysAgo,
};
}
@@ -154,15 +171,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,
@@ -188,6 +226,9 @@ export async function computeCoincidentDeviation(
let latestDay = "";
let anyDaySource = false;
let maxHistoryDays = 0;
+ // The reader's own calendar day — the anchor every vital's age is measured
+ // against, so "today" means the day the reader is having.
+ const todayKey = userDayKey(now, tz);
// Fan the baseline engine across the supported vitals. Each baseline read
// shares the one coverage probe (no per-vital re-probe).
@@ -213,6 +254,7 @@ export async function computeCoincidentDeviation(
baseline.value.low,
baseline.value.high,
baseline.value.center,
+ dayKeyAgeInDays(latest.day, todayKey) ?? Number.POSITIVE_INFINITY,
),
);
}
@@ -241,7 +283,16 @@ export async function computeCoincidentDeviation(
});
}
- const contributing = vitals.filter((v) => v.outside);
+ // Everything this flag says is about TODAY — the card is literally called
+ // "signals of the day" and every line it renders is present tense. A vital
+ // whose freshest reading is older than that cannot contribute to it: the
+ // reading describes the day it was taken on, and saying otherwise tells the
+ // reader something about a day on which nothing was measured. Stale vitals
+ // stay in `vitals` with their age, so the anatomy view still lists them and
+ // nothing silently disappears.
+ const contributing = vitals.filter(
+ (v) => v.outside && isCurrentForTodayClaim(v.daysAgo),
+ );
const fired = contributing.length >= COINCIDENT_FIRE_THRESHOLD;
// v1.18.1 P4 — when the flag fired, reframe it as illness-explained if an
diff --git a/src/lib/insights/health-status.ts b/src/lib/insights/health-status.ts
index 419f7dbc1..a1dc31092 100644
--- a/src/lib/insights/health-status.ts
+++ b/src/lib/insights/health-status.ts
@@ -17,6 +17,7 @@
import type { MeasurementType } from "@/generated/prisma/client";
import type { VitalDeviation } from "@/lib/insights/derived/coincident-deviation";
import type { ChangepointSignal } from "@/lib/insights/derived/changepoint";
+import { isCurrentForTodayClaim } from "@/lib/insights/measurement-freshness";
/** One vital sitting outside its personal band today. */
export interface HealthStatusDeviation {
@@ -58,7 +59,15 @@ export function summariseHealthStatus(
shifts: readonly ChangepointSignal[],
): HealthStatusSummary {
const deviations: HealthStatusDeviation[] = vitals
- .filter((v) => v.outside && v.direction !== "in")
+ // Present tense, so present readings only. "Your pulse is above your usual
+ // range" describes now; a reading from last week describes last week, and
+ // the card carried no date to say which. A vital whose freshest reading is
+ // outside the claim window is left out rather than dressed as current —
+ // the changepoint shifts below are dated by construction and unaffected.
+ .filter(
+ (v) =>
+ v.outside && v.direction !== "in" && isCurrentForTodayClaim(v.daysAgo),
+ )
.map((v) => ({
type: v.type,
value: v.value,
diff --git a/src/lib/insights/measurement-freshness.ts b/src/lib/insights/measurement-freshness.ts
new file mode 100644
index 000000000..54068b1ba
--- /dev/null
+++ b/src/lib/insights/measurement-freshness.ts
@@ -0,0 +1,57 @@
+/**
+ * How old a reading may be before "today" stops being true about it.
+ *
+ * Several surfaces make present-tense statements out of the freshest stored
+ * reading: the day-signals card ("one of your vitals is outside its usual
+ * range today"), the baseline-drift card ("your pulse is above your usual
+ * range"), and the narrated hero line on the dashboard. Every one of them was
+ * handed "the latest value" with no notion of when it was taken, so a reading
+ * from last week was narrated as this morning's — the reader is told something
+ * about a day on which nothing was measured at all.
+ *
+ * The answer belongs beside the data, not beside each sentence. A reading is
+ * material for a claim about today when it comes from today or from yesterday;
+ * anything older is history, and history is stated with its date.
+ *
+ * The window is deliberately one day rather than zero. A vital taken at 23:40
+ * and read at 00:10 is the same reading it was thirty minutes earlier, and a
+ * morning weigh-in narrated that evening is still current. Two days is not:
+ * by then a day has passed with no reading in it, and the sentence would be
+ * describing a gap.
+ */
+
+/** Whole days of age a reading may carry and still support a present claim. */
+export const TODAY_CLAIM_MAX_AGE_DAYS = 1;
+
+/**
+ * Whether a reading of this age may back a present-tense statement.
+ *
+ * An absent, negative or non-finite age answers `false`. A missing age is not
+ * a fresh one, and it is exactly the case that produced the wrong sentence:
+ * nothing knew how old the value was, so everything assumed it was new.
+ */
+export function isCurrentForTodayClaim(
+ daysAgo: number | null | undefined,
+): boolean {
+ return (
+ typeof daysAgo === "number" &&
+ Number.isFinite(daysAgo) &&
+ daysAgo >= 0 &&
+ daysAgo <= TODAY_CLAIM_MAX_AGE_DAYS
+ );
+}
+
+/**
+ * Whole days between two `YYYY-MM-DD` local-day keys (`then` before `today`).
+ *
+ * Both keys are already resolved in the reader's own timezone by the caller,
+ * so the arithmetic is plain calendar subtraction — parsing them as UTC
+ * midnights keeps it free of any second zone conversion. A malformed key
+ * answers `null` rather than a fabricated distance.
+ */
+export function dayKeyAgeInDays(then: string, today: string): number | null {
+ const a = Date.parse(`${then}T00:00:00.000Z`);
+ const b = Date.parse(`${today}T00:00:00.000Z`);
+ if (!Number.isFinite(a) || !Number.isFinite(b)) return null;
+ return Math.round((b - a) / 86_400_000);
+}
diff --git a/src/lib/measurements/entry-units.ts b/src/lib/measurements/entry-units.ts
new file mode 100644
index 000000000..73e2a1e45
--- /dev/null
+++ b/src/lib/measurements/entry-units.ts
@@ -0,0 +1,67 @@
+/**
+ * Where the entry form asks for one unit and the column stores another.
+ *
+ * This is not the metric/imperial display transform in `display-transform.ts`:
+ * that one follows a preference and is the identity for anyone on metric. This
+ * one holds regardless of preference, because the storage unit was chosen for
+ * the sync sources rather than for the person typing.
+ *
+ * Sleep is the case. `SLEEP_DURATION` is stored in minutes — every provider
+ * delivers minutes, and a per-stage segment is only a few of them — while a
+ * person logging a night by hand thinks in hours and the field says so. The
+ * form sent the typed number straight through, so a night entered as 7.5 was
+ * stored as seven and a half MINUTES and read back on the list as eight
+ * minutes of sleep. Nothing rejected it: seven and a half minutes is inside
+ * the column's plausibility band, because a single sleep STAGE really can be
+ * that short.
+ *
+ * The storage unit stays minutes. The conversion belongs at the entry
+ * boundary, in one place, so a second surface that starts taking sleep by hand
+ * has one answer to reach for rather than its own.
+ *
+ * Only the direction that exists is here. An inverse and a "does this type
+ * convert" predicate shipped alongside it at first, written for an editing
+ * surface that does not exist and has no ticket; they were nothing but two
+ * more things to keep true. The git history holds them if that surface ever
+ * arrives.
+ */
+
+export const MINUTES_PER_HOUR = 60;
+
+/**
+ * Entry unit → canonical unit multiplier, per measurement type. A type absent
+ * from this table enters in the unit it is stored in.
+ */
+const ENTRY_TO_CANONICAL_FACTOR: Readonly> = {
+ SLEEP_DURATION: MINUTES_PER_HOUR,
+};
+
+/**
+ * Convert a value as typed in the entry form to the unit the column stores.
+ *
+ * The result is rounded to the nearest whole canonical unit for a converted
+ * type: 7.3 hours is 438 minutes, and floating-point multiplication would
+ * otherwise store 437.99999999999994 for it. An unconverted type is returned
+ * untouched, so no existing metric changes shape.
+ */
+export function entryValueToCanonical(type: string, entered: number): number {
+ const factor = ENTRY_TO_CANONICAL_FACTOR[type];
+ if (factor === undefined || !Number.isFinite(entered)) return entered;
+ return Math.round(entered * factor);
+}
+
+/**
+ * Read a number out of an entry field, accepting the decimal comma.
+ *
+ * Most of the application's readers are German, and "7,5" is how they write
+ * seven and a half. `parseFloat` reads that as 7 and discards the rest without
+ * complaint — the worst shape of wrong, since the entry looks accepted.
+ * Returns `null` for anything that is not a number, so the caller decides what
+ * an empty field means rather than inheriting a `NaN`.
+ */
+export function parseDecimalEntry(raw: string): number | null {
+ const trimmed = raw.trim().replace(",", ".");
+ if (trimmed === "") return null;
+ const parsed = Number(trimmed);
+ return Number.isFinite(parsed) ? parsed : null;
+}
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));
+ });
+});
diff --git a/tests/integration/intake-backfill-missed-slot.test.ts b/tests/integration/intake-backfill-missed-slot.test.ts
new file mode 100644
index 000000000..9df84f04f
--- /dev/null
+++ b/tests/integration/intake-backfill-missed-slot.test.ts
@@ -0,0 +1,278 @@
+/**
+ * Backfilling a missed dose from the Verlauf ledger must record THAT slot.
+ *
+ * The ledger's "Genommen" action posts the displayed slot's own anchor as
+ * `scheduledFor` with `takenAt = now`, meaning "record this dose". The route
+ * resolved the slot by window band from `takenAt` instead, so a backfill of a
+ * slot from an earlier day matched no band, fell through to the standalone
+ * insert, and landed as an ad-hoc row anchored at the recording moment. The
+ * named slot stayed unserved: the optimistic row flipped to taken, the
+ * authoritative refetch read the slot as missed again, and the entry the user
+ * had just made was gone from the day they made it for.
+ *
+ * Everything here goes through the shipped `POST` / `GET` exports so the real
+ * `apiHandler`, the real auth resolution and the real Prisma writes answer.
+ * The assertions are on the persisted row AND on the ledger the tab reads
+ * back — the pipe between them is where the entry disappeared.
+ */
+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";
+
+import { localHmAsUtc } from "@/lib/tz/local-day";
+
+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 TZ = "Europe/Berlin";
+const DAY_MS = 24 * 60 * 60 * 1000;
+
+let counter = 0;
+
+async function makeUser() {
+ const suffix = `backfill-${counter++}`;
+ return getPrismaClient().user.create({
+ data: {
+ username: `bf-${suffix}`,
+ email: `bf-${suffix}@example.test`,
+ role: "USER",
+ timezone: TZ,
+ },
+ });
+}
+
+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);
+ return session;
+}
+
+/**
+ * Slot hours placed so neither dose window can contain the moment the test
+ * runs. A band spans roughly [anchor − 1 h, anchor + 4 h]; +5 h and +12 h from
+ * the current local hour keep "now" outside both on every clock, so the
+ * assertions never depend on when the suite happens to run.
+ */
+function slotHours(): { a: number; b: number } {
+ const hour = Number(
+ new Intl.DateTimeFormat("en-GB", {
+ timeZone: TZ,
+ hour: "2-digit",
+ hour12: false,
+ }).format(new Date()),
+ );
+ return { a: (hour + 5) % 24, b: (hour + 12) % 24 };
+}
+
+async function seedTwiceDaily(userId: string, hours: { a: number; b: number }) {
+ const hh = (h: number) => `${String(h).padStart(2, "0")}:00`;
+ const med = await getPrismaClient().medication.create({
+ data: {
+ userId,
+ name: "Ramipril",
+ dose: "5mg",
+ active: true,
+ startsOn: new Date(Date.now() - 30 * DAY_MS),
+ // The ledger clamps its window floor to the medication's creation, so a
+ // medication minted at test time would have no yesterday to read back.
+ createdAt: new Date(Date.now() - 30 * DAY_MS),
+ schedules: {
+ create: {
+ windowStart: hh(hours.a),
+ windowEnd: hh(hours.b),
+ timesOfDay: [hh(hours.a), hh(hours.b)],
+ daysOfWeek: null,
+ scheduleType: "SCHEDULED",
+ },
+ },
+ },
+ });
+ return med.id;
+}
+
+type Handler = (
+ request: NextRequest,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ context: { params: Promise },
+) => Promise;
+
+async function postIntake(medicationId: string, body: unknown) {
+ const { POST } = await import("@/app/api/medications/[id]/intake/route");
+ return (POST as Handler)(
+ new NextRequest(`http://localhost/api/medications/${medicationId}/intake`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ }),
+ { params: Promise.resolve({ id: medicationId }) },
+ );
+}
+
+async function readLedger(medicationId: string, from: Date, to: Date) {
+ const { GET } = await import("@/app/api/medications/[id]/dose-history/route");
+ const search = new URLSearchParams({
+ from: from.toISOString(),
+ to: to.toISOString(),
+ });
+ const response = await (GET as Handler)(
+ new NextRequest(
+ `http://localhost/api/medications/${medicationId}/dose-history?${search}`,
+ { method: "GET" },
+ ),
+ { params: Promise.resolve({ id: medicationId }) },
+ );
+ expect(response.status).toBe(200);
+ const body = (await response.json()) as {
+ data: {
+ rows: {
+ kind: string;
+ at: string;
+ status: string;
+ intake: { takenAt: string | null } | null;
+ }[];
+ };
+ };
+ return body.data.rows;
+}
+
+beforeEach(async () => {
+ await truncateAllTables(getPrismaClient());
+ cookieJar.clear();
+ headerJar.clear();
+});
+
+describe("POST /api/medications/[id]/intake — backfilling a missed slot", () => {
+ it("records the named slot from an earlier day, not an ad-hoc row at 'now'", async () => {
+ const prisma = getPrismaClient();
+ const user = await makeUser();
+ await signIn(user.id);
+ const hours = slotHours();
+ const medicationId = await seedTwiceDaily(user.id, hours);
+
+ // Yesterday's first slot: long past its overdue tail, so the ledger reads
+ // it as missed and the kebab offers "Genommen".
+ const missedSlot = localHmAsUtc(
+ new Date(Date.now() - DAY_MS),
+ TZ,
+ hours.a,
+ 0,
+ );
+
+ const response = await postIntake(medicationId, {
+ skipped: false,
+ scheduledFor: missedSlot.toISOString(),
+ takenAt: new Date().toISOString(),
+ });
+ expect(response.status).toBe(201);
+
+ // The row lands ON the slot the user marked.
+ const rows = await prisma.medicationIntakeEvent.findMany({
+ where: { userId: user.id, medicationId, deletedAt: null },
+ });
+ expect(rows).toHaveLength(1);
+ expect(rows[0]?.scheduledFor.getTime()).toBe(missedSlot.getTime());
+ expect(rows[0]?.takenAt).not.toBeNull();
+ expect(rows[0]?.skipped).toBe(false);
+ });
+
+ it("the ledger reads the backfilled slot as taken, not missed", async () => {
+ const user = await makeUser();
+ await signIn(user.id);
+ const hours = slotHours();
+ const medicationId = await seedTwiceDaily(user.id, hours);
+ const missedSlot = localHmAsUtc(
+ new Date(Date.now() - DAY_MS),
+ TZ,
+ hours.a,
+ 0,
+ );
+
+ await postIntake(medicationId, {
+ skipped: false,
+ scheduledFor: missedSlot.toISOString(),
+ takenAt: new Date().toISOString(),
+ });
+
+ const rows = await readLedger(
+ medicationId,
+ new Date(Date.now() - 3 * DAY_MS),
+ new Date(),
+ );
+ const slotRow = rows.find(
+ (r) => r.kind === "slot" && r.at === missedSlot.toISOString(),
+ );
+ expect(slotRow).toBeDefined();
+ expect(slotRow?.status).toMatch(/^taken_/);
+ expect(slotRow?.intake?.takenAt).not.toBeNull();
+ // And no orphan ad-hoc row at the recording moment.
+ expect(rows.filter((r) => r.kind === "ad_hoc")).toHaveLength(0);
+ });
+
+ it("still refuses to fill a slot that lies ahead of the take", async () => {
+ const prisma = getPrismaClient();
+ const user = await makeUser();
+ await signIn(user.id);
+ const hours = slotHours();
+ const medicationId = await seedTwiceDaily(user.id, hours);
+
+ // Tomorrow's first slot — the "late-morning dose must not consume the
+ // evening slot" invariant, stated across a day boundary so it holds
+ // whatever the clock says when the suite runs.
+ const futureSlot = localHmAsUtc(
+ new Date(Date.now() + DAY_MS),
+ TZ,
+ hours.a,
+ 0,
+ );
+
+ const response = await postIntake(medicationId, {
+ skipped: false,
+ scheduledFor: futureSlot.toISOString(),
+ takenAt: new Date().toISOString(),
+ });
+ expect(response.status).toBe(201);
+
+ const onFutureSlot = await prisma.medicationIntakeEvent.findMany({
+ where: {
+ userId: user.id,
+ medicationId,
+ scheduledFor: futureSlot,
+ deletedAt: null,
+ },
+ });
+ expect(onFutureSlot).toHaveLength(0);
+
+ const all = await prisma.medicationIntakeEvent.findMany({
+ where: { userId: user.id, medicationId, deletedAt: null },
+ });
+ expect(all).toHaveLength(1);
+ expect(all[0]?.scheduledFor.getTime()).toBeLessThan(futureSlot.getTime());
+ });
+});
diff --git a/tests/integration/sleep-entry-hours-round-trip.test.ts b/tests/integration/sleep-entry-hours-round-trip.test.ts
new file mode 100644
index 000000000..9389eb742
--- /dev/null
+++ b/tests/integration/sleep-entry-hours-round-trip.test.ts
@@ -0,0 +1,153 @@
+/**
+ * A night typed as hours must come back as that many hours.
+ *
+ * The manual sleep field says hours and the column stores minutes, and the
+ * form sent the typed number through unchanged: a 7.5-hour night was filed as
+ * seven and a half MINUTES. Nothing rejected it, because a single sleep stage
+ * really can be that short, so the entry looked accepted and the list showed
+ * eight minutes of sleep.
+ *
+ * Both ends were already right on their own — the field's label and the list's
+ * duration formatter. The assembly between them was what dropped the factor,
+ * so this runs the whole of it: the form's conversion, the shipped POST, the
+ * stored row, the shipped GET, and the formatter the list paints with.
+ */
+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";
+
+import {
+ entryValueToCanonical,
+ parseDecimalEntry,
+} from "@/lib/measurements/entry-units";
+import { formatDurationMinutes } from "@/lib/i18n/duration";
+
+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),
+}));
+
+let counter = 0;
+
+async function makeUser() {
+ const suffix = `sleep-${counter++}`;
+ return getPrismaClient().user.create({
+ data: {
+ username: `sl-${suffix}`,
+ email: `sl-${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);
+}
+
+/** Exactly what the form's submit path does with what the reader typed. */
+function submitPathValue(type: string, typedInTheField: string): number {
+ const typed = parseDecimalEntry(typedInTheField);
+ expect(typed).not.toBeNull();
+ return entryValueToCanonical(type, typed!);
+}
+
+async function postMeasurement(body: unknown) {
+ const { POST } = await import("@/app/api/measurements/route");
+ return (POST as (request: NextRequest) => Promise)(
+ new NextRequest("http://localhost/api/measurements", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ }),
+ );
+}
+
+/** The English catalogue's duration strings, as the list renders them. */
+const t = (key: string, params?: Record): string =>
+ key === "common.durationHoursMinutes"
+ ? `${params?.hours} h ${params?.minutes} min`
+ : `${params?.minutes} min`;
+
+beforeEach(async () => {
+ await truncateAllTables(getPrismaClient());
+ cookieJar.clear();
+ headerJar.clear();
+});
+
+describe("manual sleep entry — hours in, hours out", () => {
+ it.each([
+ ["8", 480, "8 h 0 min"],
+ ["7.5", 450, "7 h 30 min"],
+ // The decimal comma most of the readers actually type.
+ ["7,5", 450, "7 h 30 min"],
+ ["6,25", 375, "6 h 15 min"],
+ ])(
+ "a night typed as %s reads back as %s minutes (%s)",
+ async (typedInTheField, expectedMinutes, expectedLabel) => {
+ const user = await makeUser();
+ await signIn(user.id);
+ const measuredAt = new Date(Date.now() - 3_600_000).toISOString();
+
+ const response = await postMeasurement({
+ type: "SLEEP_DURATION",
+ value: submitPathValue("SLEEP_DURATION", typedInTheField),
+ measuredAt,
+ });
+ expect(response.status).toBe(201);
+
+ // The stored row, in the canonical unit.
+ const rows = await getPrismaClient().measurement.findMany({
+ where: { userId: user.id, type: "SLEEP_DURATION", deletedAt: null },
+ });
+ expect(rows).toHaveLength(1);
+ expect(rows[0]?.value).toBe(expectedMinutes);
+
+ // And what the reader is shown when the list paints that row.
+ expect(formatDurationMinutes(rows[0]!.value, t)).toBe(expectedLabel);
+ },
+ );
+
+ it("leaves a metric with no entry conversion exactly as typed", async () => {
+ const user = await makeUser();
+ await signIn(user.id);
+
+ const response = await postMeasurement({
+ type: "WEIGHT",
+ value: submitPathValue("WEIGHT", "81,4"),
+ measuredAt: new Date(Date.now() - 3_600_000).toISOString(),
+ });
+ expect(response.status).toBe(201);
+
+ const rows = await getPrismaClient().measurement.findMany({
+ where: { userId: user.id, type: "WEIGHT", deletedAt: null },
+ });
+ expect(rows[0]?.value).toBe(81.4);
+ });
+});