feat: frontend improvements - shared schedule, bottle type, settings UI, planner notifications (#146)
- Rewrite SharedSchedule to match DashboardPage rendering with time-based consumption - Add bottle package type support across all views (MedDetail, Refill, Planner, Dashboard) - Redesign settings page with colored threshold chips, validation, and stock reminder display - Add shareStockStatus toggle and send manual reminder button - Pill/pills singular/plural consistency across all views - Planner send notification via push (Shoutrrr) in addition to email - Stock overflow warning and past-missed day styling - Update README: bottles in Smart Inventory, push in Trip Planner, new ENV section - 708 passing frontend tests including new coverage for all changes
This commit is contained in:
@@ -15,6 +15,7 @@ const mockMedication: Medication = {
|
||||
id: 1,
|
||||
name: "Test Med",
|
||||
genericName: "Generic Name",
|
||||
packageType: "blister",
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 30,
|
||||
@@ -385,3 +386,197 @@ describe("MedDetailModal with refill history", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("MedDetailModal intake schedule usage display", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("does not multiply usage by personCount when intakes have per-intake takenBy", () => {
|
||||
// Two people at medication level, but each intake has its own takenBy
|
||||
const med: Medication = {
|
||||
...mockMedication,
|
||||
takenBy: ["Alice", "Bob"],
|
||||
blisters: [
|
||||
{ usage: 1, every: 1, start: "2024-01-01T09:00:00" },
|
||||
{ usage: 1, every: 1, start: "2024-01-01T21:00:00" },
|
||||
],
|
||||
intakes: [
|
||||
{ usage: 1, every: 1, start: "2024-01-01T09:00:00", takenBy: "Alice", intakeRemindersEnabled: false },
|
||||
{ usage: 1, every: 1, start: "2024-01-01T21:00:00", takenBy: "Bob", intakeRemindersEnabled: false },
|
||||
],
|
||||
};
|
||||
render(<MedDetailModal {...defaultProps} selectedMed={med} />);
|
||||
|
||||
const usageElements = document.querySelectorAll(".med-schedule-usage");
|
||||
// Each intake should show "1 pill" (not "2 pills")
|
||||
usageElements.forEach((el) => {
|
||||
expect(el.textContent).toContain("1");
|
||||
expect(el.textContent).not.toMatch(/^2\b/);
|
||||
});
|
||||
});
|
||||
|
||||
it("multiplies usage by personCount for legacy blisters without per-intake takenBy", () => {
|
||||
// Two people at medication level, legacy blisters without intakes
|
||||
const med: Medication = {
|
||||
...mockMedication,
|
||||
takenBy: ["Alice", "Bob"],
|
||||
blisters: [{ usage: 1, every: 1, start: "2024-01-01T09:00:00" }],
|
||||
// No intakes array - legacy format
|
||||
};
|
||||
render(<MedDetailModal {...defaultProps} selectedMed={med} />);
|
||||
|
||||
const usageElements = document.querySelectorAll(".med-schedule-usage");
|
||||
// Legacy: 1 pill * 2 people = "2 pills"
|
||||
expect(usageElements.length).toBe(1);
|
||||
expect(usageElements[0].textContent).toContain("2");
|
||||
});
|
||||
|
||||
it("shows correct usage for single person with per-intake takenBy", () => {
|
||||
const med: Medication = {
|
||||
...mockMedication,
|
||||
takenBy: ["Alice"],
|
||||
pillWeightMg: 500,
|
||||
blisters: [{ usage: 2, every: 1, start: "2024-01-01T09:00:00" }],
|
||||
intakes: [{ usage: 2, every: 1, start: "2024-01-01T09:00:00", takenBy: "Alice", intakeRemindersEnabled: false }],
|
||||
};
|
||||
render(<MedDetailModal {...defaultProps} selectedMed={med} />);
|
||||
|
||||
const usageElements = document.querySelectorAll(".med-schedule-usage");
|
||||
expect(usageElements.length).toBe(1);
|
||||
// Should show "2 pills (1000 mg)" - usage=2, not multiplied
|
||||
expect(usageElements[0].textContent).toContain("2");
|
||||
expect(usageElements[0].textContent).toContain("1000");
|
||||
});
|
||||
});
|
||||
|
||||
describe("MedDetailModal stock overflow warning", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows warning icon when stock exceeds package capacity", () => {
|
||||
const overflowCoverage: Coverage = {
|
||||
name: "Test Med",
|
||||
medsLeft: 49,
|
||||
daysLeft: 49,
|
||||
depletionDate: "2024-03-01",
|
||||
depletionTime: Date.now() + 49 * 86400000,
|
||||
nextDose: null,
|
||||
};
|
||||
|
||||
render(<MedDetailModal {...defaultProps} coverage={{ all: [overflowCoverage] }} />);
|
||||
|
||||
// packageSize = 1 * 1 * 30 + 0 = 30, currentStock = 49 > 30
|
||||
const warningIcon = document.querySelector(".info-tooltip.tooltip-align-left.warning-text");
|
||||
expect(warningIcon).toBeInTheDocument();
|
||||
expect(warningIcon?.getAttribute("data-tooltip")).toBe("tooltips.stockExceedsCapacity");
|
||||
});
|
||||
|
||||
it("does not show warning icon when stock is within package capacity", () => {
|
||||
render(<MedDetailModal {...defaultProps} />);
|
||||
|
||||
// packageSize = 30, currentStock = 25 < 30
|
||||
const warningIcon = document.querySelector(".info-tooltip.tooltip-align-left.warning-text");
|
||||
expect(warningIcon).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show warning icon when stock equals package capacity", () => {
|
||||
const exactCoverage: Coverage = {
|
||||
name: "Test Med",
|
||||
medsLeft: 30,
|
||||
daysLeft: 30,
|
||||
depletionDate: "2024-02-01",
|
||||
depletionTime: Date.now() + 30 * 86400000,
|
||||
nextDose: null,
|
||||
};
|
||||
|
||||
render(<MedDetailModal {...defaultProps} coverage={{ all: [exactCoverage] }} />);
|
||||
|
||||
// packageSize = 30, currentStock = 30 — equal, no warning
|
||||
const warningIcon = document.querySelector(".info-tooltip.tooltip-align-left.warning-text");
|
||||
expect(warningIcon).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("MedDetailModal bottle package type", () => {
|
||||
const bottleMed: Medication = {
|
||||
id: 2,
|
||||
name: "Bottle Med",
|
||||
genericName: null,
|
||||
packageType: "bottle",
|
||||
packCount: 0,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 1,
|
||||
looseTablets: 80,
|
||||
totalPills: 100,
|
||||
takenBy: [],
|
||||
blisters: [{ usage: 1, every: 1, start: "2024-01-01T09:00:00" }],
|
||||
updatedAt: null,
|
||||
expiryDate: null,
|
||||
notes: null,
|
||||
};
|
||||
|
||||
const bottleCoverage: Coverage = {
|
||||
name: "Bottle Med",
|
||||
medsLeft: 80,
|
||||
daysLeft: 80,
|
||||
depletionDate: "2024-06-01",
|
||||
depletionTime: Date.now() + 80 * 86400000,
|
||||
nextDose: null,
|
||||
};
|
||||
|
||||
const bottleProps = {
|
||||
...defaultProps,
|
||||
selectedMed: bottleMed,
|
||||
coverage: { all: [bottleCoverage] },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("does not show blister fields in stock info section", () => {
|
||||
render(<MedDetailModal {...bottleProps} />);
|
||||
|
||||
// Should show current stock
|
||||
expect(screen.getByText(/modal\.currentStock/i)).toBeInTheDocument();
|
||||
|
||||
// Should NOT show full blisters or open blister labels
|
||||
expect(screen.queryByText(/table\.fullBlisters/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/table\.openBlister/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows bottle type in package details section", () => {
|
||||
render(<MedDetailModal {...bottleProps} />);
|
||||
|
||||
// Should show package type as bottle
|
||||
expect(screen.getByText(/form\.packageTypeBottle/i)).toBeInTheDocument();
|
||||
|
||||
// Should show total capacity
|
||||
expect(screen.getByText(/form\.totalCapacity/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows pills-only refill modal for bottle type", () => {
|
||||
render(<MedDetailModal {...bottleProps} showRefillModal={true} />);
|
||||
|
||||
// Should show pills to add label
|
||||
expect(screen.getByText(/refill\.pillsToAdd/i)).toBeInTheDocument();
|
||||
|
||||
// Should NOT show packs label in refill
|
||||
const refillModal = document.querySelector(".refill-modal");
|
||||
// Packs label should not be present for bottle type
|
||||
expect(screen.queryByText("refill.packs")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows total pills input in edit stock modal for bottle type", () => {
|
||||
render(<MedDetailModal {...bottleProps} showEditStockModal={true} />);
|
||||
|
||||
// Should show total pills label
|
||||
expect(screen.getByText(/editStock\.totalPills/i)).toBeInTheDocument();
|
||||
|
||||
// Should NOT show full blisters or partial blister labels
|
||||
expect(screen.queryByText(/editStock\.fullBlisters/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/editStock\.partialBlisterPills/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -541,3 +541,52 @@ describe("MobileEditModal optional fields", () => {
|
||||
expect(toggle).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("MobileEditModal bottle package type", () => {
|
||||
const bottleForm: FormState = {
|
||||
...defaultForm,
|
||||
packageType: "bottle",
|
||||
packCount: "0",
|
||||
blistersPerPack: "1",
|
||||
pillsPerBlister: "1",
|
||||
looseTablets: "80",
|
||||
totalPills: "100",
|
||||
};
|
||||
|
||||
it("shows pills-only refill form for bottle type when editing", () => {
|
||||
render(<MobileEditModal {...defaultProps} form={bottleForm} editingId={1} />);
|
||||
|
||||
// Should show "pillsToAdd" label for bottle
|
||||
expect(screen.getByText(/refill\.pillsToAdd/i)).toBeInTheDocument();
|
||||
|
||||
// Should NOT show "packs" label in refill section
|
||||
const refillSection = document.querySelector(".refill-section");
|
||||
expect(refillSection).toBeInTheDocument();
|
||||
expect(refillSection!.textContent).not.toContain("refill.packs");
|
||||
expect(refillSection!.textContent).not.toContain("refill.loosePills");
|
||||
});
|
||||
|
||||
it("shows packs and loose refill form for blister type when editing", () => {
|
||||
render(<MobileEditModal {...defaultProps} form={defaultForm} editingId={1} />);
|
||||
|
||||
// Should show "packs" and "loosePills" labels for blister
|
||||
const refillSection = document.querySelector(".refill-section");
|
||||
expect(refillSection).toBeInTheDocument();
|
||||
expect(refillSection!.textContent).toContain("refill.packs");
|
||||
expect(refillSection!.textContent).toContain("refill.loosePills");
|
||||
});
|
||||
|
||||
it("shows totalCapacity and currentPills fields for bottle form", () => {
|
||||
render(<MobileEditModal {...defaultProps} form={bottleForm} />);
|
||||
|
||||
// Should show total capacity field
|
||||
expect(screen.getByText(/form\.totalCapacity/i)).toBeInTheDocument();
|
||||
// Should show current pills field
|
||||
expect(screen.getByText(/form\.currentPills/i)).toBeInTheDocument();
|
||||
|
||||
// Should NOT show blister-specific fields
|
||||
expect(screen.queryByText("form.packs")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("form.blistersPerPack")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("form.pillsPerBlister")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DashboardPage } from "../../pages/DashboardPage";
|
||||
@@ -160,6 +160,7 @@ const createMockAppContext = (overrides = {}) => ({
|
||||
setShowClearMissedConfirm: vi.fn(),
|
||||
clearingMissed: false,
|
||||
dismissMissedDoses: vi.fn(),
|
||||
loadSettings: vi.fn(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -592,7 +593,9 @@ describe("DashboardPage with email notifications", () => {
|
||||
);
|
||||
|
||||
// Reorder card should NOT be shown when reminders are active (Reminder Bar shows the info instead)
|
||||
expect(screen.queryByText(/dashboard\.reorder\.sendReminder/i)).not.toBeInTheDocument();
|
||||
// The send reminder button IS shown in the reminder status bar (not the reorder card)
|
||||
expect(document.querySelector(".reminder-status-bar")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/dashboard\.reorder\.title/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -622,6 +625,76 @@ describe("DashboardPage with shoutrrr notifications", () => {
|
||||
const statusBar = document.querySelector(".reminder-status-bar");
|
||||
expect(statusBar).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows send reminder button when stock reminders are enabled and low stock exists", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText("dashboard.reorder.sendReminder")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sends manual reminder notification on button click", async () => {
|
||||
global.fetch = vi.fn().mockImplementation((url: string) => {
|
||||
if (url === "/api/reminder/send-email") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ success: true, message: "Notification sent via push" }),
|
||||
});
|
||||
}
|
||||
// Settings refresh after successful send
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({}),
|
||||
});
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const sendButton = screen.getByText("dashboard.reorder.sendReminder");
|
||||
fireEvent.click(sendButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"/api/reminder/send-email",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Notification sent via push")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error message when manual reminder fails", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
json: () => Promise.resolve({ error: "No notification channels configured" }),
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const sendButton = screen.getByText("dashboard.reorder.sendReminder");
|
||||
fireEvent.click(sendButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No notification channels configured")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("DashboardPage with past days", () => {
|
||||
@@ -819,3 +892,69 @@ describe("DashboardPage good stock state", () => {
|
||||
expect(screen.getByText(/dashboard\.reorder\.allGood/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DashboardPage bottle package type", () => {
|
||||
const bottleMed = {
|
||||
id: 3,
|
||||
name: "Ibuprofen",
|
||||
packageType: "bottle" as const,
|
||||
packCount: 0,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 1,
|
||||
looseTablets: 100,
|
||||
totalPills: 200,
|
||||
takenBy: [],
|
||||
blisters: [{ usage: 2, every: 1, start: "2024-01-01T09:00:00Z" }],
|
||||
intakeRemindersEnabled: false,
|
||||
notes: null,
|
||||
expiryDate: null,
|
||||
imageUrl: null,
|
||||
updatedAt: null,
|
||||
};
|
||||
|
||||
const bottleCoverage = {
|
||||
name: "Ibuprofen",
|
||||
medsLeft: 100,
|
||||
daysLeft: 50,
|
||||
depletionDate: "2025-04-01",
|
||||
depletionTime: Date.now() + 50 * 86400000,
|
||||
nextDose: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
mockContextValue = createMockAppContext({
|
||||
meds: [bottleMed],
|
||||
coverage: { all: [bottleCoverage], low: [] },
|
||||
coverageByMed: { Ibuprofen: bottleCoverage },
|
||||
});
|
||||
});
|
||||
|
||||
it("renders pill count instead of blisters for bottle type", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Should show medication name
|
||||
expect(screen.getByText("Ibuprofen")).toBeInTheDocument();
|
||||
|
||||
// Should show pills count (bottle shows pillsCount, not blisters)
|
||||
expect(screen.getByText(/table\.pillsCount/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows dash for stock details column for bottle type", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// For bottle type, the stock details column shows "—"
|
||||
const dashElements = document.querySelectorAll('[data-label="table.stockDetails"]');
|
||||
const bottleDetails = Array.from(dashElements).find((el) => el.textContent === "—");
|
||||
expect(bottleDetails).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ const mockMeds = [
|
||||
id: 1,
|
||||
name: "Aspirin",
|
||||
genericName: "Acetylsalicylic acid",
|
||||
packageType: "blister" as const,
|
||||
packCount: 1,
|
||||
blistersPerPack: 2,
|
||||
pillsPerBlister: 10,
|
||||
@@ -25,6 +26,7 @@ const mockMeds = [
|
||||
id: 2,
|
||||
name: "Vitamin D",
|
||||
genericName: null,
|
||||
packageType: "blister" as const,
|
||||
packCount: 0,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 30,
|
||||
@@ -1442,4 +1444,177 @@ describe("MedicationsPage form saved state", () => {
|
||||
|
||||
expect(screen.getByText(/common\.saved/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows stock overflow warning when medsLeft exceeds package size", () => {
|
||||
const overflowMed = {
|
||||
...mockMeds[0],
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 0,
|
||||
};
|
||||
|
||||
mockContextValue = createMockContext({
|
||||
meds: [overflowMed],
|
||||
coverageByMed: {
|
||||
[overflowMed.name]: {
|
||||
name: overflowMed.name,
|
||||
medsLeft: 25,
|
||||
daysLeft: 25,
|
||||
depletionDate: "2024-02-01",
|
||||
depletionTime: Date.now() + 25 * 86400000,
|
||||
nextDose: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<MedicationsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// packageSize = 1*1*10 + 0 = 10, medsLeft = 25 > 10 → warning shown
|
||||
const warningIcon = document.querySelector(".med-total .info-tooltip.tooltip-align-left.warning-text");
|
||||
expect(warningIcon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show stock overflow warning when stock is within capacity", () => {
|
||||
const normalMed = {
|
||||
...mockMeds[0],
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 30,
|
||||
looseTablets: 0,
|
||||
};
|
||||
|
||||
mockContextValue = createMockContext({
|
||||
meds: [normalMed],
|
||||
coverageByMed: {
|
||||
[normalMed.name]: {
|
||||
name: normalMed.name,
|
||||
medsLeft: 20,
|
||||
daysLeft: 20,
|
||||
depletionDate: "2024-02-01",
|
||||
depletionTime: Date.now() + 20 * 86400000,
|
||||
nextDose: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<MedicationsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// packageSize = 30, medsLeft = 20 < 30 → no warning
|
||||
const warningIcon = document.querySelector(".med-total .info-tooltip.tooltip-align-left.warning-text");
|
||||
expect(warningIcon).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("MedicationsPage bottle package type", () => {
|
||||
const bottleMed = {
|
||||
id: 3,
|
||||
name: "Ibuprofen",
|
||||
genericName: null,
|
||||
packageType: "bottle" as const,
|
||||
packCount: 0,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 1,
|
||||
looseTablets: 150,
|
||||
totalPills: 200,
|
||||
takenBy: [],
|
||||
blisters: [{ usage: 1, every: 1, start: "2024-01-01T09:00:00Z" }],
|
||||
intakeRemindersEnabled: false,
|
||||
notes: null,
|
||||
expiryDate: null,
|
||||
imageUrl: null,
|
||||
updatedAt: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
mockContextValue = createMockContext({ meds: [bottleMed] });
|
||||
mockFormHookValue = createMockFormHook();
|
||||
});
|
||||
|
||||
it("shows bottle type and capacity instead of blister fields in med-details", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<MedicationsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const medDetails = document.querySelector(".med-details");
|
||||
expect(medDetails).toBeInTheDocument();
|
||||
|
||||
// Should show type and capacity for bottle
|
||||
expect(medDetails!.textContent).toContain("form.packageTypeBottle");
|
||||
expect(medDetails!.textContent).toContain("medications.details.totalCapacity");
|
||||
|
||||
// Should NOT show blister-specific fields
|
||||
expect(medDetails!.textContent).not.toContain("medications.details.blisters");
|
||||
expect(medDetails!.textContent).not.toContain("medications.details.pillsPerBlister");
|
||||
});
|
||||
|
||||
it("shows pills-only refill form for bottle type when editing", () => {
|
||||
mockFormHookValue = createMockFormHook({
|
||||
editingId: 3,
|
||||
form: {
|
||||
...createMockFormHook().form,
|
||||
packageType: "bottle" as const,
|
||||
totalPills: "200",
|
||||
looseTablets: "150",
|
||||
},
|
||||
});
|
||||
mockContextValue = createMockContext({ meds: [bottleMed] });
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<MedicationsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Should show "pillsToAdd" label for bottle
|
||||
expect(screen.getByText(/refill\.pillsToAdd/i)).toBeInTheDocument();
|
||||
|
||||
// Should NOT show "packs" label in refill
|
||||
const refillSection = document.querySelector(".refill-section");
|
||||
expect(refillSection).toBeInTheDocument();
|
||||
expect(refillSection!.textContent).not.toContain("refill.packs");
|
||||
});
|
||||
});
|
||||
|
||||
describe("MedicationsPage blister refill shows packs", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
mockContextValue = createMockContext({ meds: mockMeds });
|
||||
mockFormHookValue = createMockFormHook({
|
||||
editingId: 1,
|
||||
form: {
|
||||
...createMockFormHook().form,
|
||||
packageType: "blister" as const,
|
||||
packCount: "1",
|
||||
blistersPerPack: "2",
|
||||
pillsPerBlister: "10",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("shows packs and loose pills refill fields for blister type", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<MedicationsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const refillSection = document.querySelector(".refill-section");
|
||||
expect(refillSection).toBeInTheDocument();
|
||||
expect(refillSection!.textContent).toContain("refill.packs");
|
||||
expect(refillSection!.textContent).toContain("refill.loosePills");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PlannerPage } from "../../pages/PlannerPage";
|
||||
@@ -481,3 +481,101 @@ describe("PlannerPage medication detail", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("PlannerPage bottle package type", () => {
|
||||
const bottlePlannerRows = [
|
||||
{
|
||||
medicationId: 3,
|
||||
medicationName: "Ibuprofen",
|
||||
totalPills: 60,
|
||||
plannerUsage: 20,
|
||||
blisterSize: 1,
|
||||
blistersNeeded: 0,
|
||||
fullBlisters: 0,
|
||||
loosePills: 20,
|
||||
enough: true,
|
||||
packageType: "bottle" as const,
|
||||
},
|
||||
];
|
||||
|
||||
const blisterPlannerRows = [
|
||||
{
|
||||
medicationId: 1,
|
||||
medicationName: "Aspirin",
|
||||
totalPills: 60,
|
||||
plannerUsage: 20,
|
||||
blisterSize: 10,
|
||||
blistersNeeded: 2,
|
||||
fullBlisters: 2,
|
||||
loosePills: 0,
|
||||
enough: true,
|
||||
packageType: "blister" as const,
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
mockContextValue = createMockContext({ meds: mockMeds });
|
||||
});
|
||||
|
||||
it("shows dash for blisters column when bottle type", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(bottlePlannerRows),
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PlannerPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Submit the form to trigger the planner calculation
|
||||
const form = document.querySelector("form.planner");
|
||||
expect(form).toBeInTheDocument();
|
||||
await act(async () => {
|
||||
fireEvent.submit(form!);
|
||||
});
|
||||
|
||||
// For bottle type, blisters column should show "–"
|
||||
await waitFor(() => {
|
||||
const tableRows = document.querySelectorAll(".table-row");
|
||||
expect(tableRows.length).toBeGreaterThan(0);
|
||||
});
|
||||
const tableRows = document.querySelectorAll(".table-row");
|
||||
const bottleRow = Array.from(tableRows).find((row) => row.textContent?.includes("Ibuprofen"));
|
||||
expect(bottleRow).toBeTruthy();
|
||||
expect(bottleRow!.textContent).toContain("–");
|
||||
});
|
||||
|
||||
it("shows blisters calculation for blister type", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(blisterPlannerRows),
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PlannerPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Submit the form to trigger the planner calculation
|
||||
const form = document.querySelector("form.planner");
|
||||
expect(form).toBeInTheDocument();
|
||||
await act(async () => {
|
||||
fireEvent.submit(form!);
|
||||
});
|
||||
|
||||
// For blister type, should show "2 × 10"
|
||||
await waitFor(() => {
|
||||
const tableRows = document.querySelectorAll(".table-row");
|
||||
expect(tableRows.length).toBeGreaterThan(0);
|
||||
});
|
||||
const tableRows = document.querySelectorAll(".table-row");
|
||||
const blisterRow = Array.from(tableRows).find((row) => row.textContent?.includes("Aspirin"));
|
||||
expect(blisterRow).toBeTruthy();
|
||||
expect(blisterRow!.textContent).toContain("2 × 10");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,6 +30,7 @@ const createMockContext = (overrides = {}) => ({
|
||||
skipReminderIfTaken: true,
|
||||
skipRemindersForTakenDoses: false,
|
||||
stockCalculationMode: "automatic",
|
||||
shareStockStatus: true,
|
||||
stockCheckTime: "08:00",
|
||||
intakeReminderTime: "09:00",
|
||||
},
|
||||
@@ -635,6 +636,58 @@ describe("SettingsPage stock calculation mode", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("SettingsPage share stock status", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockContextValue = createMockContext({
|
||||
settings: {
|
||||
...createMockContext().settings,
|
||||
shareStockStatus: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("renders share stock status toggle", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SettingsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/settings\.stock\.shareStockStatus$/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("toggles share stock status setting", () => {
|
||||
const setSettings = vi.fn();
|
||||
mockContextValue = createMockContext({
|
||||
setSettings,
|
||||
settings: {
|
||||
...createMockContext().settings,
|
||||
shareStockStatus: true,
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SettingsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Find the toggle by its associated label text
|
||||
const label = screen.getByText(/settings\.stock\.shareStockStatus$/);
|
||||
const settingRow = label.closest(".setting-row");
|
||||
const checkbox = settingRow?.querySelector('input[type="checkbox"]') as HTMLInputElement;
|
||||
|
||||
expect(checkbox).toBeTruthy();
|
||||
expect(checkbox.checked).toBe(true);
|
||||
|
||||
// Toggle it off
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
expect(setSettings).toHaveBeenCalledWith(expect.objectContaining({ shareStockStatus: false }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("SettingsPage repeat reminders", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -898,7 +951,7 @@ describe("SettingsPage schedule overview", () => {
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/settings\.schedule\.lastSent/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/settings\.schedule\.lastIntakeSent/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -964,7 +1017,8 @@ describe("SettingsPage stock display thresholds", () => {
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/settings\.stock\.lowStockDays/i)).toBeInTheDocument();
|
||||
// Low stock is now shown as a chip label, not plain text
|
||||
expect(screen.getByText(/status\.lowStock/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows high stock days input", () => {
|
||||
@@ -974,7 +1028,8 @@ describe("SettingsPage stock display thresholds", () => {
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/settings\.stock\.highStockDays/i)).toBeInTheDocument();
|
||||
// High stock is now shown as a chip label, not plain text
|
||||
expect(screen.getByText(/status\.highStock/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("allows changing high stock days", () => {
|
||||
@@ -1011,14 +1066,14 @@ describe("SettingsPage repeat daily reminders", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows repeat daily reminders toggle", () => {
|
||||
it("shows repeat daily reminders toggle in notifications", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SettingsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/settings\.stock\.repeatDaily/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/settings\.stockReminder\.repeatDaily/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1154,6 +1209,237 @@ describe("SettingsPage importing state", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("SettingsPage stock threshold chips", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockContextValue = createMockContext();
|
||||
});
|
||||
|
||||
it("renders Critical stock chip", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SettingsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Critical chip appears in both Stock Thresholds and Notification trigger
|
||||
const criticalChips = screen.getAllByText(/status\.criticalStock/i);
|
||||
expect(criticalChips.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("renders Low stock chip", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SettingsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/status\.lowStock/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders High stock chip", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SettingsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/status\.highStock/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders stock calculation mode first in stock card", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SettingsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/settings\.stock\.calculationMode/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders thresholds section header", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SettingsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/settings\.stock\.thresholds/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders three threshold inputs (Critical, Low, High)", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SettingsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Should have a threshold-chips-group with 3 labels
|
||||
const chipGroup = document.querySelector(".threshold-chips-group");
|
||||
expect(chipGroup).toBeInTheDocument();
|
||||
const inputs = chipGroup?.querySelectorAll('input[type="number"]');
|
||||
expect(inputs?.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SettingsPage stock threshold validation", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows validation error when Critical >= Low", () => {
|
||||
mockContextValue = createMockContext({
|
||||
settings: {
|
||||
...createMockContext().settings,
|
||||
reminderDaysBefore: 30,
|
||||
lowStockDays: 30,
|
||||
highStockDays: 180,
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SettingsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/settings\.stock\.thresholdValidation/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows validation error when Low >= High", () => {
|
||||
mockContextValue = createMockContext({
|
||||
settings: {
|
||||
...createMockContext().settings,
|
||||
reminderDaysBefore: 7,
|
||||
lowStockDays: 200,
|
||||
highStockDays: 180,
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SettingsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/settings\.stock\.thresholdValidation/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show validation error when thresholds are valid", () => {
|
||||
mockContextValue = createMockContext({
|
||||
settings: {
|
||||
...createMockContext().settings,
|
||||
reminderDaysBefore: 7,
|
||||
lowStockDays: 30,
|
||||
highStockDays: 180,
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SettingsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.queryByText(/settings\.stock\.thresholdValidation/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables save button when thresholds are invalid", () => {
|
||||
mockContextValue = createMockContext({
|
||||
settings: {
|
||||
...createMockContext().settings,
|
||||
reminderDaysBefore: 30,
|
||||
lowStockDays: 30,
|
||||
highStockDays: 180,
|
||||
},
|
||||
settingsChanged: true,
|
||||
settingsSaved: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SettingsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const submitBtn = document.querySelector('button[type="submit"]');
|
||||
expect(submitBtn).toBeDisabled();
|
||||
});
|
||||
|
||||
it("enables save button when thresholds are valid and changes exist", () => {
|
||||
mockContextValue = createMockContext({
|
||||
settings: {
|
||||
...createMockContext().settings,
|
||||
reminderDaysBefore: 7,
|
||||
lowStockDays: 30,
|
||||
highStockDays: 180,
|
||||
},
|
||||
settingsChanged: true,
|
||||
settingsSaved: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SettingsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const submitBtn = document.querySelector('button[type="submit"]');
|
||||
expect(submitBtn).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("marks invalid threshold input with error styling", () => {
|
||||
mockContextValue = createMockContext({
|
||||
settings: {
|
||||
...createMockContext().settings,
|
||||
reminderDaysBefore: 30,
|
||||
lowStockDays: 30,
|
||||
highStockDays: 180,
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SettingsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const invalidLabels = document.querySelectorAll(".threshold-invalid");
|
||||
expect(invalidLabels.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SettingsPage stock reminder in notifications", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockContextValue = createMockContext();
|
||||
});
|
||||
|
||||
it("renders stock reminder section in notifications card", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SettingsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/settings\.stockReminder\.title/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders stock reminder description with Critical chip", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SettingsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/settings\.stockReminder\.description/i)).toBeInTheDocument();
|
||||
// Critical chip should appear next to the description text
|
||||
const descLabel = screen.getByText(/settings\.stockReminder\.description/i);
|
||||
const criticalChip = descLabel.querySelector(".status-chip.danger");
|
||||
expect(criticalChip).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SettingsPage no SMTP configured", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -59,6 +59,44 @@ describe("getMedTotal", () => {
|
||||
|
||||
expect(getMedTotal(med)).toBe(0);
|
||||
});
|
||||
|
||||
it("calculates bottle type from looseTablets only", () => {
|
||||
const med = {
|
||||
packageType: "bottle" as const,
|
||||
packCount: 0,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 1,
|
||||
looseTablets: 150,
|
||||
};
|
||||
|
||||
expect(getMedTotal(med)).toBe(150);
|
||||
});
|
||||
|
||||
it("calculates bottle type with stock adjustment", () => {
|
||||
const med = {
|
||||
packageType: "bottle" as const,
|
||||
packCount: 0,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 1,
|
||||
looseTablets: 150,
|
||||
stockAdjustment: -10,
|
||||
};
|
||||
|
||||
expect(getMedTotal(med)).toBe(140); // 150 + (-10) = 140
|
||||
});
|
||||
|
||||
it("ignores blister fields for bottle type", () => {
|
||||
const med = {
|
||||
packageType: "bottle" as const,
|
||||
packCount: 5,
|
||||
blistersPerPack: 10,
|
||||
pillsPerBlister: 20,
|
||||
looseTablets: 80,
|
||||
};
|
||||
|
||||
// Should use looseTablets only, NOT 5*10*20 + 80 = 1080
|
||||
expect(getMedTotal(med)).toBe(80);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPackageSize", () => {
|
||||
@@ -84,6 +122,32 @@ describe("getPackageSize", () => {
|
||||
|
||||
expect(getPackageSize(med)).toBe(10);
|
||||
});
|
||||
|
||||
it("returns looseTablets for bottle type", () => {
|
||||
const med = {
|
||||
packageType: "bottle" as const,
|
||||
packCount: 0,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 1,
|
||||
looseTablets: 200,
|
||||
};
|
||||
|
||||
expect(getPackageSize(med)).toBe(200);
|
||||
});
|
||||
|
||||
it("ignores blister fields for bottle type", () => {
|
||||
const med = {
|
||||
packageType: "bottle" as const,
|
||||
packCount: 5,
|
||||
blistersPerPack: 10,
|
||||
pillsPerBlister: 20,
|
||||
looseTablets: 80,
|
||||
stockAdjustment: 50,
|
||||
};
|
||||
|
||||
// Should use looseTablets only, ignore stockAdjustment and blister math
|
||||
expect(getPackageSize(med)).toBe(80);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FIELD_LIMITS", () => {
|
||||
|
||||
Reference in New Issue
Block a user