Merge pull request #720 from MBombeck/fix/handover-p1-batch

Four data fixes: backfilled doses, impossible readings, stale today-claims, sleep units
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-08-07 19:32:40 +02:00
commit 3778e70ff5
19 files changed
+1408 -30

No files matched your search

+103
View File
@@ -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<void> {
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<string, typeof suspects>();
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();
});
@@ -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)
@@ -37,6 +37,7 @@ function deviation(
high: 62,
outside,
direction: outside ? "above" : "in",
daysAgo: 0,
};
}
+16 -6
View File
@@ -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 (
<section
data-slot="health-status-section"
@@ -100,9 +110,9 @@ export function HealthStatusCard({
</p>
<p className="text-muted-foreground text-xs leading-snug">
{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),
})}
</p>
</div>
@@ -137,8 +147,8 @@ export function HealthStatusCard({
</p>
<p className="text-muted-foreground text-xs leading-snug">
{t("insights.healthStatus.shiftContext", {
before: s.beforeMean,
after: s.afterMean,
before: metricValue(s.metric, s.beforeMean),
after: metricValue(s.metric, s.afterMean),
})}
</p>
</div>
@@ -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({
>
<Input
id="value"
type="number"
// Not `type="number"`: its sanitisation drops a decimal comma on
// the engines that do not localise the field, and "7,5" then
// arrives as an empty string with no explanation. `inputMode`
// still raises the numeric keypad, and the submit path reads the
// comma — the same shape the labs and custom-metric fields use.
inputMode="decimal"
enterKeyHint="next"
step="any"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={
+87
View File
@@ -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, unknown>,
): 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<string, unknown>).asOf = asOf;
if (!asOf.currentForTodayClaims) stale.push(key);
}
return stale;
}
export { TODAY_CLAIM_MAX_AGE_DAYS };
+14
View File
@@ -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
@@ -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,
};
}
@@ -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<string, unknown> = {
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"] });
});
});
@@ -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();
});
});
+53 -16
View File
@@ -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<string, { sum: number; count: number }>();
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<string, { sum: number; count: number }>();
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" };
}
@@ -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
+10 -1
View File
@@ -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,
+57
View File
@@ -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);
}
+67
View File
@@ -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<Record<string, number>> = {
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;
}
+30
View File
@@ -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
@@ -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<Response>)(
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));
});
});
@@ -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<any> },
) => Promise<Response>;
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());
});
});
@@ -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<Response>)(
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, string | number>): 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);
});
});