Merge remote-tracking branch 'origin/release/v1.46.0' into fix/v146-doc-findings

This commit is contained in:
Marc-André Bombeck committed 2026-10-25 12:26:24 +01:00
commit ec8191d18f
2 files changed
+177 -51

No files matched your search

+100 -41
View File
@@ -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(
+77 -10
View File
@@ -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);
});
});