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
+223
View File
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Coverage, Medication, StockThresholds } from "../../types";
import {
buildClearMissedPayload,
buildSchedulePreview,
calculateCoverage,
computeMissedPastDoseIds,
@@ -278,6 +279,33 @@ describe("buildSchedulePreview", () => {
expect(zResult.events.map((event) => event.id)).toEqual(localResult.events.map((event) => event.id));
expect(zResult.events.map((event) => event.when)).toEqual(localResult.events.map((event) => event.when));
});
it("falls back legacy blisters to schedule events with a null intake unit", () => {
const meds: Medication[] = [
{
id: 1,
name: "Legacy Liquid",
packCount: 0,
blistersPerPack: 1,
pillsPerBlister: 1,
totalPills: 120,
looseTablets: 120,
takenBy: [],
packageType: "liquid_container",
medicationForm: "liquid",
blisters: [{ usage: 2, every: 1, start: "2024-03-15T09:00:00" }],
updatedAt: null,
},
];
const result = buildSchedulePreview(meds, "en", false);
expect(result.totalBlisters).toBe(1);
expect(result.events[0]).toMatchObject({
usage: 2,
intakeUnit: null,
});
});
});
describe("calculateCoverage", () => {
@@ -376,6 +404,41 @@ describe("calculateCoverage", () => {
expect(result.all[0].daysLeft).toBe(9); // 18 pills / 2 per day = 9 days
});
it("converts liquid intake units to ml for automatic coverage calculations", () => {
const meds: Medication[] = [
{
id: 1,
name: "Liquid Med",
packCount: 0,
blistersPerPack: 1,
pillsPerBlister: 1,
totalPills: 120,
looseTablets: 120,
takenBy: [],
packageType: "liquid_container",
medicationForm: "liquid",
blisters: [],
intakes: [
{
usage: 2,
every: 1,
start: "2024-03-14T09:00:00",
intakeUnit: "tbsp",
takenBy: null,
intakeRemindersEnabled: false,
},
],
updatedAt: null,
},
];
const result = calculateCoverage(meds, [], "en", 7, "automatic", new Set());
expect(result.all).toHaveLength(1);
expect(result.all[0].medsLeft).toBe(60);
expect(result.all[0].daysLeft).toBe(2);
});
it("per-intake takenBy counts person correctly in automatic mode", () => {
// When intakes have per-intake takenBy, each person-intake pair is counted
const meds: Medication[] = [
@@ -1987,6 +2050,83 @@ describe("dose tracking survives medication edits (regression)", () => {
});
});
describe("buildClearMissedPayload", () => {
it("collects unique missed medication ids and the latest missed day", () => {
const march10 = new Date("2024-03-10T09:00:00Z");
const march11 = new Date("2024-03-11T09:00:00Z");
const aspirinDoseMarch10 = "1-0-1710061200000";
const aspirinDoseMarch11 = "1-0-1710147600000";
const vitaminDDoseMarch11 = "2-0-1710147600000";
const calciumDoseMarch11 = "3-0-1710147600000";
const pastDays = [
{
date: march10,
meds: [{ medName: "Aspirin", doses: [{ id: aspirinDoseMarch10, takenBy: ["John"] }] }],
},
{
date: march11,
meds: [
{ medName: "Aspirin", doses: [{ id: aspirinDoseMarch11, takenBy: ["John"] }] },
{ medName: "Vitamin D", doses: [{ id: vitaminDDoseMarch11, takenBy: [] }] },
{ medName: "Calcium", doses: [{ id: calciumDoseMarch11, takenBy: [] }] },
],
},
];
const medications = [
{ id: 1, name: "Aspirin", dismissedUntil: null },
{ id: 2, name: "Vitamin D", dismissedUntil: null },
{ id: 3, name: "Calcium", dismissedUntil: "2024-03-11" },
];
const payload = buildClearMissedPayload(
pastDays,
medications,
new Set<string>(),
new Set<string>([`${aspirinDoseMarch11}-John`])
);
expect(payload).toEqual({
medicationIds: [1, 2],
until: "2024-03-11",
});
});
it("returns an empty payload when every remaining missed dose is already resolved", () => {
const march10 = new Date("2024-03-10T09:00:00Z");
const aspirinDoseMarch10 = "1-0-1710061200000";
const vitaminDDoseMarch10 = "2-0-1710061200000";
const pastDays = [
{
date: march10,
meds: [
{ medName: "Aspirin", doses: [{ id: aspirinDoseMarch10, takenBy: ["Alice"] }] },
{ medName: "Vitamin D", doses: [{ id: vitaminDDoseMarch10, takenBy: [] }] },
],
},
];
const medications = [
{ id: 1, name: "Aspirin", dismissedUntil: null },
{ id: 2, name: "Vitamin D", dismissedUntil: "2024-03-10" },
];
const payload = buildClearMissedPayload(
pastDays,
medications,
new Set<string>([`${aspirinDoseMarch10}-Alice`]),
new Set<string>()
);
expect(payload).toEqual({
medicationIds: [],
until: null,
});
});
});
// =============================================================================
// Test Helpers
// =============================================================================
@@ -2322,3 +2462,86 @@ describe("past schedule windowing", () => {
expect(past180.length).toBeGreaterThan(past90.length);
});
});
describe("weekday intake schedules", () => {
beforeEach(() => {
vi.setSystemTime(new Date("2024-03-18T12:00:00Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it("builds preview events only on selected weekdays", () => {
const meds: Medication[] = [
{
id: 1,
name: "Weekday Med",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 30,
looseTablets: 0,
takenBy: [],
packageType: "blister",
blisters: [],
intakes: [
{
usage: 1,
every: 1,
start: "2024-03-18T09:00:00",
scheduleMode: "weekdays",
weekdays: ["mon", "wed", "fri"],
intakeUnit: null,
takenBy: null,
intakeRemindersEnabled: false,
},
],
updatedAt: null,
},
];
const result = buildSchedulePreview(meds, "en", false);
const weekdayDateStrings = result.events.slice(0, 3).map((event) => event.dateStr);
expect(weekdayDateStrings).toEqual(["Mon, Mar 18", "Wed, Mar 20", "Fri, Mar 22"]);
expect(result.totalBlisters).toBe(1);
});
it("uses weekday schedules when calculating coverage", () => {
const meds: Medication[] = [
{
id: 1,
name: "Weekday Med",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
looseTablets: 0,
takenBy: [],
packageType: "blister",
blisters: [],
intakes: [
{
usage: 1,
every: 1,
start: "2024-03-18T09:00:00",
scheduleMode: "weekdays",
weekdays: ["mon", "wed", "fri"],
intakeUnit: null,
takenBy: null,
intakeRemindersEnabled: false,
},
],
updatedAt: null,
},
];
const preview = buildSchedulePreview(meds, "en", false);
const coverage = calculateCoverage(meds, preview.events, "en", 7, "automatic", new Set());
expect(coverage.all[0]).toMatchObject({
name: "Weekday Med",
medsLeft: 9,
daysLeft: 21,
});
});
});