Merge branch 'feat/medication-record-only' into release/v1.39.1

This commit is contained in:
Marc-André Bombeck committed 2026-09-25 09:37:27 +02:00
commit e03cf7c2c8
81 files changed
+2283 -274

No files matched your search

Binary file not shown.
+6 -1
View File
@@ -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).
+2 -1
View File
@@ -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,
+14 -2
View File
@@ -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,
});
});
});
+8 -1
View File
@@ -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 },
}),
+2
View File
@@ -502,6 +502,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;
+3 -1
View File
@@ -334,12 +334,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,
+3
View File
@@ -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: [
+4
View File
@@ -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
+1
View File
@@ -907,6 +907,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 ?? [],
+8 -1
View File
@@ -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: {
+2 -1
View File
@@ -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" } },
@@ -34,6 +34,7 @@ import {
MED_TARGET_MAP,
type MedTargetClass,
} from "@/lib/medications/med-target-map";
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
const MAD_TO_SIGMA = 1.4826;
@@ -164,6 +165,9 @@ export async function buildAdherenceStoryline(
where: {
userId,
active: true,
// v1.39.1 (#1033) — the adherence figure below counts tracked
// medications only, so a record-only one cannot be its subject.
...TRACKED_INTAKE_WHERE,
OR: [{ endsOn: null }, { endsOn: { gte: now } }],
},
select: { name: true, treatmentClass: true },
+2 -1
View File
@@ -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 () => {
+8 -1
View File
@@ -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,
+10 -1
View File
@@ -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,
+4
View File
@@ -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,
+7 -2
View File
@@ -72,6 +72,7 @@ import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import { listTargetsBySource } from "@/lib/links";
import { encounterKindEnum } from "@/lib/validations/encounters";
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
@@ -850,6 +851,7 @@ const getMedicationScheduleOutput: z.ZodRawShape = {
nextDueAt: z.string().nullable(),
overdue: z.boolean(),
asNeeded: z.boolean(),
intakeTracked: z.boolean(),
}),
)
.optional(),
@@ -1452,7 +1454,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,
@@ -1545,7 +1547,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,
@@ -1558,6 +1562,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();
});
});
+20 -2
View File
@@ -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,
);
+68
View File
@@ -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 };
}
+17 -5
View File
@@ -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
+7
View File
@@ -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,
});
}
}
+21 -4
View File
@@ -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.",
),
@@ -16,7 +16,7 @@ import type { ComplianceDisplay } from "@/lib/analytics/compliance";
export interface MedicationComplianceSummaryEntry {
medicationId: string;
applicable: boolean;
notApplicableReason: "NO_LOCAL_SCHEDULE" | null;
notApplicableReason: "NO_LOCAL_SCHEDULE" | "INTAKE_NOT_TRACKED" | null;
compliance7: { rate: number; streak: number };
compliance30: { rate: number };
complianceDisplay: ComplianceDisplay | null;
+7 -1
View File
@@ -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" } },
+3
View File
@@ -330,6 +330,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. On a medication whose tracking is off, an update that does not name `trackIntake` leaves its stored schedule alone: `schedules`, `asNeeded` and `oneShot` in such a request are ignored (a client that does not know the field cannot have seen the stored schedule, which is served in `recordedSchedules`). Name `trackIntake` to change the schedule of such a medication.",
),
};
/**