Files
HealthLog/tests/integration/demo-seed-shape.test.ts
T
Marc-André Bombeck e8c96eea88 docs: say what deletion keeps and for how long, and drop stale claims
The reproductive-health page promised that deleting all data or the
account erases at once. Both wait for the grace window now, the nightly
dumps keep a record until they age out, and the old pgdata volume is a
full copy until it is removed; the page says so and how to make deletion
final. The README, the getting-started note on majors, the move guide and
the Unraid and Portainer templates catch up with Postgres 18, the backups
volume and the recovery kit. The demo seed's spread bound leaves room for
the seed's own one-in-150 spread.
2026-10-25 16:47:00 +01:00

135 lines
5.4 KiB
TypeScript

/**
* The demo's short-window charts need a shape to show. Every 90-day series in
* the seed is a mean-reverting walk, and around a settled target that is flat
* noise: a 7-day trend line over the last 30 days of blood pressure drew a
* straight line, which is exactly the view the demo exists to show off. The
* seed carries a busy stretch a few weeks back that lifts blood pressure to
* about 144/91 for some ten days, and ramipril goes up on the day it
* turns.
*
* The blood-pressure chart scales one axis to fit systolic and diastolic, so a
* swing has to be large to read: a first version lifted systolic by 12 mmHg,
* its 7-day mean swung 8 to 14 mmHg, and on a 60 to 140 axis that still drew a
* nearly flat line. This runs the real `scripts/seed-demo.ts` and holds the
* last 30 days to a swing of the size that reads, to day-to-day noise small
* enough for the 7-day mean to draw an arc, to today's reading back in the
* settled band, and to the dose change landing on the turn. The seed is random,
* so the bounds leave room around the measured values (swing about 23 mmHg,
* day-to-day steps around 1 to 2 mmHg outside the climb and the fall). The
* peak also has a ceiling: past 148 the chart's padded axis jumps to 50-200.
*/
import { beforeAll, describe, expect, it, vi } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
let systolic: number[];
let diastolic: number[];
let means: number[];
let doseChanges: Array<{ effectiveFrom: Date; doseValue: number }>;
beforeAll(async () => {
const prisma = getPrismaClient();
await truncateAllTables(prisma);
const { seed } = await import("../../scripts/seed-demo");
await seed();
const user = await prisma.user.findUniqueOrThrow({
where: { username: "demo" },
select: { id: true },
});
const since = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000);
const rows = await prisma.measurement.findMany({
where: {
userId: user.id,
type: "BLOOD_PRESSURE_SYS",
source: "WITHINGS",
deletedAt: null,
measuredAt: { gte: since },
},
orderBy: { measuredAt: "asc" },
select: { value: true },
});
systolic = rows.map((r) => Number(r.value));
const diaRows = await prisma.measurement.findMany({
where: {
userId: user.id,
type: "BLOOD_PRESSURE_DIA",
source: "WITHINGS",
deletedAt: null,
measuredAt: { gte: since },
},
orderBy: { measuredAt: "asc" },
select: { value: true },
});
diastolic = diaRows.map((r) => Number(r.value));
const ramipril = await prisma.medication.findFirstOrThrow({
where: { userId: user.id, name: "Ramipril" },
select: { id: true },
});
const changes = await prisma.medicationDoseChange.findMany({
where: { medicationId: ramipril.id, effectiveFrom: { gte: since } },
select: { effectiveFrom: true, doseValue: true },
});
doseChanges = changes.map((c) => ({
effectiveFrom: c.effectiveFrom,
doseValue: Number(c.doseValue),
}));
means = [];
for (let i = 6; i < systolic.length; i++) {
const week = systolic.slice(i - 6, i + 1);
means.push(week.reduce((a, b) => a + b, 0) / week.length);
}
}, 180_000);
describe("demo seed, short-window shape", () => {
it("gives the 7-day systolic mean over 30 days a rise and fall that reads", () => {
expect(systolic.length).toBeGreaterThanOrEqual(28);
const swing = Math.max(...means) - Math.min(...means);
expect(swing).toBeGreaterThanOrEqual(18);
expect(Math.max(...systolic)).toBeGreaterThanOrEqual(140);
// One mmHg past 148 and the padded axis rounds out to 50-200 in steps of
// 50, which flattens the rise again.
expect(Math.max(...systolic)).toBeLessThanOrEqual(148);
expect(Math.max(...diastolic)).toBeGreaterThanOrEqual(88);
});
it("keeps the day-to-day steps small enough for the 7-day mean to draw an arc", () => {
const steps = systolic.slice(1).map((v, i) => Math.abs(v - systolic[i]));
const sorted = [...steps].sort((a, b) => a - b);
expect(sorted[Math.floor(sorted.length / 2)]).toBeLessThanOrEqual(3);
});
it("leaves the median of the window in the settled band", () => {
// The usual range is the median ± 3 MAD of the last 30 days. A stretch
// that fills half the window moves that median onto the stretch and
// widens the band to something like 87-181 mmHg. The seed's own spread
// reaches about ±22 in one run of 150, so the bound sits at 24, still far
// below what a stretch that long produces.
const sorted = [...systolic].sort((a, b) => a - b);
const median = sorted[Math.floor(sorted.length / 2)];
expect(median).toBeLessThanOrEqual(124);
const deviations = systolic
.map((v) => Math.abs(v - median))
.sort((a, b) => a - b);
const mad = deviations[Math.floor(deviations.length / 2)];
expect(3 * 1.4826 * mad).toBeLessThanOrEqual(24);
});
it("raises ramipril on the day the stretch turns", () => {
expect(doseChanges).toHaveLength(1);
expect(doseChanges[0].doseValue).toBe(10);
const daysBefore =
(Date.now() - doseChanges[0].effectiveFrom.getTime()) / 86_400_000;
expect(daysBefore).toBeGreaterThan(8);
expect(daysBefore).toBeLessThan(14);
});
it("brings today's systolic reading back down from the stretch", () => {
expect(systolic.at(-1)).toBeLessThanOrEqual(Math.max(...means) - 15);
expect(systolic.at(-1)).toBeLessThanOrEqual(125);
});
});