refactor(medications): drop the legacy today walker and its own zone solver

expandTodayIntakes had no caller left once the today projection moved onto
the recurrence engine and localHmAsUtc; it carried the last private
wall-clock solver in the medication code. The recurrence engine now takes
its offset and UTC-day helpers from the tz module instead of local copies.
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 17:07:02 +02:00
1 parent d65ee42d94
commit 903f0a9a22
3 files changed
+7 -315

No files matched your search

@@ -1,9 +1,7 @@
import { describe, expect, it } from "vitest";
import {
expandTodayIntakes,
parseScheduleRecurrence,
serializeScheduleRecurrence,
type TodayExpandableSchedule,
} from "@/lib/medication-schedule";
describe("medication schedule recurrence", () => {
@@ -54,111 +52,3 @@ 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,41 +3,6 @@ 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[] {
@@ -90,147 +55,3 @@ 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);
}
+7 -26
View File
@@ -4,10 +4,9 @@
* Single source of truth for "what dose slots does this schedule emit
* between A and B?" The canonical engine is introduced in this release;
* the v1.5.0 cut wires only the reminder worker (via
* `worker-helpers.ts`) through it. The today-projector
* (`expandTodayIntakes`), the cadence chart (`expandScheduleSlots`),
* and the form-level helpers continue on the legacy walker through
* v1.5.x and migrate per the read-flip plan. The medication card's
* `worker-helpers.ts`) through it. The today-projector, the cadence
* chart (`expandScheduleSlots`), and the form-level helpers continue on
* the legacy walker through v1.5.x and migrate per the read-flip plan. The medication card's
* "next intake" line reads the server-computed `nextDueAt` (this engine
* via `computeNextDueAt`) directly as of v1.8.4.
*
@@ -30,7 +29,7 @@
* are NULL and `oneShot` is false. Decodes the legacy
* `daysOfWeek` string via `parseScheduleRecurrence` and emits
* weekly slots. **Honours `intervalWeeks > 1` correctly** —
* the existing `expandTodayIntakes` skipped it (the legacy
* the retired today-projector walker skipped it (the legacy
* bi-weekly worker bug R-3 finding 5 calls out); this engine
* anchors the week phase to `startsOn ?? createdAt` and emits
* on the matching weeks only.
@@ -60,6 +59,8 @@ import { annotate } from "@/lib/logging/context";
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
import { wallClockInTz } from "@/lib/tz/wall-clock";
import { startOfLocalDayInTz } from "@/lib/tz/local-day";
import { tzOffsetMinutes } from "@/lib/tz/format";
import { startOfUtcDay } from "@/lib/tz/start-of-utc-day";
import { hhmmToMinutesOrNull } from "@/lib/medications/scheduling/hhmm";
/**
@@ -906,8 +907,7 @@ function startOfCivilWeek(day: Date): Date {
/**
* Materialise an "HH:mm" on a civil day in the user's IANA timezone,
* returning the corresponding UTC instant. DST-aware via the two-pass
* solver pattern from `medication-schedule.ts`: a wall-clock time that
* returning the corresponding UTC instant. DST-aware: a wall-clock time that
* does not exist on a spring-forward day resolves forward (02:30 in
* Europe/Berlin on 2026-03-29 becomes 03:30).
*
@@ -955,19 +955,6 @@ function applyTimeOfDayToCivilDay(day: Date, hhmm: string, tz: string): Date {
return before;
}
function tzOffsetMinutes(date: Date, tz: string): number {
const parts = wallClockInTz(date, tz);
const asIfUtc = Date.UTC(
parts.year,
parts.month - 1,
parts.day,
parts.hour,
parts.minute,
parts.second,
);
return Math.round((asIfUtc - date.getTime()) / 60_000);
}
function buildOccurrence(
at: Date,
timeOfDay: string,
@@ -1011,12 +998,6 @@ function graceWindowMs(schedule: CanonicalSchedule): number {
// UTC date helpers (RRULE day-anchor + endsOn cap arithmetic)
// ────────────────────────────────────────────────────────────────────
function startOfUtcDay(d: Date): Date {
return new Date(
Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 0, 0, 0, 0),
);
}
function endOfUtcDay(d: Date): Date {
return new Date(
Date.UTC(