mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
fix(insights): exclude unlogged days from the medication-continuity pairing
The blood-pressure medication continuity series joined every day that had a systolic reading against `takenByDay.get(day) ?? 0`, so a day with no intake record at all entered the scatter and the coefficient as a measured 0 % continuity. A week away from the app read as a week off the medication. Only days carrying an intake record are paired now. A logged skip is a real zero and stays; a day with no record is absent, and absence is not zero. The timezone-parity fixture in the route test recorded three logged days out of five and relied on the other two arriving as zeros, which pinned the defect rather than the timezone question it asks. Its two remaining days are recorded as skips, which keeps five paired days and keeps every one of them something the person actually logged.
This commit is contained in:
1 parent
04361a9fe7
commit
c2c018aa08
2 files changed
+244
-6
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -504,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