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); }); });