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
+27 -7
View File
@@ -4,6 +4,13 @@
import type { Medication } from "../types";
import { getMedDisplayName } from "../types";
import {
getIntakeFrequencyText,
getIntakeScheduleMode,
getMedicationIntakes,
getWeekdayIcsCode,
normalizeWeekdays,
} from "./intake-schedule";
/**
* Format a Date for ICS format (YYYYMMDDTHHMMSSZ)
@@ -20,20 +27,33 @@ function formatICSDate(date: Date): string {
*/
export function generateICS(med: Medication): void {
const displayName = getMedDisplayName(med);
const events = med.blisters
.map((blister, idx) => {
const start = new Date(blister.start);
const events = getMedicationIntakes(med)
.map((intake, idx) => {
const start = new Date(intake.start);
const end = new Date(start.getTime() + 15 * 60 * 1000); // 15 min duration
const interval = blister.every;
const interval = intake.every;
const pillInfo = `${blister.usage} pill${blister.usage !== 1 ? "s" : ""}${med.pillWeightMg ? ` (${blister.usage * med.pillWeightMg} mg)` : ""}`;
const pillInfo = `${intake.usage} pill${intake.usage !== 1 ? "s" : ""}${med.pillWeightMg ? ` (${intake.usage * med.pillWeightMg} mg)` : ""}`;
const summary = `💊 ${displayName} - ${pillInfo}`;
const weekdayCodes = normalizeWeekdays(intake.weekdays);
const frequencyText =
getIntakeScheduleMode(intake) === "weekdays"
? weekdayCodes.map(getWeekdayIcsCode).join(", ")
: getIntakeFrequencyText(intake, (key, options) => {
if (key === "common.daily") return "daily";
if (key === "common.everyNDays") return `every ${options?.count ?? interval} days`;
return key;
});
const rrule =
getIntakeScheduleMode(intake) === "weekdays" && weekdayCodes.length > 0
? `RRULE:FREQ=WEEKLY;BYDAY=${weekdayCodes.map(getWeekdayIcsCode).join(",")}`
: `RRULE:FREQ=DAILY;INTERVAL=${interval}`;
const description = [
`Medication: ${displayName}`,
med.genericName ? `Generic: ${med.genericName}` : "",
med.takenBy && med.takenBy.length > 0 ? `For: ${med.takenBy.join(", ")}` : "",
`Dosage: ${pillInfo}`,
`Frequency: every ${interval} day${interval !== 1 ? "s" : ""}`,
`Frequency: ${frequencyText}`,
med.notes ? `Notes: ${med.notes}` : "",
]
.filter(Boolean)
@@ -44,7 +64,7 @@ UID:medassist-ng-${med.id}-${idx}@medassist-ng
DTSTAMP:${formatICSDate(new Date())}
DTSTART:${formatICSDate(start)}
DTEND:${formatICSDate(end)}
RRULE:FREQ=DAILY;INTERVAL=${interval}
${rrule}
SUMMARY:${summary}
DESCRIPTION:${description}
BEGIN:VALARM
+139
View File
@@ -0,0 +1,139 @@
import type { Blister, Intake, ScheduleMode, WeekdayCode } from "../types";
type MedicationScheduleSource = {
intakes?: Intake[] | null;
blisters: Blister[];
intakeRemindersEnabled?: boolean;
};
type IntakeScheduleLike = {
every?: number | string | null;
scheduleMode?: ScheduleMode | null;
weekdays?: ReadonlyArray<WeekdayCode> | null;
};
type Translate = (key: string, options?: Record<string, unknown>) => string;
export const WEEKDAY_CODES: WeekdayCode[] = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
const WEEKDAY_LABELS: Record<WeekdayCode, { short: string; long: string; ics: string }> = {
mon: { short: "form.blisters.weekdaysShort.mon", long: "form.blisters.weekdaysLong.mon", ics: "MO" },
tue: { short: "form.blisters.weekdaysShort.tue", long: "form.blisters.weekdaysLong.tue", ics: "TU" },
wed: { short: "form.blisters.weekdaysShort.wed", long: "form.blisters.weekdaysLong.wed", ics: "WE" },
thu: { short: "form.blisters.weekdaysShort.thu", long: "form.blisters.weekdaysLong.thu", ics: "TH" },
fri: { short: "form.blisters.weekdaysShort.fri", long: "form.blisters.weekdaysLong.fri", ics: "FR" },
sat: { short: "form.blisters.weekdaysShort.sat", long: "form.blisters.weekdaysLong.sat", ics: "SA" },
sun: { short: "form.blisters.weekdaysShort.sun", long: "form.blisters.weekdaysLong.sun", ics: "SU" },
};
export function normalizeWeekdays(weekdays?: ReadonlyArray<WeekdayCode> | null): WeekdayCode[] {
if (!Array.isArray(weekdays) || weekdays.length === 0) return [];
const normalizedSet = new Set<WeekdayCode>();
for (const day of weekdays) {
if (WEEKDAY_CODES.includes(day)) {
normalizedSet.add(day);
}
}
return WEEKDAY_CODES.filter((day) => normalizedSet.has(day));
}
export function hasSelectedWeekdays(weekdays?: ReadonlyArray<WeekdayCode> | null): boolean {
return normalizeWeekdays(weekdays).length > 0;
}
export function getIntakeScheduleMode(schedule: IntakeScheduleLike): ScheduleMode {
return schedule.scheduleMode === "weekdays" ? "weekdays" : "interval";
}
export function getNormalizedInterval(schedule: IntakeScheduleLike): number {
const parsedEvery = Number(schedule.every);
if (!Number.isFinite(parsedEvery) || parsedEvery <= 0) return 1;
return Math.floor(parsedEvery);
}
export function getWeekdayCode(date: Date): WeekdayCode {
return WEEKDAY_CODES[(date.getDay() + 6) % 7];
}
export function getWeekdayLabel(day: WeekdayCode, t: Translate, format: "short" | "long" = "short"): string {
return t(WEEKDAY_LABELS[day][format]);
}
export function getWeekdayIcsCode(day: WeekdayCode): string {
return WEEKDAY_LABELS[day].ics;
}
export function toggleWeekdaySelection(
weekdays: ReadonlyArray<WeekdayCode> | null | undefined,
day: WeekdayCode
): WeekdayCode[] {
const normalized = normalizeWeekdays(weekdays);
if (normalized.includes(day)) {
return normalized.filter((entry) => entry !== day);
}
return normalizeWeekdays([...normalized, day]);
}
export function getMedicationIntakes(med: MedicationScheduleSource): Intake[] {
if (med.intakes && med.intakes.length > 0) {
return med.intakes;
}
return med.blisters.map((blister) => ({
usage: blister.usage,
every: blister.every,
start: blister.start,
scheduleMode: "interval",
weekdays: [],
intakeUnit: null,
takenBy: null,
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
}));
}
export function iterateIntakeOccurrences(
intake: IntakeScheduleLike,
start: Date,
end: Date,
callback: (occurrence: Date) => void
): void {
if (start > end) return;
if (getIntakeScheduleMode(intake) === "weekdays") {
const weekdays = normalizeWeekdays(intake.weekdays);
if (weekdays.length === 0) return;
const cursor = new Date(start);
while (cursor <= end) {
if (weekdays.includes(getWeekdayCode(cursor))) {
callback(new Date(cursor));
}
cursor.setDate(cursor.getDate() + 1);
}
return;
}
const interval = getNormalizedInterval(intake);
const cursor = new Date(start);
while (cursor <= end) {
callback(new Date(cursor));
cursor.setDate(cursor.getDate() + interval);
}
}
export function getIntakeDailyRate(schedule: IntakeScheduleLike): number {
if (getIntakeScheduleMode(schedule) === "weekdays") {
return normalizeWeekdays(schedule.weekdays).length / 7;
}
return 1 / getNormalizedInterval(schedule);
}
export function getIntakeFrequencyText(schedule: IntakeScheduleLike, t: Translate): string {
if (getIntakeScheduleMode(schedule) === "weekdays") {
return normalizeWeekdays(schedule.weekdays)
.map((day) => getWeekdayLabel(day, t, "short"))
.join(", ");
}
const every = getNormalizedInterval(schedule);
return every === 1 ? t("common.daily") : t("common.everyNDays", { count: every });
}
+15
View File
@@ -0,0 +1,15 @@
import type { IntakeUnit } from "../types";
type Translate = (key: string, options?: Record<string, unknown>) => string;
export function convertLiquidUsageToMl(usage: number, unit: IntakeUnit | null | undefined): number {
if (unit === "tsp") return usage * 5;
if (unit === "tbsp") return usage * 15;
return usage;
}
export function getLiquidCountUnitLabel(unit: IntakeUnit | null | undefined, usage: number, t: Translate): string {
if (unit === "tsp") return t("form.blisters.teaspoons", { count: Math.abs(usage) });
if (unit === "tbsp") return t("form.blisters.tablespoons", { count: Math.abs(usage) });
return t("form.packageAmountUnitMl");
}
+77 -94
View File
@@ -2,17 +2,10 @@
// Schedule Building and Coverage Calculations
// =============================================================================
import type {
Blister,
Coverage,
Intake,
Medication,
PackageType,
ScheduleEvent,
StockStatus,
StockThresholds,
} from "../types";
import type { Coverage, Intake, Medication, PackageType, ScheduleEvent, StockStatus, StockThresholds } from "../types";
import { getMedDisplayName, getMedTotal, isLiquidContainerPackageType, isTubePackageType } from "../types";
import { getIntakeDailyRate, getMedicationIntakes, iterateIntakeOccurrences } from "./intake-schedule";
import { convertLiquidUsageToMl } from "./intake-units";
export function parseLocalDateTime(isoString: string): Date {
const match = isoString.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):?(\d{2})?/);
@@ -39,38 +32,7 @@ function normalizeIntakeUsageForStock(intake: Intake, med: Medication): number {
const isLiquidStock = isLiquidContainerPackageType(med.packageType) || med.medicationForm === "liquid";
if (!isLiquidStock) return usage;
if (intake.intakeUnit === "tsp") return usage * 5;
if (intake.intakeUnit === "tbsp") return usage * 15;
return usage;
}
/**
* Get intakes for a medication, preferring new intakes format over legacy blisters
*/
function getIntakesForMed(med: Medication): Intake[] {
// Use new intakes array if available and non-empty
if (med.intakes && med.intakes.length > 0) {
return med.intakes;
}
// Fallback to legacy blisters (convert to Intake format)
return med.blisters.map((b) => ({
usage: b.usage,
every: b.every,
start: b.start,
intakeUnit: null,
takenBy: null, // Legacy format has no per-intake takenBy
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
}));
}
/**
* Get blisters for a medication (for backward compatibility with coverage calculations)
*/
function getBlistersForMed(med: Medication): Blister[] {
if (med.intakes && med.intakes.length > 0) {
return med.intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start }));
}
return med.blisters;
return convertLiquidUsageToMl(usage, intake.intakeUnit);
}
/**
@@ -90,13 +52,13 @@ export function buildSchedulePreview(
end.setDate(end.getDate() + 180); // 6 months horizon
meds.forEach((med) => {
const intakes = getIntakesForMed(med);
const intakes = getMedicationIntakes(med);
intakes.forEach((intake, idx) => {
const start = parseLocalDateTime(intake.start);
if (Number.isNaN(start.getTime())) return;
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + intake.every)) {
iterateIntakeOccurrences(intake, start, end, (d) => {
const isPast = d < todayStart;
if (isPast && !includePast) continue;
if (isPast && !includePast) return;
const whenMs = d.getTime();
// Use date-only timestamp for stable ID (immune to time changes)
// This ensures changing intake times doesn't invalidate past dose tracking
@@ -113,7 +75,7 @@ export function buildSchedulePreview(
dateStr: d.toLocaleDateString(locale, { weekday: "short", day: "2-digit", month: "short" }),
intakeRemindersEnabled: intake.intakeRemindersEnabled,
});
}
});
});
});
@@ -129,7 +91,7 @@ export function buildSchedulePreview(
events,
today: todayCount,
nextThree: events.length,
totalBlisters: meds.reduce((acc, m) => acc + getIntakesForMed(m).length, 0),
totalBlisters: meds.reduce((acc, med) => acc + getMedicationIntakes(med).length, 0),
};
}
@@ -147,10 +109,10 @@ export function calculateCoverage(
): { low: Coverage[]; all: Coverage[] } {
const MS_PER_DAY = 86_400_000;
const now = Date.now();
const nowDate = new Date(now);
const coverage: Coverage[] = meds.map((m) => {
const intakes = getIntakesForMed(m);
const blisters = getBlistersForMed(m);
const intakes = getMedicationIntakes(m);
// Count unique people from all intakes (for per-intake takenBy)
const uniquePeople = new Set<string>();
intakes.forEach((intake) => {
@@ -165,11 +127,9 @@ export function calculateCoverage(
// one person's dose — do NOT multiply by personCount again.
// For legacy intakes (no takenBy), the intake applies to ALL people.
let dailyRate = 0;
blisters.forEach((_s, idx) => {
const intake = intakes[idx];
if (!intake) return;
intakes.forEach((intake) => {
const usageForStock = normalizeIntakeUsageForStock(intake, m);
const baseRate = intake.every > 0 ? usageForStock / intake.every : 0;
const baseRate = usageForStock * getIntakeDailyRate(intake);
if (intake?.takenBy) {
// Per-intake takenBy: this intake is for exactly 1 person
dailyRate += baseRate;
@@ -189,29 +149,11 @@ export function calculateCoverage(
// time (early intake), that dose is also counted as consumed immediately.
// This prevents double-counting: once the scheduled time arrives, the dose
// was already counted via the early-taken path, not again via time.
blisters.forEach((s, blisterIdx) => {
const blisterStart = parseLocalDateTime(s.start).getTime();
const period = Math.max(1, s.every) * MS_PER_DAY;
const intake = intakes[blisterIdx];
if (!intake) return;
intakes.forEach((intake, blisterIdx) => {
const intakeStart = parseLocalDateTime(intake.start);
if (Number.isNaN(intakeStart.getTime())) return;
const usageForStock = normalizeIntakeUsageForStock(intake, m);
// After a stock correction, start counting consumption from the NEXT
// scheduled dose on this blister's grid, because the user's pill count
// already reflects all consumption up to the correction time.
// We align to the schedule grid so that e.g. correction at 15:40 with
// a daily 15:42 dose counts today's 15:42 dose (2 min later), not
// tomorrow's dose (24h later as the old code did).
let effectiveStart: number;
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart) {
const elapsedSinceStart = stockCorrectionCutoff - blisterStart;
const periodsElapsed = Math.floor(elapsedSinceStart / period);
effectiveStart = blisterStart + (periodsElapsed + 1) * period;
} else {
effectiveStart = blisterStart;
}
if (Number.isNaN(effectiveStart)) return;
const intakePerson = intake?.takenBy;
// For per-intake takenBy, only count for that person
@@ -223,18 +165,15 @@ export function calculateCoverage(
let timeBasedConsumed = 0;
let lastAutoConsumedDateMs = 0;
if (effectiveStart <= now) {
const occurrences = Math.floor((now - effectiveStart) / period) + 1;
timeBasedConsumed = occurrences * usageForStock * peopleForThisIntake.length;
// Date-only timestamp of the last auto-consumed dose
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period);
iterateIntakeOccurrences(intake, intakeStart, nowDate, (occurrence) => {
if (occurrence.getTime() <= stockCorrectionCutoff) return;
timeBasedConsumed += usageForStock * peopleForThisIntake.length;
lastAutoConsumedDateMs = new Date(
lastDoseTime.getFullYear(),
lastDoseTime.getMonth(),
lastDoseTime.getDate()
occurrence.getFullYear(),
occurrence.getMonth(),
occurrence.getDate()
).getTime();
}
});
// Early intakes: count future doses already marked as taken.
// The cutoff is the later of: last auto-consumed date or stock correction date.
@@ -276,16 +215,15 @@ export function calculateCoverage(
const medId = parseInt(parts[0], 10);
const blisterIdx = parseInt(parts[1], 10);
const doseTimestamp = parseInt(parts[2], 10);
if (medId === m.id && blisters[blisterIdx]) {
const intake = intakes[blisterIdx];
if (!intake) return;
const intake = intakes[blisterIdx];
if (medId === m.id && intake) {
const usageForStock = normalizeIntakeUsageForStock(intake, m);
// Convert blister start to date-only for comparison (dose timestamps are date-only)
const blisterStartDate = new Date(blisters[blisterIdx].start);
const blisterStartDateOnly = new Date(
blisterStartDate.getFullYear(),
blisterStartDate.getMonth(),
blisterStartDate.getDate()
const intakeStartDate = new Date(intake.start);
const intakeStartDateOnly = new Date(
intakeStartDate.getFullYear(),
intakeStartDate.getMonth(),
intakeStartDate.getDate()
).getTime();
// Use actual takenAt timestamp for stock correction comparison.
@@ -295,8 +233,8 @@ export function calculateCoverage(
const afterCorrectionOrNoCorrectionMs = stockCorrectionCutoff === 0 || takenAt > stockCorrectionCutoff;
if (
!Number.isNaN(blisterStartDateOnly) &&
doseTimestamp >= blisterStartDateOnly &&
!Number.isNaN(intakeStartDateOnly) &&
doseTimestamp >= intakeStartDateOnly &&
afterCorrectionOrNoCorrectionMs
) {
consumed += usageForStock;
@@ -618,3 +556,48 @@ export function computeMissedPastDoseIds(
);
return totalPastDoses.filter((id) => !takenDoses.has(id) && !dismissedDoses.has(id));
}
export function buildClearMissedPayload(
pastDays: ReadonlyArray<{
date: Date;
meds: ReadonlyArray<{
medName: string;
doses: ReadonlyArray<{ id: string; takenBy: string[] }>;
}>;
}>,
medications: ReadonlyArray<{ id: number; name: string; genericName?: string | null; dismissedUntil?: string | null }>,
takenDoses: Set<string>,
dismissedDoses: Set<string>
): { medicationIds: number[]; until: string | null } {
const medicationIds = new Set<number>();
let latestMissedDate: string | null = null;
for (const day of pastDays) {
for (const item of day.meds) {
const med = medications.find((candidate) => getMedDisplayName(candidate as Medication) === item.medName);
if (!med) continue;
const dismissedUntilDate = med.dismissedUntil ?? undefined;
const hasMissedDose = item.doses.some((dose) => {
if (isDoseDismissed(dose.id, dismissedUntilDate)) {
return false;
}
return expandDoseIds([dose]).some((doseId) => !takenDoses.has(doseId) && !dismissedDoses.has(doseId));
});
if (!hasMissedDose) continue;
medicationIds.add(med.id);
const dayDate = day.date.toISOString().slice(0, 10);
if (!latestMissedDate || dayDate > latestMissedDate) {
latestMissedDate = dayDate;
}
}
}
return {
medicationIds: [...medicationIds],
until: latestMissedDate,
};
}