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

This commit is contained in:
Marc-André Bombeck committed 2026-09-25 10:32:11 +02:00
commit 189edc728e
25 files changed
+552 -68

No files matched your search

+3 -1
View File
@@ -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}",
+3 -1
View File
@@ -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}",
+3 -1
View File
@@ -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}",
+3 -1
View File
@@ -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}",
+3 -1
View File
@@ -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}",
+3 -1
View File
@@ -4776,7 +4776,9 @@
"done": "{name}이(가) 추가되었습니다.",
"doneUnnamed": "첫 약이 추가되었습니다.",
"nextDose": "다음 복용: {when}.",
"reminders": "알림은 설정한 일정을 따릅니다."
"reminders": "알림은 설정한 일정을 따릅니다.",
"recordOnly": "기록으로만 남겨요: 알림도, 예정된 복용도 없어요.",
"asNeeded": "필요시: 알림 없이, 복용할 때 기록하세요."
},
"log-reading": {
"title": "측정값 하나 입력: {target}",
+3 -1
View File
@@ -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}",
@@ -40,7 +40,50 @@ const SCHEDULE_READ =
/** The vocabulary that honours the switch. */
const HONOURS =
/\b(?:TRACKED_INTAKE_WHERE|TRACKED_INTAKE_EVENT_WHERE|dueSchedules|scheduleWireFields|expectsDoses)\b/;
/\b(?:TRACKED_INTAKE_WHERE|TRACKED_INTAKE_EVENT_WHERE|dueSchedules|scheduleWireFields|expectsDoses|isRecordOnly)\b/;
/**
* A bulk read of intake rows. Rate readers that never touch a schedule (a
* nudge that divides taken by resolved rows, a model series that buckets
* rows per day) count a record-only medication's rows unless they filter.
* Whitespace-tolerant: the delegate and the method may sit on two lines.
*/
const INTAKE_READ =
/\bmedicationIntakeEvent\s*\.\s*(?:findMany|groupBy|count|aggregate)\s*\(/;
/**
* Intake readers that may see every medication's rows, and why: nothing in
* them is an adherence rate, an expected-dose count or a due dose.
*/
const INTAKE_MAY_SEE_EVERY_ROW: Record<string, string> = {
"app/api/admin/status/route.ts": "instance row counts",
"app/api/dashboard/summary/route.ts":
"engagement streak of days with any logged action, no rate",
"app/api/export/medications/route.ts": "export of the recorded history",
"app/api/export/route.ts": "export of the recorded history",
"lib/export/full-backup-payload.ts": "backup of the recorded history",
"app/api/sync/state/route.ts": "sync watermarks, no rate",
"lib/jobs/measurement-tombstone-cleanup.ts": "tombstone purge",
"lib/ai/coach/tools/availability.ts":
"whether any intake history exists, and its date range",
"app/api/medications/[id]/intake/route.ts":
"one medication's recorded history, listed or written",
"app/api/medications/[id]/intake/bulk-delete/route.ts":
"deletes rows the person chose",
"app/api/medications/[id]/route.ts":
"one medication's slots; tombstones on the tracking switch",
"app/api/medications/intake/bulk/route.ts": "writes recorded doses",
"lib/medications/scheduling/slot-upsert.ts":
"binds a recorded dose to its slot",
"lib/medications/scheduling/worker-helpers.ts":
"slot lookups for one medication the caller already selected",
"lib/medications/inventory/consumption.ts":
"units consumed by a recorded dose",
"lib/medications/intake-slot-dedup.ts":
"folds duplicate rows of doses already recorded",
"lib/doctor-report/collect.ts":
"administration ledger lists recorded doses; rates gate via expectsDoses",
};
/**
* Readers that may see every stored schedule row, and why. Each reason says
@@ -168,3 +211,55 @@ describe("medication intake tracking — schedule readers honour the switch", ()
).toBe(false);
});
});
const intakeReaders = sourceFiles().filter((rel) =>
INTAKE_READ.test(code(rel)),
);
describe("medication intake tracking — intake-row readers honour the switch", () => {
it("finds the intake readers it exists to police", () => {
expect(intakeReaders.length).toBeGreaterThanOrEqual(30);
for (const known of [
"lib/jobs/coach-nudge.ts",
"lib/insights/general-status.ts",
"lib/analytics/correlations-fast-path.ts",
"lib/medications/outstanding-doses.ts",
]) {
expect(intakeReaders).toContain(known);
}
});
it("every intake reader uses the vocabulary or is allowlisted", () => {
const offenders = intakeReaders.filter(
(rel) => !(rel in INTAKE_MAY_SEE_EVERY_ROW) && !HONOURS.test(usage(rel)),
);
expect(
offenders,
"These files read intake rows without honouring Medication.trackIntake. " +
"Filter with TRACKED_INTAKE_EVENT_WHERE (or read only the ids of " +
"medications filtered with TRACKED_INTAKE_WHERE), or add the file to " +
"INTAKE_MAY_SEE_EVERY_ROW with the reason it computes no rate.",
).toEqual([]);
});
it("the intake allowlist names no file that stopped reading intake rows", () => {
expect(
Object.keys(INTAKE_MAY_SEE_EVERY_ROW).filter(
(rel) => !intakeReaders.includes(rel),
),
).toEqual([]);
});
it("the intake matcher catches the shapes it claims to", () => {
expect(INTAKE_READ.test("prisma.medicationIntakeEvent.findMany({")).toBe(
true,
);
expect(
INTAKE_READ.test("prisma.medicationIntakeEvent\n .groupBy({"),
).toBe(true);
expect(INTAKE_READ.test("tx.medicationIntakeEvent . count (")).toBe(true);
expect(INTAKE_READ.test("prisma.medicationIntakeEvent.create({")).toBe(
false,
);
});
});
@@ -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<string, unknown> {
return (call as [{ where: Record<string, unknown> }])[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,
});
});
});
@@ -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")
}
/>
);
@@ -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 () => {
+4
View File
@@ -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 }
+8 -1
View File
@@ -65,6 +65,7 @@ import { isNearUtc, userDayKey } from "@/lib/tz/resolver";
import { wallClockInTz } from "@/lib/tz/wall-clock";
import { defaultLocale, type Locale } from "@/lib/i18n/config";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { TRACKED_INTAKE_EVENT_WHERE } from "@/lib/medications/intake-tracking";
/**
* v1.4.37 W2 — cold-path correlation window. Trim from 30 to 28 days
@@ -279,7 +280,13 @@ export async function computeCorrelationHypothesesFastPath(
}) as Promise<MoodRow[]>,
prisma.medicationIntakeEvent.findMany({
// v1.7.0 sync — exclude tombstoned rows.
where: { userId, deletedAt: null, scheduledFor: { gte: since } },
where: {
userId,
deletedAt: null,
scheduledFor: { gte: since },
// v1.39.1 (#1033) — compliance counts tracked medications only.
...TRACKED_INTAKE_EVENT_WHERE,
},
select: { scheduledFor: true, takenAt: true, skipped: true },
}) as Promise<IntakeRow[]>,
]);
+3 -5
View File
@@ -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<string, Date>();
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) {
+3
View File
@@ -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: {
+4
View File
@@ -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 },
});
+27 -2
View File
@@ -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<number, Array<{ id: string; createdAt: Date }>>();
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
@@ -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
+3 -5
View File
@@ -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<string, Date>();
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({
+3 -5
View File
@@ -33,6 +33,7 @@ import {
isRecordOnly,
scheduleWireFields,
} from "@/lib/medications/intake-tracking";
import { liveEraStartsByMedication } from "@/lib/medications/scheduling/live-era";
export type MedicationsListResult = Array<Record<string, unknown>>;
@@ -164,11 +165,8 @@ export async function buildMedicationsList(
else resolvedSlotsByMedId.set(e.medicationId, [mark]);
}
const eraStartByMedId = new Map<string, Date>();
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) => [
@@ -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"]);
});
});
@@ -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<string, Date> {
const out = new Map<string, Date>();
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();
}
@@ -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,
+20 -5
View File
@@ -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,
@@ -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]);
});
});