mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
feat(medications): publish the resolved reminder decision as remindersActive
This commit is contained in:
1 parent
55bfb0a439
commit
195fa28cc5
7 files changed
+204
-14
No files matched your search
@@ -47710,6 +47710,13 @@ components:
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
- type: "null"
|
||||
remindersActive:
|
||||
type: boolean
|
||||
description: "v1.42.2 — whether the server sends dose reminders for this medication: true only when
|
||||
`notificationsEnabled` is on, the medication is `active`, its intake is tracked (`trackIntake`), it is not
|
||||
`asNeeded`, and `autoMarkTaken` is off. The same decision gates every server-side reminder send. A client
|
||||
that arms local reminders arms them from this, not from `notificationsEnabled`. Read-only — computed, not
|
||||
stored."
|
||||
nextDueAt:
|
||||
anyOf:
|
||||
- type: string
|
||||
@@ -47898,6 +47905,7 @@ components:
|
||||
- rxNormCode
|
||||
- pausedAt
|
||||
- snoozedUntil
|
||||
- remindersActive
|
||||
- nextDueAt
|
||||
- nextDueOverdue
|
||||
- startsOn
|
||||
@@ -48071,6 +48079,9 @@ components:
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
- type: "null"
|
||||
remindersActive:
|
||||
type: boolean
|
||||
description: v1.42.2 — whether the server sends dose reminders for this medication. See the `Medication` component.
|
||||
nextDueAt:
|
||||
description: "Present on the READ paths only — the list and single GET compute it; the create / update responses return
|
||||
the stored row and omit it. v1.7.0 server-computed next due instant across all the medication's schedules
|
||||
@@ -48240,6 +48251,7 @@ components:
|
||||
- rxNormCode
|
||||
- pausedAt
|
||||
- snoozedUntil
|
||||
- remindersActive
|
||||
- startsOn
|
||||
- endsOn
|
||||
- oneShot
|
||||
@@ -83016,6 +83028,13 @@ components:
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
- type: "null"
|
||||
remindersActive:
|
||||
description: "Present on the READ paths only (list and single GET). v1.42.2 — whether the server sends dose reminders
|
||||
for this medication: true only when `notificationsEnabled` is on, the medication is `active`, its intake is
|
||||
tracked (`trackIntake`), it is not `asNeeded`, and `autoMarkTaken` is off. The same decision gates every
|
||||
server-side reminder send. A client that arms local reminders arms them from this, not from
|
||||
`notificationsEnabled`. Read-only — computed, not stored."
|
||||
type: boolean
|
||||
nextDueAt:
|
||||
description: "Present on the READ paths only — the list and single GET compute it; the create / update responses return
|
||||
the stored row and omit it. v1.7.0 server-computed next due instant across all the medication's schedules
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { sendsMedicationReminders } from "@/lib/medications/auto-mark-taken";
|
||||
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
@@ -264,6 +265,8 @@ export const GET = apiHandler(
|
||||
categoryLabel,
|
||||
...courseFields,
|
||||
nextDueAt: display ? display.at.toISOString() : null,
|
||||
// v1.42.2 — the server's own reminder decision, published resolved.
|
||||
remindersActive: sendsMedicationReminders(medication),
|
||||
nextDueOverdue: display?.overdue ?? false,
|
||||
// v1.39.4 (#1040) — see `resolveIntakeActionability`.
|
||||
...resolveIntakeActionability(
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* v1.42.2 — `sendsMedicationReminders` is the one reminder decision: the
|
||||
* worker and the admin check gate on it, the read paths publish it as
|
||||
* `remindersActive`. Pins the truth table and that every site calls it.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { sendsMedicationReminders } from "../auto-mark-taken";
|
||||
|
||||
describe("sendsMedicationReminders", () => {
|
||||
it("reminds only for an active, tracked, scheduled medication with reminders on and no auto-recording", () => {
|
||||
for (let bits = 0; bits < 32; bits++) {
|
||||
const med = {
|
||||
notificationsEnabled: (bits & 1) !== 0,
|
||||
autoMarkTaken: (bits & 2) !== 0,
|
||||
active: (bits & 4) !== 0,
|
||||
trackIntake: (bits & 8) !== 0,
|
||||
asNeeded: (bits & 16) !== 0,
|
||||
};
|
||||
expect(sendsMedicationReminders(med), JSON.stringify(med)).toBe(
|
||||
med.notificationsEnabled &&
|
||||
!med.autoMarkTaken &&
|
||||
med.active &&
|
||||
med.trackIntake &&
|
||||
!med.asNeeded,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("is the decision at every gate and on every published DTO", () => {
|
||||
const src = (rel: string) =>
|
||||
readFileSync(join(process.cwd(), "src", rel), "utf8");
|
||||
for (const gate of [
|
||||
"lib/jobs/reminder/medication-reminder-check.ts",
|
||||
"app/api/admin/notifications/reminder-check/route.ts",
|
||||
]) {
|
||||
expect(src(gate), gate).toMatch(/sendsMedicationReminders\(med\)/);
|
||||
// No send site falls back to the raw preference.
|
||||
expect(src(gate), gate).not.toMatch(
|
||||
/if \([^)]*med\.notificationsEnabled\)/,
|
||||
);
|
||||
}
|
||||
for (const dto of [
|
||||
"lib/medications/list-read.ts",
|
||||
"app/api/medications/[id]/route.ts",
|
||||
]) {
|
||||
expect(src(dto), dto).toMatch(
|
||||
/remindersActive:\s*sendsMedicationReminders\(/,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,21 +1,34 @@
|
||||
/**
|
||||
* v1.42.2 — "record doses automatically" (`Medication.autoMarkTaken`).
|
||||
* v1.42.2 — whether a dose reminder may be sent for a medication.
|
||||
*
|
||||
* A medication with the option on has every scheduled dose recorded as taken
|
||||
* by the `intake-auto-take` job once its time has passed. There is nothing to
|
||||
* remind about, so every reminder path asks {@link sendsMedicationReminders}
|
||||
* instead of reading `notificationsEnabled` alone. The person's own reminder
|
||||
* preference stays stored untouched: switching the option off restores
|
||||
* THE one definition. The reminder worker and the admin reminder check gate
|
||||
* every send on it, and the medication read paths publish its result as
|
||||
* `remindersActive`, so a client that arms local reminders reads the server's
|
||||
* decision instead of re-deriving it from the raw flags.
|
||||
*
|
||||
* A medication reminds only when the person wants reminders
|
||||
* (`notificationsEnabled`), it is active (not paused or ended), it has doses
|
||||
* that fall due (intake tracked, not as-needed), and its doses are not
|
||||
* recorded automatically (`autoMarkTaken`). The person's own reminder
|
||||
* preference stays stored untouched: switching auto-mark-taken off restores
|
||||
* whatever they had chosen.
|
||||
*/
|
||||
|
||||
/**
|
||||
* True when a dose reminder may be sent for this medication: reminders are
|
||||
* switched on and its doses are not recorded automatically.
|
||||
*/
|
||||
export function sendsMedicationReminders(medication: {
|
||||
export interface ReminderGateMedication {
|
||||
notificationsEnabled: boolean;
|
||||
autoMarkTaken: boolean;
|
||||
}): boolean {
|
||||
return medication.notificationsEnabled && !medication.autoMarkTaken;
|
||||
active: boolean;
|
||||
trackIntake: boolean;
|
||||
asNeeded: boolean;
|
||||
}
|
||||
|
||||
export function sendsMedicationReminders(
|
||||
medication: ReminderGateMedication,
|
||||
): boolean {
|
||||
return (
|
||||
medication.notificationsEnabled &&
|
||||
!medication.autoMarkTaken &&
|
||||
medication.active &&
|
||||
medication.trackIntake &&
|
||||
!medication.asNeeded
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
* write-invalidation semantics (`invalidateUserMedications`) cover both
|
||||
* readers. Mirrors `src/lib/dashboard/snapshot-read.ts` for the dashboard.
|
||||
*/
|
||||
import { sendsMedicationReminders } from "@/lib/medications/auto-mark-taken";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { resolveCourseFields } from "@/lib/medications/courses";
|
||||
@@ -326,6 +327,8 @@ export async function buildMedicationsList(
|
||||
(todayEventCountByMedId[m.id] ?? 0) +
|
||||
(carriesPriorRollingOccurrence ? 1 : 0),
|
||||
nextDueAt: display ? display.at.toISOString() : null,
|
||||
// v1.42.2 — the server's own reminder decision, published resolved.
|
||||
remindersActive: sendsMedicationReminders(m),
|
||||
nextDueOverdue: display?.overdue ?? false,
|
||||
nextDueScheduleId: display?.scheduleId ?? null,
|
||||
// v1.39.4 (#1040) — whether the card offers taken / skip today, and
|
||||
|
||||
@@ -278,6 +278,12 @@ export const medicationResource = z
|
||||
),
|
||||
pausedAt: z.iso.datetime({ offset: true }).nullable(),
|
||||
snoozedUntil: z.iso.datetime({ offset: true }).nullable(),
|
||||
remindersActive: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"Present on the READ paths only (list and single GET). v1.42.2 — whether the server sends dose reminders for this medication: true only when `notificationsEnabled` is on, the medication is `active`, its intake is tracked (`trackIntake`), it is not `asNeeded`, and `autoMarkTaken` is off. The same decision gates every server-side reminder send. A client that arms local reminders arms them from this, not from `notificationsEnabled`. Read-only — computed, not stored.",
|
||||
),
|
||||
nextDueAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.nullable()
|
||||
@@ -366,6 +372,11 @@ export const medicationListEntry = medicationResource
|
||||
// even though the shared base leaves them optional for the write paths.
|
||||
nextDueAt: z.iso.datetime({ offset: true }).nullable(),
|
||||
nextDueOverdue: z.boolean(),
|
||||
remindersActive: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"v1.42.2 — whether the server sends dose reminders for this medication: true only when `notificationsEnabled` is on, the medication is `active`, its intake is tracked (`trackIntake`), it is not `asNeeded`, and `autoMarkTaken` is off. The same decision gates every server-side reminder send. A client that arms local reminders arms them from this, not from `notificationsEnabled`. Read-only — computed, not stored.",
|
||||
),
|
||||
courseStatus: z
|
||||
.enum(["UPCOMING", "CURRENT", "ENDED"])
|
||||
.describe(
|
||||
@@ -418,6 +429,12 @@ export const medicationListEntry = medicationResource
|
||||
|
||||
export const medicationDetailEntry = medicationResource
|
||||
.extend({
|
||||
// The single GET always computes it; required here like on the list.
|
||||
remindersActive: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"v1.42.2 — whether the server sends dose reminders for this medication. See the `Medication` component.",
|
||||
),
|
||||
category: medicationCategoryEnum,
|
||||
categoryLabel: medicationCategoryLabelField,
|
||||
...medicationCourseFields,
|
||||
|
||||
@@ -379,3 +379,84 @@ describe("auto-mark-taken pass", () => {
|
||||
expect(off).toEqual({ autoMarkTaken: false, autoMarkTakenSince: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe("remindersActive — the published value is the gate", () => {
|
||||
it("equals sendsMedicationReminders() on the list and detail reads, for every combination", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const userId = await makeUser();
|
||||
const { sendsMedicationReminders } =
|
||||
await import("@/lib/medications/auto-mark-taken");
|
||||
at("2026-06-10T10:00:00.000Z");
|
||||
|
||||
const combos: Array<{
|
||||
notificationsEnabled: boolean;
|
||||
autoMarkTaken: boolean;
|
||||
active: boolean;
|
||||
trackIntake: boolean;
|
||||
asNeeded: boolean;
|
||||
}> = [];
|
||||
for (let bits = 0; bits < 32; bits++) {
|
||||
combos.push({
|
||||
notificationsEnabled: (bits & 1) !== 0,
|
||||
autoMarkTaken: (bits & 2) !== 0,
|
||||
active: (bits & 4) !== 0,
|
||||
trackIntake: (bits & 8) !== 0,
|
||||
asNeeded: (bits & 16) !== 0,
|
||||
});
|
||||
}
|
||||
const ids: string[] = [];
|
||||
for (const [i, combo] of combos.entries()) {
|
||||
const med = await prisma.medication.create({
|
||||
data: {
|
||||
userId,
|
||||
name: `Combo ${i}`,
|
||||
dose: "1",
|
||||
...combo,
|
||||
autoMarkTakenSince: combo.autoMarkTaken ? new Date() : null,
|
||||
...(!combo.asNeeded && {
|
||||
schedules: {
|
||||
create: {
|
||||
windowStart: "19:00",
|
||||
windowEnd: "19:00",
|
||||
timesOfDay: ["19:00"],
|
||||
rrule: "FREQ=DAILY",
|
||||
scheduleType: "SCHEDULED",
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
ids.push(med.id);
|
||||
}
|
||||
|
||||
const list = await import("@/app/api/medications/route");
|
||||
const listRes = await (list.GET as Handler)(
|
||||
new NextRequest("http://localhost/api/medications"),
|
||||
{ params: Promise.resolve({}) },
|
||||
);
|
||||
expect(listRes.status).toBe(200);
|
||||
const listBody = (await listRes.json()) as {
|
||||
data: Array<{ id: string; remindersActive: boolean }>;
|
||||
};
|
||||
const listed = new Map(
|
||||
listBody.data.map((m) => [m.id, m.remindersActive] as const),
|
||||
);
|
||||
|
||||
const detail = await import("@/app/api/medications/[id]/route");
|
||||
let trueCount = 0;
|
||||
for (const [i, id] of ids.entries()) {
|
||||
const expected = sendsMedicationReminders(combos[i]);
|
||||
if (expected) trueCount++;
|
||||
expect(listed.get(id), `list combo ${i}`).toBe(expected);
|
||||
const res = await (detail.GET as Handler)(
|
||||
new NextRequest(`http://localhost/api/medications/${id}`),
|
||||
{ params: Promise.resolve({ id }) },
|
||||
);
|
||||
const body = (await res.json()) as { data: { remindersActive: boolean } };
|
||||
expect(body.data.remindersActive, `detail combo ${i}`).toBe(expected);
|
||||
}
|
||||
// Exactly one combination reminds: on, not auto, active, tracked, scheduled.
|
||||
expect(trueCount).toBe(1);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user