mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
feat(medications): keep a medication as a record without tracking intake
A per-medication trackIntake flag (migration 0351, default on for every existing and new row). With it off the dose, dates and schedule stay stored, but nothing derives a due dose from them: the reminder worker, the manual reminder check, the today projector, the auto-miss pass, the low-stock pass, the doses card, the today list, the outstanding-dose badge, compliance and its heatmap, the doctor report, the Coach, the BP-status gate, insights, targets, achievements, the efficacy view and the MCP schedule tool all skip it. The medication wire serves such a medication with schedules: [] and the stored rows in an additive recordedSchedules field, so a client that arms local reminders from schedules stays silent without an update. Switching tracking off or back on archives a schedule revision at that instant, the untracked stretch with an empty payload, so it never counts as missed and the projector does not put a dose from before the switch back on the card. A structural guard freezes the set of schedule readers: each one filters on the flag, derives from dueSchedules, shapes the wire with scheduleWireFields, or is allowlisted with the reason it derives nothing due. The flag travels through backup, restore and the medication CSV export.
This commit is contained in:
1 parent
700de6d228
commit
b4c2706ec9
57 files changed
+1490
-182
No files matched your search
+73
-4
@@ -23103,6 +23103,13 @@ components:
|
||||
rates/streaks, but intakes still log (ad-hoc), inventory still consumes, and the history renders. Stays
|
||||
active indefinitely. Mutually exclusive with `oneShot`."
|
||||
type: boolean
|
||||
trackIntake:
|
||||
description: "v1.39.1 — intake tracking. Default true. When false the medication keeps its dose, dates and schedules as
|
||||
information, but nothing is ever due: no reminder on any channel, no projected slot, no entry on the today
|
||||
list or the doses card, and no adherence figure anywhere. Existing intake history stays stored. Switching it
|
||||
back on resumes from that moment; the time it was off never counts as missed. Omitted on update leaves the
|
||||
current value."
|
||||
type: boolean
|
||||
schedules:
|
||||
type: array
|
||||
items:
|
||||
@@ -23418,6 +23425,13 @@ components:
|
||||
rates/streaks, but intakes still log (ad-hoc), inventory still consumes, and the history renders. Stays
|
||||
active indefinitely. Mutually exclusive with `oneShot`."
|
||||
type: boolean
|
||||
trackIntake:
|
||||
description: "v1.39.1 — intake tracking. Default true. When false the medication keeps its dose, dates and schedules as
|
||||
information, but nothing is ever due: no reminder on any channel, no projected slot, no entry on the today
|
||||
list or the doses card, and no adherence figure anywhere. Existing intake history stays stored. Switching it
|
||||
back on resumes from that moment; the time it was off never counts as missed. Omitted on update leaves the
|
||||
current value."
|
||||
type: boolean
|
||||
schedules:
|
||||
type: array
|
||||
items:
|
||||
@@ -33035,6 +33049,8 @@ components:
|
||||
type: boolean
|
||||
asNeeded:
|
||||
type: boolean
|
||||
trackIntake:
|
||||
type: boolean
|
||||
deliveryForm:
|
||||
type: string
|
||||
enum:
|
||||
@@ -42557,6 +42573,13 @@ components:
|
||||
any schedule entry alongside the flag): it is never due (`nextDueAt` stays null), never reminded, and
|
||||
excluded from every compliance rate/streak — but intakes still log as ad-hoc rows, inventory still consumes
|
||||
per `unitsPerDose`, and the history renders. Stays active indefinitely. Mutually exclusive with `oneShot`."
|
||||
trackIntake:
|
||||
type: boolean
|
||||
description: "v1.39.1 — intake tracking. True (the default) for every medication that existed before the field. When
|
||||
false the medication is kept as a record: nothing is ever due (`nextDueAt` stays null), nothing reminds on
|
||||
any channel, and it is excluded from every adherence figure. Its stored schedule then travels in
|
||||
`recordedSchedules` while `schedules` is served empty, so a client that arms reminders from `schedules`
|
||||
stays silent without an update."
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
@@ -42569,6 +42592,14 @@ components:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/MedicationScheduleOutput"
|
||||
description: "The schedules in force. Empty for an as-needed medication and (v1.39.1) for a medication with
|
||||
`trackIntake: false`, whose stored schedule is in `recordedSchedules`."
|
||||
recordedSchedules:
|
||||
description: "v1.39.1 — present only when `trackIntake` is false: the stored schedule, kept as information. Nothing
|
||||
derives a due dose or a reminder from it. Absent on a tracked medication, whose schedule is `schedules`."
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/MedicationScheduleOutput"
|
||||
category:
|
||||
$ref: "#/components/schemas/MedicationCategory"
|
||||
externalSource:
|
||||
@@ -42644,6 +42675,7 @@ components:
|
||||
- endsOn
|
||||
- oneShot
|
||||
- asNeeded
|
||||
- trackIntake
|
||||
- createdAt
|
||||
- updatedAt
|
||||
- schedules
|
||||
@@ -42808,6 +42840,13 @@ components:
|
||||
any schedule entry alongside the flag): it is never due (`nextDueAt` stays null), never reminded, and
|
||||
excluded from every compliance rate/streak — but intakes still log as ad-hoc rows, inventory still consumes
|
||||
per `unitsPerDose`, and the history renders. Stays active indefinitely. Mutually exclusive with `oneShot`."
|
||||
trackIntake:
|
||||
type: boolean
|
||||
description: "v1.39.1 — intake tracking. True (the default) for every medication that existed before the field. When
|
||||
false the medication is kept as a record: nothing is ever due (`nextDueAt` stays null), nothing reminds on
|
||||
any channel, and it is excluded from every adherence figure. Its stored schedule then travels in
|
||||
`recordedSchedules` while `schedules` is served empty, so a client that arms reminders from `schedules`
|
||||
stays silent without an update."
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
@@ -42820,6 +42859,14 @@ components:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/MedicationScheduleOutput"
|
||||
description: "The schedules in force. Empty for an as-needed medication and (v1.39.1) for a medication with
|
||||
`trackIntake: false`, whose stored schedule is in `recordedSchedules`."
|
||||
recordedSchedules:
|
||||
description: "v1.39.1 — present only when `trackIntake` is false: the stored schedule, kept as information. Nothing
|
||||
derives a due dose or a reminder from it. Absent on a tracked medication, whose schedule is `schedules`."
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/MedicationScheduleOutput"
|
||||
category:
|
||||
$ref: "#/components/schemas/MedicationCategory"
|
||||
stockUnitsRemaining:
|
||||
@@ -42863,6 +42910,7 @@ components:
|
||||
- endsOn
|
||||
- oneShot
|
||||
- asNeeded
|
||||
- trackIntake
|
||||
- createdAt
|
||||
- updatedAt
|
||||
- schedules
|
||||
@@ -43615,7 +43663,9 @@ components:
|
||||
notApplicableReason:
|
||||
anyOf:
|
||||
- type: string
|
||||
const: NO_LOCAL_SCHEDULE
|
||||
enum:
|
||||
- NO_LOCAL_SCHEDULE
|
||||
- INTAKE_NOT_TRACKED
|
||||
- type: "null"
|
||||
compliance7:
|
||||
description: Seven-day adherence summary, or an all-zero compatibility placeholder when applicable is false.
|
||||
@@ -43874,10 +43924,13 @@ components:
|
||||
notApplicableReason:
|
||||
anyOf:
|
||||
- type: string
|
||||
const: NO_LOCAL_SCHEDULE
|
||||
enum:
|
||||
- NO_LOCAL_SCHEDULE
|
||||
- INTAKE_NOT_TRACKED
|
||||
- type: "null"
|
||||
description: Reason adherence is not applicable. NO_LOCAL_SCHEDULE means the medication has no HealthLog-owned
|
||||
expected-dose grid.
|
||||
description: "Reason adherence is not applicable. NO_LOCAL_SCHEDULE means the medication has no HealthLog-owned
|
||||
expected-dose grid. INTAKE_NOT_TRACKED (v1.39.1) means the medication's intake tracking is switched off
|
||||
(`trackIntake: false`): its schedule is kept as information and nothing is expected from it."
|
||||
compliance7:
|
||||
description: Seven-day adherence summary. When applicable is false, this remains a non-null all-zero compatibility
|
||||
placeholder for released clients and must not be rendered as a percentage.
|
||||
@@ -72014,6 +72067,13 @@ components:
|
||||
any schedule entry alongside the flag): it is never due (`nextDueAt` stays null), never reminded, and
|
||||
excluded from every compliance rate/streak — but intakes still log as ad-hoc rows, inventory still consumes
|
||||
per `unitsPerDose`, and the history renders. Stays active indefinitely. Mutually exclusive with `oneShot`."
|
||||
trackIntake:
|
||||
type: boolean
|
||||
description: "v1.39.1 — intake tracking. True (the default) for every medication that existed before the field. When
|
||||
false the medication is kept as a record: nothing is ever due (`nextDueAt` stays null), nothing reminds on
|
||||
any channel, and it is excluded from every adherence figure. Its stored schedule then travels in
|
||||
`recordedSchedules` while `schedules` is served empty, so a client that arms reminders from `schedules`
|
||||
stays silent without an update."
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
@@ -72026,6 +72086,14 @@ components:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/MedicationScheduleOutput"
|
||||
description: "The schedules in force. Empty for an as-needed medication and (v1.39.1) for a medication with
|
||||
`trackIntake: false`, whose stored schedule is in `recordedSchedules`."
|
||||
recordedSchedules:
|
||||
description: "v1.39.1 — present only when `trackIntake` is false: the stored schedule, kept as information. Nothing
|
||||
derives a due dose or a reminder from it. Absent on a tracked medication, whose schedule is `schedules`."
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/MedicationScheduleOutput"
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
@@ -72046,6 +72114,7 @@ components:
|
||||
- endsOn
|
||||
- oneShot
|
||||
- asNeeded
|
||||
- trackIntake
|
||||
- createdAt
|
||||
- updatedAt
|
||||
- schedules
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Per-medication intake tracking (#1033). A medication with tracking off
|
||||
-- keeps its dose, dates and schedule as a record, but nothing is due, no
|
||||
-- reminder is sent and it is left out of adherence. Every existing row
|
||||
-- defaults to tracking on, so nothing changes for anyone who does not flip it.
|
||||
ALTER TABLE "medications"
|
||||
ADD COLUMN "track_intake" BOOLEAN NOT NULL DEFAULT true;
|
||||
@@ -2610,6 +2610,25 @@ model Medication {
|
||||
/// never reminded" surface follows structurally from the empty
|
||||
/// schedule list. Mutually exclusive with `oneShot`.
|
||||
asNeeded Boolean @default(false) @map("as_needed")
|
||||
/// v1.39.1 (#1033) — per-medication intake tracking. `true` (the default,
|
||||
/// and the backfill for every existing row) is the behaviour every
|
||||
/// medication has always had. `false` keeps the medication as a record:
|
||||
/// dose, dates and the schedule rows stay stored and shown as information,
|
||||
/// but nothing derives a due dose from them — no reminder on any channel,
|
||||
/// no projected or auto-missed slot, no place on the doses card or the
|
||||
/// take-all flow, and no adherence figure anywhere (compliance, heatmap,
|
||||
/// doctor report, Coach, BP-status gate, achievements, MCP). Unlike
|
||||
/// `asNeeded` the schedule rows survive, so every schedule reader that
|
||||
/// derives a due dose must honour this flag explicitly
|
||||
/// (`src/lib/medications/intake-tracking.ts`); the structural guard
|
||||
/// `src/__tests__/medication-intake-tracking-guard.test.ts` holds the list.
|
||||
/// The medication wire serves such a medication with `schedules: []` and
|
||||
/// the stored rows under `recordedSchedules`, so a client that arms local
|
||||
/// reminders from `schedules` stays silent without an update. Switching
|
||||
/// tracking off or back on archives a schedule revision at that instant
|
||||
/// (the stretch in between archives with an empty payload), so the rate
|
||||
/// paths never count the untracked stretch as missed.
|
||||
trackIntake Boolean @default(true) @map("track_intake")
|
||||
/// v1.6.0 — route of administration. Decoupled from `treatmentClass`
|
||||
/// so the injection-site picker can surface for any INJECTION dose.
|
||||
/// A one-time injection is `oneShot = true` + `deliveryForm =
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Structural guard — every reader of medication schedules honours the
|
||||
* per-medication intake-tracking switch (#1033).
|
||||
*
|
||||
* An as-needed medication is "never due, never reminded, never counted"
|
||||
* structurally: it carries no schedule rows, so every reader that expands
|
||||
* schedules into slots finds nothing to expand. A medication with intake
|
||||
* tracking off (`Medication.trackIntake = false`) keeps its schedule rows as a
|
||||
* record. Nothing about the rows themselves says "do not derive a due dose
|
||||
* from me", so the promise holds only as long as every reader asks. A new
|
||||
* reminder path, compliance surface or projection that reads `schedules` and
|
||||
* forgets the flag would quietly start reminding a person who asked not to be
|
||||
* reminded.
|
||||
*
|
||||
* So the set of schedule readers is frozen here. A file that reads medication
|
||||
* schedules through Prisma either uses the shared vocabulary in
|
||||
* `src/lib/medications/intake-tracking.ts` (or `expectsDoses`, whose required
|
||||
* `trackIntake` field makes the type checker enforce the same thing), or it is
|
||||
* on the allowlist below with the reason it may see every stored row.
|
||||
*
|
||||
* Limits, stated plainly. The matcher is textual: it finds a Prisma
|
||||
* `schedules: true` / `schedules: { … }` include or select and the
|
||||
* `medicationSchedule.find*` delegates. A schedule read spelt some other way
|
||||
* (raw SQL on `medication_schedules`, a helper that returns rows it read
|
||||
* elsewhere) is invisible to it. It proves a file names the vocabulary, not
|
||||
* that the named helper is applied to the right query. The behavioural tests
|
||||
* beside each consumer carry that half.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
/** A Prisma read (or write payload) that touches medication schedule rows. */
|
||||
const SCHEDULE_READ =
|
||||
/\bschedules\s*:\s*(?:true\b|\{)|\bmedicationSchedule\s*\.\s*find(?:Many|First|Unique)\b/;
|
||||
|
||||
/** The vocabulary that honours the switch. */
|
||||
const HONOURS =
|
||||
/\b(?:TRACKED_INTAKE_WHERE|TRACKED_INTAKE_EVENT_WHERE|dueSchedules|scheduleWireFields|expectsDoses)\b/;
|
||||
|
||||
/**
|
||||
* Readers that may see every stored schedule row, and why. Each reason says
|
||||
* why no due dose, reminder or adherence figure can come out of the file.
|
||||
*/
|
||||
const MAY_READ_EVERY_ROW: Record<string, string> = {
|
||||
// Record-keeping: a backup, a restore and an export carry the medication
|
||||
// as stored, schedule and switch included.
|
||||
"lib/export/full-backup-payload.ts": "backup carries the stored rows",
|
||||
"lib/export/restore-backup.ts": "restore writes the stored rows back",
|
||||
"app/api/export/route.ts": "data export of the stored record",
|
||||
"app/api/export/medications/route.ts": "medication CSV export of the record",
|
||||
// Intake WRITE helpers: they run only when a dose is being recorded, bind
|
||||
// it to the slot it belongs to and consume stock. They derive nothing due.
|
||||
"lib/medications/scheduling/slot-upsert.ts":
|
||||
"binds a recorded dose to its slot",
|
||||
"lib/medications/inventory/consumption.ts":
|
||||
"units consumed by a recorded dose",
|
||||
"lib/medications/intake-slot-dedup.ts":
|
||||
"folds duplicate rows of doses already recorded",
|
||||
// Readers that hand the rows to a builder whose input TYPE requires
|
||||
// `trackIntake` and which gates on it: the per-medication compliance
|
||||
// payload (`buildCompliancePayload`, INTAKE_NOT_TRACKED) and the doctor
|
||||
// report's ledger compliance (`expectsDoses`). The report's medication
|
||||
// list shows the stored schedule as information.
|
||||
"app/api/medications/[id]/compliance/route.ts":
|
||||
"buildCompliancePayload gates on the type-required trackIntake",
|
||||
"lib/doctor-report/collect.ts":
|
||||
"buildLedgerCompliance gates via expectsDoses; list is information",
|
||||
// The MCP medication resources list the stored schedule as information,
|
||||
// with `intakeTracked` beside it; they carry no due time or rate.
|
||||
"lib/mcp/resources.ts":
|
||||
"schedule as information, intakeTracked flag beside it",
|
||||
// The Coach's GLP-1 block reads the stored cadence as information and
|
||||
// gates its next-injection prediction on the flag inline.
|
||||
"lib/ai/coach/glp1-snapshot.ts":
|
||||
"cadence as information; prediction gated on trackIntake",
|
||||
};
|
||||
|
||||
function sourceFiles(): string[] {
|
||||
return walkSourceFiles(SRC, { floor: 3000 })
|
||||
.filter((p) => !p.startsWith("generated/"))
|
||||
.filter((p) => !p.includes("__tests__"))
|
||||
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
|
||||
.sort();
|
||||
}
|
||||
|
||||
/** Drop line and block comments so a mention in prose never counts. */
|
||||
function stripComments(source: string): string {
|
||||
return source
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/(^|[^:])\/\/.*$/gm, "$1");
|
||||
}
|
||||
|
||||
function code(rel: string): string {
|
||||
return stripComments(readFileSync(join(SRC, rel), "utf8"));
|
||||
}
|
||||
|
||||
const readers = sourceFiles().filter((rel) => SCHEDULE_READ.test(code(rel)));
|
||||
|
||||
describe("medication intake tracking — schedule readers honour the switch", () => {
|
||||
it("finds the schedule readers it exists to police", () => {
|
||||
// An empty match set would agree with any allowlist. Pin the floor and
|
||||
// the readers whose silence would matter most.
|
||||
expect(readers.length).toBeGreaterThanOrEqual(30);
|
||||
for (const known of [
|
||||
"lib/jobs/reminder/medication-reminder-check.ts",
|
||||
"lib/medications/scheduling/project-today-intakes.ts",
|
||||
"lib/dashboard/meds-today.ts",
|
||||
"lib/medications/list-read.ts",
|
||||
"lib/jobs/intake-auto-skip.ts",
|
||||
]) {
|
||||
expect(readers).toContain(known);
|
||||
}
|
||||
});
|
||||
|
||||
it("every schedule reader uses the vocabulary or is allowlisted", () => {
|
||||
const offenders = readers.filter(
|
||||
(rel) => !(rel in MAY_READ_EVERY_ROW) && !HONOURS.test(code(rel)),
|
||||
);
|
||||
expect(
|
||||
offenders,
|
||||
"These files read medication schedules without honouring " +
|
||||
"Medication.trackIntake. Filter with TRACKED_INTAKE_WHERE, derive " +
|
||||
"from dueSchedules(), shape the wire with scheduleWireFields(), or " +
|
||||
"add the file to MAY_READ_EVERY_ROW with the reason it derives " +
|
||||
"nothing due.",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("the allowlist names no file that stopped reading schedules", () => {
|
||||
const stale = Object.keys(MAY_READ_EVERY_ROW).filter(
|
||||
(rel) => !readers.includes(rel),
|
||||
);
|
||||
expect(stale).toEqual([]);
|
||||
});
|
||||
|
||||
it("the matchers catch the shapes they claim to", () => {
|
||||
// Defeat check: the guard must be able to fail.
|
||||
expect(SCHEDULE_READ.test("include: { schedules: true }")).toBe(true);
|
||||
expect(SCHEDULE_READ.test("schedules: {\n select: X }")).toBe(true);
|
||||
expect(
|
||||
SCHEDULE_READ.test("prisma.medicationSchedule\n .findMany({})"),
|
||||
).toBe(true);
|
||||
expect(SCHEDULE_READ.test("const schedules = m.schedules;")).toBe(false);
|
||||
expect(HONOURS.test(stripComments("// dueSchedules(m)"))).toBe(false);
|
||||
expect(HONOURS.test("where: { ...TRACKED_INTAKE_WHERE }")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -92,7 +92,7 @@ describe("POST /api/admin/notifications/reminder-check", () => {
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as { data: { scoped: boolean } };
|
||||
expect(json.data.scoped).toBe(false);
|
||||
expect(whereOfLastFindMany()).toEqual({ active: true });
|
||||
expect(whereOfLastFindMany()).toEqual({ active: true, trackIntake: true });
|
||||
});
|
||||
|
||||
it("narrows the sweep to the named account", async () => {
|
||||
@@ -102,6 +102,7 @@ describe("POST /api/admin/notifications/reminder-check", () => {
|
||||
expect(json.data.scoped).toBe(true);
|
||||
expect(whereOfLastFindMany()).toEqual({
|
||||
active: true,
|
||||
trackIntake: true,
|
||||
userId: "cku0mock0account0id",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import { adminReminderCheckSchema } from "@/lib/validations/notifications";
|
||||
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
|
||||
import { dispatchLocalisedNotification } from "@/lib/notifications/dispatch-localised";
|
||||
import { getUserTodayBounds, getDayOfWeekInTz } from "@/lib/tz/local-day";
|
||||
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -99,6 +100,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const medications = await prisma.medication.findMany({
|
||||
where: {
|
||||
active: true,
|
||||
// v1.39.1 (#1033) — the manual check reminds exactly what the worker
|
||||
// reminds: never a medication with intake tracking off.
|
||||
...TRACKED_INTAKE_WHERE,
|
||||
// Absent selector ⇒ no `userId` key ⇒ the instance-wide sweep.
|
||||
...(scopedUserId ? { userId: scopedUserId } : {}),
|
||||
},
|
||||
|
||||
@@ -66,6 +66,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
name: true,
|
||||
dose: true,
|
||||
active: true,
|
||||
trackIntake: true,
|
||||
schedules: {
|
||||
select: {
|
||||
windowStart: true,
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
SCHEDULE_COMPLIANCE_SELECT,
|
||||
} from "@/lib/analytics/compliance";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
|
||||
|
||||
type Severity = "alert" | "caution" | "info" | "good";
|
||||
|
||||
@@ -101,7 +102,12 @@ export const GET = apiHandler(async () => {
|
||||
prisma.medication.findMany({
|
||||
// v1.16.11 — as-needed (PRN) medications never surface a compliance
|
||||
// rate (no expected doses).
|
||||
where: { userId: user.id, active: true, asNeeded: false },
|
||||
where: {
|
||||
userId: user.id,
|
||||
active: true,
|
||||
asNeeded: false,
|
||||
...TRACKED_INTAKE_WHERE,
|
||||
},
|
||||
include: {
|
||||
// Schedules through the shared compliance select so the configured
|
||||
// per-dose windows reach the engine like every other surface.
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
readMoodDayRollups,
|
||||
} from "@/lib/rollups/mood-rollups";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -381,7 +382,7 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
|
||||
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 },
|
||||
where: { userId, active: true, asNeeded: false, ...TRACKED_INTAKE_WHERE },
|
||||
// v1.15.20 — schedules through the shared compliance select so the
|
||||
// configured per-dose windows reach this surface like every other.
|
||||
include: {
|
||||
|
||||
@@ -776,3 +776,146 @@ describe("PUT /api/medications/[id] — as-needed (v1.16.11, #316)", () => {
|
||||
expect(prisma.medication.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/medications/[id] — intake tracking switch (#1033)", () => {
|
||||
const LIVE_ROW = {
|
||||
id: "s1",
|
||||
medicationId: "m1",
|
||||
windowStart: "08:00",
|
||||
windowEnd: "08:00",
|
||||
label: null,
|
||||
dose: null,
|
||||
daysOfWeek: null,
|
||||
timesOfDay: ["08:00"],
|
||||
reminderGraceMinutes: null,
|
||||
rrule: "FREQ=DAILY",
|
||||
rollingIntervalDays: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
doseWindows: null,
|
||||
};
|
||||
|
||||
function arrange(trackIntake: boolean) {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
|
||||
id: "m1",
|
||||
userId: "user-1",
|
||||
active: true,
|
||||
asNeeded: false,
|
||||
trackIntake,
|
||||
createdAt: new Date("2026-05-01T08:00:00.000Z"),
|
||||
} as never);
|
||||
vi.mocked(prisma.medication.update).mockResolvedValue({
|
||||
id: "m1",
|
||||
userId: "user-1",
|
||||
trackIntake: !trackIntake,
|
||||
unitsPerDose: 1,
|
||||
schedules: [{ ...LIVE_ROW, unitsPerDose: null }],
|
||||
} as never);
|
||||
vi.mocked(getMedicationCategories).mockResolvedValue({});
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined);
|
||||
vi.mocked(dayKeyForScheduledFor).mockReturnValue("2026-06-10");
|
||||
vi.mocked(recomputeMedicationComplianceForDay).mockResolvedValue(undefined);
|
||||
vi.mocked(prisma.medicationSchedule.findMany).mockResolvedValue([
|
||||
LIVE_ROW,
|
||||
] as never);
|
||||
vi.mocked(prisma.medicationScheduleRevision.create).mockClear();
|
||||
vi.mocked(prisma.medicationIntakeEvent.updateMany).mockClear();
|
||||
vi.mocked(prisma.medication.update).mockClear();
|
||||
}
|
||||
|
||||
it("switching off archives the schedule in force and tombstones open slots", async () => {
|
||||
arrange(true);
|
||||
const res = await PUT(putReq({ trackIntake: false }), ROUTE_CTX);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// The tracked stretch keeps its expected doses: the archived era
|
||||
// carries the live schedule and ends now.
|
||||
const revisions = vi.mocked(prisma.medicationScheduleRevision.create).mock
|
||||
.calls;
|
||||
expect(revisions).toHaveLength(1);
|
||||
const era = revisions[0][0].data as {
|
||||
payload: Array<{ timesOfDay: string[] }>;
|
||||
validFrom: Date;
|
||||
};
|
||||
expect(era.payload).toHaveLength(1);
|
||||
expect(era.payload[0].timesOfDay).toEqual(["08:00"]);
|
||||
expect(era.validFrom.toISOString()).toBe("2026-05-01T08:00:00.000Z");
|
||||
|
||||
// Open placeholders of today and later go; actioned rows never do.
|
||||
const tombstones = vi.mocked(prisma.medicationIntakeEvent.updateMany).mock
|
||||
.calls;
|
||||
expect(tombstones).toHaveLength(1);
|
||||
expect(tombstones[0][0].where).toMatchObject({
|
||||
medicationId: "m1",
|
||||
takenAt: null,
|
||||
skipped: false,
|
||||
autoMissed: false,
|
||||
deletedAt: null,
|
||||
});
|
||||
|
||||
// The flag is written field-by-field; the schedule rows are kept.
|
||||
const update = vi.mocked(prisma.medication.update).mock.calls[0][0];
|
||||
expect(update.data).toMatchObject({ trackIntake: false });
|
||||
expect(update.data).not.toHaveProperty("schedules");
|
||||
expect(prisma.medicationSchedule.deleteMany).not.toHaveBeenCalledWith({
|
||||
where: { medicationId: "m1" },
|
||||
});
|
||||
|
||||
// The wire serves it like an as-needed medication, schedule on record.
|
||||
const body = (await res.json()) as {
|
||||
data: { schedules: unknown[]; recordedSchedules?: unknown[] };
|
||||
};
|
||||
expect(body.data.schedules).toEqual([]);
|
||||
expect(body.data.recordedSchedules).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("switching back on archives the untracked stretch as expecting nothing", async () => {
|
||||
arrange(false);
|
||||
const res = await PUT(putReq({ trackIntake: true }), ROUTE_CTX);
|
||||
expect(res.status).toBe(200);
|
||||
const revisions = vi.mocked(prisma.medicationScheduleRevision.create).mock
|
||||
.calls;
|
||||
expect(revisions).toHaveLength(1);
|
||||
const era = revisions[0][0].data as { payload: unknown[] };
|
||||
// No retroactive misses: the stretch that was off expected no dose.
|
||||
expect(era.payload).toEqual([]);
|
||||
// Nothing is tombstoned on the way back on.
|
||||
expect(prisma.medicationIntakeEvent.updateMany).not.toHaveBeenCalled();
|
||||
const body = (await res.json()) as {
|
||||
data: { schedules: unknown[]; recordedSchedules?: unknown[] };
|
||||
};
|
||||
expect(body.data.schedules).toHaveLength(1);
|
||||
expect(body.data).not.toHaveProperty("recordedSchedules");
|
||||
});
|
||||
|
||||
it("an unchanged flag archives nothing", async () => {
|
||||
arrange(true);
|
||||
const res = await PUT(putReq({ trackIntake: true }), ROUTE_CTX);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.medicationScheduleRevision.create).not.toHaveBeenCalled();
|
||||
expect(prisma.medicationIntakeEvent.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a schedule edit while untracked archives the stretch as empty", async () => {
|
||||
arrange(false);
|
||||
const res = await PUT(
|
||||
putReq({
|
||||
schedules: [
|
||||
{
|
||||
windowStart: "08:00",
|
||||
windowEnd: "08:00",
|
||||
timesOfDay: ["08:00"],
|
||||
rrule: "FREQ=DAILY",
|
||||
},
|
||||
],
|
||||
}),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const era = vi.mocked(prisma.medicationScheduleRevision.create).mock
|
||||
.calls[0][0].data as { payload: unknown[] };
|
||||
expect(era.payload).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
} from "@/lib/analytics/compliance";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { dueSchedules } from "@/lib/medications/intake-tracking";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
@@ -131,8 +132,10 @@ export const GET = apiHandler(
|
||||
scheduleRevisions: med.scheduleRevisions,
|
||||
};
|
||||
|
||||
// v1.39.1 (#1033) — intake tracking off: no chip, no next dose.
|
||||
const liveSchedules = dueSchedules(med);
|
||||
const timeline = buildCadenceTimeline(
|
||||
med.schedules,
|
||||
liveSchedules,
|
||||
events,
|
||||
asOf,
|
||||
windowDays,
|
||||
@@ -141,7 +144,7 @@ export const GET = apiHandler(
|
||||
engineCtx,
|
||||
);
|
||||
const chips = complianceChips(
|
||||
med.schedules,
|
||||
liveSchedules,
|
||||
events,
|
||||
asOf,
|
||||
windowDays,
|
||||
@@ -150,7 +153,7 @@ export const GET = apiHandler(
|
||||
engineCtx,
|
||||
);
|
||||
const next = computeNextDose(
|
||||
med.schedules,
|
||||
liveSchedules,
|
||||
asOf,
|
||||
14,
|
||||
anchor,
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
type WorkerScheduleRow,
|
||||
} from "@/lib/medications/scheduling/worker-helpers";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import { dueSchedules } from "@/lib/medications/intake-tracking";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
@@ -217,7 +218,10 @@ export const GET = apiHandler(
|
||||
// A legacy daily schedule carrying only `windowStart` surfaces it as the
|
||||
// single time-of-day so the minter mints its daily band (mirrors the
|
||||
// compliance route + the ledger tally).
|
||||
const canonicalSchedules = medication.schedules.map((s) => {
|
||||
// v1.39.1 (#1033) — with intake tracking off the live era expects
|
||||
// nothing: the ledger shows the doses that were recorded and the slots
|
||||
// of archived eras, never a missed slot minted from the record.
|
||||
const canonicalSchedules = dueSchedules(medication).map((s) => {
|
||||
const canonical = buildCanonicalSchedule(s as WorkerScheduleRow);
|
||||
if (
|
||||
canonical.timesOfDay.length === 0 &&
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
shapeDoseChangeNote,
|
||||
} from "@/lib/crypto/note-cipher";
|
||||
import { NextRequest } from "next/server";
|
||||
import { dueSchedules } from "@/lib/medications/intake-tracking";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
@@ -120,12 +121,16 @@ export const GET = apiHandler(
|
||||
prefsRow?.notificationPrefs ?? null,
|
||||
medication.reorderLeadDays,
|
||||
);
|
||||
const schedules: RunwaySchedule[] = medication.schedules.map((sched) => ({
|
||||
...sched,
|
||||
// Decimal → number so the slot-aware runway math reads the wire shape.
|
||||
unitsPerDose:
|
||||
sched.unitsPerDose === null ? null : Number(sched.unitsPerDose),
|
||||
}));
|
||||
// v1.39.1 (#1033) — intake tracking off: no dose consumes stock, so no
|
||||
// runway and no low-stock flag derive from the stored schedule.
|
||||
const schedules: RunwaySchedule[] = dueSchedules(medication).map(
|
||||
(sched) => ({
|
||||
...sched,
|
||||
// Decimal → number so the slot-aware runway math reads the wire shape.
|
||||
unitsPerDose:
|
||||
sched.unitsPerDose === null ? null : Number(sched.unitsPerDose),
|
||||
}),
|
||||
);
|
||||
/** runway ≤ effective trigger AND the alert is enabled. */
|
||||
const isLowStock = (dosesRemaining: number): boolean => {
|
||||
if (runwayFloor === null) return false;
|
||||
|
||||
@@ -41,6 +41,11 @@ import { serializeScheduleUnitsPerDose } from "@/lib/medications/schedule-units-
|
||||
import { hhmmToMinutesOrNull } from "@/lib/medications/scheduling/hhmm";
|
||||
import { getUserTodayBounds } from "@/lib/tz/local-day";
|
||||
import { NextRequest } from "next/server";
|
||||
import {
|
||||
dueSchedules,
|
||||
isRecordOnly,
|
||||
scheduleWireFields,
|
||||
} from "@/lib/medications/intake-tracking";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
@@ -106,7 +111,7 @@ export const GET = apiHandler(
|
||||
// cadences re-anchor on the latest non-skipped intake, so fetch it
|
||||
// once (no-op for calendar cadences but cheap + keeps the value
|
||||
// correct for rolling injections).
|
||||
const lastIntake = medication.schedules.some(
|
||||
const lastIntake = dueSchedules(medication).some(
|
||||
(s) => s.rollingIntervalDays !== null,
|
||||
)
|
||||
? await prisma.medicationIntakeEvent.findFirst({
|
||||
@@ -157,6 +162,9 @@ export const GET = apiHandler(
|
||||
orderBy: { validUntil: "desc" },
|
||||
select: { validUntil: true },
|
||||
});
|
||||
// v1.39.1 (#1033) — intake tracking off: nothing is due from the
|
||||
// stored rows, and no runway derives from them.
|
||||
const liveSchedules = dueSchedules(medication);
|
||||
const display = computeDisplayDue({
|
||||
medication: {
|
||||
id: medication.id,
|
||||
@@ -165,7 +173,7 @@ export const GET = apiHandler(
|
||||
oneShot: medication.oneShot,
|
||||
createdAt: medication.createdAt,
|
||||
},
|
||||
schedules: medication.schedules,
|
||||
schedules: liveSchedules,
|
||||
now,
|
||||
userTz: user.timezone || "Europe/Berlin",
|
||||
lastIntakeAt: lastIntake?.takenAt ?? null,
|
||||
@@ -220,14 +228,16 @@ export const GET = apiHandler(
|
||||
? null
|
||||
: estimateUnitsRunwayDays(
|
||||
stockUnitsRemaining,
|
||||
schedulesDto,
|
||||
isRecordOnly(medication) ? [] : schedulesDto,
|
||||
Number(medication.unitsPerDose),
|
||||
);
|
||||
|
||||
return apiSuccess({
|
||||
...medication,
|
||||
unitsPerDose: Number(medication.unitsPerDose),
|
||||
schedules: schedulesDto,
|
||||
// v1.39.1 (#1033) — `schedules: []` plus `recordedSchedules` when
|
||||
// intake tracking is off (see `scheduleWireFields`).
|
||||
...scheduleWireFields(medication.trackIntake, schedulesDto),
|
||||
category,
|
||||
nextDueAt: display ? display.at.toISOString() : null,
|
||||
nextDueOverdue: display?.overdue ?? false,
|
||||
@@ -265,6 +275,8 @@ export const PUT = apiHandler(
|
||||
active: true,
|
||||
createdAt: true,
|
||||
asNeeded: true,
|
||||
// v1.39.1 (#1033) — the tracking transition below needs the before.
|
||||
trackIntake: true,
|
||||
// v1.37.0 — the pre-image the audit row carries (C4).
|
||||
name: true,
|
||||
dose: true,
|
||||
@@ -309,9 +321,16 @@ export const PUT = apiHandler(
|
||||
endsOn,
|
||||
oneShot,
|
||||
asNeeded,
|
||||
trackIntake,
|
||||
reminderGraceMinutes: topLevelGraceMinutes,
|
||||
} = parsed.data;
|
||||
|
||||
// v1.39.1 (#1033) — intake tracking transition. Off keeps the schedule
|
||||
// as a record; the live era from here on expects nothing.
|
||||
const wasTracked = existing.trackIntake !== false;
|
||||
const willTrack = trackIntake ?? wasTracked;
|
||||
const trackingChanged = wasTracked !== willTrack;
|
||||
|
||||
// ── v1.16.11 (#316) — as-needed invariants ──────────────────────
|
||||
//
|
||||
// An as-needed medication ends schedule-less, always. The Zod layer
|
||||
@@ -516,123 +535,16 @@ export const PUT = apiHandler(
|
||||
// the manager's grant ends.
|
||||
let replacedCadence: string | null = null;
|
||||
let replacedScheduleCount: number | null = null;
|
||||
let tombstonedSlotCount: number | null = null;
|
||||
let tombstonedFrom: Date | null = null;
|
||||
let tombstonedTo: Date | null = null;
|
||||
let tombstonedSlotCount = null as number | null;
|
||||
// Assigned inside `tombstoneOpenSlots`; the casts keep TypeScript from
|
||||
// narrowing them to `null` across the closure.
|
||||
let tombstonedFrom = null as Date | null;
|
||||
let tombstonedTo = null as Date | null;
|
||||
|
||||
// If schedules provided, replace all
|
||||
if (schedules && normalisedSchedules) {
|
||||
// v1.16.3 — effective dating. Before the wholesale replace below wipes
|
||||
// the old rows, archive them as ONE revision covering
|
||||
// `[previous validUntil | medication.createdAt, now)` — but only when a
|
||||
// cadence-relevant field actually changes. A no-op edit (the Zeitplan
|
||||
// tab echoing the same rows back) must not mint a phantom era.
|
||||
const previousRows = await prisma.medicationSchedule.findMany({
|
||||
where: { medicationId: id },
|
||||
});
|
||||
const previousEntries = previousRows.map((row) =>
|
||||
toRevisionPayloadEntry({
|
||||
timesOfDay: row.timesOfDay,
|
||||
windowStart: row.windowStart,
|
||||
windowEnd: row.windowEnd,
|
||||
daysOfWeek: row.daysOfWeek,
|
||||
rrule: row.rrule,
|
||||
rollingIntervalDays: row.rollingIntervalDays,
|
||||
scheduleType: row.scheduleType,
|
||||
cyclicOnWeeks: row.cyclicOnWeeks,
|
||||
cyclicOffWeeks: row.cyclicOffWeeks,
|
||||
doseWindows: row.doseWindows,
|
||||
label: row.label,
|
||||
dose: row.dose,
|
||||
reminderGraceMinutes: row.reminderGraceMinutes,
|
||||
}),
|
||||
);
|
||||
replacedScheduleCount = previousRows.length;
|
||||
replacedCadence =
|
||||
previousRows.length > 0
|
||||
? previousRows
|
||||
.map((row) =>
|
||||
[
|
||||
row.timesOfDay,
|
||||
row.rrule ?? "",
|
||||
row.rollingIntervalDays !== null
|
||||
? `every ${row.rollingIntervalDays}d`
|
||||
: "",
|
||||
row.daysOfWeek ?? "",
|
||||
]
|
||||
.filter((part) => part !== "")
|
||||
.join(" "),
|
||||
)
|
||||
.join(" | ")
|
||||
: null;
|
||||
|
||||
if (
|
||||
previousRows.length > 0 &&
|
||||
schedulesMateriallyDiffer(
|
||||
previousEntries,
|
||||
normalisedSchedules.map((n) => n.snapshot),
|
||||
)
|
||||
) {
|
||||
const lastRevision = await prisma.medicationScheduleRevision.findFirst({
|
||||
// Chain from the ACTIVE boundary: a superseded row is an
|
||||
// audit record whose `validUntil` may sit past its correction.
|
||||
where: { medicationId: id, supersededByRevisionId: null },
|
||||
orderBy: { validUntil: "desc" },
|
||||
select: { validUntil: true },
|
||||
});
|
||||
await prisma.medicationScheduleRevision.create({
|
||||
data: {
|
||||
medicationId: id,
|
||||
validFrom: lastRevision?.validUntil ?? existing.createdAt,
|
||||
validUntil: new Date(),
|
||||
payload: previousEntries as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
annotate({
|
||||
action: {
|
||||
name: "medication.schedule.revision_archived",
|
||||
entity_type: "medication",
|
||||
entity_id: id,
|
||||
},
|
||||
meta: { schedule_revision_rows: previousEntries.length },
|
||||
});
|
||||
} else if (
|
||||
previousRows.length === 0 &&
|
||||
existing.asNeeded &&
|
||||
asNeeded === false &&
|
||||
normalisedSchedules.length > 0
|
||||
) {
|
||||
// v1.16.11 — flipping as-needed OFF. The medication carried ZERO
|
||||
// schedule rows while as-needed, so the wholesale-replace archive
|
||||
// above never fires — and without a revision the live era would
|
||||
// start at the PREVIOUS revision's `validUntil` (or `createdAt`),
|
||||
// retro-painting the schedule-less as-needed stretch with the NEW
|
||||
// schedule's expected slots: every PRN day would read as missed.
|
||||
// Archive an EMPTY era covering the as-needed stretch instead; an
|
||||
// empty payload expands to zero schedules, so era-aware compliance
|
||||
// expects nothing there — exactly the as-needed contract.
|
||||
const lastRevision = await prisma.medicationScheduleRevision.findFirst({
|
||||
where: { medicationId: id, supersededByRevisionId: null },
|
||||
orderBy: { validUntil: "desc" },
|
||||
select: { validUntil: true },
|
||||
});
|
||||
await prisma.medicationScheduleRevision.create({
|
||||
data: {
|
||||
medicationId: id,
|
||||
validFrom: lastRevision?.validUntil ?? existing.createdAt,
|
||||
validUntil: new Date(),
|
||||
payload: [] as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
annotate({
|
||||
action: {
|
||||
name: "medication.schedule.revision_archived",
|
||||
entity_type: "medication",
|
||||
entity_id: id,
|
||||
},
|
||||
meta: { schedule_revision_rows: 0 },
|
||||
});
|
||||
}
|
||||
// Tombstone today's and future open pending rows of this medication.
|
||||
// Runs on a schedule replace (the old anchors are stale) and when intake
|
||||
// tracking is switched off (nothing is due any more).
|
||||
const tombstoneOpenSlots = async (): Promise<void> => {
|
||||
// A schedule replace invalidates the open slot anchors the projector /
|
||||
// reminder worker minted for the OLD times: a pending 08:00 row for a
|
||||
// medication that now doses at 20:00 would linger as a phantom slot,
|
||||
@@ -703,9 +615,195 @@ export const PUT = apiHandler(
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// If schedules provided, replace all
|
||||
if (schedules && normalisedSchedules) {
|
||||
// v1.16.3 — effective dating. Before the wholesale replace below wipes
|
||||
// the old rows, archive them as ONE revision covering
|
||||
// `[previous validUntil | medication.createdAt, now)` — but only when a
|
||||
// cadence-relevant field actually changes. A no-op edit (the Zeitplan
|
||||
// tab echoing the same rows back) must not mint a phantom era.
|
||||
const previousRows = await prisma.medicationSchedule.findMany({
|
||||
where: { medicationId: id },
|
||||
});
|
||||
const previousEntries = previousRows.map((row) =>
|
||||
toRevisionPayloadEntry({
|
||||
timesOfDay: row.timesOfDay,
|
||||
windowStart: row.windowStart,
|
||||
windowEnd: row.windowEnd,
|
||||
daysOfWeek: row.daysOfWeek,
|
||||
rrule: row.rrule,
|
||||
rollingIntervalDays: row.rollingIntervalDays,
|
||||
scheduleType: row.scheduleType,
|
||||
cyclicOnWeeks: row.cyclicOnWeeks,
|
||||
cyclicOffWeeks: row.cyclicOffWeeks,
|
||||
doseWindows: row.doseWindows,
|
||||
label: row.label,
|
||||
dose: row.dose,
|
||||
reminderGraceMinutes: row.reminderGraceMinutes,
|
||||
}),
|
||||
);
|
||||
replacedScheduleCount = previousRows.length;
|
||||
replacedCadence =
|
||||
previousRows.length > 0
|
||||
? previousRows
|
||||
.map((row) =>
|
||||
[
|
||||
row.timesOfDay,
|
||||
row.rrule ?? "",
|
||||
row.rollingIntervalDays !== null
|
||||
? `every ${row.rollingIntervalDays}d`
|
||||
: "",
|
||||
row.daysOfWeek ?? "",
|
||||
]
|
||||
.filter((part) => part !== "")
|
||||
.join(" "),
|
||||
)
|
||||
.join(" | ")
|
||||
: null;
|
||||
|
||||
// v1.39.1 (#1033) — a stretch with intake tracking off expected
|
||||
// nothing, so it archives with an empty payload whatever the rows
|
||||
// said; and a tracking transition is itself an era boundary even when
|
||||
// the rows stay the same.
|
||||
const archivedEntries = wasTracked ? previousEntries : [];
|
||||
if (
|
||||
previousRows.length > 0 &&
|
||||
(trackingChanged ||
|
||||
!wasTracked ||
|
||||
schedulesMateriallyDiffer(
|
||||
previousEntries,
|
||||
normalisedSchedules.map((n) => n.snapshot),
|
||||
))
|
||||
) {
|
||||
const lastRevision = await prisma.medicationScheduleRevision.findFirst({
|
||||
// Chain from the ACTIVE boundary: a superseded row is an
|
||||
// audit record whose `validUntil` may sit past its correction.
|
||||
where: { medicationId: id, supersededByRevisionId: null },
|
||||
orderBy: { validUntil: "desc" },
|
||||
select: { validUntil: true },
|
||||
});
|
||||
await prisma.medicationScheduleRevision.create({
|
||||
data: {
|
||||
medicationId: id,
|
||||
validFrom: lastRevision?.validUntil ?? existing.createdAt,
|
||||
validUntil: new Date(),
|
||||
payload: archivedEntries as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
annotate({
|
||||
action: {
|
||||
name: "medication.schedule.revision_archived",
|
||||
entity_type: "medication",
|
||||
entity_id: id,
|
||||
},
|
||||
meta: { schedule_revision_rows: archivedEntries.length },
|
||||
});
|
||||
} else if (
|
||||
previousRows.length === 0 &&
|
||||
existing.asNeeded &&
|
||||
asNeeded === false &&
|
||||
normalisedSchedules.length > 0
|
||||
) {
|
||||
// v1.16.11 — flipping as-needed OFF. The medication carried ZERO
|
||||
// schedule rows while as-needed, so the wholesale-replace archive
|
||||
// above never fires — and without a revision the live era would
|
||||
// start at the PREVIOUS revision's `validUntil` (or `createdAt`),
|
||||
// retro-painting the schedule-less as-needed stretch with the NEW
|
||||
// schedule's expected slots: every PRN day would read as missed.
|
||||
// Archive an EMPTY era covering the as-needed stretch instead; an
|
||||
// empty payload expands to zero schedules, so era-aware compliance
|
||||
// expects nothing there — exactly the as-needed contract.
|
||||
const lastRevision = await prisma.medicationScheduleRevision.findFirst({
|
||||
where: { medicationId: id, supersededByRevisionId: null },
|
||||
orderBy: { validUntil: "desc" },
|
||||
select: { validUntil: true },
|
||||
});
|
||||
await prisma.medicationScheduleRevision.create({
|
||||
data: {
|
||||
medicationId: id,
|
||||
validFrom: lastRevision?.validUntil ?? existing.createdAt,
|
||||
validUntil: new Date(),
|
||||
payload: [] as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
annotate({
|
||||
action: {
|
||||
name: "medication.schedule.revision_archived",
|
||||
entity_type: "medication",
|
||||
entity_id: id,
|
||||
},
|
||||
meta: { schedule_revision_rows: 0 },
|
||||
});
|
||||
}
|
||||
await tombstoneOpenSlots();
|
||||
await prisma.medicationSchedule.deleteMany({
|
||||
where: { medicationId: id },
|
||||
});
|
||||
} else if (trackingChanged) {
|
||||
// v1.39.1 (#1033) — intake tracking switched without a schedule
|
||||
// replace. Close the current era at this instant: switching OFF
|
||||
// archives the schedule that was in force (the tracked stretch keeps
|
||||
// its expected doses); switching back ON archives the untracked
|
||||
// stretch with an empty payload, so compliance never counts it as
|
||||
// missed and the live schedule, the projector and the reminder worker
|
||||
// all resume from now.
|
||||
const liveRows = await prisma.medicationSchedule.findMany({
|
||||
where: { medicationId: id },
|
||||
});
|
||||
if (liveRows.length > 0) {
|
||||
const lastRevision = await prisma.medicationScheduleRevision.findFirst({
|
||||
where: { medicationId: id, supersededByRevisionId: null },
|
||||
orderBy: { validUntil: "desc" },
|
||||
select: { validUntil: true },
|
||||
});
|
||||
const archivedEntries = wasTracked
|
||||
? liveRows.map((row) =>
|
||||
toRevisionPayloadEntry({
|
||||
timesOfDay: row.timesOfDay,
|
||||
windowStart: row.windowStart,
|
||||
windowEnd: row.windowEnd,
|
||||
daysOfWeek: row.daysOfWeek,
|
||||
rrule: row.rrule,
|
||||
rollingIntervalDays: row.rollingIntervalDays,
|
||||
scheduleType: row.scheduleType,
|
||||
cyclicOnWeeks: row.cyclicOnWeeks,
|
||||
cyclicOffWeeks: row.cyclicOffWeeks,
|
||||
doseWindows: row.doseWindows,
|
||||
label: row.label,
|
||||
dose: row.dose,
|
||||
reminderGraceMinutes: row.reminderGraceMinutes,
|
||||
}),
|
||||
)
|
||||
: [];
|
||||
await prisma.medicationScheduleRevision.create({
|
||||
data: {
|
||||
medicationId: id,
|
||||
validFrom: lastRevision?.validUntil ?? existing.createdAt,
|
||||
validUntil: new Date(),
|
||||
payload: archivedEntries as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
annotate({
|
||||
action: {
|
||||
name: "medication.schedule.revision_archived",
|
||||
entity_type: "medication",
|
||||
entity_id: id,
|
||||
},
|
||||
meta: {
|
||||
schedule_revision_rows: archivedEntries.length,
|
||||
track_intake: willTrack,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// v1.39.1 (#1033) — switching intake tracking off leaves nothing due:
|
||||
// the open placeholders of today and later go the way a schedule
|
||||
// replace sends them (a replace in the same request already did it).
|
||||
if (trackingChanged && !willTrack && !schedules) {
|
||||
await tombstoneOpenSlots();
|
||||
}
|
||||
|
||||
const baseUpdateData = {
|
||||
@@ -743,6 +841,8 @@ export const PUT = apiHandler(
|
||||
// v1.16.11 — as-needed flag, field-by-field (the invariants above
|
||||
// already guaranteed the medication ends schedule-consistent).
|
||||
...(asNeeded !== undefined && { asNeeded }),
|
||||
// v1.39.1 (#1033) — intake tracking, field-by-field.
|
||||
...(trackIntake !== undefined && { trackIntake }),
|
||||
...(normalisedSchedules && {
|
||||
schedules: {
|
||||
create: normalisedSchedules.map((n) => n.createData),
|
||||
@@ -849,6 +949,7 @@ export const PUT = apiHandler(
|
||||
dose: existing.dose,
|
||||
active: existing.active,
|
||||
asNeeded: existing.asNeeded,
|
||||
trackIntake: existing.trackIntake,
|
||||
endsOn: existing.endsOn,
|
||||
},
|
||||
after: {
|
||||
@@ -856,6 +957,7 @@ export const PUT = apiHandler(
|
||||
dose: medication.dose,
|
||||
active: medication.active,
|
||||
asNeeded: medication.asNeeded,
|
||||
trackIntake: medication.trackIntake,
|
||||
endsOn: medication.endsOn,
|
||||
},
|
||||
}),
|
||||
@@ -886,9 +988,12 @@ export const PUT = apiHandler(
|
||||
return apiSuccess({
|
||||
...medication,
|
||||
unitsPerDose: Number(medication.unitsPerDose),
|
||||
schedules: serializeScheduleUnitsPerDose(
|
||||
medication.schedules,
|
||||
medication.unitsPerDose,
|
||||
...scheduleWireFields(
|
||||
medication.trackIntake,
|
||||
serializeScheduleUnitsPerDose(
|
||||
medication.schedules,
|
||||
medication.unitsPerDose,
|
||||
),
|
||||
),
|
||||
category: normalizedCategory,
|
||||
});
|
||||
|
||||
@@ -250,6 +250,8 @@ describe("GET /api/medications/compliance", () => {
|
||||
expect(call.where).toEqual({
|
||||
userId: "user-1",
|
||||
asNeeded: false,
|
||||
// v1.39.1 (#1033) — intake tracking off is left out like PRN.
|
||||
trackIntake: true,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "@/lib/medications/compliance-payload";
|
||||
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
|
||||
|
||||
/**
|
||||
* Batched card-compliance read: one round trip for every medication the
|
||||
@@ -50,7 +51,7 @@ export const GET = apiHandler(async () => {
|
||||
// v1.16.11 — as-needed (PRN) medications carry no compliance entry:
|
||||
// the cards/table render a last-taken presentation for them instead
|
||||
// of rates, and an empty expected set must not read as 0% or 100%.
|
||||
where: { userId: user.id, asNeeded: false },
|
||||
where: { userId: user.id, asNeeded: false, ...TRACKED_INTAKE_WHERE },
|
||||
include: {
|
||||
schedules: true,
|
||||
// v1.16.3 — archived schedule eras for era-aware compliance.
|
||||
|
||||
@@ -54,6 +54,7 @@ import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake
|
||||
import { dispatchMedicationIntakeWebClear } from "@/lib/notifications/web-push-clear";
|
||||
import { notifyDelegatedIntake } from "@/lib/notifications/delegated-intake";
|
||||
import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses";
|
||||
import { TRACKED_INTAKE_EVENT_WHERE } from "@/lib/medications/intake-tracking";
|
||||
|
||||
// The query + body schemas moved to `@/lib/validations/medication` when this
|
||||
// endpoint joined the published contract: the OpenAPI registry generates from
|
||||
@@ -125,6 +126,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// v1.7.0 sync — exclude tombstoned rows from the today list.
|
||||
deletedAt: null,
|
||||
scheduledFor: { gte: todayStart, lt: todayEnd },
|
||||
// v1.39.1 (#1033) — the today list is what clients offer doses
|
||||
// from; a medication with intake tracking off is not on it.
|
||||
...TRACKED_INTAKE_EVENT_WHERE,
|
||||
},
|
||||
orderBy: { scheduledFor: "asc" },
|
||||
include: { medication: { select: { id: true, snoozedUntil: true } } },
|
||||
|
||||
@@ -25,6 +25,7 @@ import { readMedicationsListCached } from "@/lib/medications/list-read";
|
||||
import { serializeScheduleUnitsPerDose } from "@/lib/medications/schedule-units-dto";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { NextRequest } from "next/server";
|
||||
import { scheduleWireFields } from "@/lib/medications/intake-tracking";
|
||||
|
||||
// v1.32.25 — blast-radius cap on externally-mirrored medications per user.
|
||||
// A client that mints a fresh `externalId` for the same source concept on
|
||||
@@ -72,6 +73,7 @@ async function respondWithExistingMirror(
|
||||
medication: Record<string, unknown> & {
|
||||
id: string;
|
||||
unitsPerDose: unknown;
|
||||
trackIntake: boolean;
|
||||
schedules: Array<{ unitsPerDose: Prisma.Decimal | null }>;
|
||||
},
|
||||
): Promise<Response> {
|
||||
@@ -96,9 +98,13 @@ async function respondWithExistingMirror(
|
||||
...medication,
|
||||
unitsPerDose: Number(medication.unitsPerDose),
|
||||
// #219 — Decimal → number for the per-schedule column too.
|
||||
schedules: serializeScheduleUnitsPerDose(
|
||||
medication.schedules,
|
||||
Number(medication.unitsPerDose),
|
||||
// v1.39.1 (#1033) — record-only medications ship `schedules: []`.
|
||||
...scheduleWireFields(
|
||||
medication.trackIntake,
|
||||
serializeScheduleUnitsPerDose(
|
||||
medication.schedules,
|
||||
Number(medication.unitsPerDose),
|
||||
),
|
||||
),
|
||||
category,
|
||||
});
|
||||
@@ -165,6 +171,7 @@ async function postMedication(request: NextRequest): Promise<Response> {
|
||||
endsOn,
|
||||
oneShot,
|
||||
asNeeded,
|
||||
trackIntake,
|
||||
} = parsed.data;
|
||||
|
||||
// v1.28 — idempotent mirror create. A mirrored medication (Apple
|
||||
@@ -304,6 +311,10 @@ async function postMedication(request: NextRequest): Promise<Response> {
|
||||
// carries an empty `scheduleInputs`, so the nested create below
|
||||
// persists zero schedule rows.
|
||||
...(asNeeded !== undefined && { asNeeded }),
|
||||
// v1.39.1 (#1033) — intake tracking, field-by-field. Created off,
|
||||
// the schedule is stored as a record from the start; the column
|
||||
// default (true) covers every caller that does not send it.
|
||||
...(trackIntake !== undefined && { trackIntake }),
|
||||
schedules: {
|
||||
create: scheduleInputs.map((s) => {
|
||||
// Invariant 2 — default to FREQ=DAILY when nothing else is set.
|
||||
@@ -426,9 +437,13 @@ async function postMedication(request: NextRequest): Promise<Response> {
|
||||
...medication,
|
||||
unitsPerDose: Number(medication.unitsPerDose),
|
||||
// #219 — Decimal → number for the per-schedule column too.
|
||||
schedules: serializeScheduleUnitsPerDose(
|
||||
medication.schedules,
|
||||
medication.unitsPerDose,
|
||||
// v1.39.1 (#1033) — record-only medications ship `schedules: []`.
|
||||
...scheduleWireFields(
|
||||
medication.trackIntake,
|
||||
serializeScheduleUnitsPerDose(
|
||||
medication.schedules,
|
||||
medication.unitsPerDose,
|
||||
),
|
||||
),
|
||||
category: normalizedCategory,
|
||||
},
|
||||
|
||||
Binary file not shown.
@@ -23,6 +23,7 @@ import { sanitizeForPrompt } from "@/lib/insights/sanitize";
|
||||
import { readNote } from "@/lib/crypto/note-cipher";
|
||||
import { matchGlp1SideEffectTags } from "@/lib/medications/glp1-side-effect-tag-match";
|
||||
import type { Glp1SideEffectTag } from "@/lib/medications/glp1-side-effect-tags";
|
||||
import { isRecordOnly } from "@/lib/medications/intake-tracking";
|
||||
|
||||
/**
|
||||
* Recommended generic name for the canonical GLP-1 drug brand. The Coach
|
||||
@@ -338,7 +339,11 @@ export async function buildGlp1SnapshotBlock(
|
||||
}
|
||||
: null;
|
||||
|
||||
const nextInjection = predictNextInjection(schedule, lastInjection, now);
|
||||
// v1.39.1 (#1033) — with intake tracking off the cadence stays as
|
||||
// information but no injection is predicted as due.
|
||||
const nextInjection = isRecordOnly(med)
|
||||
? null
|
||||
: predictNextInjection(schedule, lastInjection, now);
|
||||
|
||||
// Inventory math over the per-item entities (v1.16.10 — the same
|
||||
// rows the Bestand tab and the consumption hook move).
|
||||
|
||||
@@ -95,6 +95,7 @@ import {
|
||||
buildDayStrainBlock,
|
||||
buildProfileContextBlocks,
|
||||
} from "./snapshot-blocks/context-blocks";
|
||||
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
|
||||
|
||||
// Test-only escape hatch — the suites import it from this module.
|
||||
export { __resetCoachSnapshotCacheForTests } from "./snapshot-cache";
|
||||
@@ -683,7 +684,7 @@ async function buildCoachSnapshotImpl(
|
||||
? prisma.medication.findMany({
|
||||
// v1.16.11 — as-needed (PRN) medications never reach the Coach
|
||||
// compliance context (no expected doses, no rate).
|
||||
where: { userId, asNeeded: false },
|
||||
where: { userId, asNeeded: false, ...TRACKED_INTAKE_WHERE },
|
||||
select: {
|
||||
id: true,
|
||||
startsOn: true,
|
||||
|
||||
@@ -256,13 +256,25 @@ export function buildComplianceDisplay(
|
||||
* `schedules ?? []` on a scheduled medication. The doctor report has excluded
|
||||
* both arms since it was written, so the report never prints a fabricated
|
||||
* 100 %; the same reason applies to every other surface, so the two arms live
|
||||
* in one predicate rather than being re-derived at each call site.
|
||||
* in one predicate rather than being re-derived at each call site. v1.39.1
|
||||
* adds the third arm: a medication whose intake tracking is off keeps its
|
||||
* schedule rows and still expects nothing.
|
||||
*/
|
||||
export function expectsDoses(medication: {
|
||||
asNeeded: boolean;
|
||||
/**
|
||||
* v1.39.1 (#1033) — intake tracking switched off keeps the schedule rows
|
||||
* as information but expects nothing from them. Required, not optional,
|
||||
* so a caller cannot reach the predicate without having read the flag.
|
||||
*/
|
||||
trackIntake: boolean;
|
||||
schedules: readonly unknown[];
|
||||
}): boolean {
|
||||
return !medication.asNeeded && medication.schedules.length > 0;
|
||||
return (
|
||||
!medication.asNeeded &&
|
||||
medication.trackIntake !== false &&
|
||||
medication.schedules.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function calculateCompliance(
|
||||
|
||||
@@ -37,6 +37,10 @@ import {
|
||||
} from "@/lib/analytics/compliance";
|
||||
import { getUserTodayBounds } from "@/lib/tz/local-day";
|
||||
import { userDayKey } from "@/lib/tz/resolver";
|
||||
import {
|
||||
TRACKED_INTAKE_EVENT_WHERE,
|
||||
TRACKED_INTAKE_WHERE,
|
||||
} from "@/lib/medications/intake-tracking";
|
||||
|
||||
export interface ScheduleAnchoredComplianceBucket {
|
||||
/** YYYY-MM-DD in the user's tz. */
|
||||
@@ -54,8 +58,10 @@ export async function buildScheduleAnchoredComplianceBuckets(
|
||||
const nowMs = now.getTime();
|
||||
const start = new Date(nowMs - days * 86_400_000);
|
||||
|
||||
// v1.39.1 (#1033) — a medication with intake tracking off expects no
|
||||
// dose, so it contributes to neither side of the daily buckets.
|
||||
const medications = await prisma.medication.findMany({
|
||||
where: { userId, active: true },
|
||||
where: { userId, active: true, ...TRACKED_INTAKE_WHERE },
|
||||
include: {
|
||||
// v1.15.20 — the shared compliance select so a future engine column
|
||||
// reaches this surface the moment it joins SCHEDULE_COMPLIANCE_SELECT.
|
||||
@@ -69,7 +75,12 @@ export async function buildScheduleAnchoredComplianceBuckets(
|
||||
|
||||
const events = await prisma.medicationIntakeEvent.findMany({
|
||||
// v1.7.0 sync — exclude tombstoned rows from the compliance buckets.
|
||||
where: { userId, deletedAt: null, scheduledFor: { gte: start } },
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
scheduledFor: { gte: start },
|
||||
...TRACKED_INTAKE_EVENT_WHERE,
|
||||
},
|
||||
select: {
|
||||
medicationId: true,
|
||||
scheduledFor: true,
|
||||
|
||||
@@ -217,7 +217,12 @@ describe("buildMedsTodayBlock — tally", () => {
|
||||
const arg = fakePrisma.medication.findMany.mock.calls[0][0] as {
|
||||
where: { userId: string; active: boolean };
|
||||
};
|
||||
expect(arg.where).toEqual({ userId: "user-1", active: true });
|
||||
// v1.39.1 (#1033) — and only medications whose intake is tracked.
|
||||
expect(arg.where).toEqual({
|
||||
userId: "user-1",
|
||||
active: true,
|
||||
trackIntake: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -43,6 +43,10 @@ import {
|
||||
type ResolvedSlotMark,
|
||||
} from "@/lib/medications/scheduling/next-due";
|
||||
import { getUserTodayBounds } from "@/lib/tz/local-day";
|
||||
import {
|
||||
TRACKED_INTAKE_EVENT_WHERE,
|
||||
TRACKED_INTAKE_WHERE,
|
||||
} from "@/lib/medications/intake-tracking";
|
||||
|
||||
export interface MedsTodayDueCandidate {
|
||||
medicationId: string;
|
||||
@@ -127,8 +131,10 @@ export async function buildMedsTodayBlock(
|
||||
|
||||
const [medications, todayEvents, latestIntakes, resolvedEvents, eraFloors] =
|
||||
await Promise.all([
|
||||
// v1.39.1 (#1033) — a medication with intake tracking off has no
|
||||
// place on the doses card: not counted, never the next due dose.
|
||||
prisma.medication.findMany({
|
||||
where: { userId, active: true },
|
||||
where: { userId, active: true, ...TRACKED_INTAKE_WHERE },
|
||||
include: { schedules: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
}),
|
||||
@@ -137,6 +143,7 @@ export async function buildMedsTodayBlock(
|
||||
userId,
|
||||
deletedAt: null,
|
||||
scheduledFor: { gte: todayStart, lt: todayEndExclusive },
|
||||
...TRACKED_INTAKE_EVENT_WHERE,
|
||||
},
|
||||
select: { takenAt: true, skipped: true },
|
||||
}),
|
||||
|
||||
@@ -481,6 +481,8 @@ export interface DoctorReportComplianceMedication {
|
||||
id: string;
|
||||
name: string;
|
||||
asNeeded: boolean;
|
||||
/** v1.39.1 (#1033) — intake tracking off: listed, never rated. */
|
||||
trackIntake: boolean;
|
||||
startsOn: Date | null;
|
||||
endsOn: Date | null;
|
||||
oneShot: boolean;
|
||||
|
||||
@@ -333,12 +333,14 @@ export async function collectDoctorReportData(
|
||||
// Medication compliance through the dose-ledger authority — the same engine
|
||||
// the detail page uses, not a raw-row tally. As-needed medications are
|
||||
// excluded: no schedule, no expected dose, no fabricated 100 % on a
|
||||
// clinical report. The medication itself stays on the list.
|
||||
// clinical report. The medication itself stays on the list. A medication
|
||||
// with intake tracking off is excluded the same way (`expectsDoses`).
|
||||
const compliance = buildLedgerCompliance(
|
||||
medications.map((m) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
asNeeded: m.asNeeded,
|
||||
trackIntake: m.trackIntake,
|
||||
startsOn: m.startsOn,
|
||||
endsOn: m.endsOn,
|
||||
oneShot: m.oneShot,
|
||||
|
||||
@@ -269,6 +269,8 @@ export function formatMedicationsForExport(
|
||||
name: string;
|
||||
dose: string;
|
||||
active: boolean;
|
||||
/** v1.39.1 (#1033) — false: kept as a record, intake not tracked. */
|
||||
trackIntake: boolean;
|
||||
schedules: Array<{
|
||||
windowStart: string;
|
||||
windowEnd: string;
|
||||
@@ -281,6 +283,7 @@ export function formatMedicationsForExport(
|
||||
name: m.name,
|
||||
dose: m.dose,
|
||||
active: m.active,
|
||||
trackIntake: m.trackIntake,
|
||||
schedules: m.schedules
|
||||
.map(
|
||||
(s) =>
|
||||
|
||||
@@ -104,6 +104,7 @@ function makePrisma() {
|
||||
endsOn: new Date("2026-08-01T00:00:00.000Z"),
|
||||
oneShot: false,
|
||||
asNeeded: false,
|
||||
trackIntake: false,
|
||||
deliveryForm: "INJECTION",
|
||||
trackInjectionSites: true,
|
||||
allowedInjectionSites: ["THIGH_LEFT", "THIGH_RIGHT"],
|
||||
@@ -649,6 +650,7 @@ describe("buildFullBackupPayload disaster-recovery mode", () => {
|
||||
expect.objectContaining({
|
||||
id: "medication-canonical",
|
||||
asNeeded: false,
|
||||
trackIntake: false,
|
||||
deliveryForm: "INJECTION",
|
||||
unitsPerDose: "0.5000",
|
||||
schedules: [
|
||||
|
||||
@@ -1004,6 +1004,10 @@ export async function buildFullBackupPayload(
|
||||
name: m.name,
|
||||
dose: m.dose,
|
||||
active: m.active,
|
||||
// v1.39.1 (#1033) — the person's decision to keep this medication as a
|
||||
// record only. Both purposes carry it: a restore that dropped it would
|
||||
// start reminding them about a medication they chose not to track.
|
||||
trackIntake: m.trackIntake,
|
||||
// Both purposes carry the creation instant: it is the floor of the
|
||||
// medication's expected slots, so a restore stamped with the restore
|
||||
// time would read the whole restored history as predating the
|
||||
|
||||
@@ -899,6 +899,7 @@ export async function restoreBackup(
|
||||
endsOn: m.endsOn ? new Date(m.endsOn) : null,
|
||||
oneShot: m.oneShot ?? false,
|
||||
asNeeded: m.asNeeded ?? false,
|
||||
trackIntake: m.trackIntake ?? true,
|
||||
deliveryForm: m.deliveryForm ?? "ORAL",
|
||||
trackInjectionSites: m.trackInjectionSites ?? false,
|
||||
allowedInjectionSites: m.allowedInjectionSites ?? [],
|
||||
|
||||
@@ -41,6 +41,10 @@ import {
|
||||
import { classifyIntakeTiming } from "@/lib/analytics/compliance";
|
||||
import { wallClockInTz } from "@/lib/tz/wall-clock";
|
||||
import { DEFAULT_TIMEZONE, userDayKey, validTimezoneOr } from "@/lib/tz/format";
|
||||
import {
|
||||
TRACKED_INTAKE_EVENT_WHERE,
|
||||
TRACKED_INTAKE_WHERE,
|
||||
} from "@/lib/medications/intake-tracking";
|
||||
|
||||
export type AuthedUser = Awaited<ReturnType<typeof requireAuth>>["user"];
|
||||
|
||||
@@ -614,6 +618,9 @@ export async function buildAchievementsResult(
|
||||
deletedAt: null,
|
||||
source: { not: "IMPORT" },
|
||||
scheduledFor: { gte: startDate, lte: now },
|
||||
// v1.39.1 (#1033) — doses of a medication with intake tracking off
|
||||
// count toward no streak or badge.
|
||||
...TRACKED_INTAKE_EVENT_WHERE,
|
||||
},
|
||||
select: {
|
||||
medicationId: true,
|
||||
@@ -626,7 +633,7 @@ export async function buildAchievementsResult(
|
||||
},
|
||||
}),
|
||||
prisma.medication.findMany({
|
||||
where: { userId },
|
||||
where: { userId, ...TRACKED_INTAKE_WHERE },
|
||||
select: {
|
||||
id: true,
|
||||
schedules: {
|
||||
|
||||
@@ -66,6 +66,7 @@ import {
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
|
||||
|
||||
/**
|
||||
* Cap on the embedded correlation / paired-daily arrays. The Pearson
|
||||
@@ -328,7 +329,7 @@ export async function prepareBloodPressureStatusForUser(
|
||||
const activeMedicationRows = await prisma.medication.findMany({
|
||||
// v1.16.11 — as-needed (PRN) medications never feed the BP-status
|
||||
// compliance gate (no expected doses, no rate).
|
||||
where: { userId, active: true, asNeeded: false },
|
||||
where: { userId, active: true, asNeeded: false, ...TRACKED_INTAKE_WHERE },
|
||||
// v1.15.20 — schedules through the shared compliance select so the
|
||||
// configured per-dose windows reach this surface like every other.
|
||||
include: {
|
||||
|
||||
@@ -59,6 +59,7 @@ import type {
|
||||
MeasurementSource,
|
||||
MeasurementType,
|
||||
} from "@/generated/prisma/client";
|
||||
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
|
||||
|
||||
/**
|
||||
* v1.21.0 (FDREXTEND) — build the user's MEDICATION_COMPLIANCE daily series.
|
||||
@@ -75,7 +76,7 @@ export async function fetchComplianceSeries(
|
||||
): Promise<NamedSeries> {
|
||||
const medications = await prisma.medication.findMany({
|
||||
// PRN (as-needed) medications have no expected doses → no defensible rate.
|
||||
where: { userId, active: true, asNeeded: false },
|
||||
where: { userId, active: true, asNeeded: false, ...TRACKED_INTAKE_WHERE },
|
||||
include: {
|
||||
schedules: { select: SCHEDULE_COMPLIANCE_SELECT },
|
||||
scheduleRevisions: { orderBy: { validFrom: "asc" } },
|
||||
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
buildWeightTargetFeature,
|
||||
type WeightTargetFeature,
|
||||
} from "@/lib/targets/weight-trend";
|
||||
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
|
||||
// The briefing read blocks and the signals-of-day builder moved to
|
||||
// sibling modules; re-exported so every existing call site keeps
|
||||
// importing from here.
|
||||
@@ -1655,7 +1656,7 @@ export async function extractFeatures(
|
||||
const medications = await prisma.medication.findMany({
|
||||
// v1.16.11 — as-needed (PRN) medications never surface a compliance
|
||||
// rate (no expected doses), so the insight features exclude them.
|
||||
where: { userId, active: true, asNeeded: false },
|
||||
where: { userId, active: true, asNeeded: false, ...TRACKED_INTAKE_WHERE },
|
||||
// v1.15.20 — schedules through the shared compliance select so the
|
||||
// configured per-dose windows reach this surface like every other.
|
||||
include: {
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
type PreparedStatusCard,
|
||||
} from "@/lib/insights/status-card-generation";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
|
||||
|
||||
// 360 daily days + 24 monthly windows ≈ 1080 days of intake history.
|
||||
const COMPLIANCE_HISTORY_DAYS = 360 + 24 * 30;
|
||||
@@ -197,7 +198,7 @@ export async function prepareMedicationComplianceStatusForUser(
|
||||
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 },
|
||||
where: { userId, active: true, asNeeded: false, ...TRACKED_INTAKE_WHERE },
|
||||
// v1.15.20 — schedules through the shared compliance select so the
|
||||
// configured per-dose windows reach this surface like every other.
|
||||
include: {
|
||||
|
||||
@@ -185,7 +185,12 @@ describe("handleReminderCheck — as-needed skip (v1.16.11, #316)", () => {
|
||||
const args = prismaMock.medication.findMany.mock.calls[0][0] as {
|
||||
where: Record<string, unknown>;
|
||||
};
|
||||
expect(args.where).toEqual({ active: true, asNeeded: false });
|
||||
// v1.39.1 (#1033) — nor a medication with intake tracking off.
|
||||
expect(args.where).toEqual({
|
||||
active: true,
|
||||
asNeeded: false,
|
||||
trackIntake: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("dispatches nothing when the candidate set is empty", async () => {
|
||||
|
||||
@@ -66,6 +66,7 @@ import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import { DOSE_WINDOW_DEFAULTS } from "@/lib/medications/scheduling/dose-window-defaults";
|
||||
import { normaliseDoseWindows } from "@/lib/medications/scheduling/worker-helpers";
|
||||
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
|
||||
|
||||
export const INTAKE_AUTO_SKIP_QUEUE = "intake-auto-skip";
|
||||
|
||||
@@ -234,8 +235,14 @@ export async function runIntakeAutoSkipPass(
|
||||
usersByMedication.set(candidate.medicationId, users);
|
||||
}
|
||||
|
||||
// v1.39.1 (#1033) — a medication with intake tracking off expects no
|
||||
// dose, so a placeholder left on it never becomes a miss. Switching
|
||||
// tracking off tombstones its open placeholders; this is the backstop.
|
||||
const medications = await prisma.medication.findMany({
|
||||
where: { id: { in: [...usersByMedication.keys()] } },
|
||||
where: {
|
||||
id: { in: [...usersByMedication.keys()] },
|
||||
...TRACKED_INTAKE_WHERE,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
|
||||
@@ -78,6 +78,7 @@ import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { coerceLocale, type Locale } from "@/lib/i18n/config";
|
||||
import { resolveJobLocale } from "@/lib/i18n/job-locale";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
|
||||
|
||||
/** Pg-boss queue + cron — imported by the reminder worker's bootstrap. */
|
||||
export const MEDICATION_LOW_STOCK_QUEUE = "medication-low-stock";
|
||||
@@ -367,7 +368,14 @@ export async function runMedicationLowStockTick(
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
medications: {
|
||||
some: { active: true, pausedAt: null, inventoryItems: { some: {} } },
|
||||
some: {
|
||||
active: true,
|
||||
pausedAt: null,
|
||||
// v1.39.1 (#1033) — no low-stock notice for a medication with
|
||||
// intake tracking off: no dose consumes its stock.
|
||||
...TRACKED_INTAKE_WHERE,
|
||||
inventoryItems: { some: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
select: { id: true, locale: true, notificationPrefs: true },
|
||||
@@ -390,6 +398,7 @@ export async function runMedicationLowStockTick(
|
||||
userId: user.id,
|
||||
active: true,
|
||||
pausedAt: null,
|
||||
...TRACKED_INTAKE_WHERE,
|
||||
inventoryItems: { some: {} },
|
||||
},
|
||||
select: {
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
} from "@/lib/notifications/reminder-dedup";
|
||||
import { getUserTodayBounds as getUserTodayBoundsUtil } from "@/lib/tz/local-day";
|
||||
import { getWorkerPrisma, parseTimeToMinutes } from "./shared";
|
||||
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
|
||||
|
||||
export interface ReminderCheckPayload {
|
||||
triggeredAt: string;
|
||||
@@ -165,8 +166,11 @@ export async function handleReminderCheck(
|
||||
// zero schedules anyway (the per-schedule loop below would be a
|
||||
// no-op), but the explicit predicate keeps them out of the tick's
|
||||
// per-medication intake reads entirely.
|
||||
// v1.39.1 (#1033) — a medication with intake tracking off keeps its
|
||||
// schedule rows as a record, so it must be excluded explicitly: no
|
||||
// reminder on any channel and no missed-dose placeholder.
|
||||
const medications = await prisma.medication.findMany({
|
||||
where: { active: true, asNeeded: false },
|
||||
where: { active: true, asNeeded: false, ...TRACKED_INTAKE_WHERE },
|
||||
include: {
|
||||
schedules: true,
|
||||
phaseConfig: true,
|
||||
|
||||
@@ -192,6 +192,9 @@ export const MCP_RESOURCES: McpResourceDefinition[] = [
|
||||
dose: med.dose,
|
||||
treatmentClass: med.treatmentClass,
|
||||
asNeeded: med.asNeeded,
|
||||
// v1.39.1 (#1033) — false: kept as a record, nothing is due and
|
||||
// no adherence applies; the schedules below are information.
|
||||
intakeTracked: med.trackIntake,
|
||||
paused: med.pausedAt !== null,
|
||||
startsOn: med.startsOn ? med.startsOn.toISOString() : null,
|
||||
endsOn: med.endsOn ? med.endsOn.toISOString() : null,
|
||||
@@ -519,6 +522,7 @@ export const MCP_RESOURCE_TEMPLATES: McpResourceTemplateDefinition[] = [
|
||||
dose: med.dose,
|
||||
treatmentClass: med.treatmentClass,
|
||||
asNeeded: med.asNeeded,
|
||||
intakeTracked: med.trackIntake,
|
||||
paused: med.pausedAt !== null,
|
||||
startsOn: med.startsOn ? med.startsOn.toISOString() : null,
|
||||
endsOn: med.endsOn ? med.endsOn.toISOString() : null,
|
||||
|
||||
@@ -71,6 +71,7 @@ import { fenceUserText, scrubFenceMarkers } from "@/lib/ai/coach/data-fence";
|
||||
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { listTargetsBySource } from "@/lib/links";
|
||||
import type { McpAuthContext } from "./auth";
|
||||
import { dueSchedules } from "@/lib/medications/intake-tracking";
|
||||
|
||||
/**
|
||||
* Tool annotations (MCP 2025-11-25). The cloud connectors REQUIRE these on every
|
||||
@@ -849,6 +850,7 @@ const getMedicationScheduleOutput: z.ZodRawShape = {
|
||||
nextDueAt: z.string().nullable(),
|
||||
overdue: z.boolean(),
|
||||
asNeeded: z.boolean(),
|
||||
intakeTracked: z.boolean(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
@@ -1447,7 +1449,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [
|
||||
name: "get_medication_schedule",
|
||||
title: "Get the medication schedule",
|
||||
description:
|
||||
"Fetch when the user's active medications are next due, and which are overdue right now. Reuses the same server-authoritative recurrence engine the medication cards render (open overdue slots win over future ones). Returns one row per active medication: name, dose, next-due instant, an overdue flag, and an as-needed (PRN) flag. As-needed medications have no scheduled due time (nextDueAt: null). Returns { present: false } when no medications are tracked.",
|
||||
"Fetch when the user's active medications are next due, and which are overdue right now. Reuses the same server-authoritative recurrence engine the medication cards render (open overdue slots win over future ones). Returns one row per active medication: name, dose, next-due instant, an overdue flag, an as-needed (PRN) flag, and an intakeTracked flag. As-needed medications and medications whose intake tracking is off (intakeTracked: false, kept as a record only) have no due time (nextDueAt: null). Returns { present: false } when no medications are tracked.",
|
||||
inputShape: {},
|
||||
annotations: READ_ONLY_ANNOTATIONS,
|
||||
outputShape: getMedicationScheduleOutput,
|
||||
@@ -1540,7 +1542,9 @@ export const MCP_TOOLS: McpToolDefinition[] = [
|
||||
oneShot: m.oneShot,
|
||||
createdAt: m.createdAt,
|
||||
},
|
||||
schedules: m.schedules,
|
||||
// v1.39.1 (#1033) — nothing is due on a medication with intake
|
||||
// tracking off.
|
||||
schedules: dueSchedules(m),
|
||||
now,
|
||||
userTz,
|
||||
lastIntakeAt: lastTakenAtByMedId.get(m.id) ?? null,
|
||||
@@ -1553,6 +1557,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [
|
||||
nextDueAt: display ? display.at.toISOString() : null,
|
||||
overdue: display ? display.overdue : false,
|
||||
asNeeded: m.asNeeded,
|
||||
intakeTracked: m.trackIntake,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ it("counts an off-schedule row as taken only when it has a taken time", async ()
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
asNeeded: false,
|
||||
trackIntake: true,
|
||||
schedules: [
|
||||
{
|
||||
windowStart: "08:00",
|
||||
|
||||
@@ -69,6 +69,7 @@ for (const tz of ["Asia/Kolkata", "America/New_York"]) {
|
||||
endsOn: new Date("2026-09-28T00:00:00Z"),
|
||||
oneShot: false,
|
||||
asNeeded: false,
|
||||
trackIntake: true,
|
||||
schedules: [
|
||||
{
|
||||
windowStart: "09:00",
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* #1033 — the shared vocabulary for per-medication intake tracking, and the
|
||||
* two adherence entry points that read it (`expectsDoses`, the per-medication
|
||||
* compliance payload).
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { medicationIntakeEvent: { findMany: vi.fn() } },
|
||||
}));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { expectsDoses } from "@/lib/analytics/compliance";
|
||||
import { buildCompliancePayload } from "@/lib/medications/compliance-payload";
|
||||
import {
|
||||
dueSchedules,
|
||||
isRecordOnly,
|
||||
scheduleWireFields,
|
||||
TRACKED_INTAKE_WHERE,
|
||||
TRACKED_INTAKE_EVENT_WHERE,
|
||||
} from "@/lib/medications/intake-tracking";
|
||||
|
||||
const schedule = {
|
||||
windowStart: "08:00",
|
||||
windowEnd: "08:00",
|
||||
timesOfDay: ["08:00"],
|
||||
daysOfWeek: null,
|
||||
rrule: "FREQ=DAILY",
|
||||
rollingIntervalDays: null,
|
||||
reminderGraceMinutes: null,
|
||||
scheduleType: "SCHEDULED" as const,
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
};
|
||||
|
||||
describe("intake-tracking vocabulary", () => {
|
||||
it("filters reads to tracked medications", () => {
|
||||
expect(TRACKED_INTAKE_WHERE).toEqual({ trackIntake: true });
|
||||
expect(TRACKED_INTAKE_EVENT_WHERE).toEqual({
|
||||
medication: { trackIntake: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("only an explicit false is record-only; a projection without the flag is tracked", () => {
|
||||
expect(isRecordOnly({ trackIntake: false })).toBe(true);
|
||||
expect(isRecordOnly({ trackIntake: true })).toBe(false);
|
||||
expect(isRecordOnly({})).toBe(false);
|
||||
});
|
||||
|
||||
it("dueSchedules keeps the rows only while intake is tracked", () => {
|
||||
expect(dueSchedules({ trackIntake: true, schedules: [schedule] })).toEqual([
|
||||
schedule,
|
||||
]);
|
||||
expect(dueSchedules({ trackIntake: false, schedules: [schedule] })).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it("the wire serves a record-only medication like an as-needed one", () => {
|
||||
expect(scheduleWireFields(true, [schedule])).toEqual({
|
||||
schedules: [schedule],
|
||||
});
|
||||
// No `recordedSchedules` key at all on a tracked medication.
|
||||
expect("recordedSchedules" in scheduleWireFields(true, [schedule])).toBe(
|
||||
false,
|
||||
);
|
||||
expect(scheduleWireFields(false, [schedule])).toEqual({
|
||||
schedules: [],
|
||||
recordedSchedules: [schedule],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("expectsDoses", () => {
|
||||
it("a scheduled, tracked medication expects doses", () => {
|
||||
expect(
|
||||
expectsDoses({ asNeeded: false, trackIntake: true, schedules: [1] }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("intake tracking off expects nothing even with a schedule on record", () => {
|
||||
expect(
|
||||
expectsDoses({ asNeeded: false, trackIntake: false, schedules: [1] }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildCompliancePayload", () => {
|
||||
it("answers not-applicable with its own reason and reads no history", async () => {
|
||||
const payload = await buildCompliancePayload(
|
||||
{
|
||||
id: "med-record",
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
asNeeded: false,
|
||||
trackIntake: false,
|
||||
schedules: [schedule],
|
||||
},
|
||||
"user-1",
|
||||
"Europe/Berlin",
|
||||
);
|
||||
expect(payload).toMatchObject({
|
||||
applicable: false,
|
||||
notApplicableReason: "INTAKE_NOT_TRACKED",
|
||||
dailyCompliance: {},
|
||||
complianceDisplay: null,
|
||||
});
|
||||
// Released clients decode these as non-null; they stay present and zero.
|
||||
expect(payload.compliance7.rate).toBe(0);
|
||||
expect(payload.compliance30.totalExpected).toBe(0);
|
||||
expect(prisma.medicationIntakeEvent.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from "@/lib/analytics/compliance";
|
||||
import type { DoseHistoryRow } from "@/lib/medications/scheduling/dose-history";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import { isRecordOnly } from "@/lib/medications/intake-tracking";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
@@ -37,10 +38,11 @@ export interface CompliancePayload {
|
||||
/**
|
||||
* False only when adherence is not meaningful for this medication.
|
||||
* Today that means a scheduled (non-PRN) medication with no local schedule,
|
||||
* such as an Apple Health mirror whose cadence remains source-owned.
|
||||
* such as an Apple Health mirror whose cadence remains source-owned, and
|
||||
* (v1.39.1, #1033) a medication whose intake tracking is switched off.
|
||||
*/
|
||||
applicable: boolean;
|
||||
notApplicableReason: "NO_LOCAL_SCHEDULE" | null;
|
||||
notApplicableReason: "NO_LOCAL_SCHEDULE" | "INTAKE_NOT_TRACKED" | null;
|
||||
compliance7: ComplianceResult;
|
||||
compliance30: ComplianceResult;
|
||||
dailyCompliance: Record<string, DailyComplianceEntry>;
|
||||
@@ -74,6 +76,8 @@ export interface ComplianceMedicationInput {
|
||||
endsOn: Date | null;
|
||||
oneShot: boolean;
|
||||
asNeeded: boolean;
|
||||
/** v1.39.1 (#1033) — intake tracking off: no adherence, heatmap empty. */
|
||||
trackIntake: boolean;
|
||||
schedules: Parameters<typeof buildMedicationComplianceBundle>[1];
|
||||
/** v1.16.3 — archived schedule eras for era-aware compliance. */
|
||||
scheduleRevisions?: Parameters<
|
||||
@@ -179,6 +183,20 @@ export async function buildCompliancePayload(
|
||||
// rendered as adherence. Mark this shape explicitly not-applicable BEFORE
|
||||
// reading intake history or invoking the arithmetic.
|
||||
//
|
||||
// v1.39.1 (#1033) — a medication with intake tracking off keeps its
|
||||
// schedule as a record and expects nothing from it. Same not-applicable
|
||||
// shape, its own reason, so an aware client can say why.
|
||||
if (isRecordOnly(medication)) {
|
||||
return {
|
||||
applicable: false,
|
||||
notApplicableReason: "INTAKE_NOT_TRACKED",
|
||||
compliance7: NOT_APPLICABLE_LEGACY_COMPLIANCE,
|
||||
compliance30: NOT_APPLICABLE_LEGACY_COMPLIANCE,
|
||||
dailyCompliance: {},
|
||||
complianceDisplay: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Keep PRN behaviour unchanged. The batched endpoint already excludes PRN
|
||||
// medications, and a direct per-id read retains its existing payload.
|
||||
if (!medication.asNeeded && !expectsDoses(medication)) {
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
type MedTarget,
|
||||
type MedTargetTier,
|
||||
} from "@/lib/medications/med-target-map";
|
||||
import { dueSchedules } from "@/lib/medications/intake-tracking";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
@@ -592,9 +593,11 @@ export async function buildMedicationEfficacy(
|
||||
lastNonSkippedTakenAt(events),
|
||||
timezone,
|
||||
);
|
||||
// v1.39.1 (#1033) — with intake tracking off the live era expects
|
||||
// nothing, so the adherence band shows no fabricated misses.
|
||||
const bundle = buildMedicationComplianceBundle(
|
||||
events,
|
||||
med.schedules,
|
||||
dueSchedules(med),
|
||||
ctx,
|
||||
now,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* v1.39.1 (#1033) — per-medication intake tracking.
|
||||
*
|
||||
* `Medication.trackIntake = false` keeps a medication as a record: its dose,
|
||||
* dates and schedule rows stay stored and shown, but nothing is ever due. An
|
||||
* as-needed medication reaches "never due" structurally, by carrying no
|
||||
* schedule rows at all. A record-only medication keeps its rows, so every
|
||||
* reader that derives a due dose, a reminder, a projected slot or an
|
||||
* adherence figure from them has to opt out explicitly. The helpers below are
|
||||
* the one vocabulary for that, and
|
||||
* `src/__tests__/medication-intake-tracking-guard.test.ts` fails when a
|
||||
* schedule reader uses none of them and is not on its reviewed allowlist.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Prisma `where` fragment for a medication read: only medications whose
|
||||
* intake is tracked. Spread it next to `asNeeded: false` on every read that
|
||||
* feeds a due dose, a reminder or an adherence figure.
|
||||
*/
|
||||
export const TRACKED_INTAKE_WHERE = { trackIntake: true } as const;
|
||||
|
||||
/**
|
||||
* Prisma `where` fragment for an intake-event read that should only see
|
||||
* events of tracked medications (the today list, the outstanding-dose badge).
|
||||
*/
|
||||
export const TRACKED_INTAKE_EVENT_WHERE = {
|
||||
medication: { trackIntake: true },
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* True only for an explicit `trackIntake: false`. The column is NOT NULL
|
||||
* with a default of true, so on a full row the two readings agree; on a
|
||||
* partial projection that did not select the flag, the medication reads as
|
||||
* tracked, the behaviour every medication had before the switch existed.
|
||||
*/
|
||||
export function isRecordOnly(medication: { trackIntake?: boolean }): boolean {
|
||||
return medication.trackIntake === false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The schedule rows that are in force for due-dose purposes: the stored rows
|
||||
* when intake is tracked, none when it is not. Era-aware readers pass this as
|
||||
* the LIVE schedule, so the stretch since tracking was switched off expects
|
||||
* nothing while archived eras before it keep their own schedule.
|
||||
*/
|
||||
export function dueSchedules<T>(medication: {
|
||||
trackIntake: boolean;
|
||||
schedules: readonly T[];
|
||||
}): T[] {
|
||||
return isRecordOnly(medication) ? [] : [...medication.schedules];
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire shape for a medication's schedules. A record-only medication is served
|
||||
* with `schedules: []`, the way an as-needed one is, so a client that arms
|
||||
* local reminders or derives due doses from `schedules` (the shipped iOS app
|
||||
* does both) stays silent without an update. The stored rows travel in the
|
||||
* additive `recordedSchedules` field, which is present only on a record-only
|
||||
* medication; a tracked medication carries them in `schedules` as before.
|
||||
*/
|
||||
export function scheduleWireFields<T>(
|
||||
trackIntake: boolean,
|
||||
schedules: T[],
|
||||
): { schedules: T[]; recordedSchedules?: T[] } {
|
||||
return trackIntake === false
|
||||
? { schedules: [], recordedSchedules: schedules }
|
||||
: { schedules };
|
||||
}
|
||||
@@ -28,6 +28,11 @@ import {
|
||||
} from "@/lib/medications/scheduling/next-due";
|
||||
import { getUserTodayBounds } from "@/lib/tz/local-day";
|
||||
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import {
|
||||
dueSchedules,
|
||||
isRecordOnly,
|
||||
scheduleWireFields,
|
||||
} from "@/lib/medications/intake-tracking";
|
||||
|
||||
export type MedicationsListResult = Array<Record<string, unknown>>;
|
||||
|
||||
@@ -207,6 +212,9 @@ export async function buildMedicationsList(
|
||||
const now = new Date();
|
||||
|
||||
return medications.map((m) => {
|
||||
// v1.39.1 (#1033) — intake tracking off: the stored rows are a record,
|
||||
// not a dose plan. Nothing is due and no runway derives from them.
|
||||
const liveSchedules = dueSchedules(m);
|
||||
// v1.16.4 — an OPEN overdue slot (anchor passed, still inside its
|
||||
// catch-up band, unresolved) surfaces FIRST with `nextDueOverdue:
|
||||
// true`; only a closed or resolved band falls through to the future
|
||||
@@ -220,7 +228,7 @@ export async function buildMedicationsList(
|
||||
oneShot: m.oneShot,
|
||||
createdAt: m.createdAt,
|
||||
},
|
||||
schedules: m.schedules,
|
||||
schedules: liveSchedules,
|
||||
now,
|
||||
userTz,
|
||||
lastIntakeAt: lastTakenAtDateByMedicationId[m.id] ?? null,
|
||||
@@ -230,8 +238,9 @@ export async function buildMedicationsList(
|
||||
const displaySchedule =
|
||||
display?.scheduleId === undefined
|
||||
? null
|
||||
: (m.schedules.find((schedule) => schedule.id === display.scheduleId) ??
|
||||
null);
|
||||
: (liveSchedules.find(
|
||||
(schedule) => schedule.id === display.scheduleId,
|
||||
) ?? null);
|
||||
// A prior-day rolling occurrence can remain the one authoritative
|
||||
// actionable dose while today's same HH:mm window has already passed.
|
||||
// The client window reducer uses this count to decide whether a passed
|
||||
@@ -257,6 +266,7 @@ export async function buildMedicationsList(
|
||||
m.schedules,
|
||||
m.unitsPerDose,
|
||||
);
|
||||
const liveSchedulesDto = isRecordOnly(m) ? [] : schedulesDto;
|
||||
// v1.37.19 — slot-aware doses figure: divide the units pool by the
|
||||
// schedule-weighted average units per dose, not the medication-level
|
||||
// column alone (wrong for any per-slot medication). Falls back to the
|
||||
@@ -277,7 +287,7 @@ export async function buildMedicationsList(
|
||||
? null
|
||||
: estimateUnitsRunwayDays(
|
||||
stockUnitsRemaining,
|
||||
schedulesDto,
|
||||
liveSchedulesDto,
|
||||
Number(m.unitsPerDose),
|
||||
);
|
||||
return {
|
||||
@@ -286,7 +296,9 @@ export async function buildMedicationsList(
|
||||
// the string Prisma would otherwise serialise a Decimal to.
|
||||
unitsPerDose: Number(m.unitsPerDose),
|
||||
// #219 — same Decimal → number unwrap for the per-schedule column.
|
||||
schedules: schedulesDto,
|
||||
// v1.39.1 (#1033) — `schedules: []` plus `recordedSchedules` when
|
||||
// intake tracking is off (see `scheduleWireFields`).
|
||||
...scheduleWireFields(m.trackIntake, schedulesDto),
|
||||
category: categoryMap[m.id] ?? "OTHER",
|
||||
// v1.32.25 — provenance echo. Surfacing the mirror source lets the
|
||||
// web UI and an operator tell an externally-mirrored row (today only
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getUserTodayBounds } from "@/lib/tz/local-day";
|
||||
import { TRACKED_INTAKE_EVENT_WHERE } from "@/lib/medications/intake-tracking";
|
||||
|
||||
/**
|
||||
* Count the user's outstanding (pending) doses for today in their timezone.
|
||||
@@ -31,6 +32,12 @@ export async function countOutstandingDosesToday(
|
||||
takenAt: null,
|
||||
skipped: false,
|
||||
autoMissed: false,
|
||||
// A tombstoned placeholder is gone for every read surface; it must
|
||||
// not keep a badge count alive.
|
||||
deletedAt: null,
|
||||
// v1.39.1 (#1033) — nothing of a medication with intake tracking
|
||||
// off is outstanding.
|
||||
...TRACKED_INTAKE_EVENT_WHERE,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
} from "@/lib/medications/scheduling/worker-helpers";
|
||||
import { localHmAsUtc } from "@/lib/tz/local-day";
|
||||
import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups";
|
||||
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
|
||||
|
||||
export interface ProjectTodayIntakesResult {
|
||||
projected: number;
|
||||
@@ -50,14 +51,28 @@ export async function projectTodayIntakesAndRecompute(input: {
|
||||
}): Promise<ProjectTodayIntakesResult> {
|
||||
const { userId, userTz, todayStart, todayEnd } = input;
|
||||
|
||||
// v1.39.1 (#1033) — a medication with intake tracking off keeps its
|
||||
// schedule rows as a record; nothing is projected from them.
|
||||
const activeMedications = await prisma.medication.findMany({
|
||||
where: { userId, active: true },
|
||||
where: { userId, active: true, ...TRACKED_INTAKE_WHERE },
|
||||
select: {
|
||||
id: true,
|
||||
startsOn: true,
|
||||
endsOn: true,
|
||||
oneShot: true,
|
||||
createdAt: true,
|
||||
// The newest active schedule revision. When it archived an EMPTY
|
||||
// payload, the stretch up to its `validUntil` expected no dose at all
|
||||
// (intake tracking was off, or the medication was as-needed), so the
|
||||
// live schedule starts there and no slot anchored before it is
|
||||
// projected. Turning tracking back on at 15:00 therefore does not put
|
||||
// the morning dose on the doses card.
|
||||
scheduleRevisions: {
|
||||
where: { supersededByRevisionId: null },
|
||||
orderBy: { validUntil: "desc" },
|
||||
take: 1,
|
||||
select: { validUntil: true, payload: true },
|
||||
},
|
||||
schedules: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -97,6 +112,13 @@ export async function projectTodayIntakesAndRecompute(input: {
|
||||
|
||||
for (const med of activeMedications) {
|
||||
if (med.schedules.length === 0) continue;
|
||||
const latestRevision = med.scheduleRevisions?.[0];
|
||||
const expectedNothingUntil =
|
||||
latestRevision &&
|
||||
Array.isArray(latestRevision.payload) &&
|
||||
latestRevision.payload.length === 0
|
||||
? latestRevision.validUntil.getTime()
|
||||
: null;
|
||||
|
||||
// Rolling cadence anchors off the last logged intake. One findFirst
|
||||
// per medication, scoped to `takenAt IS NOT NULL` — byte-identical to
|
||||
@@ -149,9 +171,16 @@ export async function projectTodayIntakesAndRecompute(input: {
|
||||
for (const slotTime of slotTimes) {
|
||||
const [h, m] = slotTime.split(":").map(Number);
|
||||
if (!Number.isFinite(h) || !Number.isFinite(m)) continue;
|
||||
const scheduledFor = localHmAsUtc(now, userTz, h, m);
|
||||
if (
|
||||
expectedNothingUntil !== null &&
|
||||
scheduledFor.getTime() < expectedNothingUntil
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
projected.push({
|
||||
medicationId: schedule.medicationId,
|
||||
scheduledFor: localHmAsUtc(now, userTz, h, m),
|
||||
scheduledFor,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,9 +222,24 @@ export const medicationResource = z
|
||||
.describe(
|
||||
"v1.16.11 as-needed (PRN) flag. When true the medication carries ZERO schedules (the write routes 422 on any schedule entry alongside the flag): it is never due (`nextDueAt` stays null), never reminded, and excluded from every compliance rate/streak — but intakes still log as ad-hoc rows, inventory still consumes per `unitsPerDose`, and the history renders. Stays active indefinitely. Mutually exclusive with `oneShot`.",
|
||||
),
|
||||
trackIntake: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"v1.39.1 — intake tracking. True (the default) for every medication that existed before the field. When false the medication is kept as a record: nothing is ever due (`nextDueAt` stays null), nothing reminds on any channel, and it is excluded from every adherence figure. Its stored schedule then travels in `recordedSchedules` while `schedules` is served empty, so a client that arms reminders from `schedules` stays silent without an update.",
|
||||
),
|
||||
createdAt: z.iso.datetime({ offset: true }),
|
||||
updatedAt: z.iso.datetime({ offset: true }),
|
||||
schedules: z.array(medicationScheduleResource),
|
||||
schedules: z
|
||||
.array(medicationScheduleResource)
|
||||
.describe(
|
||||
"The schedules in force. Empty for an as-needed medication and (v1.39.1) for a medication with `trackIntake: false`, whose stored schedule is in `recordedSchedules`.",
|
||||
),
|
||||
recordedSchedules: z
|
||||
.array(medicationScheduleResource)
|
||||
.optional()
|
||||
.describe(
|
||||
"v1.39.1 — present only when `trackIntake` is false: the stored schedule, kept as information. Nothing derives a due dose or a reminder from it. Absent on a tracked medication, whose schedule is `schedules`.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "Medication",
|
||||
@@ -761,10 +776,10 @@ export const medicationComplianceResponse = z
|
||||
"Whether a local adherence percentage applies to this medication.",
|
||||
),
|
||||
notApplicableReason: z
|
||||
.literal("NO_LOCAL_SCHEDULE")
|
||||
.enum(["NO_LOCAL_SCHEDULE", "INTAKE_NOT_TRACKED"])
|
||||
.nullable()
|
||||
.describe(
|
||||
"Reason adherence is not applicable. NO_LOCAL_SCHEDULE means the medication has no HealthLog-owned expected-dose grid.",
|
||||
"Reason adherence is not applicable. NO_LOCAL_SCHEDULE means the medication has no HealthLog-owned expected-dose grid. INTAKE_NOT_TRACKED (v1.39.1) means the medication's intake tracking is switched off (`trackIntake: false`): its schedule is kept as information and nothing is expected from it.",
|
||||
),
|
||||
compliance7: complianceResult.describe(
|
||||
"Seven-day adherence summary. When applicable is false, this remains a non-null all-zero compatibility placeholder for released clients and must not be rendered as a percentage.",
|
||||
@@ -797,7 +812,9 @@ export const medicationComplianceSummaryEntry = z
|
||||
.describe(
|
||||
"Whether this medication has a local expected-dose grid and therefore a displayable adherence percentage.",
|
||||
),
|
||||
notApplicableReason: z.literal("NO_LOCAL_SCHEDULE").nullable(),
|
||||
notApplicableReason: z
|
||||
.enum(["NO_LOCAL_SCHEDULE", "INTAKE_NOT_TRACKED"])
|
||||
.nullable(),
|
||||
compliance7: complianceResult.describe(
|
||||
"Seven-day adherence summary, or an all-zero compatibility placeholder when applicable is false.",
|
||||
),
|
||||
|
||||
@@ -35,6 +35,7 @@ import { buildVitalTargets } from "./vitals-builder";
|
||||
import type { TargetItem, TargetMoodEntry, TargetValueByType } from "./types";
|
||||
|
||||
import type { User } from "@/generated/prisma/client";
|
||||
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
|
||||
|
||||
function getAge(dateOfBirth: Date): number {
|
||||
const today = new Date();
|
||||
@@ -167,7 +168,12 @@ export async function buildTargetsResponse(user: AuthedUser) {
|
||||
),
|
||||
limit(async () => {
|
||||
const activeMedications = await prisma.medication.findMany({
|
||||
where: { userId, active: true, asNeeded: false },
|
||||
where: {
|
||||
userId,
|
||||
active: true,
|
||||
asNeeded: false,
|
||||
...TRACKED_INTAKE_WHERE,
|
||||
},
|
||||
include: {
|
||||
schedules: { select: SCHEDULE_COMPLIANCE_SELECT },
|
||||
scheduleRevisions: { orderBy: { validFrom: "asc" } },
|
||||
|
||||
@@ -329,6 +329,9 @@ const medicationSchema = z
|
||||
endsOn: isoDateTime.nullable().optional(),
|
||||
oneShot: z.boolean().optional(),
|
||||
asNeeded: z.boolean().optional(),
|
||||
// v1.39.1 (#1033) — absent in a backup written before the field existed;
|
||||
// the restore defaults it to tracking on, the behaviour those had.
|
||||
trackIntake: z.boolean().optional(),
|
||||
deliveryForm: z.enum(MedicationDeliveryForm).optional(),
|
||||
trackInjectionSites: z.boolean().optional(),
|
||||
allowedInjectionSites: z.array(z.enum(InjectionSite)).optional(),
|
||||
|
||||
@@ -70,6 +70,17 @@ const courseWindowFields = {
|
||||
.describe(
|
||||
"As-needed (PRN) medication (pain relief, rescue inhaler). When true the medication carries NO schedules (supplying any schedule entry is a 422): it is never due, never reminded, and excluded from compliance rates/streaks, but intakes still log (ad-hoc), inventory still consumes, and the history renders. Stays active indefinitely. Mutually exclusive with `oneShot`.",
|
||||
),
|
||||
/**
|
||||
* v1.39.1 (#1033) — per-medication intake tracking. Default true. False
|
||||
* keeps the medication (dose, dates, schedule) as a record: nothing is
|
||||
* due, nothing reminds, no adherence figure. Omitted leaves it as is.
|
||||
*/
|
||||
trackIntake: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"v1.39.1 — intake tracking. Default true. When false the medication keeps its dose, dates and schedules as information, but nothing is ever due: no reminder on any channel, no projected slot, no entry on the today list or the doses card, and no adherence figure anywhere. Existing intake history stays stored. Switching it back on resumes from that moment; the time it was off never counts as missed. Omitted on update leaves the current value.",
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -262,6 +262,8 @@ describe("canonical disaster-recovery backup round-trip", () => {
|
||||
startsOn: new Date("2026-06-01T00:00:00.000Z"),
|
||||
endsOn: new Date("2026-08-01T00:00:00.000Z"),
|
||||
asNeeded: false,
|
||||
// Not the default, so the round trip proves it travels (#1033).
|
||||
trackIntake: false,
|
||||
deliveryForm: "INJECTION",
|
||||
trackInjectionSites: true,
|
||||
allowedInjectionSites: ["THIGH_LEFT", "THIGH_RIGHT"],
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
/**
|
||||
* #1033 — per-medication intake tracking, against a real Postgres.
|
||||
*
|
||||
* A medication with `trackIntake = false` keeps its schedule rows as a
|
||||
* record. These tests prove, through the real worker, projector, compliance
|
||||
* builders and routes, that nothing derives a due dose from those rows: no
|
||||
* reminder, no placeholder, no place on the doses card or the today list, no
|
||||
* adherence figure. And that switching tracking back on resumes from that
|
||||
* moment, with no miss counted for the stretch it was off.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete: (name: string) => {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/notifications/dispatcher", () => ({
|
||||
dispatchNotification: vi.fn().mockResolvedValue({
|
||||
dispatched: true,
|
||||
channelsAttempted: 1,
|
||||
channelsSucceeded: 1,
|
||||
}),
|
||||
}));
|
||||
|
||||
const { dispatchNotification } = await import("@/lib/notifications/dispatcher");
|
||||
const { handleReminderCheck } =
|
||||
await import("@/lib/jobs/reminder/medication-reminder-check");
|
||||
const { buildMedsTodayBlock } = await import("@/lib/dashboard/meds-today");
|
||||
const { buildScheduleAnchoredComplianceBuckets } =
|
||||
await import("@/lib/analytics/schedule-anchored-compliance");
|
||||
const { runIntakeAutoSkipPass } = await import("@/lib/jobs/intake-auto-skip");
|
||||
|
||||
const TZ = "Europe/Berlin";
|
||||
let counter = 0;
|
||||
|
||||
type Handler = (
|
||||
request: NextRequest,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
context: { params: Promise<any> },
|
||||
) => Promise<Response>;
|
||||
|
||||
async function makeUser(): Promise<string> {
|
||||
const suffix = `${Date.now()}-${counter++}`;
|
||||
const user = await getPrismaClient().user.create({
|
||||
data: {
|
||||
username: `track-intake-${suffix}`,
|
||||
email: `track-intake-${suffix}@example.test`,
|
||||
timezone: TZ,
|
||||
locale: "en",
|
||||
},
|
||||
});
|
||||
const session = await getPrismaClient().session.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
return user.id;
|
||||
}
|
||||
|
||||
async function seedDaily(
|
||||
userId: string,
|
||||
name: string,
|
||||
trackIntake: boolean,
|
||||
createdAt = new Date("2026-06-01T00:00:00.000Z"),
|
||||
): Promise<string> {
|
||||
const medication = await getPrismaClient().medication.create({
|
||||
data: {
|
||||
userId,
|
||||
name,
|
||||
dose: "1 tablet",
|
||||
trackIntake,
|
||||
createdAt,
|
||||
schedules: {
|
||||
create: {
|
||||
windowStart: "08:00",
|
||||
windowEnd: "08:00",
|
||||
timesOfDay: ["08:00"],
|
||||
rrule: "FREQ=DAILY",
|
||||
scheduleType: "SCHEDULED",
|
||||
},
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
return medication.id;
|
||||
}
|
||||
|
||||
function at(iso: string): void {
|
||||
vi.setSystemTime(new Date(iso));
|
||||
}
|
||||
|
||||
function request(url: string, init?: { method: string; body?: unknown }) {
|
||||
return new NextRequest(`http://localhost${url}`, {
|
||||
method: init?.method ?? "GET",
|
||||
...(init?.body !== undefined && {
|
||||
body: JSON.stringify(init.body),
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async function call<T>(
|
||||
handler: Handler,
|
||||
url: string,
|
||||
params: Record<string, string> = {},
|
||||
init?: { method: string; body?: unknown },
|
||||
): Promise<{ status: number; data: T }> {
|
||||
const res = await handler(request(url, init), {
|
||||
params: Promise.resolve(params),
|
||||
});
|
||||
const json = (await res.json()) as { data: T };
|
||||
return { status: res.status, data: json.data };
|
||||
}
|
||||
|
||||
function dispatchedFor(medicationId: string): number {
|
||||
return vi
|
||||
.mocked(dispatchNotification)
|
||||
.mock.calls.filter(
|
||||
([arg]) =>
|
||||
(arg as { metadata?: { medicationId?: string } }).metadata
|
||||
?.medicationId === medicationId,
|
||||
).length;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.mocked(dispatchNotification).mockClear();
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
await truncateAllTables(getPrismaClient());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("intake tracking off — nothing is due", () => {
|
||||
it("sends no reminder and mints no placeholder, on any tick", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const userId = await makeUser();
|
||||
const tracked = await seedDaily(userId, "Tracked", true);
|
||||
const record = await seedDaily(userId, "Record", false);
|
||||
|
||||
// 08:30, 12:00 and 20:00 local: inside the window, past the late mark,
|
||||
// past the miss mark (the worker mints a missed placeholder there).
|
||||
for (const tick of [
|
||||
"2026-06-10T06:30:00.000Z",
|
||||
"2026-06-10T10:00:00.000Z",
|
||||
"2026-06-10T18:00:00.000Z",
|
||||
]) {
|
||||
at(tick);
|
||||
await handleReminderCheck([]);
|
||||
}
|
||||
|
||||
expect(dispatchedFor(tracked)).toBeGreaterThan(0);
|
||||
expect(dispatchedFor(record)).toBe(0);
|
||||
expect(
|
||||
await prisma.medicationIntakeEvent.count({
|
||||
where: { medicationId: record },
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("stays off the doses card, the today list and the take flows", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const userId = await makeUser();
|
||||
const tracked = await seedDaily(userId, "Tracked", true);
|
||||
const record = await seedDaily(userId, "Record", false);
|
||||
at("2026-06-10T09:00:00.000Z"); // 11:00 local, the 08:00 dose overdue
|
||||
|
||||
const block = await buildMedsTodayBlock(prisma, userId, TZ, new Date());
|
||||
expect(block.activeCount).toBe(1);
|
||||
expect(block.dueCandidates.map((c) => c.medicationId)).toEqual([tracked]);
|
||||
|
||||
const intakeRoute = await import("@/app/api/medications/intake/route");
|
||||
const today = await call<Array<{ medicationId: string }>>(
|
||||
intakeRoute.GET as Handler,
|
||||
"/api/medications/intake?scope=today",
|
||||
);
|
||||
expect(today.status).toBe(200);
|
||||
expect(today.data.map((r) => r.medicationId)).toEqual([tracked]);
|
||||
expect(
|
||||
await prisma.medicationIntakeEvent.count({
|
||||
where: { medicationId: record },
|
||||
}),
|
||||
).toBe(0);
|
||||
|
||||
// The list serves the record-only medication the way it serves an
|
||||
// as-needed one: no schedules in force, the stored ones on record.
|
||||
const listRoute = await import("@/app/api/medications/route");
|
||||
const list = await call<
|
||||
Array<{
|
||||
id: string;
|
||||
trackIntake: boolean;
|
||||
schedules: unknown[];
|
||||
recordedSchedules?: Array<{ timesOfDay: string[] }>;
|
||||
nextDueAt: string | null;
|
||||
}>
|
||||
>(listRoute.GET as Handler, "/api/medications");
|
||||
const recordRow = list.data.find((m) => m.id === record)!;
|
||||
const trackedRow = list.data.find((m) => m.id === tracked)!;
|
||||
expect(recordRow.trackIntake).toBe(false);
|
||||
expect(recordRow.schedules).toEqual([]);
|
||||
expect(recordRow.recordedSchedules?.[0]?.timesOfDay).toEqual(["08:00"]);
|
||||
expect(recordRow.nextDueAt).toBeNull();
|
||||
expect(trackedRow.schedules).toHaveLength(1);
|
||||
expect(trackedRow).not.toHaveProperty("recordedSchedules");
|
||||
expect(trackedRow.nextDueAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it("is left out of every adherence read", async () => {
|
||||
const userId = await makeUser();
|
||||
const tracked = await seedDaily(userId, "Tracked", true);
|
||||
const record = await seedDaily(userId, "Record", false);
|
||||
at("2026-06-10T20:00:00.000Z");
|
||||
|
||||
// Dashboard daily buckets: one expected dose a day, the tracked one.
|
||||
const buckets = await buildScheduleAnchoredComplianceBuckets(
|
||||
userId,
|
||||
5,
|
||||
TZ,
|
||||
new Date(),
|
||||
);
|
||||
expect(buckets.length).toBeGreaterThan(0);
|
||||
for (const bucket of buckets.slice(0, -1)) {
|
||||
expect(bucket.scheduled).toBeLessThanOrEqual(1);
|
||||
}
|
||||
|
||||
// The batched card read carries only the tracked medication.
|
||||
const batch = await import("@/app/api/medications/compliance/route");
|
||||
const summary = await call<Array<{ medicationId: string }>>(
|
||||
batch.GET as Handler,
|
||||
"/api/medications/compliance",
|
||||
);
|
||||
expect(summary.data.map((e) => e.medicationId)).toEqual([tracked]);
|
||||
|
||||
// The per-medication read answers not-applicable, with its reason.
|
||||
const perId = await import("@/app/api/medications/[id]/compliance/route");
|
||||
const payload = await call<{
|
||||
applicable: boolean;
|
||||
notApplicableReason: string | null;
|
||||
dailyCompliance: Record<string, unknown>;
|
||||
}>(perId.GET as Handler, `/api/medications/${record}/compliance`, {
|
||||
id: record,
|
||||
});
|
||||
expect(payload.status).toBe(200);
|
||||
expect(payload.data.applicable).toBe(false);
|
||||
expect(payload.data.notApplicableReason).toBe("INTAKE_NOT_TRACKED");
|
||||
expect(payload.data.dailyCompliance).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("switching intake tracking back on", () => {
|
||||
it("resumes from that moment with no miss for the stretch it was off", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const userId = await makeUser();
|
||||
const medicationId = await seedDaily(userId, "Statin", true);
|
||||
const route = await import("@/app/api/medications/[id]/route");
|
||||
|
||||
// Four doses taken on time, 1–4 June.
|
||||
for (const day of ["01", "02", "03", "04"]) {
|
||||
await prisma.medicationIntakeEvent.create({
|
||||
data: {
|
||||
userId,
|
||||
medicationId,
|
||||
scheduledFor: new Date(`2026-06-${day}T06:00:00.000Z`),
|
||||
takenAt: new Date(`2026-06-${day}T06:02:00.000Z`),
|
||||
source: "WEB",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 5 June, 07:00 local: the doses card has minted today's pending 08:00.
|
||||
at("2026-06-05T05:00:00.000Z");
|
||||
await buildMedsTodayBlock(prisma, userId, TZ, new Date());
|
||||
expect(
|
||||
await prisma.medicationIntakeEvent.count({
|
||||
where: { medicationId, takenAt: null, deletedAt: null },
|
||||
}),
|
||||
).toBe(1);
|
||||
|
||||
// 07:30 local: tracking off. The open placeholder goes with it.
|
||||
at("2026-06-05T05:30:00.000Z");
|
||||
const off = await call<{ trackIntake: boolean; schedules: unknown[] }>(
|
||||
route.PUT as Handler,
|
||||
`/api/medications/${medicationId}`,
|
||||
{ id: medicationId },
|
||||
{ method: "PUT", body: { trackIntake: false } },
|
||||
);
|
||||
expect(off.status).toBe(200);
|
||||
expect(off.data.trackIntake).toBe(false);
|
||||
expect(off.data.schedules).toEqual([]);
|
||||
expect(
|
||||
await prisma.medicationIntakeEvent.count({
|
||||
where: { medicationId, takenAt: null, deletedAt: null },
|
||||
}),
|
||||
).toBe(0);
|
||||
// The stored schedule is untouched.
|
||||
expect(
|
||||
await prisma.medicationSchedule.count({ where: { medicationId } }),
|
||||
).toBe(1);
|
||||
|
||||
// A week off. The auto-miss pass runs; nothing becomes a miss.
|
||||
at("2026-06-11T12:00:00.000Z");
|
||||
await runIntakeAutoSkipPass(prisma, { nowMs: Date.now() });
|
||||
expect(
|
||||
await prisma.medicationIntakeEvent.count({
|
||||
where: { medicationId, autoMissed: true },
|
||||
}),
|
||||
).toBe(0);
|
||||
|
||||
// 12 June, 15:00 local: tracking back on.
|
||||
at("2026-06-12T13:00:00.000Z");
|
||||
const on = await call<{ trackIntake: boolean; schedules: unknown[] }>(
|
||||
route.PUT as Handler,
|
||||
`/api/medications/${medicationId}`,
|
||||
{ id: medicationId },
|
||||
{ method: "PUT", body: { trackIntake: true } },
|
||||
);
|
||||
expect(on.status).toBe(200);
|
||||
expect(on.data.trackIntake).toBe(true);
|
||||
expect(on.data.schedules).toHaveLength(1);
|
||||
|
||||
// Today's 08:00 lies before the switch: not projected, not reminded.
|
||||
const block = await buildMedsTodayBlock(prisma, userId, TZ, new Date());
|
||||
expect(block.scheduledToday).toBe(0);
|
||||
expect(block.dueCandidates.every((c) => c.overdue === false)).toBe(true);
|
||||
await handleReminderCheck([]);
|
||||
expect(dispatchedFor(medicationId)).toBe(0);
|
||||
|
||||
// Adherence: the four taken doses count, the untracked week does not.
|
||||
const perId = await import("@/app/api/medications/[id]/compliance/route");
|
||||
const payload = await call<{
|
||||
applicable: boolean;
|
||||
compliance30: { taken: number; missed: number; totalExpected: number };
|
||||
}>(perId.GET as Handler, `/api/medications/${medicationId}/compliance`, {
|
||||
id: medicationId,
|
||||
});
|
||||
expect(payload.data.applicable).toBe(true);
|
||||
expect(payload.data.compliance30.taken).toBe(4);
|
||||
expect(payload.data.compliance30.missed).toBe(0);
|
||||
|
||||
// From the next morning on, it is a tracked medication again.
|
||||
at("2026-06-13T06:30:00.000Z");
|
||||
await handleReminderCheck([]);
|
||||
expect(dispatchedFor(medicationId)).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user