fix(demo): give the short-window blood pressure 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 the view the demo
exists to show.

A busy stretch three to four weeks back now lifts blood pressure and
pulse and lets them settle again, the mood dips with it, and weekend
readings sit a touch higher. Blood pressure also pulls back towards its
target harder, so a seed no longer ends several mmHg above the band its
stored texts describe; today's reading settles on the days after the
stretch. The two stored texts that summarise the last weeks mention the
stretch.

An integration test runs the real seed and holds the 7-day mean to a
visible swing and today's reading to the settled side of it.
This commit is contained in:
Marc-André Bombeck committed 2026-10-24 18:28:19 +02:00
1 parent 087d0011fc
commit a61fe1c715
2 files changed
+127 -9

No files matched your search

+62 -9
View File
@@ -225,11 +225,15 @@ function randomWalk(
// fresh seed — iOS #33); clamping every step keeps the series inside the
// same band the input validator enforces.
clamp?: { min: number; max: number },
// How hard each step pulls back towards the target. The default lets a
// series wander; blood pressure needs a firmer pull, or a seed could end
// several mmHg above the settled band its stored texts describe.
reversion = 0.03,
): number[] {
const values: number[] = [start];
for (let i = 1; i < days; i++) {
const prev = values[i - 1];
const drift = (target - prev) * 0.03; // mean reversion
const drift = (target - prev) * reversion;
const noise = (Math.random() - 0.5) * volatility;
let next = Math.round((prev + drift + noise) * 10) / 10;
if (clamp) next = Math.min(clamp.max, Math.max(clamp.min, next));
@@ -397,6 +401,23 @@ async function bulkInsert(
}
}
/**
* A busy stretch inside the 90-day window, in days before "now": blood
* pressure and pulse rise and settle again, and the mood dips with them.
* Without it every short-window series was flat noise around its target and a
* 7-day trend line over 30 days drew a straight line; with it the line has a
* shape to follow. It ends well before today, so the latest readings, the
* tiles and the stored texts still describe a settled, optimal band.
*/
const BUSY_STRETCH = { from: 27, to: 12 } as const;
/** 0 outside the busy stretch, rising smoothly to 1 at its middle. */
function busyStretch(daysBefore: number): number {
const { from, to } = BUSY_STRETCH;
if (daysBefore > from || daysBefore < to) return 0;
return Math.sin((Math.PI * (from - daysBefore)) / (from - to)) ** 2;
}
/** Pollen season bump: 0 outside `[from, to]`, peaking at `peak` mid-season. */
function pollenSeason(
doy: number,
@@ -563,6 +584,7 @@ export async function seed() {
span,
3,
VALUE_RANGES.BLOOD_PRESSURE_SYS,
0.1,
);
// Diastolic BP: 82 → settled ~76 (clamped)
const diaBP = randomWalk(
@@ -571,6 +593,7 @@ export async function seed() {
span,
2.5,
VALUE_RANGES.BLOOD_PRESSURE_DIA,
0.1,
);
// Spot pulse (daytime heart rate): ~72, gently lower
const pulse = randomWalk(74, 70, span, 3, VALUE_RANGES.PULSE);
@@ -597,9 +620,35 @@ export async function seed() {
);
// Steps: 6500 → a solid ~9000
const steps = randomWalk(6500, 9000, span, 1500);
for (const series of [weights, sysBP, diaBP, pulse, restingHr]) {
// The busy stretch lifts blood pressure and pulse, and weekends read a
// touch higher than weekdays, as home readings tend to.
for (let i = 0; i < span; i++) {
const before = days - i;
const weekday = daysAgo(before).getDay();
const weekend = weekday === 0 || weekday === 6 ? 1 : 0;
const busy = busyStretch(before);
const lift = (
series: number[],
by: number,
range: { min: number; max: number },
) => {
series[i] = Math.min(
range.max,
Math.max(range.min, Math.round((series[i] + by) * 10) / 10),
);
};
lift(sysBP, busy * 12 + weekend * 2, VALUE_RANGES.BLOOD_PRESSURE_SYS);
lift(diaBP, busy * 7 + weekend * 1, VALUE_RANGES.BLOOD_PRESSURE_DIA);
lift(pulse, busy * 6, VALUE_RANGES.PULSE);
}
for (const series of [weights, restingHr]) {
settleLatest(series);
}
// Today's reading settles on the days after the busy stretch only, so it
// describes the band the series came back to.
for (const series of [sysBP, diaBP, pulse]) {
settleLatest(series, BUSY_STRETCH.to - 1);
}
// Each series carries the source that writes it on a real account, the
// way the integration writes it: the Withings scale and cuff (weight,
@@ -1580,7 +1629,9 @@ export async function seed() {
if (Math.random() < 0.05) continue;
const date = daysAgo(days - i);
const rawScore = Math.round(Math.min(5, Math.max(1, moodTrend[i])));
// The busy stretch costs about a point of mood at its height.
const moodScore = moodTrend[i] - 1.2 * busyStretch(days - i);
const rawScore = Math.round(Math.min(5, Math.max(1, moodScore)));
const mood = moodOptions[5 - rawScore]; // invert: score 5 = SUPER_GUT
const tags = tagOptions[5 - rawScore];
const loggedAt = new Date(date);
@@ -3302,8 +3353,9 @@ export async function seed() {
content: "How has my blood pressure been trending lately?",
},
answer(
"Your blood pressure has been settling nicely. Over the last few " +
"weeks the morning readings have drifted into the optimal band, and " +
"Your blood pressure has been settling nicely. After a higher " +
"stretch about three weeks ago, the morning readings have come back " +
"down into the optimal band, and " +
"your resting heart rate is sitting in a comfortable range too. " +
"Keeping up the steady sleep and daily movement looks like it is " +
"paying off — worth staying consistent with the morning measurements " +
@@ -3675,10 +3727,11 @@ export async function seed() {
{
scope: "blood-pressure",
text:
"Your blood pressure is averaging about 118/76 over the last few " +
"weeks — down roughly 10 mmHg systolic from where this quarter " +
"started, and now comfortably inside the optimal band. That is a " +
"real, earned shift, not day-to-day noise. Keeping the morning " +
"Your blood pressure is averaging about 118/76 over the last two " +
"weeks, back down after a busier stretch before that. It sits " +
"roughly 10 mmHg systolic below where this quarter started, " +
"comfortably inside the optimal band. That is a real, earned " +
"shift, not day-to-day noise. Keeping the morning " +
"readings on a steady schedule will make the trend even easier to " +
"follow.",
},
+65
View File
@@ -0,0 +1,65 @@
/**
* 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 now carries a busy stretch a few weeks back that lifts blood pressure
* and lets it settle again.
*
* This runs the real `scripts/seed-demo.ts` and holds the last 30 days of
* systolic readings to two things at once: the 7-day mean moves visibly, and
* today's reading has come back down from the stretch. Before the stretch the
* swing measured 2 to 3 mmHg; with it, about 8 to 14. The seed is random, so the
* bounds sit between the two rather than at either.
*/
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 means: 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));
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 visible rise and fall", () => {
expect(systolic.length).toBeGreaterThanOrEqual(28);
const swing = Math.max(...means) - Math.min(...means);
expect(swing).toBeGreaterThanOrEqual(6);
});
it("brings today's systolic reading back down from the stretch", () => {
expect(systolic.at(-1)).toBeLessThanOrEqual(Math.max(...means) - 3);
});
});