From 4e9e8649ecb2d57ad2438564ac1dca4c89ae7064 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 7 Aug 2026 14:03:09 +0200 Subject: [PATCH 1/5] fix(medications): record the slot a backfilled dose names Marking a missed dose taken from the Verlauf ledger showed the row flip to taken and then fall straight back to missed, with the entry nowhere on the day it was made for. The ledger posts the displayed slot's own anchor as `scheduledFor` and means "record THIS dose". The route resolved the slot by window band from `takenAt` instead, which answers a different question: which slot the recording moment falls into. Backfilling an older slot matches no band, so the write 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 was overwritten by the authoritative refetch, and the dose sat under today with no sign of where the user had put it. A named instant that is a real slot of this medication now decides the binding, validated against the same band anchors the "diesem Slot zuordnen" pin uses, so an arbitrary client instant still records ad-hoc. A slot that lies ahead of the take is still refused by the existing forward guard, so a late-morning dose cannot consume the evening slot. When the naming and the clock disagree the binding rests on the user's choice, so the row stores it as such and the read ledger anchors it there rather than dropping back to ad-hoc; an ordinary on-time take resolves to the same anchor either way and is untouched. The tests drive the shipped POST and the shipped dose-history GET, and assert the persisted row and the ledger read back from it. --- src/app/api/medications/[id]/intake/route.ts | 47 +++ .../intake-backfill-missed-slot.test.ts | 278 ++++++++++++++++++ 2 files changed, 325 insertions(+) create mode 100644 tests/integration/intake-backfill-missed-slot.test.ts 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/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()); + }); +}); From 4563bce5b3b3e9728c3c90a2dc1f752648d9c105 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 7 Aug 2026 14:16:17 +0200 Subject: [PATCH 2/5] fix(insights): keep impossible readings out of the personal band MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The baseline-drift card reported a pulse in the hundreds of millions against a usual range in the tens of thousands. Neither figure is a heart rate, and both came from the same place. The application declares a plausibility domain per metric, and every interactive, import, MCP and Telegram write validates against it. The derivation layer did not ask. So a single stored value outside that domain — a provider unit-decode slip, or a row from a writer that never had the gate — was folded into the day mean, carried by the median into the band, and then held against itself as "currently". The two numbers in the sentence were wrong by different amounts, which is why the line read as nonsense rather than as an exaggeration. Both readers now refuse a value the application has already declared impossible: the per-day means behind the band, and the latest-day mean the deviation reads. A day left with no plausible reading contributes nothing rather than a fabricated point, and the vital drops out of the comparison — the coverage floor already knows how to report an absent day. The rollup branch applies the same rule to a bucket mean, since the rollup writer averages whatever is stored. Everything downstream of `readDayMeanSeries` inherits it, which is every consumer of the usual range: the band strip, the drift card, the changepoint detector, readiness, trajectory, the coach read. The card also printed raw doubles. Each figure now renders at the metric's own decimal precision through the shared formatter, so a mean reads as a number rather than as a float and follows the reader's number convention. Left open, and recorded for a separate pass: the provider sync writers do not enforce the domain on the way in, so the impossible rows keep landing. --- .../insights/health-status-card.tsx | 22 ++- .../__tests__/baseline-plausibility.test.ts | 115 +++++++++++++ src/lib/insights/derived/baseline.ts | 69 ++++++-- .../insights/derived/coincident-deviation.ts | 28 ++- src/lib/measurements/value-domain.ts | 30 ++++ .../health-status-implausible-values.test.ts | 160 ++++++++++++++++++ 6 files changed, 399 insertions(+), 25 deletions(-) create mode 100644 src/lib/insights/derived/__tests__/baseline-plausibility.test.ts create mode 100644 tests/integration/health-status-implausible-values.test.ts 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)); + }); +}); From dfd9b4679b4f24d4fd63eb6e08f366fc978d933c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 7 Aug 2026 14:24:13 +0200 Subject: [PATCH 3/5] fix(insights): stop making claims about today out of older readings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hero line said a pulse was well above its 30-day average today. Nothing had measured a pulse for five days. The day-signals card flagged the same reading, and the baseline-drift card said the value was above the usual range, present tense, on a day that held no reading at all. One class, and it sits in the data preparation rather than in any of the sentences. Each surface was handed "the freshest stored value" with no notion of when it was taken, so each of them assumed now. The age was reachable — the aggregates carry `newestDaysAgo` inside their coverage object — but it read as a statistics footnote rather than as the fact that governs the tense of every sentence about the metric, and none of the three consulted it. A reading is material for a claim about today when it comes from today or from yesterday. One day rather than zero: a vital taken at 23:40 and read at 00:10 is the same reading it was half an hour earlier. Two is not, because by then a whole day has passed with nothing in it and the sentence would be describing a gap. The rule now lives beside the data. The vital deviations carry the age of the reading they were computed from, and the two deterministic consumers apply it: the day-signals flag counts only current vitals toward its count, and the baseline-drift card surfaces only current deviations. Stale vitals stay in the list with their age, so the anatomy view still shows them and nothing silently disappears; the dated level shifts were never today claims and are untouched. For the narrated surfaces the same fact is stamped onto every metric block of the Coach snapshot before anything reads it, so the age travels with the numbers into the hero, the briefing, the Coach reply and the retrieval tools instead of each of them guessing. A block narrated off a stale series also lands in the wide event, which is where it should have been visible all along. --- .../coincident-deviation-card.test.tsx | 1 + src/lib/ai/coach/snapshot-freshness.ts | 87 +++++++++++ src/lib/ai/coach/snapshot.ts | 14 ++ .../insights/__tests__/health-status.test.ts | 2 + .../__tests__/measurement-freshness.test.ts | 138 ++++++++++++++++++ .../insights/derived/coincident-deviation.ts | 31 +++- src/lib/insights/health-status.ts | 11 +- src/lib/insights/measurement-freshness.ts | 57 ++++++++ 8 files changed, 339 insertions(+), 2 deletions(-) create mode 100644 src/lib/ai/coach/snapshot-freshness.ts create mode 100644 src/lib/insights/__tests__/measurement-freshness.test.ts create mode 100644 src/lib/insights/measurement-freshness.ts 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/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/coincident-deviation.ts b/src/lib/insights/derived/coincident-deviation.ts index 6217971ab..152c4401f 100644 --- a/src/lib/insights/derived/coincident-deviation.ts +++ b/src/lib/insights/derived/coincident-deviation.ts @@ -41,6 +41,10 @@ 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; @@ -70,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 { @@ -102,6 +116,7 @@ export function classifyDeviation( low: number, high: number, center: number, + daysAgo: number = 0, ): VitalDeviation { const above = value > high; const below = value < low; @@ -113,6 +128,7 @@ export function classifyDeviation( high, outside: above || below, direction: above ? "above" : below ? "below" : "in", + daysAgo, }; } @@ -210,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). @@ -235,6 +254,7 @@ export async function computeCoincidentDeviation( baseline.value.low, baseline.value.high, baseline.value.center, + dayKeyAgeInDays(latest.day, todayKey) ?? Number.POSITIVE_INFINITY, ), ); } @@ -263,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); +} From d97e3c0976affdeb4d082d0a71788dd076ef7026 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 7 Aug 2026 14:30:10 +0200 Subject: [PATCH 4/5] fix(measurements): store a hand-entered night in the unit the field asks for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The manual sleep field is labelled in hours. The column stores minutes, because that is what every sync source delivers and because a single stage segment is only a few of them. The form sent the typed number straight through, so a night entered as 7.5 was filed as seven and a half minutes and the list showed eight minutes of sleep. Nothing rejected it. Seven and a half minutes is inside the column's plausibility band — a stage really can be that short — so the entry looked accepted and the loss was silent. Both ends were already right: the field says hours, the list formats minutes as hours and minutes. The assembly between them was where the factor went missing, which is why the round-trip test runs the whole of it — the form's conversion, the shipped POST, the stored row, and the formatter the list paints with. The storage unit stays minutes. The conversion sits at the entry boundary in one place with its inverse beside it, so a future editing surface cannot land on a different answer, and it rounds to whole minutes rather than storing 437.99999999999994 for 7.3 hours. The value field also lost "7,5" outright. `type="number"` sanitises a decimal comma away on the engines that do not localise the field, so the entry arrived as an empty string with no explanation, and `parseFloat` would have read it as 7 on the engines where it survived. The field now takes the same shape the labs and custom-metric fields use — a numeric keypad and a submit path that reads the comma — so every decimal metric on the form gains it, not only sleep. `scripts/diagnose-manual-sleep-hours.ts` lists the rows already stored this way. It reports and does not repair: an eight-minute stage 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. --- scripts/diagnose-manual-sleep-hours.ts | 103 ++++++++++++ .../measurements/measurement-form.tsx | 25 ++- src/lib/measurements/entry-units.ts | 73 +++++++++ .../sleep-entry-hours-round-trip.test.ts | 153 ++++++++++++++++++ 4 files changed, 351 insertions(+), 3 deletions(-) create mode 100644 scripts/diagnose-manual-sleep-hours.ts create mode 100644 src/lib/measurements/entry-units.ts create mode 100644 tests/integration/sleep-entry-hours-round-trip.test.ts 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/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/measurements/entry-units.ts b/src/lib/measurements/entry-units.ts new file mode 100644 index 000000000..147886507 --- /dev/null +++ b/src/lib/measurements/entry-units.ts @@ -0,0 +1,73 @@ +/** + * 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, with the inverse beside it so a future editing + * surface cannot land on a different answer. + */ + +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, +}; + +/** Whether the type's entry unit differs from the unit its column stores. */ +export function hasEntryUnitConversion(type: string): boolean { + return type in ENTRY_TO_CANONICAL_FACTOR; +} + +/** + * 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); +} + +/** The inverse: a stored value expressed in the unit the entry form asks for. */ +export function canonicalValueToEntry(type: string, canonical: number): number { + const factor = ENTRY_TO_CANONICAL_FACTOR[type]; + if (factor === undefined || !Number.isFinite(canonical)) return canonical; + return canonical / 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/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); + }); +}); From 76698684799792ee0d0e4f72a6f16e8dd1e9063d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 7 Aug 2026 18:32:38 +0200 Subject: [PATCH 5/5] refactor(measurements): drop the entry-unit helpers nothing calls `hasEntryUnitConversion` and `canonicalValueToEntry` shipped beside the hours-to-minutes conversion for an editing surface that does not exist and has no ticket. Neither has a caller, so both were only two more things to keep true, and the dead-code gate is right to say so. The module comment claimed the inverse was there so a future surface could not land on a different answer. It says what is actually true now: one direction, one place. The git history keeps the rest if that surface ever arrives. --- src/lib/measurements/entry-units.ts | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/src/lib/measurements/entry-units.ts b/src/lib/measurements/entry-units.ts index 147886507..73e2a1e45 100644 --- a/src/lib/measurements/entry-units.ts +++ b/src/lib/measurements/entry-units.ts @@ -16,8 +16,14 @@ * that short. * * The storage unit stays minutes. The conversion belongs at the entry - * boundary, in one place, with the inverse beside it so a future editing - * surface cannot land on a different answer. + * 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; @@ -30,11 +36,6 @@ const ENTRY_TO_CANONICAL_FACTOR: Readonly> = { SLEEP_DURATION: MINUTES_PER_HOUR, }; -/** Whether the type's entry unit differs from the unit its column stores. */ -export function hasEntryUnitConversion(type: string): boolean { - return type in ENTRY_TO_CANONICAL_FACTOR; -} - /** * Convert a value as typed in the entry form to the unit the column stores. * @@ -49,13 +50,6 @@ export function entryValueToCanonical(type: string, entered: number): number { return Math.round(entered * factor); } -/** The inverse: a stored value expressed in the unit the entry form asks for. */ -export function canonicalValueToEntry(type: string, canonical: number): number { - const factor = ENTRY_TO_CANONICAL_FACTOR[type]; - if (factor === undefined || !Number.isFinite(canonical)) return canonical; - return canonical / factor; -} - /** * Read a number out of an entry field, accepting the decimal comma. *