mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
feat(insights): look for level shifts in thirteen vitals over 180 days
The health-status read scanned five vitals over 90 days for a dated, sustained level shift. It now scans every vital with a stable daily level (adding RMSSD, diastolic pressure, pulse, body fat, respiratory rate, oxygen saturation, walking heart rate and VO2 max) over 180 days, reads one value per day per metric, leaves a metric unread when its module is off, and reports how many days stand on each side of the break. The Coach keeps its five-metric, 90-day scan unchanged.
This commit is contained in:
1 parent
9be720146c
commit
6320c54edd
16 files changed
+347
-24
No files matched your search
@@ -66698,6 +66698,16 @@ components:
|
||||
enum:
|
||||
- up
|
||||
- down
|
||||
beforeDays:
|
||||
description: v1.44 — days with a value before the break.
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
afterDays:
|
||||
description: v1.44 — days with a value from the break on.
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
required:
|
||||
- metric
|
||||
- breakDate
|
||||
|
||||
+2
-1
@@ -4748,7 +4748,8 @@
|
||||
"shiftDown": "{vital} hat sich gegenüber deiner jüngsten Norm nach unten verschoben.",
|
||||
"rangeContext": "Aktuell {value}, sonst {low}–{high}.",
|
||||
"rangeContextTimeOfDay": "Aktuell {value}, zu dieser Tageszeit sonst {low}–{high}.",
|
||||
"shiftContext": "Etwa {before} davor, {after} seitdem."
|
||||
"shiftContext": "Etwa {before} davor, {after} seitdem.",
|
||||
"shiftDays": "Grundlage: {before} Tage davor und {after} seither."
|
||||
},
|
||||
"breathingScreening": {
|
||||
"sectionTitle": "Atmung im Schlaf",
|
||||
|
||||
+2
-1
@@ -4748,7 +4748,8 @@
|
||||
"shiftDown": "{vital} has shifted down from your recent normal.",
|
||||
"rangeContext": "Now {value}, usually {low}–{high}.",
|
||||
"rangeContextTimeOfDay": "Now {value}, usually {low}–{high} at this time of day.",
|
||||
"shiftContext": "Around {before} before, {after} since."
|
||||
"shiftContext": "Around {before} before, {after} since.",
|
||||
"shiftDays": "Based on {before} days before and {after} since."
|
||||
},
|
||||
"breathingScreening": {
|
||||
"sectionTitle": "Breathing during sleep",
|
||||
|
||||
+2
-1
@@ -4748,7 +4748,8 @@
|
||||
"shiftDown": "{vital} ha bajado respecto a tu normal reciente.",
|
||||
"rangeContext": "Ahora {value}, normalmente {low}–{high}.",
|
||||
"rangeContextTimeOfDay": "Ahora {value}, normalmente {low}–{high} a esta hora del día.",
|
||||
"shiftContext": "Cerca de {before} antes, {after} desde entonces."
|
||||
"shiftContext": "Cerca de {before} antes, {after} desde entonces.",
|
||||
"shiftDays": "Con {before} días antes y {after} desde entonces."
|
||||
},
|
||||
"breathingScreening": {
|
||||
"sectionTitle": "Respiración durante el sueño",
|
||||
|
||||
+2
-1
@@ -4748,7 +4748,8 @@
|
||||
"shiftDown": "{vital} a diminué par rapport à votre normale récente.",
|
||||
"rangeContext": "Actuellement {value}, habituellement {low}–{high}.",
|
||||
"rangeContextTimeOfDay": "Actuellement {value}, habituellement {low}–{high} à ce moment de la journée.",
|
||||
"shiftContext": "Environ {before} avant, {after} depuis."
|
||||
"shiftContext": "Environ {before} avant, {after} depuis.",
|
||||
"shiftDays": "Sur {before} jours avant et {after} depuis."
|
||||
},
|
||||
"breathingScreening": {
|
||||
"sectionTitle": "Respiration pendant le sommeil",
|
||||
|
||||
+2
-1
@@ -4748,7 +4748,8 @@
|
||||
"shiftDown": "{vital} si è spostato verso il basso rispetto alla tua norma recente.",
|
||||
"rangeContext": "Ora {value}, di solito {low}–{high}.",
|
||||
"rangeContextTimeOfDay": "Ora {value}, di solito {low}–{high} a quest'ora del giorno.",
|
||||
"shiftContext": "Circa {before} prima, {after} da allora."
|
||||
"shiftContext": "Circa {before} prima, {after} da allora.",
|
||||
"shiftDays": "Su {before} giorni prima e {after} da allora."
|
||||
},
|
||||
"breathingScreening": {
|
||||
"sectionTitle": "Respirazione durante il sonno",
|
||||
|
||||
+2
-1
@@ -4748,7 +4748,8 @@
|
||||
"shiftDown": "{vital}: 최근 평소 수준보다 내려갔어요.",
|
||||
"rangeContext": "지금 {value}, 평소 {low}–{high}.",
|
||||
"rangeContextTimeOfDay": "지금 {value}, 이 시간대 평소 {low}–{high}.",
|
||||
"shiftContext": "이전엔 약 {before}, 이후엔 {after}."
|
||||
"shiftContext": "이전엔 약 {before}, 이후엔 {after}.",
|
||||
"shiftDays": "이전 {before}일과 이후 {after}일 기준입니다."
|
||||
},
|
||||
"breathingScreening": {
|
||||
"sectionTitle": "수면 중 호흡",
|
||||
|
||||
+2
-1
@@ -4748,7 +4748,8 @@
|
||||
"shiftDown": "{vital} is gedaald ten opzichte van wat recent normaal was voor jou.",
|
||||
"rangeContext": "Nu {value}, meestal {low}–{high}.",
|
||||
"rangeContextTimeOfDay": "Nu {value}, op dit moment van de dag meestal {low}–{high}.",
|
||||
"shiftContext": "Eerst rond {before}, sindsdien {after}."
|
||||
"shiftContext": "Eerst rond {before}, sindsdien {after}.",
|
||||
"shiftDays": "Op basis van {before} dagen ervoor en {after} sindsdien."
|
||||
},
|
||||
"breathingScreening": {
|
||||
"sectionTitle": "Ademhaling tijdens de slaap",
|
||||
|
||||
+2
-1
@@ -4748,7 +4748,8 @@
|
||||
"shiftDown": "{vital} przesunął się w dół względem Twojej ostatniej normy.",
|
||||
"rangeContext": "Teraz {value}, zwykle {low}–{high}.",
|
||||
"rangeContextTimeOfDay": "Teraz {value}, zwykle {low}–{high} o tej porze dnia.",
|
||||
"shiftContext": "Około {before} wcześniej, {after} od tego czasu."
|
||||
"shiftContext": "Około {before} wcześniej, {after} od tego czasu.",
|
||||
"shiftDays": "Na podstawie {before} dni wcześniej i {after} od tego czasu."
|
||||
},
|
||||
"breathingScreening": {
|
||||
"sectionTitle": "Oddychanie podczas snu",
|
||||
|
||||
@@ -23,7 +23,8 @@ import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { loadBaselineProfile } from "@/lib/insights/derived";
|
||||
import { computeCoincidentDeviation } from "@/lib/insights/derived/coincident-deviation";
|
||||
import { buildChangepointSignals } from "@/lib/insights/derived/changepoint";
|
||||
import { buildExtendedChangepointSignals } from "@/lib/insights/derived/changepoint";
|
||||
import { resolveModuleMap } from "@/lib/modules/gate";
|
||||
import { summariseHealthStatus } from "@/lib/insights/health-status";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -45,7 +46,13 @@ export const GET = apiHandler(async () => {
|
||||
tz: user.timezone,
|
||||
now,
|
||||
});
|
||||
const shifts = await buildChangepointSignals(user.id, now);
|
||||
// v1.44 — the wider scan: every vital with a stable daily level over 180
|
||||
// days, a metric whose module is off left unread.
|
||||
const shifts = await buildExtendedChangepointSignals(
|
||||
user.id,
|
||||
now,
|
||||
await resolveModuleMap(user.id),
|
||||
);
|
||||
|
||||
const vitals = coincident.status === "ok" ? coincident.value.vitals : [];
|
||||
const summary = summariseHealthStatus(vitals, shifts);
|
||||
|
||||
@@ -155,6 +155,12 @@ export function HealthStatusCard({
|
||||
before: metricValue(s.metric, s.beforeMean),
|
||||
after: metricValue(s.metric, s.afterMean),
|
||||
})}
|
||||
{s.beforeDays !== undefined && s.afterDays !== undefined
|
||||
? ` ${t("insights.healthStatus.shiftDays", {
|
||||
before: s.beforeDays,
|
||||
after: s.afterDays,
|
||||
})}`
|
||||
: null}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const readDayMeanSeries = vi.fn();
|
||||
vi.mock("@/lib/insights/derived/baseline", () => ({
|
||||
readDayMeanSeries: (...args: unknown[]) => readDayMeanSeries(...args),
|
||||
}));
|
||||
vi.mock("@/lib/rollups/measurement-coverage", () => ({
|
||||
probeRollupCoverage: vi.fn(async () => new Map()),
|
||||
}));
|
||||
|
||||
import {
|
||||
buildChangepointSignals,
|
||||
buildExtendedChangepointSignals,
|
||||
CHANGEPOINT_WINDOW_DAYS,
|
||||
EXTENDED_CHANGEPOINT_METRICS,
|
||||
EXTENDED_CHANGEPOINT_WINDOW_DAYS,
|
||||
} from "../changepoint";
|
||||
import { shiftDateKey } from "@/lib/tz/format";
|
||||
|
||||
const NOW = new Date("2026-10-21T08:00:00.000Z");
|
||||
|
||||
/** 20 days at `a`, then 20 at `b`, a day apart from 2026-06-01. */
|
||||
function step(a: number, b: number) {
|
||||
return Array.from({ length: 40 }, (_, i) => ({
|
||||
day: shiftDateKey("2026-06-01", i),
|
||||
mean: i < 20 ? a : b,
|
||||
}));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
readDayMeanSeries.mockReset();
|
||||
});
|
||||
|
||||
describe("buildExtendedChangepointSignals", () => {
|
||||
it("scans every extended metric over 180 days and reports segment sizes", async () => {
|
||||
readDayMeanSeries.mockImplementation(async (_u: string, metric: string) =>
|
||||
metric === "RESPIRATORY_RATE"
|
||||
? { points: step(14, 17), source: "DAY" }
|
||||
: { points: [], source: "DAY" },
|
||||
);
|
||||
const shifts = await buildExtendedChangepointSignals("u1", NOW, {});
|
||||
const read = readDayMeanSeries.mock.calls.map((c) => c[1]);
|
||||
expect(read).toEqual([...EXTENDED_CHANGEPOINT_METRICS]);
|
||||
for (const call of readDayMeanSeries.mock.calls) {
|
||||
expect(call[2]).toBe(EXTENDED_CHANGEPOINT_WINDOW_DAYS);
|
||||
}
|
||||
expect(shifts).toEqual([
|
||||
expect.objectContaining({
|
||||
metric: "RESPIRATORY_RATE",
|
||||
breakDate: "2026-06-21",
|
||||
beforeMean: 14,
|
||||
afterMean: 17,
|
||||
direction: "up",
|
||||
beforeDays: 20,
|
||||
afterDays: 20,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves a metric unread when its module is off", async () => {
|
||||
readDayMeanSeries.mockResolvedValue({ points: [], source: "DAY" });
|
||||
await buildExtendedChangepointSignals("u1", NOW, {
|
||||
recovery: false,
|
||||
glucose: false,
|
||||
});
|
||||
const read = readDayMeanSeries.mock.calls.map((c) => c[1]);
|
||||
expect(read).not.toContain("HRV_RMSSD");
|
||||
expect(read).not.toContain("RESTING_HEART_RATE");
|
||||
expect(read).not.toContain("BLOOD_GLUCOSE");
|
||||
expect(read).toContain("WEIGHT");
|
||||
});
|
||||
|
||||
it("caps at five, strongest first", async () => {
|
||||
let k = 0;
|
||||
readDayMeanSeries.mockImplementation(async () => {
|
||||
k += 1;
|
||||
return { points: step(10, 10 + k), source: "DAY" };
|
||||
});
|
||||
const shifts = await buildExtendedChangepointSignals("u1", NOW, {});
|
||||
expect(shifts).toHaveLength(5);
|
||||
const magnitudes = shifts.map((s) => s.magnitude);
|
||||
expect([...magnitudes].sort((a, b) => b - a)).toEqual(magnitudes);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildChangepointSignals (the Coach's scan)", () => {
|
||||
it("keeps five metrics, 90 days and its shape", async () => {
|
||||
readDayMeanSeries.mockResolvedValue({
|
||||
points: step(56, 63),
|
||||
source: "DAY",
|
||||
});
|
||||
const shifts = await buildChangepointSignals("u1", NOW);
|
||||
expect(readDayMeanSeries).toHaveBeenCalledTimes(5);
|
||||
expect(readDayMeanSeries.mock.calls[0][2]).toBe(CHANGEPOINT_WINDOW_DAYS);
|
||||
expect(shifts).toHaveLength(2);
|
||||
expect(Object.keys(shifts[0]).sort()).toEqual([
|
||||
"afterMean",
|
||||
"beforeMean",
|
||||
"breakDate",
|
||||
"direction",
|
||||
"magnitude",
|
||||
"metric",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,8 @@
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage";
|
||||
import { readDayMeanSeries } from "@/lib/insights/derived/baseline";
|
||||
import { moduleForMeasurementType } from "@/lib/modules/measurement-scope";
|
||||
import type { SurfaceModuleMap } from "@/lib/modules/surface";
|
||||
|
||||
const MAD_TO_SIGMA = 1.4826;
|
||||
|
||||
@@ -184,24 +186,60 @@ const MAX_CHANGEPOINTS = 2;
|
||||
const round1 = (n: number): number => Math.round(n * 10) / 10;
|
||||
|
||||
/**
|
||||
* Read the DAY-mean series for a small set of stable metrics and surface any
|
||||
* validated level shift. Best-effort + fault-isolated per metric: a read
|
||||
* failure on one metric never sinks the others. Returns the strongest
|
||||
* changepoints (by magnitude), capped, or an empty array when nothing fires.
|
||||
* v1.44 — the metrics the health-status surface scans: the five above plus
|
||||
* every other vital with a stable daily level and enough readings to carry a
|
||||
* 180-day window. Activity volumes, audio and sleep stay out: the first two are
|
||||
* day-to-day behaviour rather than a level, and a sleep "day mean" over stage
|
||||
* rows is not a night. Wrist temperature stays out for the same privacy reason
|
||||
* it is not a correlation channel.
|
||||
*/
|
||||
export async function buildChangepointSignals(
|
||||
userId: string,
|
||||
now: Date = new Date(),
|
||||
): Promise<ChangepointSignal[]> {
|
||||
const coverage = await probeRollupCoverage(userId);
|
||||
const found: ChangepointSignal[] = [];
|
||||
export const EXTENDED_CHANGEPOINT_METRICS: readonly MeasurementType[] = [
|
||||
...CHANGEPOINT_METRICS,
|
||||
"HRV_RMSSD",
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
"PULSE",
|
||||
"BODY_FAT",
|
||||
"RESPIRATORY_RATE",
|
||||
"OXYGEN_SATURATION",
|
||||
"WALKING_HEART_RATE_AVERAGE",
|
||||
"VO2_MAX",
|
||||
];
|
||||
|
||||
for (const metric of CHANGEPOINT_METRICS) {
|
||||
/** Window of the extended scan (days). */
|
||||
export const EXTENDED_CHANGEPOINT_WINDOW_DAYS = 180;
|
||||
/** At most this many shifts on the health-status surface. */
|
||||
const MAX_EXTENDED_CHANGEPOINTS = 5;
|
||||
|
||||
/** A changepoint with the day counts of the two segments it compares. */
|
||||
export interface ExtendedChangepointSignal extends ChangepointSignal {
|
||||
/** Days with a value before the break. */
|
||||
beforeDays: number;
|
||||
/** Days with a value from the break on. */
|
||||
afterDays: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan a metric set for one validated shift each, fault-isolated per metric,
|
||||
* strongest first, capped. One per-day read per metric (DAY rollups, or the
|
||||
* per-day SQL fold for a window past the DAY tier), never raw readings in
|
||||
* process, so the cost is a row per day per metric whatever the density.
|
||||
*/
|
||||
async function scanChangepoints(
|
||||
userId: string,
|
||||
now: Date,
|
||||
metrics: readonly MeasurementType[],
|
||||
windowDays: number,
|
||||
max: number,
|
||||
): Promise<ExtendedChangepointSignal[]> {
|
||||
const coverage = await probeRollupCoverage(userId);
|
||||
const found: ExtendedChangepointSignal[] = [];
|
||||
|
||||
for (const metric of metrics) {
|
||||
try {
|
||||
const { points } = await readDayMeanSeries(
|
||||
userId,
|
||||
metric,
|
||||
CHANGEPOINT_WINDOW_DAYS,
|
||||
windowDays,
|
||||
now,
|
||||
coverage,
|
||||
);
|
||||
@@ -217,6 +255,8 @@ export async function buildChangepointSignals(
|
||||
afterMean: round1(shift.afterMean),
|
||||
direction: shift.direction,
|
||||
magnitude: round1(shift.magnitude),
|
||||
beforeDays: shift.breakIndex + 1,
|
||||
afterDays: points.length - shift.breakIndex - 1,
|
||||
});
|
||||
} catch {
|
||||
// Fault-isolated — skip this metric, keep scanning.
|
||||
@@ -224,5 +264,57 @@ export async function buildChangepointSignals(
|
||||
}
|
||||
|
||||
found.sort((a, b) => b.magnitude - a.magnitude);
|
||||
return found.slice(0, MAX_CHANGEPOINTS);
|
||||
return found.slice(0, max);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.44 — the health-status scan: {@link EXTENDED_CHANGEPOINT_METRICS} over
|
||||
* {@link EXTENDED_CHANGEPOINT_WINDOW_DAYS} days, a metric whose module is off
|
||||
* left unread. The Coach keeps {@link buildChangepointSignals}: what reaches a
|
||||
* model is not widened by this.
|
||||
*/
|
||||
export async function buildExtendedChangepointSignals(
|
||||
userId: string,
|
||||
now: Date,
|
||||
modules: SurfaceModuleMap,
|
||||
): Promise<ExtendedChangepointSignal[]> {
|
||||
const metrics = EXTENDED_CHANGEPOINT_METRICS.filter((metric) => {
|
||||
const owner = moduleForMeasurementType(metric);
|
||||
return owner === null || modules[owner] !== false;
|
||||
});
|
||||
return scanChangepoints(
|
||||
userId,
|
||||
now,
|
||||
metrics,
|
||||
EXTENDED_CHANGEPOINT_WINDOW_DAYS,
|
||||
MAX_EXTENDED_CHANGEPOINTS,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the DAY-mean series for a small set of stable metrics and surface any
|
||||
* validated level shift. Best-effort + fault-isolated per metric: a read
|
||||
* failure on one metric never sinks the others. Returns the strongest
|
||||
* changepoints (by magnitude), capped, or an empty array when nothing fires.
|
||||
*/
|
||||
export async function buildChangepointSignals(
|
||||
userId: string,
|
||||
now: Date = new Date(),
|
||||
): Promise<ChangepointSignal[]> {
|
||||
const found = await scanChangepoints(
|
||||
userId,
|
||||
now,
|
||||
CHANGEPOINT_METRICS,
|
||||
CHANGEPOINT_WINDOW_DAYS,
|
||||
MAX_CHANGEPOINTS,
|
||||
);
|
||||
// The Coach snapshot's shape is unchanged: no segment day counts.
|
||||
return found.map((signal) => ({
|
||||
metric: signal.metric,
|
||||
breakDate: signal.breakDate,
|
||||
beforeMean: signal.beforeMean,
|
||||
afterMean: signal.afterMean,
|
||||
direction: signal.direction,
|
||||
magnitude: signal.magnitude,
|
||||
}));
|
||||
}
|
||||
@@ -46,6 +46,10 @@ export interface HealthStatusShift {
|
||||
beforeMean: number;
|
||||
afterMean: number;
|
||||
direction: "up" | "down";
|
||||
/** v1.44 — days with a value before the break, when the scan reports it. */
|
||||
beforeDays?: number;
|
||||
/** v1.44 — days with a value from the break on. */
|
||||
afterDays?: number;
|
||||
}
|
||||
|
||||
export interface HealthStatusSummary {
|
||||
@@ -62,7 +66,9 @@ export interface HealthStatusSummary {
|
||||
*/
|
||||
export function summariseHealthStatus(
|
||||
vitals: readonly VitalDeviation[],
|
||||
shifts: readonly ChangepointSignal[],
|
||||
shifts: ReadonlyArray<
|
||||
ChangepointSignal & { beforeDays?: number; afterDays?: number }
|
||||
>,
|
||||
): HealthStatusSummary {
|
||||
const deviations: HealthStatusDeviation[] = vitals
|
||||
// Present tense, so present readings only. "Your pulse is above your usual
|
||||
@@ -88,6 +94,9 @@ export function summariseHealthStatus(
|
||||
beforeMean: s.beforeMean,
|
||||
afterMean: s.afterMean,
|
||||
direction: s.direction,
|
||||
...(s.beforeDays !== undefined && s.afterDays !== undefined
|
||||
? { beforeDays: s.beforeDays, afterDays: s.afterDays }
|
||||
: {}),
|
||||
}));
|
||||
|
||||
return {
|
||||
|
||||
@@ -44,6 +44,16 @@ const healthStatusResponse = z
|
||||
beforeMean: z.number(),
|
||||
afterMean: z.number(),
|
||||
direction: z.enum(["up", "down"]),
|
||||
beforeDays: z
|
||||
.number()
|
||||
.int()
|
||||
.optional()
|
||||
.describe("v1.44 — days with a value before the break."),
|
||||
afterDays: z
|
||||
.number()
|
||||
.int()
|
||||
.optional()
|
||||
.describe("v1.44 — days with a value from the break on."),
|
||||
}),
|
||||
),
|
||||
generatedAt: z.string().describe("ISO-8601 instant the read was computed."),
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* v1.44 — the health-status changepoint scan over 180 days against a real
|
||||
* Postgres. Past the DAY tier's 90-day reach the read folds per day in SQL
|
||||
* (`readDayAggregates`); this proves that path finds a step, reports its
|
||||
* segment sizes, and leaves a switched-off module's metric unread.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
import { buildExtendedChangepointSignals } from "@/lib/insights/derived/changepoint";
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function at(daysAgo: number): Date {
|
||||
const d = new Date(Date.now() - daysAgo * DAY_MS);
|
||||
d.setUTCHours(12, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
});
|
||||
|
||||
describe("extended changepoint scan — real per-day fold", () => {
|
||||
it("finds a step in a metric past the five, over 120 days", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: "cp-extended",
|
||||
email: "cp-extended@example.test",
|
||||
role: "USER",
|
||||
timezone: "UTC",
|
||||
},
|
||||
});
|
||||
const rows = [];
|
||||
// 120 days ago to 61 days ago at 14, then 60 days at 17, two readings a day.
|
||||
for (let d = 120; d >= 1; d -= 1) {
|
||||
const value = d > 60 ? 14 : 17;
|
||||
rows.push(
|
||||
{ value, measuredAt: at(d) },
|
||||
{ value, measuredAt: new Date(at(d).getTime() + 3_600_000) },
|
||||
);
|
||||
rows.push({
|
||||
value: d > 60 ? 140 : 150,
|
||||
measuredAt: at(d),
|
||||
type: "BLOOD_GLUCOSE" as const,
|
||||
unit: "mg/dL",
|
||||
});
|
||||
}
|
||||
await prisma.measurement.createMany({
|
||||
data: rows.map((r) => ({
|
||||
userId: user.id,
|
||||
type: "RESPIRATORY_RATE" as const,
|
||||
unit: "count/min",
|
||||
source: "APPLE_HEALTH" as const,
|
||||
...r,
|
||||
})),
|
||||
});
|
||||
|
||||
const shifts = await buildExtendedChangepointSignals(user.id, new Date(), {
|
||||
glucose: false,
|
||||
});
|
||||
expect(shifts.map((s) => s.metric)).toEqual(["RESPIRATORY_RATE"]);
|
||||
expect(shifts[0]).toMatchObject({
|
||||
beforeMean: 14,
|
||||
afterMean: 17,
|
||||
direction: "up",
|
||||
beforeDays: 60,
|
||||
afterDays: 60,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user