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 (

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