From d839fbdb11dc9cffe281219beba5b3d5456756ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 25 Oct 2026 11:34:40 +0100 Subject: [PATCH] fix(demo): lift the busy-stretch blood pressure far enough to read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The blood-pressure chart scales one axis to fit systolic and diastolic, so the 12 mmHg the busy stretch added came out as a nearly flat line on a 60 to 140 axis, even with the 7-day trend on. The stretch now climbs over two days to about 144/91, holds for some ten days, and comes back down over the following days after ramipril goes from 5 to 10 mg. A dose change and a family-practice visit on the day it turns carry that step, and the medication now reads 10 mg. The day-to-day walk is quieter, with a little reading scatter on top, so the 7-day mean draws an arc rather than jitter. Two limits keep the rest of the page honest. The lift stops short of 149 mmHg: one more and the padded axis rounds out to 50 to 200 and flattens the rise again. And the stretch stays well under half of the last 30 days: the usual range is the median ± 3 MAD of that window, and a longer stretch moved the median onto it and widened the band to about 87 to 181 mmHg. The stored texts that describe the last weeks mention the stretch and the dose increase. The seed-shape test holds the larger swing, the ceiling, the smaller day-to-day steps, the median of the window and the dose change on the turn. --- scripts/seed-demo.ts | 141 +++++++++++++++------- tests/integration/demo-seed-shape.test.ts | 87 +++++++++++-- 2 files changed, 177 insertions(+), 51 deletions(-) diff --git a/scripts/seed-demo.ts b/scripts/seed-demo.ts index f81712b1d..44eddabb2 100644 --- a/scripts/seed-demo.ts +++ b/scripts/seed-demo.ts @@ -9,7 +9,9 @@ * - 1 admin user (demo/demo123demo123) * - 90 days of measurements (weight, BP, pulse, resting HR, body fat, * sleep, steps, fortnightly waist) — flat-or-improving trends, - * most-recent point from today + * most-recent point from today; blood pressure has one busy stretch two + * to three weeks back (about 144/91 for some ten days) that comes down + * after a ramipril dose increase * - A full week of per-stage sleep nights (awake/REM/light(core)/deep), * ~7.5 h each, including last night * - 90 days of body composition (fat/lean/muscle mass, total body water, @@ -42,8 +44,9 @@ * temperature, period flow, fertile-window mucus/OPK, per-day symptoms, * and a cached forward prediction * - Two practitioners, a knee arthroscopy with its operative report and - * discharge letter linked (small generated PDFs), its follow-up visit, and - * a check-up booked for tomorrow morning + * discharge letter linked (small generated PDFs), its follow-up visit, the + * blood-pressure visit that raised ramipril, and a check-up booked for + * tomorrow morning * - An AI-configured Coach with five sample conversations spread from * today back over a few weeks * - App settings (registration disabled, English locale, the key-backup @@ -403,19 +406,45 @@ 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. + * pressure climbs over two days to about 144/91 and stays there for some ten + * days, pulse rises with it and the mood dips. At the family practice on + * `doseUp` ramipril goes from 5 to 10 mg, and over the following days the + * readings come back down to the settled band. Without it every short-window + * series was flat noise around its target, and on a blood-pressure axis that + * has to fit both systolic and diastolic, even a 15 mmHg swing read as a flat + * line. It ends a few days before today, so the latest readings, the tiles and + * the stored texts describe a settled, optimal band again. + * + * It is also no longer than this on purpose. The personal usual range is the + * median ± 3 MAD of the last 30 days: once the stretch fills about half of that + * window, the median lands on the stretch and the band reads something like + * 87-181 mmHg. Kept to this length the band stays around 107-132. */ -const BUSY_STRETCH = { from: 27, to: 12 } as const; +const BUSY_STRETCH = { from: 21, peak: 19, doseUp: 11, settled: 6 } as const; -/** 0 outside the busy stretch, rising smoothly to 1 at its middle. */ +/** + * Systolic, diastolic and pulse on top of the settled band at the stretch's + * height. The chart pads its axis and rounds it to even ticks: with systolic + * readings up to about 148 the 30-day axis runs 60 to 160 in steps of 20, one + * mmHg more and it jumps to 50 to 200 in steps of 50, which flattens the very + * rise the stretch is there to show. So the lift stops short of that. + */ +const BUSY_STRETCH_LIFT = { sys: 25, dia: 15, pulse: 6 } as const; + +/** Ramipril dose after the stretch, in mg (from 5 mg before it). */ +const RAMIPRIL_STRETCH_DOSE = 10; + +/** + * 0 outside the busy stretch, 1 while it holds: a smooth climb from `from` to + * `peak`, level until the dose change on `doseUp`, a smooth fall to `settled`. + */ 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; + const { from, peak, doseUp, settled } = BUSY_STRETCH; + const smooth = (t: number) => t * t * (3 - 2 * t); + if (daysBefore >= from || daysBefore <= settled) return 0; + if (daysBefore > peak) return smooth((from - daysBefore) / (from - peak)); + if (daysBefore >= doseUp) return 1; + return smooth((daysBefore - settled) / (doseUp - settled)); } /** Pollen season bump: 0 outside `[from, to]`, peaking at `peak` mid-season. */ @@ -577,12 +606,14 @@ export async function seed() { // Weight: 86.5 → trending down to a stable ~82 (healthy BMI ~24.7 at 182cm) const weights = randomWalk(86.5, 82.0, span, 0.6); - // Systolic BP: 128 → settled in the optimal band ~118 (clamped) + // Systolic BP: 128 → settled in the optimal band ~118 (clamped). The day- + // to-day noise is kept small, so the 7-day trend over the busy stretch + // below draws an arc rather than jitter. const sysBP = randomWalk( 128, 118, span, - 3, + 1.2, VALUE_RANGES.BLOOD_PRESSURE_SYS, 0.1, ); @@ -591,7 +622,7 @@ export async function seed() { 82, 76, span, - 2.5, + 1, VALUE_RANGES.BLOOD_PRESSURE_DIA, 0.1, ); @@ -637,9 +668,21 @@ export async function seed() { 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); + // A little reading-to-reading scatter on top of the slow walk, as a + // cuff has; the 7-day mean averages it out. + lift( + sysBP, + busy * BUSY_STRETCH_LIFT.sys + weekend * 1 + (Math.random() - 0.5) * 3, + VALUE_RANGES.BLOOD_PRESSURE_SYS, + ); + lift( + diaBP, + busy * BUSY_STRETCH_LIFT.dia + + weekend * 0.5 + + (Math.random() - 0.5) * 2, + VALUE_RANGES.BLOOD_PRESSURE_DIA, + ); + lift(pulse, busy * BUSY_STRETCH_LIFT.pulse, VALUE_RANGES.PULSE); } for (const series of [weights, restingHr]) { settleLatest(series); @@ -647,7 +690,7 @@ export async function seed() { // 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); + settleLatest(series, BUSY_STRETCH.settled); } // Each series carries the source that writes it on a real account, the @@ -1406,19 +1449,21 @@ export async function seed() { console.log("Creating medications..."); // Medication 1: Ramipril (blood pressure). Started about three years - // back at 2.5 mg and raised to 5 mg a year later; the multi-year history - // section below writes the dose changes, a short sick-day pause and the - // intakes before the 90-day window. `starts_on` is the stated start the - // timeline lane draws from. + // back at 2.5 mg, raised to 5 mg a year later, and to 10 mg during the + // busy stretch inside the 90-day window; the multi-year history section + // below writes the dose changes, a short sick-day pause and the intakes + // before the 90-day window. `starts_on` is the stated start the timeline + // lane draws from. const med1Id = cuid(); await client.query( `INSERT INTO medications (id, user_id, name, dose, active, notifications_enabled, starts_on, created_at, updated_at) - VALUES ($1, $2, 'Ramipril', '5mg', true, true, $3, $4, $4)`, + VALUES ($1, $2, 'Ramipril', $5, true, true, $3, $4, $4)`, [ med1Id, userId, formatDate(daysAgo(RAMIPRIL_START_DAYS_AGO)), daysAgo(RAMIPRIL_START_DAYS_AGO), + `${RAMIPRIL_STRETCH_DOSE}mg`, ], ); const sched1Id = cuid(); @@ -2311,6 +2356,16 @@ export async function seed() { reason: "Follow-up two weeks after the arthroscopy.", outcome: "Healing well, full range of motion. Physiotherapy continues.", }); + // The visit that turned the busy stretch: ten days of high home readings, + // ramipril raised that morning. + await insertEncounter({ + occurredAt: daysAgoAt(BUSY_STRETCH.doseUp, 8, 15), + status: "DONE", + kind: "ROUTINE", + practitionerId: familyPracticeId, + reason: "Home blood pressure high for about ten days.", + outcome: `Readings around 144/91 during a stressful stretch at work. Ramipril raised to ${RAMIPRIL_STRETCH_DOSE} mg, home readings again in two weeks.`, + }); await insertEncounter({ occurredAt: daysAgoAt(-1, 9, 30), status: "PLANNED", @@ -2633,10 +2688,12 @@ export async function seed() { intakeRows, ); - // The ramipril dose history and its sick-day pause. + // The ramipril dose history and its sick-day pause. The last step falls + // inside the 90-day window, on the day the busy stretch turns. for (const [n, value] of [ [RAMIPRIL_START_DAYS_AGO, 2.5], [RAMIPRIL_DOSE_UP_DAYS_AGO, 5], + [BUSY_STRETCH.doseUp, RAMIPRIL_STRETCH_DOSE], ] as const) { await client.query( `INSERT INTO medication_dose_changes (id, medication_id, effective_from, dose_value, dose_unit, created_at) @@ -3353,9 +3410,10 @@ export async function seed() { content: "How has my blood pressure been trending lately?", }, answer( - "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 blood pressure has come back down nicely. During the stressful " + + "stretch two to three weeks ago your morning readings sat around " + + "144/91 for about ten days; since ramipril went up to 10 mg they " + + "have eased back into the optimal band within about a week, 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 " + @@ -3633,10 +3691,11 @@ export async function seed() { keyFindings: [ { tone: "good", - headline: "Blood pressure has settled into the optimal range.", + headline: "Blood pressure is back in the optimal range.", detail: - "Your 30-day average is well inside the optimal band and lower " + - "than where the quarter began.", + "After a higher stretch two to three weeks ago and a ramipril " + + "dose increase, your readings have come back down below where " + + "the quarter began.", delta: "↓ ~10 mmHg", sourceWindow: "30d", sourceMetric: "bp", @@ -3674,7 +3733,7 @@ export async function seed() { ], }, trendAnnotations: { - bp: "Your systolic is trending down into the optimal band — a pattern worth keeping.", + bp: "After a higher stretch and a ramipril dose increase, your systolic has come back down into the optimal band.", weight: "Weight has levelled off after an earlier decline — a stable, healthy place to hold.", mood: "Mood has been on a quiet upward run across the quarter.", @@ -3727,11 +3786,10 @@ export async function seed() { { scope: "blood-pressure", text: - "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 " + + "Your blood pressure is back around 118/76 this week. Two to three " + + "weeks ago it climbed to about 144/91 and held there for about ten " + + "days; after ramipril went up to 10 mg it came down over the " + + "following days and now sits inside the optimal band again. Keeping the morning " + "readings on a steady schedule will make the trend even easier to " + "follow.", }, @@ -3846,8 +3904,9 @@ export async function seed() { period: "month", text: "Over the past month the gains from earlier in the quarter have " + - "become your new normal. Blood pressure has held in the optimal " + - "band rather than just dipping into it, weight has stabilised at a " + + "become your new normal. Blood pressure had one higher stretch, " + + "came back down after the ramipril dose went up and now sits in the " + + "optimal band again, weight has stabilised at a " + "healthy point, and resting heart rate has stayed low alongside " + "consistent daily activity. Sleep lengthened and steadied, and " + "your mood trend continued its quiet climb. The month reads less " + @@ -3962,7 +4021,7 @@ export async function seed() { ` Cycle: ${cycleLengths.length} cycles with BBT, flow, mucus/OPK, symptoms + a forecast`, ); console.log( - ` Visits: knee arthroscopy (2 linked PDFs) + follow-up, check-up booked for tomorrow`, + ` Visits: knee arthroscopy (2 linked PDFs) + follow-up, a blood-pressure visit, check-up booked for tomorrow`, ); console.log(` Coach: ${coachConversations.length} conversations`); console.log( diff --git a/tests/integration/demo-seed-shape.test.ts b/tests/integration/demo-seed-shape.test.ts index 849b1d9af..d35aa6742 100644 --- a/tests/integration/demo-seed-shape.test.ts +++ b/tests/integration/demo-seed-shape.test.ts @@ -3,14 +3,20 @@ * 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. + * 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. * - * 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. + * 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"; @@ -21,7 +27,9 @@ vi.mock("@/lib/db-compat", () => ({ })); let systolic: number[]; +let diastolic: number[]; let means: number[]; +let doseChanges: Array<{ effectiveFrom: Date; doseValue: number }>; beforeAll(async () => { const prisma = getPrismaClient(); @@ -45,6 +53,30 @@ beforeAll(async () => { 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); @@ -53,13 +85,48 @@ beforeAll(async () => { }, 180_000); describe("demo seed, short-window shape", () => { - it("gives the 7-day systolic mean over 30 days a visible rise and fall", () => { + 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(6); + 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. + 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(20); + }); + + 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) - 3); + expect(systolic.at(-1)).toBeLessThanOrEqual(Math.max(...means) - 15); + expect(systolic.at(-1)).toBeLessThanOrEqual(125); }); });