feat: enable weekday-based medication scheduling

Closes #463
This commit is contained in:
Daniel Volz
2026-03-20 14:58:25 +01:00
committed by GitHub
parent 29f4c4e48d
commit 68ab79c713
35 changed files with 1856 additions and 841 deletions
+328 -88
View File
@@ -6,14 +6,34 @@
import { getDateLocale, type Language } from "../i18n/translations.js";
import { isLiquidContainerPackageType, isTubePackageType } from "./package-profiles.js";
export const CANONICAL_WEEKDAY_ORDER = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const;
export type Weekday = (typeof CANONICAL_WEEKDAY_ORDER)[number];
export type IntakeScheduleMode = "interval" | "weekdays";
type ScheduleLike = {
every: number;
start: string;
scheduleMode?: IntakeScheduleMode;
weekdays?: Weekday[];
};
// Legacy type - individual blister schedule (DEPRECATED: use Intake instead)
export type Blister = { usage: number; every: number; start: string };
export type Blister = {
usage: number;
every: number;
start: string;
scheduleMode?: IntakeScheduleMode;
weekdays?: Weekday[];
};
// New unified intake type with per-intake takenBy
export type Intake = {
usage: number;
every: number;
start: string;
scheduleMode?: IntakeScheduleMode;
weekdays?: Weekday[];
intakeUnit?: "ml" | "tsp" | "tbsp" | null;
takenBy: string | null; // Person taking this specific intake (null = use medication-level takenBy)
intakeRemindersEnabled: boolean;
@@ -22,6 +42,278 @@ export type Intake = {
const isValidIntakeUnit = (value: unknown): value is "ml" | "tsp" | "tbsp" =>
value === "ml" || value === "tsp" || value === "tbsp";
const weekdayToJavascriptDay: Record<Weekday, number> = {
mon: 1,
tue: 2,
wed: 3,
thu: 4,
fri: 5,
sat: 6,
sun: 0,
};
function isWeekday(value: unknown): value is Weekday {
return typeof value === "string" && CANONICAL_WEEKDAY_ORDER.includes(value as Weekday);
}
function normalizeScheduleMode(value: unknown): IntakeScheduleMode {
return value === "weekdays" ? "weekdays" : "interval";
}
function toDateOnly(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0);
}
export function getDateOnlyTimestamp(date: Date): number {
return toDateOnly(date).getTime();
}
export function getWeekdayFromDate(date: Date): Weekday {
const weekday = CANONICAL_WEEKDAY_ORDER.find((entry) => weekdayToJavascriptDay[entry] === date.getDay());
return weekday ?? "mon";
}
export function getWeekdayFromStart(start: string): Weekday {
const startDate = parseLocalDateTime(start);
if (Number.isNaN(startDate.getTime())) {
return "mon";
}
return getWeekdayFromDate(startDate);
}
export function normalizeWeekdays(value: unknown, start: string): Weekday[] {
if (!Array.isArray(value)) {
return [getWeekdayFromStart(start)];
}
const uniqueWeekdays = new Set<Weekday>();
for (const weekday of value) {
if (isWeekday(weekday)) {
uniqueWeekdays.add(weekday);
}
}
const normalized = CANONICAL_WEEKDAY_ORDER.filter((weekday) => uniqueWeekdays.has(weekday));
return normalized.length > 0 ? normalized : [getWeekdayFromStart(start)];
}
function createOccurrenceAtDate(date: Date, startDate: Date): number {
return new Date(
date.getFullYear(),
date.getMonth(),
date.getDate(),
startDate.getHours(),
startDate.getMinutes(),
startDate.getSeconds(),
startDate.getMilliseconds()
).getTime();
}
function getNormalizedWeekdays(schedule: ScheduleLike): Weekday[] {
if (schedule.scheduleMode !== "weekdays") {
return [];
}
if (schedule.weekdays && schedule.weekdays.length > 0) {
return schedule.weekdays;
}
return [getWeekdayFromStart(schedule.start)];
}
export function getAverageOccurrencesPerDay(
schedule: Pick<ScheduleLike, "every" | "start" | "scheduleMode" | "weekdays">
): number {
if (schedule.scheduleMode === "weekdays") {
return getNormalizedWeekdays(schedule).length / 7;
}
return 1 / Math.max(1, schedule.every);
}
export function getMaxScheduledGapDays(
schedule: Pick<ScheduleLike, "every" | "start" | "scheduleMode" | "weekdays">
): number {
if (schedule.scheduleMode !== "weekdays") {
return Math.max(1, schedule.every);
}
const weekdays = getNormalizedWeekdays(schedule).map((weekday) => CANONICAL_WEEKDAY_ORDER.indexOf(weekday));
if (weekdays.length === 0) {
return 7;
}
let maxGap = 0;
for (let index = 0; index < weekdays.length; index++) {
const current = weekdays[index];
const next = weekdays[(index + 1) % weekdays.length];
const gap = index === weekdays.length - 1 ? next + 7 - current : next - current;
if (gap > maxGap) {
maxGap = gap;
}
}
return maxGap || 7;
}
export function getScheduleMatchWindowMs(
schedule: Pick<ScheduleLike, "every" | "start" | "scheduleMode" | "weekdays">
): number {
return (getMaxScheduledGapDays(schedule) * 86_400_000) / 2;
}
export function getNextScheduledOccurrenceTime(
schedule: Pick<ScheduleLike, "every" | "start" | "scheduleMode" | "weekdays">,
fromMs: number,
inclusive: boolean = true
): number | null {
const startDate = parseLocalDateTime(schedule.start);
const startTime = startDate.getTime();
if (Number.isNaN(startTime)) {
return null;
}
const lowerBound = inclusive ? fromMs : fromMs + 1;
if (schedule.scheduleMode !== "weekdays") {
const period = Math.max(1, schedule.every) * 86_400_000;
if (startTime >= lowerBound) {
return startTime;
}
const intervals = Math.ceil((lowerBound - startTime) / period);
return startTime + intervals * period;
}
const candidateStart = Math.max(lowerBound, startTime);
const candidateDateOnly = toDateOnly(new Date(candidateStart));
let nextOccurrence: number | null = null;
for (const weekday of getNormalizedWeekdays(schedule)) {
const candidateDate = new Date(candidateDateOnly);
const offsetDays = (weekdayToJavascriptDay[weekday] - candidateDate.getDay() + 7) % 7;
candidateDate.setDate(candidateDate.getDate() + offsetDays);
let occurrenceMs = createOccurrenceAtDate(candidateDate, startDate);
if (occurrenceMs < candidateStart) {
candidateDate.setDate(candidateDate.getDate() + 7);
occurrenceMs = createOccurrenceAtDate(candidateDate, startDate);
}
if (nextOccurrence === null || occurrenceMs < nextOccurrence) {
nextOccurrence = occurrenceMs;
}
}
return nextOccurrence;
}
export function forEachScheduledOccurrenceInRange(
schedule: Pick<ScheduleLike, "every" | "start" | "scheduleMode" | "weekdays">,
rangeStartMs: number,
rangeEndMs: number,
callback: (occurrenceMs: number) => void
): void {
if (!Number.isFinite(rangeStartMs) || !Number.isFinite(rangeEndMs) || rangeEndMs < rangeStartMs) {
return;
}
const startDate = parseLocalDateTime(schedule.start);
const startTime = startDate.getTime();
if (Number.isNaN(startTime) || rangeEndMs < startTime) {
return;
}
if (schedule.scheduleMode !== "weekdays") {
const period = Math.max(1, schedule.every) * 86_400_000;
let occurrenceMs = startTime;
if (occurrenceMs < rangeStartMs) {
const intervals = Math.ceil((rangeStartMs - occurrenceMs) / period);
occurrenceMs += intervals * period;
}
for (; occurrenceMs <= rangeEndMs; occurrenceMs += period) {
if (occurrenceMs >= rangeStartMs) {
callback(occurrenceMs);
}
}
return;
}
const lowerBound = Math.max(rangeStartMs, startTime);
const firstDateOnly = toDateOnly(new Date(lowerBound));
for (const weekday of getNormalizedWeekdays(schedule)) {
const occurrenceDate = new Date(firstDateOnly);
const offsetDays = (weekdayToJavascriptDay[weekday] - occurrenceDate.getDay() + 7) % 7;
occurrenceDate.setDate(occurrenceDate.getDate() + offsetDays);
let occurrenceMs = createOccurrenceAtDate(occurrenceDate, startDate);
if (occurrenceMs < lowerBound) {
occurrenceDate.setDate(occurrenceDate.getDate() + 7);
occurrenceMs = createOccurrenceAtDate(occurrenceDate, startDate);
}
while (occurrenceMs <= rangeEndMs) {
callback(occurrenceMs);
occurrenceDate.setDate(occurrenceDate.getDate() + 7);
occurrenceMs = createOccurrenceAtDate(occurrenceDate, startDate);
}
}
}
export function countScheduledOccurrencesInRange(
schedule: Pick<ScheduleLike, "every" | "start" | "scheduleMode" | "weekdays">,
rangeStartMs: number,
rangeEndMs: number
): { count: number; lastOccurrenceMs: number | null } {
let count = 0;
let lastOccurrenceMs: number | null = null;
forEachScheduledOccurrenceInRange(schedule, rangeStartMs, rangeEndMs, (occurrenceMs) => {
count += 1;
if (lastOccurrenceMs === null || occurrenceMs > lastOccurrenceMs) {
lastOccurrenceMs = occurrenceMs;
}
});
return { count, lastOccurrenceMs };
}
export function normalizeIntake(
value: {
usage?: unknown;
every?: unknown;
start?: unknown;
scheduleMode?: unknown;
weekdays?: unknown;
intakeUnit?: unknown;
takenBy?: unknown;
intakeRemindersEnabled?: unknown;
},
defaultIntakeRemindersEnabled: boolean = false
): Intake {
const start = typeof value.start === "string" ? value.start : new Date().toISOString();
const scheduleMode = normalizeScheduleMode(value.scheduleMode);
let every = 1;
if (scheduleMode !== "weekdays") {
if (typeof value.every === "number" && Number.isFinite(value.every) && value.every >= 1) {
every = value.every;
}
}
return {
usage: typeof value.usage === "number" && Number.isFinite(value.usage) ? value.usage : 0,
every,
start,
scheduleMode,
weekdays: scheduleMode === "weekdays" ? normalizeWeekdays(value.weekdays, start) : [],
intakeUnit: isValidIntakeUnit(value.intakeUnit) ? value.intakeUnit : null,
takenBy: typeof value.takenBy === "string" && value.takenBy.trim() ? value.takenBy.trim() : null,
intakeRemindersEnabled:
typeof value.intakeRemindersEnabled === "boolean" ? value.intakeRemindersEnabled : defaultIntakeRemindersEnabled,
};
}
/**
* Normalize intake usage for stock math.
*
@@ -225,15 +517,7 @@ export function parseIntakesJson(
try {
const parsed = JSON.parse(intakesJson);
if (Array.isArray(parsed) && parsed.length > 0) {
return parsed.map((intake: Record<string, unknown>) => ({
usage: typeof intake.usage === "number" ? intake.usage : 0,
every: typeof intake.every === "number" ? intake.every : 1,
start: typeof intake.start === "string" ? intake.start : new Date().toISOString(),
intakeUnit: isValidIntakeUnit(intake.intakeUnit) ? intake.intakeUnit : null,
takenBy: typeof intake.takenBy === "string" && intake.takenBy.trim() ? intake.takenBy.trim() : null,
intakeRemindersEnabled:
typeof intake.intakeRemindersEnabled === "boolean" ? intake.intakeRemindersEnabled : false,
}));
return parsed.map((intake: Record<string, unknown>) => normalizeIntake(intake));
}
} catch {
// Fall through to legacy parsing
@@ -243,14 +527,18 @@ export function parseIntakesJson(
// Fallback to legacy parallel arrays
if (legacyRow) {
const blisters = parseBlisters(legacyRow);
return blisters.map((b) => ({
usage: b.usage,
every: b.every,
start: b.start,
intakeUnit: null,
takenBy: null, // Legacy format has no per-intake takenBy
intakeRemindersEnabled: medicationIntakeRemindersEnabled ?? false,
}));
return blisters.map((b) =>
normalizeIntake(
{
usage: b.usage,
every: b.every,
start: b.start,
intakeUnit: null,
takenBy: null,
},
medicationIntakeRemindersEnabled ?? false
)
);
}
return [];
@@ -303,7 +591,7 @@ export function personTakesMedication(person: string, medicationTakenBy: string[
/** Calculate daily usage from blisters */
export function calculateDailyUsage(blisters: Blister[]): number {
return blisters.reduce((sum, s) => sum + s.usage / s.every, 0);
return blisters.reduce((sum, blister) => sum + blister.usage * getAverageOccurrencesPerDay(blister), 0);
}
/** Calculate depletion information for a medication */
@@ -370,50 +658,31 @@ export function getTodaysIntakes(
for (let blisterIdx = 0; blisterIdx < intakes.length; blisterIdx++) {
const intake = intakes[blisterIdx];
const startTime = parseLocalDateTime(intake.start).getTime();
const intervalMs = intake.every * 24 * 60 * 60 * 1000;
if (intervalMs <= 0) continue;
// Determine takenBy for this intake
// If intake has its own takenBy, use it; otherwise null (no specific person)
const effectiveTakenBy = intake.takenBy || null;
// Find all occurrences that fall within today
let currentTime = startTime;
// If start is in the past, calculate the first occurrence on or after todayStart
if (currentTime < todayStart.getTime()) {
const elapsed = todayStart.getTime() - startTime;
const intervals = Math.floor(elapsed / intervalMs);
currentTime = startTime + intervals * intervalMs;
}
// Collect all intakes for today
while (currentTime <= todayEnd.getTime()) {
if (currentTime >= todayStart.getTime()) {
const intakeDate = new Date(currentTime);
result.push({
medName,
medicationId,
blisterIndex: blisterIdx,
usage: intake.usage,
intakeTime: intakeDate,
intakeTimeStr: intakeDate.toLocaleTimeString(locale, {
hour: "2-digit",
minute: "2-digit",
timeZone: timezone,
}),
takenBy: effectiveTakenBy,
pillWeightMg,
doseUnit,
});
}
currentTime += intervalMs;
}
forEachScheduledOccurrenceInRange(intake, todayStart.getTime(), todayEnd.getTime(), (occurrenceMs) => {
const intakeDate = new Date(occurrenceMs);
result.push({
medName,
medicationId,
blisterIndex: blisterIdx,
usage: intake.usage,
intakeTime: intakeDate,
intakeTimeStr: intakeDate.toLocaleTimeString(locale, {
hour: "2-digit",
minute: "2-digit",
timeZone: timezone,
}),
takenBy: effectiveTakenBy,
pillWeightMg,
doseUnit,
});
});
}
return result;
return result.sort((left, right) => left.intakeTime.getTime() - right.intakeTime.getTime());
}
/**
@@ -444,40 +713,11 @@ export function getUpcomingIntakes(
for (let blisterIdx = 0; blisterIdx < intakes.length; blisterIdx++) {
const intake = intakes[blisterIdx];
const startTime = parseLocalDateTime(intake.start).getTime();
const intervalMs = intake.every * 24 * 60 * 60 * 1000;
if (intervalMs <= 0) continue;
// Determine takenBy for this intake
const effectiveTakenBy = intake.takenBy || null;
// Find the next scheduled intake time (could be today or in the future)
let nextTime = startTime;
// If start is in the past, calculate occurrences
if (nextTime < now) {
const elapsed = now - startTime;
const intervals = Math.floor(elapsed / intervalMs);
// Check the current occurrence (today's scheduled time, even if past)
const currentOccurrence = startTime + intervals * intervalMs;
// And the next occurrence
const nextOccurrence = startTime + (intervals + 1) * intervalMs;
// If today's occurrence notification time falls in current minute and intake hasn't happened
const currentNotifyTime = currentOccurrence - minutesBefore * 60 * 1000;
if (currentNotifyTime >= currentMinuteStart && currentOccurrence > now) {
nextTime = currentOccurrence;
} else if (currentNotifyTime < currentMinuteStart && currentOccurrence > now) {
// CATCH-UP: The notify window was missed (e.g. due to system sleep/restart)
// but the intake time is still in the future — include it so the advance
// reminder can still be sent rather than falling into a dead zone.
nextTime = currentOccurrence;
} else {
nextTime = nextOccurrence;
}
}
const nextTime = getNextScheduledOccurrenceTime(intake, now, true);
if (nextTime === null) continue;
// Calculate when we should notify for this intake
const notifyTime = nextTime - minutesBefore * 60 * 1000;