Merge pull request #191 from MBombeck/fix/medication-schedule-null-days-of-week

fix(medications): treat null/empty daysOfWeek as every-day schedule
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-05-21 11:13:26 +02:00
commit 28594dc852
4 files changed
+481 -2

No files matched your search

@@ -7,8 +7,17 @@ vi.mock("@/lib/db", () => ({
findMany: vi.fn(),
findUnique: vi.fn(),
update: vi.fn(),
// v1.4.39 W-SERVER-FIX — `scope=today` now backfills missing
// rows for schedules whose window opens today (covers daily
// meds with `daysOfWeek: null` whose reminder hasn't fired yet).
createMany: vi.fn(),
},
medication: {
update: vi.fn(),
// v1.4.39 W-SERVER-FIX — today's projection reads active meds +
// their schedules to know what to backfill.
findMany: vi.fn(),
},
medication: { update: vi.fn() },
medicationComplianceRollup: {
findFirst: vi.fn(),
findMany: vi.fn(),
@@ -65,6 +74,13 @@ beforeEach(() => {
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
// v1.4.39 W-SERVER-FIX — default the today-projection prisma reads
// to "no active medications" so existing compliance / POST tests
// exercise their original paths without an unmocked-call failure.
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationIntakeEvent.createMany).mockResolvedValue({
count: 0,
} as never);
// v1.4.39 W-MED — default the coverage probe to "uncovered" so the
// legacy compliance test still exercises the live-fallback branch
// and finds the legacy mocked intake events.
@@ -123,6 +139,112 @@ describe("GET /api/medications/intake", () => {
expect(body.data[0].status).toBe("pending");
});
it("backfills missing today rows for daily meds (daysOfWeek=null)", async () => {
// v1.4.39 W-SERVER-FIX regression — the operator's Ramipril
// (Morgens) ships `daysOfWeek: null` in the DB ("every day" per
// schema). Pre-fix, the endpoint returned `[]` until the reminder
// worker entered RED phase. Post-fix, the GET projects the schedule
// and idempotently mints any missing rows so the iOS Dashboard +
// "Erfassen" sheet have an intake row to mark.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findMany).mockResolvedValue([
{
id: "med-ramipril",
schedules: [
{
id: "sched-morgens",
medicationId: "med-ramipril",
windowStart: "07:00",
windowEnd: "09:00",
daysOfWeek: null,
},
],
},
] as never);
// First findMany call (pre-backfill existence probe) returns empty;
// second call (post-backfill list) returns the freshly minted row.
vi.mocked(prisma.medicationIntakeEvent.findMany)
.mockResolvedValueOnce([] as never)
.mockResolvedValueOnce([
{
id: "e-new",
medicationId: "med-ramipril",
scheduledFor: new Date("2026-05-21T05:00:00.000Z"),
takenAt: null,
skipped: false,
medication: { id: "med-ramipril", snoozedUntil: null },
},
] as never);
vi.mocked(prisma.medicationIntakeEvent.createMany).mockResolvedValue({
count: 1,
} as never);
const res = await GET(
new NextRequest("http://localhost/api/medications/intake?scope=today"),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: Array<{ id: string; status: string }>;
};
expect(body.data).toHaveLength(1);
expect(body.data[0].id).toBe("e-new");
expect(body.data[0].status).toBe("pending");
expect(prisma.medicationIntakeEvent.createMany).toHaveBeenCalledTimes(1);
expect(
vi.mocked(prisma.medicationIntakeEvent.createMany).mock.calls[0][0],
).toMatchObject({
data: [
{
userId: "user-1",
medicationId: "med-ramipril",
skipped: false,
source: "REMINDER",
},
],
});
});
it("does not double-mint when today's rows already exist", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const scheduledFor = new Date("2026-05-21T05:00:00.000Z");
vi.mocked(prisma.medication.findMany).mockResolvedValue([
{
id: "med-ramipril",
schedules: [
{
id: "sched-morgens",
medicationId: "med-ramipril",
windowStart: "07:00",
windowEnd: "09:00",
daysOfWeek: null,
},
],
},
] as never);
// The existence probe sees a row for the exact projected slot, so
// createMany must not fire.
vi.mocked(prisma.medicationIntakeEvent.findMany)
.mockResolvedValueOnce([
{ medicationId: "med-ramipril", scheduledFor },
] as never)
.mockResolvedValueOnce([
{
id: "e-existing",
medicationId: "med-ramipril",
scheduledFor,
takenAt: null,
skipped: false,
medication: { id: "med-ramipril", snoozedUntil: null },
},
] as never);
const res = await GET(
new NextRequest("http://localhost/api/medications/intake?scope=today"),
);
expect(res.status).toBe(200);
expect(prisma.medicationIntakeEvent.createMany).not.toHaveBeenCalled();
});
it("returns compliance buckets for the last N days", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
+69 -1
View File
@@ -33,6 +33,7 @@ import {
enqueueUserMedicationComplianceBackfill,
type ComplianceBucket as RollupComplianceBucket,
} from "@/lib/medications/compliance-rollups";
import { expandTodayIntakes } from "@/lib/medication-schedule";
const querySchema = z.object({
scope: z.enum(["today", "compliance"]),
@@ -85,6 +86,69 @@ export const GET = apiHandler(async (request: NextRequest) => {
if (scope === "today") {
const todayStart = startOfDayInTz(new Date(), userTz);
const todayEnd = new Date(todayStart.getTime() + 86_400_000);
// v1.4.39 W-SERVER-FIX — project pending intake rows for every
// active schedule whose window opens today, then idempotently
// backfill any missing rows. Pre-fix the endpoint returned `[]`
// for daily meds (`schedule.daysOfWeek = null` in the DB) until
// the reminder worker entered the RED phase at the end of the
// dose window — leaving the iOS Dashboard tile + the "Erfassen"
// sheet empty for the whole morning. The projection re-uses the
// canonical `parseScheduleRecurrence` (null/empty = every day)
// so the daysOfWeek semantics stay aligned across every reader.
const activeMedications = await prisma.medication.findMany({
where: { userId: user.id, active: true },
select: {
id: true,
schedules: {
select: {
id: true,
medicationId: true,
windowStart: true,
windowEnd: true,
daysOfWeek: true,
},
},
},
});
const projected = expandTodayIntakes(
activeMedications.flatMap((m) => m.schedules),
new Date(),
userTz,
);
const existing = await prisma.medicationIntakeEvent.findMany({
where: {
userId: user.id,
scheduledFor: { gte: todayStart, lt: todayEnd },
},
select: { medicationId: true, scheduledFor: true },
});
const existingKey = new Set(
existing.map((e) => `${e.medicationId}|${e.scheduledFor.toISOString()}`),
);
const missing = projected.filter(
(p) =>
!existingKey.has(`${p.medicationId}|${p.scheduledFor.toISOString()}`),
);
if (missing.length > 0) {
await prisma.medicationIntakeEvent.createMany({
data: missing.map((m) => ({
userId: user.id,
medicationId: m.medicationId,
scheduledFor: m.scheduledFor,
takenAt: null,
skipped: false,
// `REMINDER` is the same source the reminder worker uses to
// mint RED-phase rows — semantically "the server projected
// this slot before the user logged anything". The IntakeSource
// enum has no separate `SCHEDULER` literal; reusing REMINDER
// keeps the doctor-report + analytics filters byte-stable.
source: "REMINDER",
})),
});
}
const events = await prisma.medicationIntakeEvent.findMany({
where: {
userId: user.id,
@@ -96,7 +160,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
annotate({
action: { name: "medications.intake.today" },
meta: { count: events.length },
meta: {
count: events.length,
projected: projected.length,
backfilled: missing.length,
},
});
return apiSuccess(
@@ -1,7 +1,9 @@
import { describe, expect, it } from "vitest";
import {
expandTodayIntakes,
parseScheduleRecurrence,
serializeScheduleRecurrence,
type TodayExpandableSchedule,
} from "@/lib/medication-schedule";
describe("medication schedule recurrence", () => {
@@ -52,3 +54,111 @@ describe("medication schedule recurrence", () => {
).toBe("i4;");
});
});
describe("expandTodayIntakes", () => {
// 2026-05-21 is a Thursday → weekday 4. Pin a reference instant
// safely inside the Europe/Berlin day so every test below shares
// the same "today" anchor regardless of the host clock.
const REFERENCE = new Date("2026-05-21T12:00:00Z");
const TZ = "Europe/Berlin";
const daily: TodayExpandableSchedule = {
id: "sched-daily",
medicationId: "med-daily",
windowStart: "07:00",
windowEnd: "09:00",
daysOfWeek: null,
};
it("emits a slot for a schedule with daysOfWeek=null (every day)", () => {
// This is the bug the W-SERVER-FIX patch addresses: the operator's
// Ramipril (Morgens) + Metformin (Abends) both ship `daysOfWeek:
// null` in the DB ("every day" per the schema annotation) and the
// today-intake endpoint returned `[]`. With the fix, the projector
// emits one slot per schedule.
const slots = expandTodayIntakes([daily], REFERENCE, TZ);
expect(slots).toHaveLength(1);
expect(slots[0]).toMatchObject({
scheduleId: "sched-daily",
medicationId: "med-daily",
});
});
it('treats daysOfWeek="" (empty string) as every day', () => {
const slots = expandTodayIntakes(
[{ ...daily, daysOfWeek: "" }],
REFERENCE,
TZ,
);
expect(slots).toHaveLength(1);
});
it("treats all-seven-days (0..6) as every day", () => {
const slots = expandTodayIntakes(
[{ ...daily, daysOfWeek: "0,1,2,3,4,5,6" }],
REFERENCE,
TZ,
);
expect(slots).toHaveLength(1);
});
it("respects an explicit weekday filter that excludes today", () => {
// Thursday is weekday 4; "1" = Monday only → no slot today.
const slots = expandTodayIntakes(
[{ ...daily, daysOfWeek: "1" }],
REFERENCE,
TZ,
);
expect(slots).toHaveLength(0);
});
it("includes a slot when the explicit weekday filter contains today", () => {
// Thursday = weekday 4; allow {2,4} → today matches.
const slots = expandTodayIntakes(
[{ ...daily, daysOfWeek: "2,4" }],
REFERENCE,
TZ,
);
expect(slots).toHaveLength(1);
});
it("emits slots for two daysOfWeek-null schedules (Ramipril + Metformin shape)", () => {
// The operator's real fixture: morning + evening daily meds.
const morning: TodayExpandableSchedule = {
id: "sched-ramipril",
medicationId: "med-ramipril",
windowStart: "07:00",
windowEnd: "09:00",
daysOfWeek: null,
};
const evening: TodayExpandableSchedule = {
id: "sched-metformin",
medicationId: "med-metformin",
windowStart: "19:00",
windowEnd: "21:00",
daysOfWeek: null,
};
const slots = expandTodayIntakes([morning, evening], REFERENCE, TZ);
expect(slots).toHaveLength(2);
expect(slots.map((s) => s.medicationId).sort()).toEqual([
"med-metformin",
"med-ramipril",
]);
});
it("anchors scheduledFor at the schedule's windowStart in the user tz", () => {
const slots = expandTodayIntakes([daily], REFERENCE, TZ);
expect(slots).toHaveLength(1);
const iso = slots[0].scheduledFor.toISOString();
// 07:00 Europe/Berlin on 2026-05-21 (DST in effect → UTC+2) → 05:00Z.
expect(iso).toBe("2026-05-21T05:00:00.000Z");
});
it("skips multi-week cadence (handled by the reminder worker)", () => {
const biweekly: TodayExpandableSchedule = {
...daily,
daysOfWeek: "i2;1,3,5",
};
expect(expandTodayIntakes([biweekly], REFERENCE, TZ)).toEqual([]);
});
});
+179
View File
@@ -3,6 +3,41 @@ export interface ScheduleRecurrence {
intervalWeeks: number;
}
/**
* Minimal schedule shape consumed by `expandTodayIntakes`. Mirrors the
* relevant subset of the Prisma `MedicationSchedule` row (`id`,
* `windowStart`, `windowEnd`, `daysOfWeek`) plus the parent
* `medicationId` so the projected slots carry enough context for the
* intake-route caller to upsert into `MedicationIntakeEvent` without
* re-joining.
*
* `daysOfWeek` is the encoded string per `serializeScheduleRecurrence`
* (e.g. `null` for daily, `"1,3,5"` for Mon/Wed/Fri, `"i2;1"` for every
* other Monday). The DB schema column documents `null = daily` as the
* convention every reader honours.
*/
export interface TodayExpandableSchedule {
id: string;
medicationId: string;
/** "HH:mm" 24h, user-tz reference. */
windowStart: string;
/** "HH:mm" 24h. May wrap midnight (`windowEnd < windowStart`). */
windowEnd: string;
/** Encoded recurrence string per `serializeScheduleRecurrence`. */
daysOfWeek: string | null;
}
/**
* One projected dose slot for "today" in the user's timezone. The
* `scheduledFor` is the UTC instant that corresponds to the schedule's
* `windowStart` applied to today's local-day boundary in `timeZone`.
*/
export interface TodayExpandedSlot {
scheduleId: string;
medicationId: string;
scheduledFor: Date;
}
const ENCODED_PATTERN = /^i([1-4]);(.*)$/;
function normalizeDays(days: number[]): number[] {
@@ -55,3 +90,147 @@ export function serializeScheduleRecurrence(
if (intervalWeeks === 1) return days.join(",");
return `i${intervalWeeks};${days.join(",")}`;
}
/**
* Project the supplied schedules to the dose slots they would produce
* for "today" in the user's timezone. Treats `daysOfWeek` of `null`,
* `""`, or "no valid weekdays after normalization" as "every day" — the
* documented DB convention (`prisma/schema.prisma` annotates the column
* `null = daily`). Explicit weekday arrays (`"1,3,5"`) still filter as
* before, so a Monday-only schedule emits nothing on a Sunday.
*
* Used by `/api/medications/intake?scope=today` to backfill placeholder
* `MedicationIntakeEvent` rows for active medications whose reminder
* window has not yet opened — without this projection the endpoint
* returned `[]` for daily meds before the reminder worker entered the
* RED phase, leaving the iOS Dashboard tile + the "Erfassen" sheet
* empty for the whole morning.
*
* The function is pure (no DB access) so the test surface stays inside
* this file. The route owns the upsert.
*/
export function expandTodayIntakes(
schedules: TodayExpandableSchedule[],
reference: Date,
timeZone: string,
): TodayExpandedSlot[] {
const slots: TodayExpandedSlot[] = [];
const today = localDayParts(reference, timeZone);
for (const schedule of schedules) {
const recurrence = parseScheduleRecurrence(schedule.daysOfWeek);
// Day-of-week filter. Empty array (the parsed shape for `null` /
// `""` / "no valid weekdays") means "every day" — no filter.
if (
recurrence.daysOfWeek.length > 0 &&
!recurrence.daysOfWeek.includes(today.weekday)
) {
continue;
}
// Multi-week cadence isn't anchored without a medication start
// date here, so `intervalWeeks > 1` is conservatively skipped from
// the today projection. The reminder worker still mints those rows
// when their window opens because it has the medication context;
// those meds are GLP-1-style weeklies which the iOS today list
// doesn't depend on as a primary surface.
if (recurrence.intervalWeeks > 1) continue;
const [h, m] = schedule.windowStart.split(":").map(Number);
if (!Number.isFinite(h) || !Number.isFinite(m)) continue;
const scheduledFor = instantAtLocalHm(today, h, m, timeZone);
slots.push({
scheduleId: schedule.id,
medicationId: schedule.medicationId,
scheduledFor,
});
}
return slots;
}
interface LocalDayParts {
year: number;
month: number;
day: number;
weekday: number;
}
const WEEKDAY_MAP: Record<string, number> = {
Sun: 0,
Mon: 1,
Tue: 2,
Wed: 3,
Thu: 4,
Fri: 5,
Sat: 6,
};
function localDayParts(date: Date, timeZone: string): LocalDayParts {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
weekday: "short",
}).formatToParts(date);
const get = (type: Intl.DateTimeFormatPartTypes): string =>
parts.find((p) => p.type === type)?.value ?? "0";
return {
year: Number(get("year")),
month: Number(get("month")),
day: Number(get("day")),
weekday: WEEKDAY_MAP[get("weekday")] ?? 0,
};
}
function instantAtLocalHm(
day: LocalDayParts,
hour: number,
minute: number,
timeZone: string,
): Date {
// Two-pass solver — first guess assumes wall-clock equals UTC, then
// correct by the zone's offset at that approximate instant. Two
// iterations suffice for every IANA zone (the second pass adjusts
// across DST transitions).
let guess = new Date(
Date.UTC(day.year, day.month - 1, day.day, hour, minute, 0, 0),
);
for (let i = 0; i < 2; i++) {
const offsetMin = tzOffsetMinutes(guess, timeZone);
guess = new Date(
Date.UTC(day.year, day.month - 1, day.day, hour, minute, 0, 0) -
offsetMin * 60_000,
);
}
return guess;
}
function tzOffsetMinutes(date: Date, timeZone: string): number {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).formatToParts(date);
const get = (type: Intl.DateTimeFormatPartTypes): number =>
Number(parts.find((p) => p.type === type)?.value ?? "0");
let hour = get("hour");
if (hour === 24) hour = 0;
const asIfUtc = Date.UTC(
get("year"),
get("month") - 1,
get("day"),
hour,
get("minute"),
get("second"),
);
return Math.round((asIfUtc - date.getTime()) / 60000);
}