feat: reports, timeline toggles, and stock correction improvements (#236)

* refactor(frontend): modularize styles and polish modal/ui interactions

* feat: add report workflow and timeline/settings improvements

* fix: resolve CI failures for backend typing, lint, and playwright config
This commit is contained in:
Daniel Volz
2026-02-20 18:52:59 +01:00
committed by GitHub
parent 89d565bc9d
commit 052751b2ba
74 changed files with 8815 additions and 4027 deletions
@@ -64,6 +64,8 @@ const defaultProps = {
onEditStockFullBlistersChange: vi.fn(),
editStockPartialBlisterPills: 0,
onEditStockPartialBlisterPillsChange: vi.fn(),
editStockLoosePills: 0,
onEditStockLoosePillsChange: vi.fn(),
editStockSaving: false,
onSubmitStockCorrection: vi.fn(),
};
@@ -100,7 +102,8 @@ describe("MedDetailModal", () => {
it("renders close button", () => {
render(<MedDetailModal {...defaultProps} />);
const closeBtn = screen.getByText("×");
const closeButtons = screen.getAllByRole("button", { name: /common\.close/i });
const closeBtn = closeButtons[0];
expect(closeBtn).toBeInTheDocument();
});
@@ -108,7 +111,8 @@ describe("MedDetailModal", () => {
const onClose = vi.fn();
render(<MedDetailModal {...defaultProps} onClose={onClose} />);
const closeBtn = screen.getByText("×");
const closeButtons = screen.getAllByRole("button", { name: /common\.close/i });
const closeBtn = closeButtons[0];
fireEvent.click(closeBtn);
expect(onClose).toHaveBeenCalledTimes(1);
@@ -144,6 +148,23 @@ describe("MedDetailModal", () => {
expect(screen.getByText("Test notes")).toBeInTheDocument();
});
it("shows loose pills in stock details for blister medications", () => {
const medWithLoose: Medication = {
...mockMedication,
pillsPerBlister: 5,
looseTablets: 2,
};
const coverageWithLoose: Coverage = {
...mockCoverage,
medsLeft: 50,
};
render(<MedDetailModal {...defaultProps} selectedMed={medWithLoose} coverage={{ all: [coverageWithLoose] }} />);
expect(screen.getByText("+ 2 modal.loosePills", { exact: false })).toBeInTheDocument();
});
it("shows prescription details section when prescription is enabled", () => {
const med: Medication = {
...mockMedication,
@@ -341,6 +362,26 @@ describe("MedDetailModal with refill modal", () => {
expect(onRefillPacksChange).toHaveBeenCalledWith(0);
expect(onRefillLooseChange).toHaveBeenCalledWith(0);
});
it("shows package size breakdown key for blister stock correction", () => {
render(<MedDetailModal {...defaultProps} showEditStockModal={true} />);
expect(screen.queryByText("editStock.packageSizeBreakdown")).not.toBeInTheDocument();
expect(document.querySelector(".edit-stock-live-breakdown")).toBeInTheDocument();
});
it("shows numeric package size text for bottle stock correction", () => {
const bottleMed: Medication = {
...mockMedication,
packageType: "bottle",
totalPills: 150,
looseTablets: 130,
};
render(<MedDetailModal {...defaultProps} selectedMed={bottleMed} showEditStockModal={true} />);
expect(screen.getByText("editStock.packageSize_150")).toBeInTheDocument();
});
});
describe("MedDetailModal actions", () => {
@@ -373,10 +414,18 @@ describe("MedDetailModal actions", () => {
const generateICSSpy = vi.spyOn(utils, "generateICS").mockImplementation(() => "BEGIN:VCALENDAR");
render(<MedDetailModal {...defaultProps} />);
fireEvent.click(screen.getByTitle("modal.exportTooltip"));
fireEvent.click(screen.getByRole("button", { name: /modal\.exportTooltip/i }));
expect(generateICSSpy).toHaveBeenCalledWith(mockMedication);
});
it("calls onOpenEditStockModal when stock correction icon is clicked", () => {
const onOpenEditStockModal = vi.fn();
render(<MedDetailModal {...defaultProps} onOpenEditStockModal={onOpenEditStockModal} />);
fireEvent.click(screen.getByRole("button", { name: /editStock\.buttonLabel/i }));
expect(onOpenEditStockModal).toHaveBeenCalledTimes(1);
});
it("does not render export calendar button when no blisters exist", () => {
const medWithoutBlisters: Medication = {
...mockMedication,
@@ -384,7 +433,7 @@ describe("MedDetailModal actions", () => {
};
render(<MedDetailModal {...defaultProps} selectedMed={medWithoutBlisters} />);
expect(screen.queryByTitle("modal.exportTooltip")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /modal\.exportTooltip/i })).not.toBeInTheDocument();
});
});
@@ -465,6 +514,23 @@ describe("MedDetailModal nested modal overlays", () => {
fireEvent.click(overlays[1]);
expect(onCloseEditStockModal).toHaveBeenCalledTimes(1);
});
it("renders only edit stock modal in editStockOnly mode", () => {
render(<MedDetailModal {...defaultProps} showEditStockModal={true} editStockOnly={true} />);
expect(screen.getByText("editStock.title")).toBeInTheDocument();
expect(screen.queryByText("form.sections.schedule")).not.toBeInTheDocument();
});
it("closes edit stock modal on Escape", () => {
const onCloseEditStockModal = vi.fn();
render(
<MedDetailModal {...defaultProps} showEditStockModal={true} onCloseEditStockModal={onCloseEditStockModal} />
);
fireEvent.keyDown(document, { key: "Escape" });
expect(onCloseEditStockModal).toHaveBeenCalled();
});
});
describe("MedDetailModal with low stock", () => {
@@ -592,12 +658,93 @@ describe("MedDetailModal intake schedule usage display", () => {
});
});
describe("MedDetailModal partial blister normalization", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("carries partial pills into full blisters when partial reaches pillsPerBlister", () => {
const onEditStockFullBlistersChange = vi.fn();
const onEditStockPartialBlisterPillsChange = vi.fn();
const blisterMed: Medication = {
...mockMedication,
packCount: 10,
blistersPerPack: 5,
pillsPerBlister: 5,
looseTablets: 0,
};
// full=12, partial=4 (one below pillsPerBlister)
render(
<MedDetailModal
{...defaultProps}
selectedMed={blisterMed}
showEditStockModal={true}
editStockFullBlisters={12}
editStockPartialBlisterPills={4}
editStockLoosePills={0}
onEditStockFullBlistersChange={onEditStockFullBlistersChange}
onEditStockPartialBlisterPillsChange={onEditStockPartialBlisterPillsChange}
/>
);
// Find the increment button for the partial blister pills stepper
// The partial stepper is the second stepper in the modal
const incrementButtons = document.querySelectorAll(".stepper-btn.increment");
const partialIncrementBtn = incrementButtons[1]; // full[0], partial[1], loose[2]
expect(partialIncrementBtn).not.toBeDisabled();
// Press + on partial: 4 → 5 = pillsPerBlister → normalization carries to full
fireEvent.click(partialIncrementBtn);
// full should have been called with 13 (12 + 1 carry)
expect(onEditStockFullBlistersChange).toHaveBeenCalledWith(13);
// partial should have been called with 0 (5 % 5 = 0)
expect(onEditStockPartialBlisterPillsChange).toHaveBeenCalledWith(0);
});
it("does not carry partial pills into full when below pillsPerBlister", () => {
const onEditStockFullBlistersChange = vi.fn();
const onEditStockPartialBlisterPillsChange = vi.fn();
const blisterMed: Medication = {
...mockMedication,
packCount: 10,
blistersPerPack: 5,
pillsPerBlister: 5,
looseTablets: 0,
};
// full=12, partial=0
render(
<MedDetailModal
{...defaultProps}
selectedMed={blisterMed}
showEditStockModal={true}
editStockFullBlisters={12}
editStockPartialBlisterPills={0}
editStockLoosePills={0}
onEditStockFullBlistersChange={onEditStockFullBlistersChange}
onEditStockPartialBlisterPillsChange={onEditStockPartialBlisterPillsChange}
/>
);
const incrementButtons = document.querySelectorAll(".stepper-btn.increment");
const partialIncrementBtn = incrementButtons[1];
fireEvent.click(partialIncrementBtn);
// full should not change (1 partial pill with pbb=5 is NOT a carry)
expect(onEditStockFullBlistersChange).toHaveBeenCalledWith(12);
// partial should go to 1
expect(onEditStockPartialBlisterPillsChange).toHaveBeenCalledWith(1);
});
});
describe("MedDetailModal stock overflow warning", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("shows warning icon when stock exceeds package capacity", () => {
it("does not show overflow warning icon with live stock denominator", () => {
const overflowCoverage: Coverage = {
name: "Test Med",
medsLeft: 49,
@@ -609,10 +756,9 @@ describe("MedDetailModal stock overflow warning", () => {
render(<MedDetailModal {...defaultProps} coverage={{ all: [overflowCoverage] }} />);
// packageSize = 1 * 1 * 30 + 0 = 30, currentStock = 49 > 30
// Live denominator uses current stock, so overflow warning is not shown in detail row.
const warningIcon = document.querySelector(".info-tooltip.tooltip-align-left.warning-text");
expect(warningIcon).toBeInTheDocument();
expect(warningIcon?.getAttribute("data-tooltip")).toBe("tooltips.stockExceedsCapacity");
expect(warningIcon).not.toBeInTheDocument();
});
it("does not show warning icon when stock is within package capacity", () => {