mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge remote-tracking branch 'origin/release/v1.46.0' into fix/v146-doc-findings
This commit is contained in:
commit
ec8191d18f
2 files changed
+177
-51
No files matched your search
+100
-41
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user