mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
Merge fix/guarded-twins
# Conflicts: # CHANGELOG.md
This commit is contained in:
commit
ee4afc7b9e
33 files changed
+1164
-73
No files matched your search
@@ -776,15 +776,20 @@ describe("GET /api/insights/comprehensive — mood × metric local-day pairing (
|
||||
scheduleRevisions: [],
|
||||
pauseEras: [],
|
||||
};
|
||||
// Intake taken on the first three days, missed on the last two → a
|
||||
// non-degenerate continuity series.
|
||||
const intakeEvents = days.slice(0, 3).map((d) => ({
|
||||
// Intake taken on the first three days, logged as skipped on the last
|
||||
// two → a non-degenerate continuity series. The skips used to be absent
|
||||
// rows: a day with no record read as a measured 0 % continuity, which is
|
||||
// the fabrication the route no longer makes, and a fixture that relies on
|
||||
// it would be pinning the defect rather than the timezone question this
|
||||
// test asks. Recording the two days as skips keeps five paired days AND
|
||||
// keeps every one of them a thing the person actually logged.
|
||||
const intakeEvents = days.map((d, i) => ({
|
||||
medicationId: "med-bp",
|
||||
userId: "user-comp-1",
|
||||
deletedAt: null,
|
||||
scheduledFor: new Date(`${d}T07:00:00Z`),
|
||||
takenAt: new Date(`${d}T07:05:00Z`),
|
||||
skipped: false,
|
||||
takenAt: i < 3 ? new Date(`${d}T07:05:00Z`) : null,
|
||||
skipped: i >= 3,
|
||||
}));
|
||||
|
||||
function primeMocks() {
|
||||
@@ -827,3 +832,227 @@ describe("GET /api/insights/comprehensive — mood × metric local-day pairing (
|
||||
expect(utcBody.data.bpMedicationCorrelation).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// A medication with no schedule expects no dose, so it has no adherence to
|
||||
// report. `calculateCompliance` answers `rate: 100, totalExpected: 0` for that
|
||||
// case — arithmetically true and a lie on a surface that reads the number as
|
||||
// adherence. The doctor report has excluded both the PRN arm and the empty
|
||||
// schedule since it was written, with the reason beside it; this envelope
|
||||
// filtered only PRN, so a scheduled medication saved without a schedule (which
|
||||
// `POST /api/medications` accepts) reported perfect adherence to the reader and
|
||||
// to the prompt built from the same array.
|
||||
describe("GET /api/insights/comprehensive — no adherence without an expectation", () => {
|
||||
const EMPTY_AGGREGATE = {
|
||||
summaries: {},
|
||||
bpRawRows: { sys: [], dia: [] },
|
||||
weightRawRows: [],
|
||||
dailyByType: {},
|
||||
firstMeasurementAt: null,
|
||||
totalMeasurements: 0,
|
||||
};
|
||||
|
||||
type MedBody = {
|
||||
data: {
|
||||
medications: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
compliance7: number;
|
||||
compliance30: number;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
function scheduledMed(id: string, name: string, schedules: unknown[]) {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
dose: "5mg",
|
||||
active: true,
|
||||
asNeeded: false,
|
||||
createdAt: new Date("2026-04-01T00:00:00Z"),
|
||||
schedules,
|
||||
scheduleRevisions: [],
|
||||
pauseEras: [],
|
||||
};
|
||||
}
|
||||
|
||||
const SCHEDULE = {
|
||||
id: "sch-1",
|
||||
windowStart: "07:00",
|
||||
windowEnd: "08:00",
|
||||
timesOfDay: ["07:00"],
|
||||
daysOfWeek: null,
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
reminderGraceMinutes: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
doseWindows: [],
|
||||
};
|
||||
|
||||
it("leaves a scheduled medication with no schedule out of the compliance list", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
(buildComprehensiveAggregate as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
EMPTY_AGGREGATE,
|
||||
);
|
||||
(prisma.medication.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
scheduledMed("med-none", "Unscheduled", []),
|
||||
scheduledMed("med-real", "Amlodipine", [SCHEDULE]),
|
||||
]);
|
||||
|
||||
const body = (await (await callGet(makeReq())).json()) as MedBody;
|
||||
|
||||
const names = body.data.medications.map((m) => m.name);
|
||||
expect(names).not.toContain("Unscheduled");
|
||||
// The medication that DOES expect a dose still reports, so this is a
|
||||
// filter and not a blanket suppression.
|
||||
expect(names).toContain("Amlodipine");
|
||||
});
|
||||
|
||||
it("never reports 100 % for a medication with nothing expected of it", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
(buildComprehensiveAggregate as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
EMPTY_AGGREGATE,
|
||||
);
|
||||
(prisma.medication.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
scheduledMed("med-none", "Unscheduled", []),
|
||||
]);
|
||||
|
||||
const body = (await (await callGet(makeReq())).json()) as MedBody;
|
||||
|
||||
expect(body.data.medications).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// The medication-continuity correlation joined every day that had a systolic
|
||||
// reading against `takenByDay.get(day) ?? 0`. A day the reader simply never
|
||||
// logged an intake for therefore entered the scatter and the coefficient as a
|
||||
// measured 0 % continuity. Absence is not zero anywhere else in this project
|
||||
// and it is not zero here: a day with a logged skip is a real zero and stays,
|
||||
// a day with no record at all drops out.
|
||||
describe("GET /api/insights/comprehensive — an unlogged day is not a zero", () => {
|
||||
type BpBody = {
|
||||
data: {
|
||||
bpMedicationScatterData: Array<{ continuityPct: number; sysBP: number }>;
|
||||
bpMedicationCorrelation: { n: number } | null;
|
||||
};
|
||||
};
|
||||
|
||||
const DAYS = [
|
||||
"2026-05-01",
|
||||
"2026-05-02",
|
||||
"2026-05-03",
|
||||
"2026-05-04",
|
||||
"2026-05-05",
|
||||
];
|
||||
|
||||
const MEDICATION = {
|
||||
id: "med-bp",
|
||||
name: "Amlodipine",
|
||||
dose: "5mg",
|
||||
active: true,
|
||||
asNeeded: false,
|
||||
createdAt: new Date("2026-04-01T00:00:00Z"),
|
||||
schedules: [
|
||||
{
|
||||
id: "sch-1",
|
||||
windowStart: "07:00",
|
||||
windowEnd: "08:00",
|
||||
timesOfDay: ["07:00"],
|
||||
daysOfWeek: null,
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
reminderGraceMinutes: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
doseWindows: [],
|
||||
},
|
||||
],
|
||||
scheduleRevisions: [],
|
||||
pauseEras: [],
|
||||
};
|
||||
|
||||
function primeBp(
|
||||
events: Array<{ day: string; takenAt: Date | null; skipped: boolean }>,
|
||||
) {
|
||||
(buildComprehensiveAggregate as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
{
|
||||
summaries: {},
|
||||
bpRawRows: { sys: [], dia: [] },
|
||||
weightRawRows: [],
|
||||
dailyByType: {
|
||||
BLOOD_PRESSURE_SYS: DAYS.map((d, i) => ({
|
||||
day: d,
|
||||
value: 120 + i * 2,
|
||||
})),
|
||||
},
|
||||
firstMeasurementAt: new Date(),
|
||||
totalMeasurements: 10,
|
||||
},
|
||||
);
|
||||
(prisma.medication.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
MEDICATION,
|
||||
]);
|
||||
(
|
||||
prisma.medicationIntakeEvent.findMany as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue(
|
||||
events.map((e) => ({
|
||||
medicationId: "med-bp",
|
||||
userId: "user-comp-1",
|
||||
deletedAt: null,
|
||||
scheduledFor: new Date(`${e.day}T07:00:00Z`),
|
||||
takenAt: e.takenAt,
|
||||
skipped: e.skipped,
|
||||
})),
|
||||
);
|
||||
vi.mocked(getMedicationCategories).mockResolvedValue({
|
||||
"med-bp": "BLOOD_PRESSURE",
|
||||
});
|
||||
}
|
||||
|
||||
it("keeps only the days that carry an intake record", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// Three days logged; the last two were never touched.
|
||||
primeBp(
|
||||
DAYS.slice(0, 3).map((day) => ({
|
||||
day,
|
||||
takenAt: new Date(`${day}T07:05:00Z`),
|
||||
skipped: false,
|
||||
})),
|
||||
);
|
||||
|
||||
const body = (await (await callGet(makeReq())).json()) as BpBody;
|
||||
|
||||
expect(body.data.bpMedicationScatterData).toHaveLength(3);
|
||||
// The two unlogged days would have entered as 0 % against their own
|
||||
// systolic reading; both are gone.
|
||||
expect(body.data.bpMedicationScatterData.map((p) => p.sysBP)).not.toContain(
|
||||
126,
|
||||
);
|
||||
expect(
|
||||
body.data.bpMedicationScatterData.every((p) => p.continuityPct === 100),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a logged skip, which is a real zero", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeBp([
|
||||
...DAYS.slice(0, 3).map((day) => ({
|
||||
day,
|
||||
takenAt: new Date(`${day}T07:05:00Z`),
|
||||
skipped: false,
|
||||
})),
|
||||
{ day: DAYS[3], takenAt: null, skipped: true },
|
||||
]);
|
||||
|
||||
const body = (await (await callGet(makeReq())).json()) as BpBody;
|
||||
|
||||
expect(body.data.bpMedicationScatterData).toHaveLength(4);
|
||||
expect(body.data.bpMedicationScatterData).toContainEqual({
|
||||
continuityPct: 0,
|
||||
sysBP: 126,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import {
|
||||
buildComplianceMedicationContext,
|
||||
calculateCompliance,
|
||||
expectsDoses,
|
||||
lastNonSkippedTakenAt,
|
||||
SCHEDULE_COMPLIANCE_SELECT,
|
||||
} from "@/lib/analytics/compliance";
|
||||
@@ -365,7 +366,7 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
|
||||
|
||||
// ── Medication compliance ────────────────────────────────
|
||||
// Bounded reads — left untouched per the directive.
|
||||
const medications = await prisma.medication.findMany({
|
||||
const medicationRows = await prisma.medication.findMany({
|
||||
// v1.16.11 — as-needed (PRN) medications never surface a compliance
|
||||
// rate (no expected doses).
|
||||
where: { userId, active: true, asNeeded: false },
|
||||
@@ -379,6 +380,12 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
|
||||
pauseEras: { select: { pausedAt: true, resumedAt: true } },
|
||||
},
|
||||
});
|
||||
// A medication with no schedule expects no dose, and `calculateCompliance`
|
||||
// answers 100 % for that case. The `asNeeded: false` filter above catches
|
||||
// only the PRN arm; the empty schedule is reachable on a scheduled
|
||||
// medication and would otherwise report perfect adherence.
|
||||
const medications = medicationRows.filter(expectsDoses);
|
||||
|
||||
const categoryMap = await getMedicationCategories(
|
||||
medications.map((m) => m.id),
|
||||
);
|
||||
@@ -497,14 +504,23 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
|
||||
}
|
||||
|
||||
const takenByDay = new Map<string, number>();
|
||||
// Days that carry an intake record of ANY kind. A logged skip is a real
|
||||
// zero and belongs in the series; a day the person never touched is not a
|
||||
// zero, it is nothing, and pairing it against that day's systolic reading
|
||||
// invented a data point they never produced. Absence is not zero, here as
|
||||
// everywhere else in this project, so the unlogged day drops out of both
|
||||
// the scatter and the coefficient rather than dragging continuity down.
|
||||
const loggedDays = new Set<string>();
|
||||
for (const event of bpMedicationEvents) {
|
||||
if (event.skipped || !event.takenAt) continue;
|
||||
const dayKey = event.scheduledFor.toISOString().slice(0, 10);
|
||||
loggedDays.add(dayKey);
|
||||
if (event.skipped || !event.takenAt) continue;
|
||||
takenByDay.set(dayKey, (takenByDay.get(dayKey) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const pairs: Array<{ a: number; b: number; date: Date }> = [];
|
||||
for (const [dayKey, avgSys] of sysByDay.entries()) {
|
||||
if (!loggedDays.has(dayKey)) continue;
|
||||
const taken = takenByDay.get(dayKey) ?? 0;
|
||||
const continuity = Math.min(1, taken / expectedBpIntakesPerDay);
|
||||
pairs.push({
|
||||
|
||||
Reference in new issue
Block a user