mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
fix(medications): leave record-only doses out of the nudge, the status model and the BP correlation
Three readers compute a rate straight from intake rows without reading a schedule: the weekly compliance nudge, the adherence series behind the general status, and the BP-versus-compliance correlation. They now read tracked medications only. The structural guard covers intake-row readers as well as schedule readers, with a whitespace-tolerant matcher, a floor on the match count and a reviewed allowlist.
This commit is contained in:
1 parent
e03cf7c2c8
commit
1d1570733e
4 files changed
+111
-2
No files matched your search
@@ -40,7 +40,50 @@ const SCHEDULE_READ =
|
||||
|
||||
/** The vocabulary that honours the switch. */
|
||||
const HONOURS =
|
||||
/\b(?:TRACKED_INTAKE_WHERE|TRACKED_INTAKE_EVENT_WHERE|dueSchedules|scheduleWireFields|expectsDoses)\b/;
|
||||
/\b(?:TRACKED_INTAKE_WHERE|TRACKED_INTAKE_EVENT_WHERE|dueSchedules|scheduleWireFields|expectsDoses|isRecordOnly)\b/;
|
||||
|
||||
/**
|
||||
* A bulk read of intake rows. Rate readers that never touch a schedule (a
|
||||
* nudge that divides taken by resolved rows, a model series that buckets
|
||||
* rows per day) count a record-only medication's rows unless they filter.
|
||||
* Whitespace-tolerant: the delegate and the method may sit on two lines.
|
||||
*/
|
||||
const INTAKE_READ =
|
||||
/\bmedicationIntakeEvent\s*\.\s*(?:findMany|groupBy|count|aggregate)\s*\(/;
|
||||
|
||||
/**
|
||||
* Intake readers that may see every medication's rows, and why: nothing in
|
||||
* them is an adherence rate, an expected-dose count or a due dose.
|
||||
*/
|
||||
const INTAKE_MAY_SEE_EVERY_ROW: Record<string, string> = {
|
||||
"app/api/admin/status/route.ts": "instance row counts",
|
||||
"app/api/dashboard/summary/route.ts":
|
||||
"engagement streak of days with any logged action, no rate",
|
||||
"app/api/export/medications/route.ts": "export of the recorded history",
|
||||
"app/api/export/route.ts": "export of the recorded history",
|
||||
"lib/export/full-backup-payload.ts": "backup of the recorded history",
|
||||
"app/api/sync/state/route.ts": "sync watermarks, no rate",
|
||||
"lib/jobs/measurement-tombstone-cleanup.ts": "tombstone purge",
|
||||
"lib/ai/coach/tools/availability.ts":
|
||||
"whether any intake history exists, and its date range",
|
||||
"app/api/medications/[id]/intake/route.ts":
|
||||
"one medication's recorded history, listed or written",
|
||||
"app/api/medications/[id]/intake/bulk-delete/route.ts":
|
||||
"deletes rows the person chose",
|
||||
"app/api/medications/[id]/route.ts":
|
||||
"one medication's slots; tombstones on the tracking switch",
|
||||
"app/api/medications/intake/bulk/route.ts": "writes recorded doses",
|
||||
"lib/medications/scheduling/slot-upsert.ts":
|
||||
"binds a recorded dose to its slot",
|
||||
"lib/medications/scheduling/worker-helpers.ts":
|
||||
"slot lookups for one medication the caller already selected",
|
||||
"lib/medications/inventory/consumption.ts":
|
||||
"units consumed by a recorded dose",
|
||||
"lib/medications/intake-slot-dedup.ts":
|
||||
"folds duplicate rows of doses already recorded",
|
||||
"lib/doctor-report/collect.ts":
|
||||
"administration ledger lists recorded doses; rates gate via expectsDoses",
|
||||
};
|
||||
|
||||
/**
|
||||
* Readers that may see every stored schedule row, and why. Each reason says
|
||||
@@ -168,3 +211,55 @@ describe("medication intake tracking — schedule readers honour the switch", ()
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
const intakeReaders = sourceFiles().filter((rel) =>
|
||||
INTAKE_READ.test(code(rel)),
|
||||
);
|
||||
|
||||
describe("medication intake tracking — intake-row readers honour the switch", () => {
|
||||
it("finds the intake readers it exists to police", () => {
|
||||
expect(intakeReaders.length).toBeGreaterThanOrEqual(30);
|
||||
for (const known of [
|
||||
"lib/jobs/coach-nudge.ts",
|
||||
"lib/insights/general-status.ts",
|
||||
"lib/analytics/correlations-fast-path.ts",
|
||||
"lib/medications/outstanding-doses.ts",
|
||||
]) {
|
||||
expect(intakeReaders).toContain(known);
|
||||
}
|
||||
});
|
||||
|
||||
it("every intake reader uses the vocabulary or is allowlisted", () => {
|
||||
const offenders = intakeReaders.filter(
|
||||
(rel) => !(rel in INTAKE_MAY_SEE_EVERY_ROW) && !HONOURS.test(usage(rel)),
|
||||
);
|
||||
expect(
|
||||
offenders,
|
||||
"These files read intake rows without honouring Medication.trackIntake. " +
|
||||
"Filter with TRACKED_INTAKE_EVENT_WHERE (or read only the ids of " +
|
||||
"medications filtered with TRACKED_INTAKE_WHERE), or add the file to " +
|
||||
"INTAKE_MAY_SEE_EVERY_ROW with the reason it computes no rate.",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("the intake allowlist names no file that stopped reading intake rows", () => {
|
||||
expect(
|
||||
Object.keys(INTAKE_MAY_SEE_EVERY_ROW).filter(
|
||||
(rel) => !intakeReaders.includes(rel),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("the intake matcher catches the shapes it claims to", () => {
|
||||
expect(INTAKE_READ.test("prisma.medicationIntakeEvent.findMany({")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
INTAKE_READ.test("prisma.medicationIntakeEvent\n .groupBy({"),
|
||||
).toBe(true);
|
||||
expect(INTAKE_READ.test("tx.medicationIntakeEvent . count (")).toBe(true);
|
||||
expect(INTAKE_READ.test("prisma.medicationIntakeEvent.create({")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -65,6 +65,7 @@ import { isNearUtc, userDayKey } from "@/lib/tz/resolver";
|
||||
import { wallClockInTz } from "@/lib/tz/wall-clock";
|
||||
import { defaultLocale, type Locale } from "@/lib/i18n/config";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { TRACKED_INTAKE_EVENT_WHERE } from "@/lib/medications/intake-tracking";
|
||||
|
||||
/**
|
||||
* v1.4.37 W2 — cold-path correlation window. Trim from 30 to 28 days
|
||||
@@ -279,7 +280,13 @@ export async function computeCorrelationHypothesesFastPath(
|
||||
}) as Promise<MoodRow[]>,
|
||||
prisma.medicationIntakeEvent.findMany({
|
||||
// v1.7.0 sync — exclude tombstoned rows.
|
||||
where: { userId, deletedAt: null, scheduledFor: { gte: since } },
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
scheduledFor: { gte: since },
|
||||
// v1.39.1 (#1033) — compliance counts tracked medications only.
|
||||
...TRACKED_INTAKE_EVENT_WHERE,
|
||||
},
|
||||
select: { scheduledFor: true, takenAt: true, skipped: true },
|
||||
}) as Promise<IntakeRow[]>,
|
||||
]);
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
} from "@/lib/insights/status-card-generation";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver";
|
||||
import { TRACKED_INTAKE_EVENT_WHERE } from "@/lib/medications/intake-tracking";
|
||||
|
||||
// Derived from canonical enum so a new measurement type is auto-included
|
||||
// in the AI general-status fetch (V3 audit: enum drift cousins).
|
||||
@@ -243,6 +244,8 @@ export async function prepareGeneralStatusForUser(
|
||||
userId,
|
||||
deletedAt: null,
|
||||
scheduledFor: { gte: intakeWindowStart },
|
||||
// v1.39.1 (#1033) — adherence counts tracked medications only.
|
||||
...TRACKED_INTAKE_EVENT_WHERE,
|
||||
},
|
||||
orderBy: { scheduledFor: "asc" },
|
||||
select: {
|
||||
|
||||
@@ -111,6 +111,7 @@ import {
|
||||
DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
|
||||
type HealthProfileAiSection,
|
||||
} from "@/lib/validations/health-profile-facts";
|
||||
import { TRACKED_INTAKE_EVENT_WHERE } from "@/lib/medications/intake-tracking";
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
const SELF_CONTEXT_AI_SECTIONS = [
|
||||
@@ -556,6 +557,9 @@ export async function findTriggerForUser(
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
scheduledFor: { gte: sevenDaysAgo, lte: now },
|
||||
// v1.39.1 (#1033) — a medication kept as a record expects no dose;
|
||||
// its rows must not raise a compliance nudge.
|
||||
...TRACKED_INTAKE_EVENT_WHERE,
|
||||
},
|
||||
select: { takenAt: true, skipped: true, autoMissed: true },
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user