Files
medassist-ng/frontend/src/test/utils/schedule.test.ts
T
Daniel Volz 99ef5bd622 feat: streamline dashboard UI and improve refill reminder (#86)
- Hide Reorder Reminder card when reminders are enabled (avoids redundancy with Reminder Bar)
- Show all low stock medications in Reminder Bar instead of just the next one
- Rename 'Reorder' to 'Refill' throughout the app
- Make medication names clickable in Refill Reminder card (opens detail modal)
- Add daysLeft display for each low stock medication
- Update translations (EN + DE)
2026-01-30 22:21:05 +01:00

576 lines
14 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Coverage, Medication, StockThresholds } from "../../types";
import {
buildSchedulePreview,
calculateCoverage,
getNextReminderForMed,
getReminderStatusText,
getStockStatus,
} from "../../utils/schedule";
describe("buildSchedulePreview", () => {
beforeEach(() => {
vi.setSystemTime(new Date("2024-03-15T12:00:00Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it("returns empty events for empty medications array", () => {
const result = buildSchedulePreview([], "en", false);
expect(result.events).toEqual([]);
expect(result.today).toBe(0);
expect(result.totalBlisters).toBe(0);
});
it("returns empty for non-array input", () => {
const result = buildSchedulePreview(null as unknown as Medication[], "en", false);
expect(result.events).toEqual([]);
});
it("builds events for medication with schedule", () => {
const meds: Medication[] = [
{
id: 1,
name: "TestMed",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 30,
looseTablets: 0,
takenBy: ["John"],
blisters: [
{
usage: 1,
every: 1,
start: "2024-03-14T09:00:00",
},
],
updatedAt: null,
},
];
const result = buildSchedulePreview(meds, "en", true);
expect(result.events.length).toBeGreaterThan(0);
expect(result.totalBlisters).toBe(1);
});
it("filters out past events when includePast is false", () => {
const meds: Medication[] = [
{
id: 1,
name: "TestMed",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 30,
looseTablets: 0,
takenBy: [],
blisters: [
{
usage: 1,
every: 1,
start: "2024-03-01T09:00:00",
},
],
updatedAt: null,
},
];
const withPast = buildSchedulePreview(meds, "en", true);
const withoutPast = buildSchedulePreview(meds, "en", false);
expect(withPast.events.length).toBeGreaterThanOrEqual(withoutPast.events.length);
});
it("handles invalid date in blister start", () => {
const meds: Medication[] = [
{
id: 1,
name: "TestMed",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 30,
looseTablets: 0,
takenBy: [],
blisters: [
{
usage: 1,
every: 1,
start: "invalid-date",
},
],
updatedAt: null,
},
];
const result = buildSchedulePreview(meds, "en", true);
// Should not crash, events for invalid dates are skipped
expect(Array.isArray(result.events)).toBe(true);
});
it("sorts events by time", () => {
const meds: Medication[] = [
{
id: 1,
name: "Morning Med",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 30,
looseTablets: 0,
takenBy: [],
blisters: [
{
usage: 1,
every: 1,
start: "2024-03-15T09:00:00",
},
],
updatedAt: null,
},
{
id: 2,
name: "Evening Med",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 30,
looseTablets: 0,
takenBy: [],
blisters: [
{
usage: 1,
every: 1,
start: "2024-03-15T21:00:00",
},
],
updatedAt: null,
},
];
const result = buildSchedulePreview(meds, "en", false);
for (let i = 1; i < result.events.length; i++) {
expect(result.events[i].when).toBeGreaterThanOrEqual(result.events[i - 1].when);
}
});
});
describe("calculateCoverage", () => {
beforeEach(() => {
vi.setSystemTime(new Date("2024-03-15T12:00:00Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it("calculates coverage for medication with schedule", () => {
const meds: Medication[] = [
{
id: 1,
name: "TestMed",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 30,
looseTablets: 0,
takenBy: [],
blisters: [
{
usage: 1,
every: 1,
start: "2024-03-15T09:00:00",
},
],
updatedAt: null,
},
];
const events = [{ medName: "TestMed", when: Date.now() }];
const result = calculateCoverage(meds, events, "en", 7, "automatic", new Set());
expect(result.all).toHaveLength(1);
expect(result.all[0].name).toBe("TestMed");
expect(result.all[0].daysLeft).toBeDefined();
});
it("handles medication with no schedule", () => {
const meds: Medication[] = [
{
id: 1,
name: "NoSchedule",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 30,
looseTablets: 0,
takenBy: [],
blisters: [],
updatedAt: null,
},
];
const result = calculateCoverage(meds, [], "en", 7, "automatic", new Set());
expect(result.all).toHaveLength(1);
expect(result.all[0].daysLeft).toBeNull();
});
it("filters low stock medications", () => {
const meds: Medication[] = [
{
id: 1,
name: "LowStock",
packCount: 0,
blistersPerPack: 1,
pillsPerBlister: 30,
looseTablets: 5,
takenBy: [],
blisters: [
{
usage: 1,
every: 1,
start: "2024-03-15T09:00:00",
},
],
updatedAt: null,
},
];
const result = calculateCoverage(meds, [], "en", 7, "automatic", new Set());
expect(result.low.length).toBeGreaterThanOrEqual(0);
});
it("respects manual stock calculation mode", () => {
const meds: Medication[] = [
{
id: 1,
name: "TestMed",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 30,
looseTablets: 0,
takenBy: [],
blisters: [
{
usage: 1,
every: 1,
start: "2024-03-10T09:00:00",
},
],
updatedAt: null,
},
];
const takenDoses = new Set(["1-0-1710061200000"]);
const result = calculateCoverage(meds, [], "en", 7, "manual", takenDoses);
expect(result.all).toHaveLength(1);
});
it("handles multiple takenBy people", () => {
const meds: Medication[] = [
{
id: 1,
name: "SharedMed",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 30,
looseTablets: 0,
takenBy: ["Alice", "Bob"],
blisters: [
{
usage: 1,
every: 1,
start: "2024-03-15T09:00:00",
},
],
updatedAt: null,
},
];
const result = calculateCoverage(meds, [], "en", 7, "automatic", new Set());
expect(result.all).toHaveLength(1);
// Daily rate should be doubled for 2 people
});
});
describe("getStockStatus", () => {
const thresholds: StockThresholds = {
lowStockDays: 30,
normalStockDays: 90,
highStockDays: 180,
};
it("returns out-of-stock when medsLeft is 0", () => {
const result = getStockStatus(5, 0, thresholds);
expect(result.level).toBe("out-of-stock");
expect(result.className).toBe("danger");
});
it("returns out-of-stock when daysLeft is 0", () => {
const result = getStockStatus(0, 5, thresholds);
expect(result.level).toBe("out-of-stock");
expect(result.className).toBe("danger");
});
it("returns high when daysLeft > highStockDays", () => {
const result = getStockStatus(200, 100, thresholds);
expect(result.level).toBe("high");
expect(result.className).toBe("high");
});
it("returns normal when daysLeft >= lowStockDays", () => {
const result = getStockStatus(50, 100, thresholds);
expect(result.level).toBe("normal");
expect(result.className).toBe("success");
});
it("returns low when daysLeft < lowStockDays", () => {
const result = getStockStatus(20, 100, thresholds);
expect(result.level).toBe("low");
expect(result.className).toBe("warning");
});
it("returns normal when daysLeft is null but medsLeft > 0", () => {
const result = getStockStatus(null, 100, thresholds);
expect(result.level).toBe("normal");
expect(result.label).toBe("status.noSchedule");
});
});
describe("getNextReminderForMed", () => {
beforeEach(() => {
vi.setSystemTime(new Date("2024-03-15T12:00:00Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it('returns "—" when no depletion time', () => {
const med: Coverage = {
name: "Test",
medsLeft: 100,
daysLeft: null,
depletionDate: null,
depletionTime: null,
nextDose: null,
};
expect(getNextReminderForMed(med, 7, "en")).toBe("—");
});
it('returns "Due now" when reminder time is past', () => {
const now = Date.now();
const med: Coverage = {
name: "Test",
medsLeft: 5,
daysLeft: 3,
depletionDate: null,
depletionTime: now + 3 * 86400000,
nextDose: null,
};
// Reminder 7 days before = already past
expect(getNextReminderForMed(med, 7, "en")).toBe("Due now");
});
it("returns formatted date for future reminder", () => {
const now = Date.now();
const med: Coverage = {
name: "Test",
medsLeft: 100,
daysLeft: 30,
depletionDate: null,
depletionTime: now + 30 * 86400000,
nextDose: null,
};
const result = getNextReminderForMed(med, 7, "en-US");
expect(result).not.toBe("—");
expect(result).not.toBe("Due now");
});
});
describe("getReminderStatusText", () => {
const mockT = (key: string, options?: Record<string, unknown>) => {
if (options?.count) return `${key} (${options.count})`;
if (options?.days) return `${key} (${options.days})`;
return key;
};
it("shows empty stock warning first", () => {
const emptyMed: Coverage = {
name: "Empty",
medsLeft: 0,
daysLeft: 0,
depletionDate: null,
depletionTime: null,
nextDose: null,
};
const result = getReminderStatusText(7, 30, [], [emptyMed], null, null, null, mockT, "en");
expect(result.lines[0].text).toContain("dashboard.reminders.emptyStock");
expect(result.lines[0].className).toBe("danger-text");
});
it("shows all ok when everything is fine", () => {
const healthyMed: Coverage = {
name: "Healthy",
medsLeft: 100,
daysLeft: 60,
depletionDate: null,
depletionTime: Date.now() + 60 * 86400000,
nextDose: null,
};
const result = getReminderStatusText(7, 30, [], [healthyMed], null, null, null, mockT, "en");
expect(result.lines[0].text).toContain("dashboard.reminders.allOk");
});
it("includes last sent info if available", () => {
// For healthy meds with no upcoming reminders, it goes to the final fallback
// which returns allStockOk and includes lastReminder info
const healthyMed: Coverage = {
name: "Healthy",
medsLeft: 100,
daysLeft: 200,
depletionDate: null,
depletionTime: Date.now() + 200 * 86400000,
nextDose: null,
};
const result = getReminderStatusText(
7,
30,
[],
[healthyMed],
"2024-03-10T10:00:00Z",
"stock",
"email",
mockT,
"en"
);
// Either allOk or allStockOk includes last reminder info
const hasLastReminder = result.lines.some(
(l) => l.text.includes("lastReminder") || l.text.includes("allOk") || l.text.includes("allStockOk")
);
expect(hasLastReminder).toBe(true);
});
it("shows low warning for medications running low", () => {
const lowMed: Coverage = {
name: "RunningLow",
medsLeft: 20,
daysLeft: 20,
depletionDate: null,
depletionTime: Date.now() + 20 * 86400000,
nextDose: null,
};
const result = getReminderStatusText(7, 30, [], [lowMed], null, null, null, mockT, "en");
expect(result.lines.some((l) => l.text.includes("lowWarning") || l.text.includes("needRefill"))).toBe(true);
});
it("handles intake reminder type with push channel", () => {
const emptyMed: Coverage = {
name: "Empty",
medsLeft: 0,
daysLeft: 0,
depletionDate: null,
depletionTime: null,
nextDose: null,
};
const result = getReminderStatusText(7, 30, [], [emptyMed], "2024-03-10T10:00:00Z", "intake", "push", mockT, "en");
expect(result.lines[0].className).toBe("danger-text");
});
it("handles both channel type", () => {
const emptyMed: Coverage = {
name: "Empty",
medsLeft: 0,
daysLeft: 0,
depletionDate: null,
depletionTime: null,
nextDose: null,
};
const result = getReminderStatusText(7, 30, [], [emptyMed], "2024-03-10T10:00:00Z", "stock", "both", mockT, "en");
expect(result.lines[0].className).toBe("danger-text");
});
it("shows needRefill when below critical threshold", () => {
const criticalMed: Coverage = {
name: "Critical",
medsLeft: 5,
daysLeft: 5,
depletionDate: null,
depletionTime: Date.now() + 5 * 86400000,
nextDose: null,
};
const result = getReminderStatusText(7, 30, [criticalMed], [criticalMed], null, null, null, mockT, "en");
expect(result.lines.some((l) => l.text.includes("needRefill"))).toBe(true);
});
it("shows low warning when below low threshold but above critical", () => {
const lowMed: Coverage = {
name: "Low",
medsLeft: 20,
daysLeft: 20,
depletionDate: null,
depletionTime: Date.now() + 20 * 86400000,
nextDose: null,
};
const result = getReminderStatusText(7, 30, [], [lowMed], null, null, null, mockT, "en");
expect(result.lines.some((l) => l.text.includes("lowWarning"))).toBe(true);
});
it("returns noRemindersNeeded when all ok and no last sent", () => {
const result = getReminderStatusText(7, 30, [], [], null, null, null, mockT, "en");
expect(result.lines.some((l) => l.text.includes("noRemindersNeeded") || l.text.includes("allStockOk"))).toBe(true);
});
it("handles empty and critical meds together", () => {
const emptyMed: Coverage = {
name: "Empty",
medsLeft: 0,
daysLeft: 0,
depletionDate: null,
depletionTime: null,
nextDose: null,
};
const criticalMed: Coverage = {
name: "Critical",
medsLeft: 5,
daysLeft: 5,
depletionDate: null,
depletionTime: Date.now() + 5 * 86400000,
nextDose: null,
};
const lowMed: Coverage = {
name: "Low",
medsLeft: 20,
daysLeft: 20,
depletionDate: null,
depletionTime: Date.now() + 20 * 86400000,
nextDose: null,
};
const result = getReminderStatusText(
7,
30,
[criticalMed],
[emptyMed, criticalMed, lowMed],
null,
null,
null,
mockT,
"en"
);
expect(result.lines[0].text).toContain("emptyStock");
expect(result.lines.length).toBeGreaterThan(1);
});
});