mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
19 files changed
+1408
-30
No files matched your search
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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 };
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user