Merge branch 'feat/v144-analysis' into release/v1.44.0

This commit is contained in:
Marc-André Bombeck committed 2026-10-21 22:27:59 +02:00
commit 67d04630c3
74 files changed
+4633 -120

No files matched your search

@@ -16,7 +16,7 @@ vi.mock("@/lib/db", () => {
createMany: vi.fn(),
},
biomarkerMerge: { findFirst: vi.fn(async () => null) },
labResult: { deleteMany: vi.fn() },
labResult: { deleteMany: vi.fn(), findMany: vi.fn(async () => []) },
auditLog: { create: vi.fn().mockResolvedValue({}) },
};
// `$transaction` is set in `beforeEach`: the batch form for the delete, the
@@ -50,7 +50,7 @@ vi.mock("next/headers", () => ({
})),
}));
import { PUT, DELETE } from "../route";
import { GET, PUT, DELETE } from "../route";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
@@ -105,6 +105,56 @@ beforeEach(() => {
vi.mocked(prisma.biomarkerMerge.findFirst).mockResolvedValue(null as never);
});
describe("GET /api/biomarkers/[id] — trend (v1.44)", () => {
const YEAR_MS = 365.25 * 86_400_000;
const getReq = () =>
new NextRequest("http://localhost/api/biomarkers/bm-1", { method: "GET" });
it("carries the marker's slope over every stored reading", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.biomarker.findFirst).mockResolvedValue(EXISTING as never);
const t0 = Date.now() - 2 * YEAR_MS;
vi.mocked(prisma.labResult.findMany).mockResolvedValue([
{ takenAt: new Date(t0), value: 60 },
{ takenAt: new Date(t0 + YEAR_MS), value: 70 },
{ takenAt: new Date(t0 + 2 * YEAR_MS), value: 80 },
] as never);
const res = await GET(getReq(), params("bm-1"));
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.name).toBe("LDL");
expect(body.data.trend).toMatchObject({
status: "ok",
n: 3,
slopePerYear: 10,
projection: "exits",
exitBound: "high",
});
expect(prisma.labResult.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
userId: "user-1",
biomarkerId: "bm-1",
deletedAt: null,
}),
}),
);
});
it("answers insufficient below three readings", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.biomarker.findFirst).mockResolvedValue(EXISTING as never);
vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never);
const body = await (await GET(getReq(), params("bm-1"))).json();
expect(body.data.trend).toEqual({
status: "insufficient",
reason: "too_few_values",
n: 0,
spanDays: 0,
});
});
});
describe("DELETE /api/biomarkers/[id]", () => {
it("drops the marker and its readings in one userId-narrowed transaction", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+37 -3
View File
@@ -26,6 +26,10 @@ import { mergedInto } from "@/lib/labs/biomarker-merge";
import { encryptContextToBytes } from "@/lib/labs/biomarker-store";
import { matchAnalyteKey } from "@/lib/labs/analyte-match";
import { annotate } from "@/lib/logging/context";
import { computeLabTrend } from "@/lib/labs/lab-trend";
/** Upper bound on readings the trend line reads. */
const LAB_TREND_READ_CAP = 2000;
import { updateBiomarkerSchema } from "@/lib/validations/biomarkers";
import { effectiveBound, isInvertedRange } from "@/lib/validations/labs";
@@ -58,14 +62,44 @@ export const GET = apiHandler(
});
}
const aliases = await aliasNamesByMarker(prisma, user.id, [row]);
const [aliases, readings] = await Promise.all([
aliasNamesByMarker(prisma, user.id, [row]),
// v1.44 — the marker's numeric readings for the trend line. Every
// reading is stored in the marker's unit, so the series needs no
// conversion; the cap is far above any real panel history.
prisma.labResult.findMany({
where: {
userId: user.id,
biomarkerId: id,
deletedAt: null,
value: { not: null },
},
orderBy: { takenAt: "asc" },
take: LAB_TREND_READ_CAP,
select: { takenAt: true, value: true },
}),
]);
const trend = computeLabTrend(
readings.flatMap((r) =>
r.value === null ? [] : [{ takenAt: r.takenAt, value: r.value }],
),
{ low: row.lowerBound, high: row.upperBound },
new Date(),
);
annotate({
action: { name: "labs.biomarker.get" },
meta: { biomarkerId: id },
meta: {
biomarkerId: id,
trend: trend.status,
...(trend.status === "ok" ? { projection: trend.projection } : {}),
},
});
return apiSuccess(serialiseBiomarker(row, aliases.get(row.id) ?? []));
return apiSuccess({
...serialiseBiomarker(row, aliases.get(row.id) ?? []),
trend,
});
},
);
@@ -3,6 +3,10 @@ import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
// v1.44 — the extended channel families (intake, training, room climate).
nutrientIntakeDay: { findMany: async () => [] },
workout: { findMany: async () => [] },
strainTrimpCache: { findMany: async () => [] },
auditLog: { create: vi.fn() },
appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
user: {
@@ -245,6 +245,11 @@ async function buildCorrelationsResponse(
custom_metric_channels: diagnostics.customMetricChannels,
symptom_event_channels: diagnostics.symptomEventChannels,
custom_metric_days: diagnostics.customMetricDays,
// v1.44 — reach of the extended families.
nutrient_days: diagnostics.nutrientDays,
alcohol_days: diagnostics.alcoholDays,
training_days: diagnostics.trainingDays,
indoor_nights: diagnostics.indoorNights,
},
});
+9 -2
View File
@@ -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);
@@ -479,4 +479,50 @@ describe("GET /api/mental-health/assessments", () => {
.calls[0][0] as { orderBy: unknown };
expect(findArgs.orderBy).toEqual([{ takenAt: "desc" }, { id: "desc" }]);
});
it("carries each screener's change against its meaningful-change threshold (v1.44)", async () => {
const at = (d: string) => new Date(`${d}T09:00:00.000Z`);
const history = [
{
id: "p2",
instrument: "PHQ9",
totalScore: 8,
takenAt: at("2026-06-01"),
},
{
id: "p1",
instrument: "PHQ9",
totalScore: 15,
takenAt: at("2026-02-01"),
},
];
vi.mocked(prisma.mentalHealthAssessment.findMany)
.mockResolvedValueOnce([] as never)
.mockResolvedValueOnce(history as never);
const res = await (GET as unknown as (r: NextRequest) => Promise<Response>)(
new NextRequest(
new URL("http://localhost/api/mental-health/assessments?limit=1"),
),
);
const body = await res.json();
expect(body.data.trends).toEqual([
expect.objectContaining({
instrument: "PHQ9",
n: 2,
threshold: 5,
changeFromPrevious: -7,
sincePrevious: "improved",
}),
]);
// The trend read selects totals and dates only, never the answers.
const trendArgs = vi.mocked(prisma.mentalHealthAssessment.findMany).mock
.calls[1][0] as { select: Record<string, boolean>; where: unknown };
expect(Object.keys(trendArgs.select).sort()).toEqual([
"id",
"instrument",
"takenAt",
"totalScore",
]);
expect(trendArgs.where).toMatchObject({ deletedAt: null });
});
});
+26 -2
View File
@@ -58,6 +58,16 @@ import type {
MeasurementSource,
} from "@/generated/prisma/client";
import { apiListSuccess } from "@/lib/api/list-cap";
import {
computeAssessmentTrends,
TREND_INSTRUMENTS,
} from "@/lib/mental-health/assessment-trend";
/**
* Upper bound on administrations the trends read; years of weekly check-ins
* across three instruments stay far below it. The cap falls on the oldest.
*/
const TREND_READ_CAP = 1500;
import { listPage } from "@/lib/http/list-query";
function shapeRow(row: {
@@ -149,11 +159,25 @@ export const GET = apiHandler(async (request: NextRequest) => {
},
}),
);
// v1.44 — the change of each screener against its meaningful-change
// threshold, over the whole history rather than the page: totals and dates
// only, never an item answer.
const trendRows = await prisma.mentalHealthAssessment.findMany({
where: {
userId: user.id,
deletedAt: null,
instrument: { in: TREND_INSTRUMENTS },
},
orderBy: [{ takenAt: "desc" }, { id: "desc" }],
take: TREND_READ_CAP,
select: { id: true, instrument: true, totalScore: true, takenAt: true },
});
const trends = computeAssessmentTrends(trendRows);
annotate({
action: { name: "mental-health.list" },
meta: { count: rows.length },
meta: { count: rows.length, trends: trends.length },
});
return apiListSuccess({ assessments: rows.map(shapeRow) }, cap);
return apiListSuccess({ assessments: rows.map(shapeRow), trends }, cap);
});
// `withIdempotency` lets a double-tap / retry re-send the same
@@ -26,6 +26,8 @@ vi.mock("@/lib/db", () => ({
user: {
findUnique: vi.fn(),
},
// v1.44 — the first page carries the training load.
strainTrimpCache: { findMany: vi.fn(async () => []) },
},
}));
@@ -423,3 +425,45 @@ describe("GET /api/workouts — canonical dedup", () => {
);
});
});
describe("GET /api/workouts — training load (v1.44)", () => {
beforeEach(() => {
vi.clearAllMocks();
__resetAllCachesForTests();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
sourcePriorityJson: null,
} as never);
vi.mocked(requireModuleEnabled).mockResolvedValue({
enabled: true,
} as never);
vi.mocked(prisma.workout.findMany).mockResolvedValue([] as never);
});
it("carries fitness, fatigue and form on the first page only", async () => {
const yesterday = new Date(Date.now() - 86_400_000)
.toISOString()
.slice(0, 10);
const rows = Array.from({ length: 50 }, (_, i) => ({
day: new Date(
Date.parse(`${yesterday}T00:00:00Z`) - (49 - i) * 86_400_000,
)
.toISOString()
.slice(0, 10),
dayTrimp: 80,
}));
vi.mocked(prisma.strainTrimpCache.findMany).mockResolvedValue(
rows as never,
);
const first = await (await GET(makeRequest())).json();
expect(first.data.trainingLoad).toMatchObject({
status: "ok",
asOf: yesterday,
daysCovered: 50,
trainingDays: 50,
warmingUp: false,
});
const later = await (await GET(makeRequest("?offset=100"))).json();
expect(later.data.trainingLoad).toBeUndefined();
});
});