mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
Merge branch 'feat/v144-analysis' into release/v1.44.0
This commit is contained in:
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);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user