Files
HealthLog/src/lib/medication-schedule.ts
T

237 lines
7.1 KiB
TypeScript

export interface ScheduleRecurrence {
daysOfWeek: number[];
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[] {
return [...new Set(days)]
.filter((day) => Number.isInteger(day) && day >= 0 && day <= 6)
.sort((a, b) => a - b);
}
export function parseScheduleRecurrence(
value: string | null | undefined,
): ScheduleRecurrence {
if (!value) {
return { daysOfWeek: [], intervalWeeks: 1 };
}
const encoded = ENCODED_PATTERN.exec(value);
if (encoded) {
const intervalWeeks = Number(encoded[1]);
const days = encoded[2]
? encoded[2]
.split(",")
.map((token) => Number(token))
.filter((num) => Number.isFinite(num))
: [];
return {
daysOfWeek: normalizeDays(days),
intervalWeeks:
intervalWeeks >= 1 && intervalWeeks <= 4 ? intervalWeeks : 1,
};
}
// Legacy format: "1,2,3"
const legacyDays = value
.split(",")
.map((token) => Number(token))
.filter((num) => Number.isFinite(num));
return { daysOfWeek: normalizeDays(legacyDays), intervalWeeks: 1 };
}
export function serializeScheduleRecurrence(
recurrence: ScheduleRecurrence,
): string | null {
const days = normalizeDays(recurrence.daysOfWeek);
const intervalWeeks =
recurrence.intervalWeeks >= 1 && recurrence.intervalWeeks <= 4
? recurrence.intervalWeeks
: 1;
if (intervalWeeks === 1 && days.length === 0) return null;
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);
}