From 1d1570733e06ca5a02adc1e0c9e73b0413020c50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 25 Sep 2026 10:03:08 +0200 Subject: [PATCH 1/6] fix(medications): leave record-only doses out of the nudge, the status model and the BP correlation Three readers compute a rate straight from intake rows without reading a schedule: the weekly compliance nudge, the adherence series behind the general status, and the BP-versus-compliance correlation. They now read tracked medications only. The structural guard covers intake-row readers as well as schedule readers, with a whitespace-tolerant matcher, a floor on the match count and a reviewed allowlist. --- .../medication-intake-tracking-guard.test.ts | 97 ++++++++++++++++++- src/lib/analytics/correlations-fast-path.ts | 9 +- src/lib/insights/general-status.ts | 3 + src/lib/jobs/coach-nudge.ts | 4 + 4 files changed, 111 insertions(+), 2 deletions(-) diff --git a/src/__tests__/medication-intake-tracking-guard.test.ts b/src/__tests__/medication-intake-tracking-guard.test.ts index 8d166dd6a..7691b4e7e 100644 --- a/src/__tests__/medication-intake-tracking-guard.test.ts +++ b/src/__tests__/medication-intake-tracking-guard.test.ts @@ -40,7 +40,50 @@ const SCHEDULE_READ = /** The vocabulary that honours the switch. */ const HONOURS = - /\b(?:TRACKED_INTAKE_WHERE|TRACKED_INTAKE_EVENT_WHERE|dueSchedules|scheduleWireFields|expectsDoses)\b/; + /\b(?:TRACKED_INTAKE_WHERE|TRACKED_INTAKE_EVENT_WHERE|dueSchedules|scheduleWireFields|expectsDoses|isRecordOnly)\b/; + +/** + * A bulk read of intake rows. Rate readers that never touch a schedule (a + * nudge that divides taken by resolved rows, a model series that buckets + * rows per day) count a record-only medication's rows unless they filter. + * Whitespace-tolerant: the delegate and the method may sit on two lines. + */ +const INTAKE_READ = + /\bmedicationIntakeEvent\s*\.\s*(?:findMany|groupBy|count|aggregate)\s*\(/; + +/** + * Intake readers that may see every medication's rows, and why: nothing in + * them is an adherence rate, an expected-dose count or a due dose. + */ +const INTAKE_MAY_SEE_EVERY_ROW: Record = { + "app/api/admin/status/route.ts": "instance row counts", + "app/api/dashboard/summary/route.ts": + "engagement streak of days with any logged action, no rate", + "app/api/export/medications/route.ts": "export of the recorded history", + "app/api/export/route.ts": "export of the recorded history", + "lib/export/full-backup-payload.ts": "backup of the recorded history", + "app/api/sync/state/route.ts": "sync watermarks, no rate", + "lib/jobs/measurement-tombstone-cleanup.ts": "tombstone purge", + "lib/ai/coach/tools/availability.ts": + "whether any intake history exists, and its date range", + "app/api/medications/[id]/intake/route.ts": + "one medication's recorded history, listed or written", + "app/api/medications/[id]/intake/bulk-delete/route.ts": + "deletes rows the person chose", + "app/api/medications/[id]/route.ts": + "one medication's slots; tombstones on the tracking switch", + "app/api/medications/intake/bulk/route.ts": "writes recorded doses", + "lib/medications/scheduling/slot-upsert.ts": + "binds a recorded dose to its slot", + "lib/medications/scheduling/worker-helpers.ts": + "slot lookups for one medication the caller already selected", + "lib/medications/inventory/consumption.ts": + "units consumed by a recorded dose", + "lib/medications/intake-slot-dedup.ts": + "folds duplicate rows of doses already recorded", + "lib/doctor-report/collect.ts": + "administration ledger lists recorded doses; rates gate via expectsDoses", +}; /** * Readers that may see every stored schedule row, and why. Each reason says @@ -168,3 +211,55 @@ describe("medication intake tracking — schedule readers honour the switch", () ).toBe(false); }); }); + +const intakeReaders = sourceFiles().filter((rel) => + INTAKE_READ.test(code(rel)), +); + +describe("medication intake tracking — intake-row readers honour the switch", () => { + it("finds the intake readers it exists to police", () => { + expect(intakeReaders.length).toBeGreaterThanOrEqual(30); + for (const known of [ + "lib/jobs/coach-nudge.ts", + "lib/insights/general-status.ts", + "lib/analytics/correlations-fast-path.ts", + "lib/medications/outstanding-doses.ts", + ]) { + expect(intakeReaders).toContain(known); + } + }); + + it("every intake reader uses the vocabulary or is allowlisted", () => { + const offenders = intakeReaders.filter( + (rel) => !(rel in INTAKE_MAY_SEE_EVERY_ROW) && !HONOURS.test(usage(rel)), + ); + expect( + offenders, + "These files read intake rows without honouring Medication.trackIntake. " + + "Filter with TRACKED_INTAKE_EVENT_WHERE (or read only the ids of " + + "medications filtered with TRACKED_INTAKE_WHERE), or add the file to " + + "INTAKE_MAY_SEE_EVERY_ROW with the reason it computes no rate.", + ).toEqual([]); + }); + + it("the intake allowlist names no file that stopped reading intake rows", () => { + expect( + Object.keys(INTAKE_MAY_SEE_EVERY_ROW).filter( + (rel) => !intakeReaders.includes(rel), + ), + ).toEqual([]); + }); + + it("the intake matcher catches the shapes it claims to", () => { + expect(INTAKE_READ.test("prisma.medicationIntakeEvent.findMany({")).toBe( + true, + ); + expect( + INTAKE_READ.test("prisma.medicationIntakeEvent\n .groupBy({"), + ).toBe(true); + expect(INTAKE_READ.test("tx.medicationIntakeEvent . count (")).toBe(true); + expect(INTAKE_READ.test("prisma.medicationIntakeEvent.create({")).toBe( + false, + ); + }); +}); diff --git a/src/lib/analytics/correlations-fast-path.ts b/src/lib/analytics/correlations-fast-path.ts index b2a412044..1779e4df9 100644 --- a/src/lib/analytics/correlations-fast-path.ts +++ b/src/lib/analytics/correlations-fast-path.ts @@ -65,6 +65,7 @@ import { isNearUtc, userDayKey } from "@/lib/tz/resolver"; import { wallClockInTz } from "@/lib/tz/wall-clock"; import { defaultLocale, type Locale } from "@/lib/i18n/config"; import { getServerTranslator } from "@/lib/i18n/server-translator"; +import { TRACKED_INTAKE_EVENT_WHERE } from "@/lib/medications/intake-tracking"; /** * v1.4.37 W2 — cold-path correlation window. Trim from 30 to 28 days @@ -279,7 +280,13 @@ export async function computeCorrelationHypothesesFastPath( }) as Promise, prisma.medicationIntakeEvent.findMany({ // v1.7.0 sync — exclude tombstoned rows. - where: { userId, deletedAt: null, scheduledFor: { gte: since } }, + where: { + userId, + deletedAt: null, + scheduledFor: { gte: since }, + // v1.39.1 (#1033) — compliance counts tracked medications only. + ...TRACKED_INTAKE_EVENT_WHERE, + }, select: { scheduledFor: true, takenAt: true, skipped: true }, }) as Promise, ]); diff --git a/src/lib/insights/general-status.ts b/src/lib/insights/general-status.ts index ed398a136..f506cae52 100644 --- a/src/lib/insights/general-status.ts +++ b/src/lib/insights/general-status.ts @@ -44,6 +44,7 @@ import { } from "@/lib/insights/status-card-generation"; import { annotate } from "@/lib/logging/context"; import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver"; +import { TRACKED_INTAKE_EVENT_WHERE } from "@/lib/medications/intake-tracking"; // Derived from canonical enum so a new measurement type is auto-included // in the AI general-status fetch (V3 audit: enum drift cousins). @@ -243,6 +244,8 @@ export async function prepareGeneralStatusForUser( userId, deletedAt: null, scheduledFor: { gte: intakeWindowStart }, + // v1.39.1 (#1033) — adherence counts tracked medications only. + ...TRACKED_INTAKE_EVENT_WHERE, }, orderBy: { scheduledFor: "asc" }, select: { diff --git a/src/lib/jobs/coach-nudge.ts b/src/lib/jobs/coach-nudge.ts index 52c8b698e..9b008ed54 100644 --- a/src/lib/jobs/coach-nudge.ts +++ b/src/lib/jobs/coach-nudge.ts @@ -111,6 +111,7 @@ import { DEFAULT_HEALTH_PROFILE_AI_SECTIONS, type HealthProfileAiSection, } from "@/lib/validations/health-profile-facts"; +import { TRACKED_INTAKE_EVENT_WHERE } from "@/lib/medications/intake-tracking"; const MS_PER_DAY = 24 * 60 * 60 * 1000; const SELF_CONTEXT_AI_SECTIONS = [ @@ -556,6 +557,9 @@ export async function findTriggerForUser( userId: user.id, deletedAt: null, scheduledFor: { gte: sevenDaysAgo, lte: now }, + // v1.39.1 (#1033) — a medication kept as a record expects no dose; + // its rows must not raise a compliance nudge. + ...TRACKED_INTAKE_EVENT_WHERE, }, select: { takenAt: true, skipped: true, autoMissed: true }, }); From 37c6b0e14465181b29f483be1bacfadb62981384 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 25 Sep 2026 10:03:08 +0200 Subject: [PATCH 2/6] fix(medications): never mint a slot from before the live schedule era The projector floored today's slots only on an empty archived era, the reminder worker and the next-due reads on the newest revision boundary, and the auto-miss pass on the creation instant alone. Switching tracking back on and then moving a dose time the same afternoon therefore put the morning dose on the card, and the next day's pass stamped it a miss; a same-day times edit on any medication did the same. One helper now names the live era start (the newest active revision's boundary) and every path floors on it: the projector, the worker, the doses card, the medication list, the MCP schedule tool and the auto-miss pass, which leaves a placeholder minted after the switch for a slot before it. --- src/lib/dashboard/meds-today.ts | 8 +-- src/lib/jobs/intake-auto-skip.ts | 29 ++++++++- .../reminder/medication-reminder-check.ts | 13 ++-- src/lib/mcp/tools.ts | 8 +-- src/lib/medications/list-read.ts | 8 +-- .../scheduling/__tests__/live-era.test.ts | 36 ++++++++++ src/lib/medications/scheduling/live-era.ts | 65 +++++++++++++++++++ .../scheduling/project-today-intakes.ts | 36 ++++------ 8 files changed, 154 insertions(+), 49 deletions(-) create mode 100644 src/lib/medications/scheduling/__tests__/live-era.test.ts create mode 100644 src/lib/medications/scheduling/live-era.ts diff --git a/src/lib/dashboard/meds-today.ts b/src/lib/dashboard/meds-today.ts index 4a15cc486..7cefb726d 100644 --- a/src/lib/dashboard/meds-today.ts +++ b/src/lib/dashboard/meds-today.ts @@ -47,6 +47,7 @@ import { TRACKED_INTAKE_EVENT_WHERE, TRACKED_INTAKE_WHERE, } from "@/lib/medications/intake-tracking"; +import { liveEraStartsByMedication } from "@/lib/medications/scheduling/live-era"; export interface MedsTodayDueCandidate { medicationId: string; @@ -198,11 +199,8 @@ export async function buildMedsTodayBlock( if (list) list.push(mark); else resolvedSlotsByMedId.set(e.medicationId, [mark]); } - const eraStartByMedId = new Map(); - for (const f of eraFloors) { - if (f._max.validUntil) - eraStartByMedId.set(f.medicationId, f._max.validUntil); - } + // The live era start per medication (see `live-era.ts`). + const eraStartByMedId = liveEraStartsByMedication(eraFloors); const dueCandidates: MedsTodayDueCandidate[] = []; for (const m of medications) { diff --git a/src/lib/jobs/intake-auto-skip.ts b/src/lib/jobs/intake-auto-skip.ts index 18bec6cee..fbbad4e86 100644 --- a/src/lib/jobs/intake-auto-skip.ts +++ b/src/lib/jobs/intake-auto-skip.ts @@ -67,6 +67,10 @@ 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"; +import { + LIVE_ERA_REVISION_ARGS, + liveEraStart, +} from "@/lib/medications/scheduling/live-era"; export const INTAKE_AUTO_SKIP_QUEUE = "intake-auto-skip"; @@ -249,16 +253,24 @@ export async function runIntakeAutoSkipPass( schedules: { select: { rrule: true, rollingIntervalDays: true, doseWindows: true }, }, + scheduleRevisions: LIVE_ERA_REVISION_ARGS, }, }); // Group medications by their derived delay so one `updateMany` covers // each distinct cutoff instead of one query per medication. - const medsByDelay = new Map>(); + const medsByDelay = new Map< + number, + Array<{ id: string; createdAt: Date; eraStart: Date | null }> + >(); for (const medication of medications) { const delayMs = medicationAutoMissDelayMs(medication.schedules); const group = medsByDelay.get(delayMs) ?? []; - group.push({ id: medication.id, createdAt: medication.createdAt }); + group.push({ + id: medication.id, + createdAt: medication.createdAt, + eraStart: liveEraStart(medication.scheduleRevisions), + }); medsByDelay.set(delayMs, group); } @@ -272,9 +284,22 @@ export async function runIntakeAutoSkipPass( scheduledFor: { lt: new Date(nowMs - delayMs) }, // Per medication, only slots from its creation on: a placeholder // for a slot before it was never an expected dose (see above). + // + // And only slots the schedule in force at the time expected: a + // placeholder anchored before the live era start but minted after it + // came from the NEW schedule projected back over the part of the + // day that belonged to the previous era (or to none, while intake + // tracking was off). Earlier placeholders minted before the switch + // were real expectations of the old era and still resolve. OR: group.map((m) => ({ medicationId: m.id, scheduledFor: { gte: m.createdAt }, + ...(m.eraStart && { + NOT: { + scheduledFor: { lt: m.eraStart }, + createdAt: { gte: m.eraStart }, + }, + }), })), }, // `syncVersion` bumps so delta-sync clients pick up the terminal diff --git a/src/lib/jobs/reminder/medication-reminder-check.ts b/src/lib/jobs/reminder/medication-reminder-check.ts index 40ae77f48..fd46f6a15 100644 --- a/src/lib/jobs/reminder/medication-reminder-check.ts +++ b/src/lib/jobs/reminder/medication-reminder-check.ts @@ -40,6 +40,10 @@ import { import { getUserTodayBounds as getUserTodayBoundsUtil } from "@/lib/tz/local-day"; import { getWorkerPrisma, parseTimeToMinutes } from "./shared"; import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking"; +import { + LIVE_ERA_REVISION_ARGS, + liveEraStart as liveEraStartOf, +} from "@/lib/medications/scheduling/live-era"; export interface ReminderCheckPayload { triggeredAt: string; @@ -174,12 +178,7 @@ export async function handleReminderCheck( include: { schedules: true, phaseConfig: true, - scheduleRevisions: { - where: { supersededByRevisionId: null }, - orderBy: { validUntil: "desc" }, - take: 1, - select: { validUntil: true }, - }, + scheduleRevisions: LIVE_ERA_REVISION_ARGS, user: { select: { id: true, @@ -249,7 +248,7 @@ export async function handleReminderCheck( // skips and USER_PIN decisions are mutations, whose Prisma @updatedAt // timestamp records the decision. Ordinary takes use their takenAt. const liveEraStart = - med.scheduleRevisions?.[0]?.validUntil.getTime() ?? null; + liveEraStartOf(med.scheduleRevisions)?.getTime() ?? null; const liveEraEvents = liveEraStart === null ? todayEvents diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts index 6a01484b6..bc5c80366 100644 --- a/src/lib/mcp/tools.ts +++ b/src/lib/mcp/tools.ts @@ -73,6 +73,7 @@ import { listTargetsBySource } from "@/lib/links"; import { encounterKindEnum } from "@/lib/validations/encounters"; import type { McpAuthContext } from "./auth"; import { dueSchedules } from "@/lib/medications/intake-tracking"; +import { liveEraStartsByMedication } from "@/lib/medications/scheduling/live-era"; /** * Tool annotations (MCP 2025-11-25). The cloud connectors REQUIRE these on every @@ -1532,11 +1533,8 @@ export const MCP_TOOLS: McpToolDefinition[] = [ if (list) list.push(mark); else resolvedSlotsByMedId.set(e.medicationId, [mark]); } - const eraStartByMedId = new Map(); - for (const f of eraFloors) { - if (f._max.validUntil) - eraStartByMedId.set(f.medicationId, f._max.validUntil); - } + // The live era start per medication (see `live-era.ts`). + const eraStartByMedId = liveEraStartsByMedication(eraFloors); const rows = medications.map((m) => { const display = computeDisplayDue({ diff --git a/src/lib/medications/list-read.ts b/src/lib/medications/list-read.ts index 65023b81d..0556502e1 100644 --- a/src/lib/medications/list-read.ts +++ b/src/lib/medications/list-read.ts @@ -33,6 +33,7 @@ import { isRecordOnly, scheduleWireFields, } from "@/lib/medications/intake-tracking"; +import { liveEraStartsByMedication } from "@/lib/medications/scheduling/live-era"; export type MedicationsListResult = Array>; @@ -164,11 +165,8 @@ export async function buildMedicationsList( else resolvedSlotsByMedId.set(e.medicationId, [mark]); } - const eraStartByMedId = new Map(); - for (const f of eraFloors) { - if (f._max.validUntil) - eraStartByMedId.set(f.medicationId, f._max.validUntil); - } + // The live era start per medication (see `live-era.ts`). + const eraStartByMedId = liveEraStartsByMedication(eraFloors); const lastTakenAtByMedicationId = Object.fromEntries( latestIntakes.map((entry) => [ diff --git a/src/lib/medications/scheduling/__tests__/live-era.test.ts b/src/lib/medications/scheduling/__tests__/live-era.test.ts new file mode 100644 index 000000000..0362674dc --- /dev/null +++ b/src/lib/medications/scheduling/__tests__/live-era.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; + +import { + isBeforeLiveEra, + liveEraStart, + liveEraStartsByMedication, +} from "@/lib/medications/scheduling/live-era"; + +const d = (iso: string) => new Date(iso); + +describe("live era start", () => { + it("is null without a revision", () => { + expect(liveEraStart(undefined)).toBeNull(); + expect(liveEraStart([])).toBeNull(); + expect(isBeforeLiveEra(d("2026-06-10T06:00:00Z"), null)).toBe(false); + }); + + it("is the newest revision boundary, and floors earlier slots", () => { + const start = liveEraStart([ + { validUntil: d("2026-06-01T00:00:00Z") }, + { validUntil: d("2026-06-10T12:30:00Z") }, + ]); + expect(start?.toISOString()).toBe("2026-06-10T12:30:00.000Z"); + expect(isBeforeLiveEra(d("2026-06-10T06:00:00Z"), start)).toBe(true); + expect(isBeforeLiveEra(d("2026-06-10T12:30:00Z"), start)).toBe(false); + expect(isBeforeLiveEra(d("2026-06-11T06:00:00Z"), start)).toBe(false); + }); + + it("maps a groupBy of active revisions", () => { + const map = liveEraStartsByMedication([ + { medicationId: "a", _max: { validUntil: d("2026-06-10T12:30:00Z") } }, + { medicationId: "b", _max: { validUntil: null } }, + ]); + expect([...map.keys()]).toEqual(["a"]); + }); +}); diff --git a/src/lib/medications/scheduling/live-era.ts b/src/lib/medications/scheduling/live-era.ts new file mode 100644 index 000000000..4c98b446d --- /dev/null +++ b/src/lib/medications/scheduling/live-era.ts @@ -0,0 +1,65 @@ +/** + * The start of a medication's LIVE schedule era: the instant from which its + * current `MedicationSchedule` rows are in force. + * + * A schedule replace, or switching intake tracking off or back on, archives + * the previous era as a `MedicationScheduleRevision` ending "now". The newest + * active revision's `validUntil` is therefore where the live rows began. A + * slot the live rows would produce BEFORE that instant was never expected: + * the day's earlier doses belonged to the previous era (or to no era at all, + * while tracking was off). Minting it would put a dose on the card that the + * person could not have taken on this schedule, and the auto-miss pass would + * later stamp it a miss. + * + * Every path that mints or displays a slot from the live rows floors on this + * one instant: the today projector, the reminder worker, the next-due read + * (doses card, medication list, MCP) and the auto-miss pass. `null` means the + * medication has never had a schedule revision: the live rows have been in + * force since it was created, and the callers' own creation handling applies. + */ + +/** + * Prisma relation args selecting the newest ACTIVE revision's boundary. + * Superseded revisions are audit records whose `validUntil` may sit past a + * later correction, so only active rows count. + */ +export const LIVE_ERA_REVISION_ARGS = { + where: { supersededByRevisionId: null }, + orderBy: { validUntil: "desc" }, + take: 1, + select: { validUntil: true }, +} as const; + +/** The live era start from revisions read with {@link LIVE_ERA_REVISION_ARGS}. */ +export function liveEraStart( + revisions: readonly { validUntil: Date }[] | null | undefined, +): Date | null { + if (!revisions || revisions.length === 0) return null; + let newest = revisions[0].validUntil; + for (const r of revisions) { + if (r.validUntil.getTime() > newest.getTime()) newest = r.validUntil; + } + return newest; +} + +/** + * Live era starts per medication from a `medicationScheduleRevision.groupBy` + * over active revisions with `_max: { validUntil: true }`. + */ +export function liveEraStartsByMedication( + rows: readonly { + medicationId: string; + _max: { validUntil: Date | null }; + }[], +): Map { + const out = new Map(); + for (const row of rows) { + if (row._max.validUntil) out.set(row.medicationId, row._max.validUntil); + } + return out; +} + +/** True when a slot anchored at `slot` predates the live era. */ +export function isBeforeLiveEra(slot: Date, eraStart: Date | null): boolean { + return eraStart !== null && slot.getTime() < eraStart.getTime(); +} diff --git a/src/lib/medications/scheduling/project-today-intakes.ts b/src/lib/medications/scheduling/project-today-intakes.ts index 976f7f107..940162cc9 100644 --- a/src/lib/medications/scheduling/project-today-intakes.ts +++ b/src/lib/medications/scheduling/project-today-intakes.ts @@ -37,6 +37,11 @@ import { import { localHmAsUtc } from "@/lib/tz/local-day"; import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking"; +import { + isBeforeLiveEra, + LIVE_ERA_REVISION_ARGS, + liveEraStart, +} from "@/lib/medications/scheduling/live-era"; export interface ProjectTodayIntakesResult { projected: number; @@ -61,18 +66,10 @@ export async function projectTodayIntakesAndRecompute(input: { 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 }, - }, + // The live era start: no slot anchored before it is projected. Turning + // tracking back on at 15:00, or moving a dose time at 14:30, therefore + // never puts a dose from earlier that day on the card. + scheduleRevisions: LIVE_ERA_REVISION_ARGS, schedules: { select: { id: true, @@ -112,13 +109,7 @@ 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; + const eraStart = liveEraStart(med.scheduleRevisions); // Rolling cadence anchors off the last logged intake. One findFirst // per medication, scoped to `takenAt IS NOT NULL` — byte-identical to @@ -172,12 +163,7 @@ export async function projectTodayIntakesAndRecompute(input: { 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; - } + if (isBeforeLiveEra(scheduledFor, eraStart)) continue; projected.push({ medicationId: schedule.medicationId, scheduledFor, From 0e242cfea1b94509ba747b7b2389379559db1e15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 25 Sep 2026 10:03:09 +0200 Subject: [PATCH 3/6] fix(telegram): do not offer or record doses of a record-only medication --- .../telegram/webhook/__tests__/route.test.ts | 42 +++++++++++++++++++ src/lib/telegram-webhook-handlers.ts | 25 ++++++++--- 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/src/app/api/telegram/webhook/__tests__/route.test.ts b/src/app/api/telegram/webhook/__tests__/route.test.ts index d9a1a364b..6b41c470b 100644 --- a/src/app/api/telegram/webhook/__tests__/route.test.ts +++ b/src/app/api/telegram/webhook/__tests__/route.test.ts @@ -1181,3 +1181,45 @@ describe("Telegram webhook — GET verification is rate-limited first", () => { ); }); }); + +/** + * #1033 — a medication kept as a record is never offered, recorded or snoozed + * from Telegram, the same as on the web. + */ +describe("POST /api/telegram/webhook — record-only medications", () => { + function textUpdate(text: string) { + return { + update_id: 300, + message: { message_id: 30, text, chat: { id: 7777 }, from: { id: 7777 } }, + }; + } + + function whereOf(call: unknown): Record { + return (call as [{ where: Record }])[0].where; + } + + it("/add lists tracked medications only", async () => { + await POST(tgRequest(textUpdate("/add"))); + const where = whereOf(vi.mocked(prisma.medication.findMany).mock.calls[0]); + expect(where).toMatchObject({ active: true, trackIntake: true }); + }); + + it("the 'taken' keyword resolves tracked medications only", async () => { + await POST(tgRequest(textUpdate("taken Ramipril"))); + const where = whereOf(vi.mocked(prisma.medication.findFirst).mock.calls[0]); + expect(where).toMatchObject({ active: true, trackIntake: true }); + }); + + it("a reminder button on a record-only medication records nothing", async () => { + await POST(tgRequest(callbackUpdate("taken:med-1"))); + // The gate lookup: a record-only medication resolves to nothing here, + // so no dose is written (later lookups only bind an admitted dose). + const calls = vi.mocked(prisma.medication.findFirst).mock.calls; + expect(calls.length).toBeGreaterThan(0); + expect(whereOf(calls[0])).toMatchObject({ + id: "med-1", + active: true, + trackIntake: true, + }); + }); +}); diff --git a/src/lib/telegram-webhook-handlers.ts b/src/lib/telegram-webhook-handlers.ts index f458bf6cc..474d54dec 100644 --- a/src/lib/telegram-webhook-handlers.ts +++ b/src/lib/telegram-webhook-handlers.ts @@ -33,6 +33,10 @@ import { isTelegramCapturableType, logTelegramMeasurement, } from "@/lib/measurements/create-from-telegram"; +// v1.39.1 (#1033) — every Telegram path that offers, records or snoozes a +// dose (the /add list, the "taken" keyword, the reminder buttons) leaves a +// medication kept as a record out, as the web and the today list do. +import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking"; /** * Telegram webhook update handlers, split out of @@ -301,7 +305,7 @@ async function markMedicationTaken( ): Promise<{ ok: boolean; message: string; medicationName?: string }> { const { t } = getServerTranslator(locale); const medication = await prisma.medication.findFirst({ - where: { id: medicationId, userId, active: true }, + where: { id: medicationId, userId, active: true, ...TRACKED_INTAKE_WHERE }, select: { id: true, name: true }, }); if (!medication) { @@ -518,7 +522,12 @@ export async function handleCallback(update: TelegramUpdate) { } const medication = await prisma.medication.findFirst({ - where: { id: medicationId, userId: user.id, active: true }, + where: { + id: medicationId, + userId: user.id, + active: true, + ...TRACKED_INTAKE_WHERE, + }, select: { id: true, name: true }, }); if (!medication) { @@ -560,7 +569,12 @@ export async function handleCallback(update: TelegramUpdate) { } const medication = await prisma.medication.findFirst({ - where: { id: medicationId, userId: user.id, active: true }, + where: { + id: medicationId, + userId: user.id, + active: true, + ...TRACKED_INTAKE_WHERE, + }, select: { id: true, name: true }, }); if (!medication) { @@ -1189,7 +1203,7 @@ export async function handleTextMessage(update: TelegramUpdate) { if (/^\/add\b/i.test(text)) { const userMsgId = message?.message_id; const meds = await prisma.medication.findMany({ - where: { userId: user.id, active: true }, + where: { userId: user.id, active: true, ...TRACKED_INTAKE_WHERE }, select: { id: true, name: true, dose: true }, orderBy: { name: "asc" }, }); @@ -1297,6 +1311,7 @@ export async function handleTextMessage(update: TelegramUpdate) { where: { userId: user.id, active: true, + ...TRACKED_INTAKE_WHERE, name: { equals: nameInput, mode: "insensitive" }, }, select: { id: true }, @@ -1304,7 +1319,7 @@ export async function handleTextMessage(update: TelegramUpdate) { medicationId = med?.id ?? null; } else { const meds = await prisma.medication.findMany({ - where: { userId: user.id, active: true }, + where: { userId: user.id, active: true, ...TRACKED_INTAKE_WHERE }, select: { id: true, name: true }, orderBy: { name: "asc" }, take: 2, From ff3ef9147c51086a62fa2939c77c4112f10d15bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 25 Sep 2026 10:03:09 +0200 Subject: [PATCH 4/6] fix(coach): give no supply span for a GLP-1 medication kept as a record --- .../ai/coach/__tests__/glp1-snapshot.test.ts | 17 +++++++++++++++++ src/lib/ai/coach/glp1-snapshot.ts | 4 ++++ 2 files changed, 21 insertions(+) diff --git a/src/lib/ai/coach/__tests__/glp1-snapshot.test.ts b/src/lib/ai/coach/__tests__/glp1-snapshot.test.ts index 1d93cc3c8..51a1485b5 100644 --- a/src/lib/ai/coach/__tests__/glp1-snapshot.test.ts +++ b/src/lib/ai/coach/__tests__/glp1-snapshot.test.ts @@ -158,6 +158,23 @@ describe("buildGlp1SnapshotBlock", () => { }); }); + it("keeps the pen count but no supply span when intake is not tracked (#1033)", async () => { + prismaMock.medication.findMany.mockResolvedValue([ + fakeMedication({ + trackIntake: false, + unitsPerDose: 1, + inventoryItems: [{ state: "ACTIVE", unitsTotal: 4, unitsRemaining: 4 }], + }), + ]); + const out = await buildGlp1SnapshotBlock("user-1"); + expect(out?.medications[0].penInventory).toEqual({ + pensRemaining: 1, + dosesRemaining: 4, + weeksOfSupplyApprox: null, + }); + expect(out?.medications[0].nextInjection).toBeNull(); + }); + // W1 — ledger-only accounts (the pre-item delta writer) keep their // pen count in the Coach prompt instead of a silent omission. it("falls back to the legacy ledger when the medication has zero items", async () => { diff --git a/src/lib/ai/coach/glp1-snapshot.ts b/src/lib/ai/coach/glp1-snapshot.ts index a36c48d36..c452eaa8e 100644 --- a/src/lib/ai/coach/glp1-snapshot.ts +++ b/src/lib/ai/coach/glp1-snapshot.ts @@ -385,6 +385,10 @@ export async function buildGlp1SnapshotBlock( weeksOfSupplyApprox = Math.round(dosesRemaining / dosesPerWeek); } + // v1.39.1 (#1033) — with intake tracking off no dose consumes the + // stock, so a supply span derived from the cadence would be invented. + if (isRecordOnly(med)) weeksOfSupplyApprox = null; + const penInventory: PenInventory | null = pensRemaining !== null ? { pensRemaining, dosesRemaining, weeksOfSupplyApprox } From 367bc3fca574e9d3e7b03f7002152d435d00af3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 25 Sep 2026 10:03:10 +0200 Subject: [PATCH 5/6] fix(onboarding): say what happens after adding an as-needed or record-only medication --- messages/de.json | 4 +++- messages/en.json | 4 +++- messages/es.json | 4 +++- messages/fr.json | 4 +++- messages/it.json | 4 +++- messages/ko.json | 4 +++- messages/pl.json | 4 +++- .../onboarding/first-result-screen.tsx | 18 +++++++++++++----- 8 files changed, 34 insertions(+), 12 deletions(-) diff --git a/messages/de.json b/messages/de.json index e4e72dbce..91be96968 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4776,7 +4776,9 @@ "done": "{name} ist angelegt.", "doneUnnamed": "Dein erstes Medikament ist angelegt.", "nextDose": "Nächste Dosis: {when}.", - "reminders": "Erinnerungen folgen dem Plan, den du festgelegt hast." + "reminders": "Erinnerungen folgen dem Plan, den du festgelegt hast.", + "recordOnly": "Nur dokumentiert: keine Erinnerungen, nichts wird fällig.", + "asNeeded": "Bei Bedarf: keine Erinnerungen, erfasse eine Dosis, wenn du sie nimmst." }, "log-reading": { "title": "Einen Wert eintragen: {target}", diff --git a/messages/en.json b/messages/en.json index 0c340894f..8080fdeca 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4776,7 +4776,9 @@ "done": "{name} is added.", "doneUnnamed": "Your first medication is added.", "nextDose": "Next dose: {when}.", - "reminders": "Reminders follow the schedule you set." + "reminders": "Reminders follow the schedule you set.", + "recordOnly": "Kept as a record: no reminders, nothing falls due.", + "asNeeded": "As needed: no reminders, log a dose when you take it." }, "log-reading": { "title": "Enter one reading: {target}", diff --git a/messages/es.json b/messages/es.json index e9d0ca45c..634426433 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4776,7 +4776,9 @@ "done": "{name} está añadido.", "doneUnnamed": "Tu primer medicamento está añadido.", "nextDose": "Próxima dosis: {when}.", - "reminders": "Los recordatorios siguen el horario que fijaste." + "reminders": "Los recordatorios siguen el horario que fijaste.", + "recordOnly": "Solo registro: sin recordatorios, nada queda pendiente.", + "asNeeded": "Según necesidad: sin recordatorios, registra cada toma cuando la hagas." }, "log-reading": { "title": "Registra una medición: {target}", diff --git a/messages/fr.json b/messages/fr.json index 5842e22d8..a484a7842 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4776,7 +4776,9 @@ "done": "{name} est ajouté.", "doneUnnamed": "Votre premier médicament est ajouté.", "nextDose": "Prochaine prise : {when}.", - "reminders": "Les rappels suivent l'horaire que vous avez défini." + "reminders": "Les rappels suivent l'horaire que vous avez défini.", + "recordOnly": "Conservé pour mémoire : pas de rappel, rien n’est dû.", + "asNeeded": "Au besoin : pas de rappel, notez une prise quand vous la prenez." }, "log-reading": { "title": "Saisir une mesure : {target}", diff --git a/messages/it.json b/messages/it.json index 0e0cd8098..bb4930b22 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4776,7 +4776,9 @@ "done": "{name} è stato aggiunto.", "doneUnnamed": "Il tuo primo farmaco è stato aggiunto.", "nextDose": "Prossima dose: {when}.", - "reminders": "I promemoria seguono l'orario che hai impostato." + "reminders": "I promemoria seguono l'orario che hai impostato.", + "recordOnly": "Solo annotato: niente promemoria, nulla è dovuto.", + "asNeeded": "Al bisogno: niente promemoria, registra una dose quando la prendi." }, "log-reading": { "title": "Inserisci una misurazione: {target}", diff --git a/messages/ko.json b/messages/ko.json index 9e0246818..05d21b73c 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -4776,7 +4776,9 @@ "done": "{name}이(가) 추가되었습니다.", "doneUnnamed": "첫 약이 추가되었습니다.", "nextDose": "다음 복용: {when}.", - "reminders": "알림은 설정한 일정을 따릅니다." + "reminders": "알림은 설정한 일정을 따릅니다.", + "recordOnly": "기록으로만 남겨요: 알림도, 예정된 복용도 없어요.", + "asNeeded": "필요시: 알림 없이, 복용할 때 기록하세요." }, "log-reading": { "title": "측정값 하나 입력: {target}", diff --git a/messages/pl.json b/messages/pl.json index 188ac08d9..738e81890 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4776,7 +4776,9 @@ "done": "{name} został dodany.", "doneUnnamed": "Twój pierwszy lek został dodany.", "nextDose": "Następna dawka: {when}.", - "reminders": "Przypomnienia podążają za ustawionym harmonogramem." + "reminders": "Przypomnienia podążają za ustawionym harmonogramem.", + "recordOnly": "Tylko zapis: bez przypomnień, nic nie przypada.", + "asNeeded": "W razie potrzeby: bez przypomnień, zapisz dawkę, gdy ją przyjmiesz." }, "log-reading": { "title": "Wpisz jeden pomiar: {target}", diff --git a/src/components/onboarding/first-result-screen.tsx b/src/components/onboarding/first-result-screen.tsx index 069d3d0d7..948ba61c0 100644 --- a/src/components/onboarding/first-result-screen.tsx +++ b/src/components/onboarding/first-result-screen.tsx @@ -384,6 +384,10 @@ function ConnectNoticeTile({ interface MedicationDetail { name: string; nextDueAt: string | null; + /** v1.16.11 — as-needed: never reminded. */ + asNeeded?: boolean; + /** v1.39.1 (#1033) — false: kept as a record, never reminded. */ + trackIntake?: boolean; } function AddMedicationTask({ @@ -418,11 +422,15 @@ function AddMedicationTask({ : t("onboarding.flow.first-result.add-medication.doneUnnamed") } detail={ - detail.data?.nextDueAt - ? t("onboarding.flow.first-result.add-medication.nextDose", { - when: fmt.dateTime(detail.data.nextDueAt), - }) - : t("onboarding.flow.first-result.add-medication.reminders") + detail.data?.trackIntake === false + ? t("onboarding.flow.first-result.add-medication.recordOnly") + : detail.data?.asNeeded + ? t("onboarding.flow.first-result.add-medication.asNeeded") + : detail.data?.nextDueAt + ? t("onboarding.flow.first-result.add-medication.nextDose", { + when: fmt.dateTime(detail.data.nextDueAt), + }) + : t("onboarding.flow.first-result.add-medication.reminders") } /> ); From e1478a884dc2139f1897953f4441a9d5e2cd7840 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 25 Sep 2026 10:31:22 +0200 Subject: [PATCH 6/6] test(medications): pin the live-era floor, the nudge and the same-day edit cases --- .../medication-intake-tracking.test.ts | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) diff --git a/tests/integration/medication-intake-tracking.test.ts b/tests/integration/medication-intake-tracking.test.ts index 1d40a1ef7..68ab45991 100644 --- a/tests/integration/medication-intake-tracking.test.ts +++ b/tests/integration/medication-intake-tracking.test.ts @@ -439,3 +439,173 @@ describe("the shipped iPhone app editing a record-only medication", () => { ).toBe(0); }); }); + +describe("rate readers that never read a schedule", () => { + it("a week of misses on a record-only medication raises no compliance nudge", async () => { + const prisma = getPrismaClient(); + const userId = await makeUser(); + const record = await seedDaily(userId, "Record", false); + at("2026-06-10T04:00:00.000Z"); + // Seven auto-missed slots left over from before tracking went off. + for (let d = 1; d <= 7; d++) { + await prisma.medicationIntakeEvent.create({ + data: { + userId, + medicationId: record, + scheduledFor: new Date(Date.UTC(2026, 5, 10 - d, 6, 0)), + autoMissed: true, + source: "REMINDER", + }, + }); + } + const { findTriggerForUser } = await import("@/lib/jobs/coach-nudge"); + const user = { + id: userId, + heightCm: null, + dateOfBirth: null, + gender: null, + thresholdsJson: null, + timezone: TZ, + }; + const groups = { medication: true, vitals: false, routine: false }; + expect( + await findTriggerForUser(prisma, user, new Date(), groups), + ).toBeNull(); + + // The same rows on a tracked medication do nudge. + await prisma.medication.update({ + where: { id: record }, + data: { trackIntake: true }, + }); + expect(await findTriggerForUser(prisma, user, new Date(), groups)).toBe( + "compliance", + ); + }); +}); + +describe("the live era floors every slot minted from the live schedule", () => { + const route = () => import("@/app/api/medications/[id]/route"); + + async function putSchedule(medicationId: string, time: string) { + const { PUT } = await route(); + return call( + PUT as Handler, + `/api/medications/${medicationId}`, + { id: medicationId }, + { + method: "PUT", + body: { + trackIntake: true, + schedules: [ + { + windowStart: time, + windowEnd: time, + timesOfDay: [time], + rrule: "FREQ=DAILY", + }, + ], + }, + }, + ); + } + + async function pendingFor(medicationId: string) { + return getPrismaClient().medicationIntakeEvent.findMany({ + where: { medicationId, deletedAt: null, takenAt: null, skipped: false }, + select: { scheduledFor: true, autoMissed: true }, + }); + } + + it("tracking back on, then a times edit the same afternoon, mints no earlier slot", async () => { + const prisma = getPrismaClient(); + const userId = await makeUser(); + const medicationId = await seedDaily(userId, "Statin", false); + const { PUT } = await route(); + + at("2026-06-10T12:00:00.000Z"); // 14:00 local: tracking back on + const on = await call( + PUT as Handler, + `/api/medications/${medicationId}`, + { id: medicationId }, + { method: "PUT", body: { trackIntake: true } }, + ); + expect(on.status).toBe(200); + + at("2026-06-10T12:30:00.000Z"); // 14:30 local: 08:00 moves to 09:00 + expect((await putSchedule(medicationId, "09:00")).status).toBe(200); + + await buildMedsTodayBlock(prisma, userId, TZ, new Date()); + expect(await pendingFor(medicationId)).toEqual([]); + + // Next morning's 09:00 is a real dose again; the next day's pass stamps + // nothing from the day of the switch. + at("2026-06-11T22:00:00.000Z"); + await runIntakeAutoSkipPass(prisma, { nowMs: Date.now() }); + expect( + await prisma.medicationIntakeEvent.count({ + where: { medicationId, autoMissed: true }, + }), + ).toBe(0); + }); + + it("a same-day times edit on a tracked medication mints no earlier slot", async () => { + const prisma = getPrismaClient(); + const userId = await makeUser(); + const medicationId = await seedDaily(userId, "Ramipril", true); + + at("2026-06-10T12:30:00.000Z"); // 14:30 local: 08:00 moves to 09:00 + expect((await putSchedule(medicationId, "09:00")).status).toBe(200); + const block = await buildMedsTodayBlock(prisma, userId, TZ, new Date()); + expect(block.scheduledToday).toBe(0); + expect((block.dueCandidates ?? []).every((c) => !c.overdue)).toBe(true); + expect(await pendingFor(medicationId)).toEqual([]); + + // The worker does not remind the 09:00 slot either. + await handleReminderCheck([]); + expect(dispatchedFor(medicationId)).toBe(0); + }); + + it("auto-miss leaves a placeholder minted after the switch for a slot before it", async () => { + const prisma = getPrismaClient(); + const userId = await makeUser(); + const medicationId = await seedDaily(userId, "Metformin", true); + const eraStart = new Date("2026-06-10T12:30:00.000Z"); + await prisma.medicationScheduleRevision.create({ + data: { + medicationId, + validFrom: new Date("2026-06-01T00:00:00.000Z"), + validUntil: eraStart, + payload: [], + }, + }); + // A real expectation of the old era, minted that morning. + await prisma.medicationIntakeEvent.create({ + data: { + userId, + medicationId, + scheduledFor: new Date("2026-06-09T06:00:00.000Z"), + source: "REMINDER", + createdAt: new Date("2026-06-09T04:00:00.000Z"), + }, + }); + // A phantom: minted after the switch for a slot before it. + await prisma.medicationIntakeEvent.create({ + data: { + userId, + medicationId, + scheduledFor: new Date("2026-06-10T07:00:00.000Z"), + source: "REMINDER", + createdAt: new Date("2026-06-10T13:00:00.000Z"), + }, + }); + + at("2026-06-12T12:00:00.000Z"); + await runIntakeAutoSkipPass(prisma, { nowMs: Date.now() }); + const rows = await prisma.medicationIntakeEvent.findMany({ + where: { medicationId }, + orderBy: { scheduledFor: "asc" }, + select: { autoMissed: true }, + }); + expect(rows.map((r) => r.autoMissed)).toEqual([true, false]); + }); +});