mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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:
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user