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
@@ -92,4 +92,22 @@ describe("ConfirmModal", () => {
const confirmBtn = screen.getByText("Yes");
expect(confirmBtn.className).toContain("success");
});
it("applies warning variant when specified", () => {
render(<ConfirmModal {...defaultProps} confirmVariant="warning" />);
const confirmBtn = screen.getByText("Yes");
expect(confirmBtn.className).toContain("warning");
});
it("applies custom overlay class", () => {
const { container } = render(<ConfirmModal {...defaultProps} overlayClassName="nested-confirm" />);
const overlay = container.querySelector(".modal-overlay");
expect(overlay?.className).toContain("nested-confirm");
});
it("calls onCancel when Escape is pressed", () => {
render(<ConfirmModal {...defaultProps} />);
fireEvent.keyDown(document, { key: "Escape" });
expect(defaultProps.onCancel).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,68 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { DateInput } from "../../components/DateInput";
vi.mock("../../utils/formatters", () => ({
formatDate: vi.fn(() => "14.02.2026"),
getNumericLocale: vi.fn(() => "de-DE"),
}));
describe("DateInput", () => {
it("renders placeholder display when value is empty", () => {
render(<DateInput value="" onChange={vi.fn()} placeholder="Select date" />);
expect(screen.getByText("Select date")).toBeInTheDocument();
expect(screen.getByDisplayValue("")).toHaveAttribute("type", "date");
});
it("renders formatted date display when value exists", () => {
render(<DateInput value="2026-02-14" onChange={vi.fn()} />);
expect(screen.getByText("14.02.2026")).toBeInTheDocument();
expect(screen.getByDisplayValue("2026-02-14")).toBeInTheDocument();
});
it("tries showPicker on wrapper click", () => {
render(<DateInput value="2026-02-14" onChange={vi.fn()} />);
const input = screen.getByDisplayValue("2026-02-14") as HTMLInputElement & {
showPicker?: () => void;
};
const showPicker = vi.fn();
input.showPicker = showPicker;
fireEvent.click(input.closest(".date-input-wrapper") as HTMLElement);
expect(showPicker).toHaveBeenCalledTimes(1);
});
it("falls back to focus when showPicker throws", () => {
render(<DateInput value="2026-02-14" onChange={vi.fn()} />);
const input = screen.getByDisplayValue("2026-02-14") as HTMLInputElement & {
showPicker?: () => void;
};
input.showPicker = vi.fn(() => {
throw new Error("showPicker not supported");
});
const focusSpy = vi.spyOn(input, "focus").mockImplementation(() => {});
fireEvent.click(input.closest(".date-input-wrapper") as HTMLElement);
expect(focusSpy).toHaveBeenCalledTimes(1);
});
it("triggers picker fallback on Enter and Space", () => {
render(<DateInput value="2026-02-14" onChange={vi.fn()} />);
const input = screen.getByDisplayValue("2026-02-14") as HTMLInputElement & {
showPicker?: () => void;
};
const showPicker = vi.fn();
input.showPicker = showPicker;
const wrapper = input.closest(".date-input-wrapper") as HTMLElement;
fireEvent.keyDown(wrapper, { key: "Enter" });
fireEvent.keyDown(wrapper, { key: " " });
expect(showPicker).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,40 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { DateTimeInput } from "../../components/DateTimeInput";
vi.mock("../../utils/formatters", () => ({
formatDateTime: vi.fn(() => "14.02.2026, 20:30"),
getNumericLocale: vi.fn(() => "de-DE"),
}));
describe("DateTimeInput", () => {
it("renders placeholder when value is empty", () => {
render(<DateTimeInput value="" onChange={vi.fn()} placeholder="Select date time" />);
expect(screen.getByText("Select date time")).toBeInTheDocument();
expect(screen.getByDisplayValue("")).toHaveAttribute("type", "datetime-local");
});
it("renders formatted datetime display", () => {
render(<DateTimeInput value="2026-02-14T20:30" onChange={vi.fn()} />);
expect(screen.getByText("14.02.2026, 20:30")).toBeInTheDocument();
expect(screen.getByDisplayValue("2026-02-14T20:30")).toBeInTheDocument();
});
it("uses picker on click and keyboard", () => {
render(<DateTimeInput value="2026-02-14T20:30" onChange={vi.fn()} />);
const input = screen.getByDisplayValue("2026-02-14T20:30") as HTMLInputElement & {
showPicker?: () => void;
};
const showPicker = vi.fn();
input.showPicker = showPicker;
const wrapper = input.closest(".date-input-wrapper") as HTMLElement;
fireEvent.click(wrapper);
fireEvent.keyDown(wrapper, { key: "Enter" });
expect(showPicker).toHaveBeenCalledTimes(2);
});
});
@@ -46,6 +46,15 @@ describe("Lightbox", () => {
expect(onClose).toHaveBeenCalled();
});
it("calls onClose when Escape key is pressed", () => {
const onClose = vi.fn();
render(<Lightbox {...defaultProps} onClose={onClose} />);
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalled();
});
it("does not call onClose when image is clicked", () => {
const onClose = vi.fn();
render(<Lightbox {...defaultProps} onClose={onClose} />);
@@ -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", () => {
@@ -170,10 +170,11 @@ describe("MobileEditModal", () => {
expect(screen.getByText(/form\.pillsPerBlister/i)).toBeInTheDocument();
});
it("renders loose tablets input", () => {
it("does not render loose tablets input in package section", () => {
render(<MobileEditModal {...defaultProps} />);
expect(screen.getByText(/form\.loose/i)).toBeInTheDocument();
expect(screen.queryByText(/form\.loosePills/i)).not.toBeInTheDocument();
expect(screen.getByText(/form\.total/i)).toBeInTheDocument();
});
it("renders intake schedules section", () => {
@@ -206,14 +207,14 @@ describe("MobileEditModal", () => {
it("renders add intake button", () => {
render(<MobileEditModal {...defaultProps} />);
expect(screen.getByText(/form\.blisters\.addIntake/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /form\.blisters\.addIntake/i })).toBeInTheDocument();
});
it("calls onAddIntake when add intake clicked", () => {
const onAddIntake = vi.fn();
render(<MobileEditModal {...defaultProps} onAddIntake={onAddIntake} />);
const addBtn = screen.getByText(/form\.blisters\.addIntake/i);
const addBtn = screen.getByRole("button", { name: /form\.blisters\.addIntake/i });
fireEvent.click(addBtn);
expect(onAddIntake).toHaveBeenCalledTimes(1);
@@ -698,7 +699,7 @@ describe("MobileEditModal optional fields", () => {
render(<MobileEditModal {...defaultProps} form={form} onAddIntake={onAddIntake} />);
fireEvent.click(screen.getByText(/form\.blisters\.addIntake/i));
fireEvent.click(screen.getByRole("button", { name: /form\.blisters\.addIntake/i }));
expect(onAddIntake).toHaveBeenCalledWith("OnlyPerson");
});
});
@@ -714,29 +715,6 @@ describe("MobileEditModal bottle package type", () => {
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} />);
@@ -752,7 +730,7 @@ describe("MobileEditModal bottle package type", () => {
});
});
describe("MobileEditModal refill and image actions", () => {
describe("MobileEditModal image actions", () => {
const baseMed = {
id: 1,
name: "Aspirin",
@@ -776,52 +754,6 @@ describe("MobileEditModal refill and image actions", () => {
imageUrl: null,
};
it("calls onSubmitRefill when refill button is clicked", () => {
const onSubmitRefill = vi.fn().mockResolvedValue(undefined);
render(
<MobileEditModal
{...defaultProps}
editingId={1}
meds={[baseMed]}
refillLoose={2}
onSubmitRefill={onSubmitRefill}
/>
);
fireEvent.click(screen.getByRole("button", { name: /refill\.button/i }));
expect(onSubmitRefill).toHaveBeenCalledWith(1);
});
it("disables refill button when refill values are empty", () => {
render(<MobileEditModal {...defaultProps} editingId={1} meds={[baseMed]} refillPacks={0} refillLoose={0} />);
const refillButton = screen.getByRole("button", { name: /refill\.button/i });
expect(refillButton).toBeDisabled();
});
it("shows refill preview for singular pill", () => {
render(<MobileEditModal {...defaultProps} editingId={1} meds={[baseMed]} refillPacks={0} refillLoose={1} />);
expect(document.querySelector(".refill-preview")?.textContent).toContain("+1 common.pill");
});
it("disables refill button while refill is saving", () => {
render(
<MobileEditModal
{...defaultProps}
editingId={1}
meds={[baseMed]}
refillPacks={1}
refillLoose={0}
refillSaving={true}
/>
);
const refillButton = screen.getByRole("button", { name: /common\.saving/i });
expect(refillButton).toBeDisabled();
});
it("calls onUploadMedImage when selecting a file", () => {
const onUploadMedImage = vi.fn().mockResolvedValue(undefined);
render(<MobileEditModal {...defaultProps} editingId={1} meds={[baseMed]} onUploadMedImage={onUploadMedImage} />);
@@ -65,4 +65,28 @@ describe("ProfileModal", () => {
expect(onClose).not.toHaveBeenCalled();
});
it("calls onClose when Escape is pressed on overlay", () => {
const onClose = vi.fn();
render(<ProfileModal isOpen={true} onClose={onClose} />);
const overlay = document.querySelector(".modal-overlay");
if (overlay) {
fireEvent.keyDown(overlay, { key: "Escape" });
}
expect(onClose).toHaveBeenCalledTimes(1);
});
it("does not close on non-escape keydown", () => {
const onClose = vi.fn();
render(<ProfileModal isOpen={true} onClose={onClose} />);
const overlay = document.querySelector(".modal-overlay");
if (overlay) {
fireEvent.keyDown(overlay, { key: "Enter" });
}
expect(onClose).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,147 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import ReportModal from "../../components/ReportModal";
import type { Medication } from "../../types";
function createMedication(overrides: Partial<Medication> = {}): Medication {
return {
id: 1,
name: "Aspirin",
genericName: "Acetylsalicylic acid",
takenBy: ["Alice"],
packageType: "blister",
packCount: 2,
blistersPerPack: 2,
pillsPerBlister: 10,
looseTablets: 0,
blisters: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00.000Z" }],
updatedAt: null,
...overrides,
};
}
describe("ReportModal", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders and closes when cancel is clicked", () => {
const onClose = vi.fn();
render(<ReportModal isOpen={true} onClose={onClose} medications={[createMedication()]} />);
expect(screen.getByText(/report\.title/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /common\.cancel/i }));
expect(onClose).toHaveBeenCalledTimes(1);
});
it("generates text report and closes modal", async () => {
const onClose = vi.fn();
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: async () => ({
1: {
dosesTaken: 2,
dosesDismissed: 0,
firstDoseAt: "2026-01-01T08:00:00.000Z",
lastDoseAt: "2026-01-02T08:00:00.000Z",
refills: [],
},
}),
});
render(<ReportModal isOpen={true} onClose={onClose} medications={[createMedication()]} />);
fireEvent.click(screen.getByRole("radio", { name: /report\.formatTxt/i }));
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
"/api/medications/report-data",
expect.objectContaining({ method: "POST" })
);
});
expect(onClose).toHaveBeenCalledTimes(1);
expect(URL.createObjectURL).toHaveBeenCalled();
});
it("generates printable report when PDF format is selected", async () => {
const onClose = vi.fn();
const mockWrite = vi.fn();
const mockClose = vi.fn();
const mockPrint = vi.fn();
const openSpy = vi.spyOn(window, "open").mockReturnValue({
document: {
write: mockWrite,
close: mockClose,
},
onload: null,
print: mockPrint,
} as unknown as Window);
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: async () => ({
1: {
dosesTaken: 0,
dosesDismissed: 0,
firstDoseAt: null,
lastDoseAt: null,
refills: [],
},
}),
});
render(<ReportModal isOpen={true} onClose={onClose} medications={[createMedication()]} />);
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
await waitFor(() => {
expect(openSpy).toHaveBeenCalled();
expect(mockWrite).toHaveBeenCalled();
expect(mockClose).toHaveBeenCalled();
});
expect(onClose).toHaveBeenCalledTimes(1);
});
it("shows person filter and supports deselect/select all", () => {
const onClose = vi.fn();
render(
<ReportModal
isOpen={true}
onClose={onClose}
medications={[
createMedication({ id: 1, name: "Alice Med", takenBy: ["Alice"] }),
createMedication({ id: 2, name: "Bob Med", takenBy: ["Bob"] }),
]}
/>
);
expect(screen.getByText(/report\.filterByPerson/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole("checkbox", { name: "Alice" }));
expect(screen.getByText("Alice Med")).toBeInTheDocument();
expect(screen.queryByText("Bob Med")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /report\.deselectAll/i }));
expect(screen.getByRole("button", { name: /report\.generate/i })).toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: /report\.selectAll/i }));
expect(screen.getByRole("button", { name: /report\.generate/i })).not.toBeDisabled();
});
it("generates markdown report and keeps modal open on fetch error", async () => {
const onClose = vi.fn();
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({ ok: false });
render(<ReportModal isOpen={true} onClose={onClose} medications={[createMedication()]} />);
fireEvent.click(screen.getByRole("radio", { name: /report\.formatMd/i }));
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
await waitFor(() => {
expect(global.fetch).toHaveBeenCalled();
});
expect(onClose).not.toHaveBeenCalled();
});
});
@@ -54,7 +54,7 @@ describe("ShareDialog", () => {
it("calls onClose when close button is clicked", () => {
render(<ShareDialog {...defaultProps} />);
fireEvent.click(screen.getByText("×"));
fireEvent.click(screen.getByRole("button", { name: /common\.close/i }));
expect(defaultProps.onClose).toHaveBeenCalled();
});
@@ -73,13 +73,13 @@ describe("ShareDialog", () => {
it("calls onCopyShareLink when copy button is clicked", () => {
render(<ShareDialog {...defaultProps} shareLink="http://example.com/share/abc123" />);
fireEvent.click(screen.getByText("📋"));
fireEvent.click(screen.getByRole("button", { name: /share\.copyLink/i }));
expect(defaultProps.onCopyShareLink).toHaveBeenCalled();
});
it("shows copied indicator after copy", () => {
render(<ShareDialog {...defaultProps} shareLink="http://example.com/share/abc123" shareCopied={true} />);
expect(screen.getByText("✓")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /share\.copied/i })).toBeInTheDocument();
});
it("selects link text when input is clicked", () => {
@@ -1,4 +1,4 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SharedSchedule } from "../../components/SharedSchedule";
@@ -13,95 +13,22 @@ function renderSharedSchedule(path: string) {
);
}
function expandTodayIfCollapsed() {
const todayDivider = document.querySelector(".day-block.today .day-divider.clickable") as HTMLDivElement;
expect(todayDivider).toBeInTheDocument();
const todayBlock = document.querySelector(".day-block.today") as HTMLDivElement;
if (todayBlock?.classList.contains("collapsed")) {
fireEvent.click(todayDivider);
}
}
function createSharedData(overrides: Record<string, unknown> = {}) {
const now = new Date();
const yesterday = new Date(now);
yesterday.setDate(now.getDate() - 1);
yesterday.setHours(9, 0, 0, 0);
function createSharedData() {
return {
sharedBy: "Owner",
takenBy: "Max",
scheduleDays: 30,
shareStockStatus: true,
stockCalculationMode: "automatic",
stockThresholds: {
lowStockDays: 7,
normalStockDays: 30,
highStockDays: 90,
reminderDaysBefore: 7,
expiryWarningDays: 30,
},
medications: [
{
id: 1,
name: "Ibuprofen",
genericName: "Ibu",
takenBy: ["Max"],
packageType: "blister",
packCount: 1,
blistersPerPack: 2,
pillsPerBlister: 10,
looseTablets: 0,
pillWeightMg: null,
doseUnit: "mg",
expiryDate: null,
notes: null,
intakeRemindersEnabled: false,
blisters: [{ usage: 1, every: 1, start: yesterday.toISOString() }],
intakes: [
{ usage: 1, every: 1, start: yesterday.toISOString(), takenBy: "Max", intakeRemindersEnabled: false },
],
updatedAt: null,
dismissedUntil: null,
lastStockCorrectionAt: null,
},
],
...overrides,
medications: [],
};
}
function mockShareFetch(
token: string,
sharedData: Record<string, unknown>,
doses: Array<{ doseId: string; dismissed?: boolean }> = []
) {
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string, init?: RequestInit) => {
if (url === `/api/share/${token}/doses` && (!init || !init.method || init.method === "GET")) {
return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses }) });
}
if (url === `/api/share/${token}`) {
return Promise.resolve({ ok: true, json: () => Promise.resolve(sharedData) });
}
if (url === `/api/share/${token}/doses` && init?.method === "POST") {
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
}
if (url.startsWith(`/api/share/${token}/doses/`) && init?.method === "DELETE") {
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
}
return Promise.reject(new Error(`Unexpected URL: ${url}`));
});
}
describe.skip("SharedSchedule", () => {
describe("SharedSchedule", () => {
beforeEach(() => {
vi.clearAllMocks();
window.localStorage.clear();
vi.spyOn(global, "setInterval").mockImplementation(() => 1 as unknown as ReturnType<typeof setInterval>);
vi.spyOn(global, "clearInterval").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => {
const first = String(args[0] ?? "");
if (first.includes("not wrapped in act")) return;
});
vi.spyOn(globalThis, "setInterval").mockImplementation(() => 1 as unknown as ReturnType<typeof setInterval>);
vi.spyOn(globalThis, "clearInterval").mockImplementation(() => {});
});
afterEach(() => {
@@ -109,52 +36,27 @@ describe.skip("SharedSchedule", () => {
vi.restoreAllMocks();
});
it("closes theme menu when clicking outside", async () => {
const sharedData = createSharedData();
mockShareFetch("token-123", sharedData);
renderSharedSchedule("/share/token-123");
await waitFor(() => {
expect(screen.getByText(/share\.scheduleFor/i)).toBeInTheDocument();
});
fireEvent.click(screen.getByTitle("theme.title"));
expect(document.querySelector(".theme-menu.open")).toBeInTheDocument();
fireEvent.click(document.body);
expect(document.querySelector(".theme-menu.open")).not.toBeInTheDocument();
});
it("shows loading state initially", async () => {
let resolveShare: ((value: unknown) => void) | null = null;
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string) => {
if (url === "/api/share/token-123/doses") {
it("renders shared schedule shell for valid token", async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string, init?: RequestInit) => {
if (url === "/api/share/token-123/doses" && (!init || !init.method || init.method === "GET")) {
return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses: [] }) });
}
if (url === "/api/share/token-123") {
return new Promise((resolve) => {
resolveShare = resolve;
});
return Promise.resolve({ ok: true, json: () => Promise.resolve(createSharedData()) });
}
return Promise.reject(new Error(`Unexpected URL: ${url}`));
});
renderSharedSchedule("/share/token-123");
expect(screen.getByText("common.loading")).toBeInTheDocument();
resolveShare?.({
ok: true,
json: () => Promise.resolve(createSharedData()),
});
await waitFor(() => {
expect(screen.queryByText("common.loading")).not.toBeInTheDocument();
expect(screen.getByText(/share\.scheduleFor/i)).toBeInTheDocument();
expect(screen.getByText("share.noSchedule")).toBeInTheDocument();
});
});
it("renders not found error for 404 links", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string) => {
it("renders not found state for missing share link", async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string) => {
if (url === "/api/share/token-123/doses") {
return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses: [] }) });
}
@@ -171,26 +73,8 @@ describe.skip("SharedSchedule", () => {
});
});
it("renders generic error for unexpected status codes", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string) => {
if (url === "/api/share/token-123/doses") {
return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses: [] }) });
}
if (url === "/api/share/token-123") {
return Promise.resolve({ ok: false, status: 500, json: () => Promise.resolve({}) });
}
return Promise.reject(new Error(`Unexpected URL: ${url}`));
});
renderSharedSchedule("/share/token-123");
await waitFor(() => {
expect(screen.getByText("share.error")).toBeInTheDocument();
});
});
it("renders expired link state for 410 responses", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string) => {
it("renders expired state for expired share links", async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string) => {
if (url === "/api/share/token-123/doses") {
return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses: [] }) });
}
@@ -216,31 +100,13 @@ describe.skip("SharedSchedule", () => {
});
});
it("renders schedule shell for valid shared data", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string) => {
if (url === "/api/share/token-123/doses") {
it("renders generic error when loading share data fails", async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string, init?: RequestInit) => {
if (url === "/api/share/token-123/doses" && (!init || !init.method || init.method === "GET")) {
return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses: [] }) });
}
if (url === "/api/share/token-123") {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
sharedBy: "Owner",
takenBy: "Max",
scheduleDays: 30,
shareStockStatus: true,
stockCalculationMode: "automatic",
stockThresholds: {
lowStockDays: 7,
normalStockDays: 30,
highStockDays: 90,
reminderDaysBefore: 7,
expiryWarningDays: 30,
},
medications: [],
}),
});
return Promise.reject(new Error("network failed"));
}
return Promise.reject(new Error(`Unexpected URL: ${url}`));
});
@@ -248,265 +114,7 @@ describe.skip("SharedSchedule", () => {
renderSharedSchedule("/share/token-123");
await waitFor(() => {
expect(screen.getByText(/share.scheduleFor/i)).toBeInTheDocument();
expect(screen.getByText("share.noSchedule")).toBeInTheDocument();
expect(screen.getByText("share.error")).toBeInTheDocument();
});
});
it("opens theme menu and switches to light theme", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string) => {
if (url === "/api/share/token-123/doses") {
return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses: [] }) });
}
if (url === "/api/share/token-123") {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
sharedBy: "Owner",
takenBy: "Max",
scheduleDays: 30,
shareStockStatus: true,
stockCalculationMode: "automatic",
stockThresholds: {
lowStockDays: 7,
normalStockDays: 30,
highStockDays: 90,
reminderDaysBefore: 7,
expiryWarningDays: 30,
},
medications: [],
}),
});
}
return Promise.reject(new Error(`Unexpected URL: ${url}`));
});
renderSharedSchedule("/share/token-123");
await waitFor(() => {
expect(screen.getByText(/share.scheduleFor/i)).toBeInTheDocument();
});
fireEvent.click(screen.getByTitle("theme.title"));
fireEvent.click(screen.getByRole("button", { name: /theme\.light/i }));
expect(document.documentElement.getAttribute("data-theme")).toBe("light");
});
it("renders schedule rows for populated data and can expand future days", async () => {
const sharedData = createSharedData();
mockShareFetch("token-123", sharedData);
renderSharedSchedule("/share/token-123");
await waitFor(() => {
expect(screen.getByText(/share\.scheduleFor/i)).toBeInTheDocument();
expect(screen.getByText("Ibuprofen")).toBeInTheDocument();
});
const futureToggle = document.querySelector(".future-days-toggle");
expect(futureToggle).toBeInTheDocument();
fireEvent.click(futureToggle as Element);
await waitFor(() => {
expect(document.querySelectorAll(".day-block").length).toBeGreaterThan(1);
});
});
it("marks and undoes a dose via shared API", async () => {
const sharedData = createSharedData();
mockShareFetch("token-123", sharedData);
renderSharedSchedule("/share/token-123");
await waitFor(() => {
expect(screen.getByText("Ibuprofen")).toBeInTheDocument();
});
const takeButton = document.querySelector(".dose-btn.take:not([disabled])") as HTMLButtonElement;
expect(takeButton).toBeInTheDocument();
fireEvent.click(takeButton);
await waitFor(() => {
expect(global.fetch as ReturnType<typeof vi.fn>).toHaveBeenCalledWith(
"/api/share/token-123/doses",
expect.objectContaining({ method: "POST" })
);
});
});
it("undos a taken dose via shared API", async () => {
const sharedData = createSharedData();
const today = new Date();
const todayDateOnlyMs = new Date(today.getFullYear(), today.getMonth(), today.getDate()).getTime();
mockShareFetch("token-123", sharedData, [{ doseId: `1-0-${todayDateOnlyMs}-Max` }]);
renderSharedSchedule("/share/token-123");
await waitFor(() => {
expect(screen.getByText(/share\.scheduleFor/i)).toBeInTheDocument();
});
expandTodayIfCollapsed();
const undoButton = await waitFor(() => {
const button = document.querySelector(".dose-btn.undo") as HTMLButtonElement | null;
expect(button).toBeInTheDocument();
return button as HTMLButtonElement;
});
fireEvent.click(undoButton);
await waitFor(() => {
expect(
(global.fetch as ReturnType<typeof vi.fn>).mock.calls.some((call) => {
const [url, init] = call as [string, RequestInit | undefined];
return typeof url === "string" && url.includes("/api/share/token-123/doses/") && init?.method === "DELETE";
})
).toBe(true);
});
});
it("hides stock status chips when shareStockStatus is false", async () => {
const sharedData = createSharedData({ shareStockStatus: false });
mockShareFetch("token-123", sharedData);
renderSharedSchedule("/share/token-123");
await waitFor(() => {
expect(screen.getByText("Ibuprofen")).toBeInTheDocument();
});
expect(document.querySelector(".status-chip")).not.toBeInTheDocument();
});
it("opens and closes lightbox for medication image", async () => {
const pushStateSpy = vi.spyOn(window.history, "pushState").mockImplementation(() => {});
const backSpy = vi.spyOn(window.history, "back").mockImplementation(() => {});
const sharedData = createSharedData({
medications: [
{
...createSharedData().medications[0],
imageUrl: "ibuprofen.png",
},
],
});
mockShareFetch("token-123", sharedData);
renderSharedSchedule("/share/token-123");
await waitFor(() => {
expect(screen.getByText(/share\.scheduleFor/i)).toBeInTheDocument();
});
expandTodayIfCollapsed();
const avatar = await waitFor(() => {
const element = document.querySelector(".day-block.today .med-avatar.clickable") as HTMLDivElement | null;
expect(element).toBeInTheDocument();
return element as HTMLDivElement;
});
fireEvent.click(avatar);
expect(pushStateSpy).toHaveBeenCalled();
expect(document.querySelector(".lightbox-overlay")).toBeInTheDocument();
fireEvent.click(document.querySelector(".lightbox-overlay") as HTMLDivElement);
expect(backSpy).toHaveBeenCalled();
});
it("reverts optimistic taken state when mark-dose request fails", async () => {
const sharedData = createSharedData();
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string, init?: RequestInit) => {
if (url === "/api/share/token-123/doses" && (!init || !init.method || init.method === "GET")) {
return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses: [] }) });
}
if (url === "/api/share/token-123") {
return Promise.resolve({ ok: true, json: () => Promise.resolve(sharedData) });
}
if (url === "/api/share/token-123/doses" && init?.method === "POST") {
return Promise.reject(new Error("post failed"));
}
return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses: [] }) });
});
renderSharedSchedule("/share/token-123");
await waitFor(() => {
expect(screen.getByText(/share\.scheduleFor/i)).toBeInTheDocument();
});
expandTodayIfCollapsed();
const takeButton = await waitFor(() => {
const button = document.querySelector(".dose-btn.take:not([disabled])") as HTMLButtonElement | null;
expect(button).toBeInTheDocument();
return button as HTMLButtonElement;
});
fireEvent.click(takeButton);
await waitFor(() => {
expect(document.querySelector(".dose-btn.undo")).not.toBeInTheDocument();
expect(document.querySelector(".dose-btn.take:not([disabled])")).toBeInTheDocument();
});
});
it("reverts optimistic undo state when undo request fails", async () => {
const today = new Date();
const todayDateOnlyMs = new Date(today.getFullYear(), today.getMonth(), today.getDate()).getTime();
const sharedData = createSharedData();
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string, init?: RequestInit) => {
if (url === "/api/share/token-123/doses" && (!init || !init.method || init.method === "GET")) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ doses: [{ doseId: `1-0-${todayDateOnlyMs}-Max` }] }),
});
}
if (url === "/api/share/token-123") {
return Promise.resolve({ ok: true, json: () => Promise.resolve(sharedData) });
}
if (url.startsWith("/api/share/token-123/doses/") && init?.method === "DELETE") {
return Promise.reject(new Error("delete failed"));
}
return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses: [] }) });
});
renderSharedSchedule("/share/token-123");
await waitFor(() => {
expect(screen.getByText(/share\.scheduleFor/i)).toBeInTheDocument();
});
expandTodayIfCollapsed();
const undoButton = await waitFor(() => {
const button = document.querySelector(".dose-btn.undo") as HTMLButtonElement | null;
expect(button).toBeInTheDocument();
return button as HTMLButtonElement;
});
fireEvent.click(undoButton);
await waitFor(() => {
expect(document.querySelector(".dose-btn.undo")).toBeInTheDocument();
});
});
it("persists manual collapse state in localStorage", async () => {
const setItemSpy = vi.spyOn(window.localStorage, "setItem");
const sharedData = createSharedData();
mockShareFetch("token-123", sharedData);
renderSharedSchedule("/share/token-123");
await waitFor(() => {
expect(screen.getByText(/share\.scheduleFor/i)).toBeInTheDocument();
});
const todayDivider = document.querySelector(".day-block.today .day-divider.clickable") as HTMLDivElement;
fireEvent.click(todayDivider);
expect(setItemSpy).toHaveBeenCalled();
expect(
setItemSpy.mock.calls.some((call) => String(call[0]).includes("share_token-123_collapsedDays")) ||
setItemSpy.mock.calls.some((call) => String(call[0]).includes("share_token-123_expandedDays"))
).toBe(true);
});
});
@@ -0,0 +1,99 @@
import { render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SharedSchedule } from "../../components/SharedSchedule";
function renderSharedSchedule(path: string) {
return render(
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route path="/share/:token" element={<SharedSchedule />} />
</Routes>
</MemoryRouter>
);
}
function createSharedData(overrides: Record<string, unknown> = {}) {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
yesterday.setHours(9, 0, 0, 0);
return {
sharedBy: "Owner",
takenBy: "Max",
scheduleDays: 30,
shareStockStatus: true,
shareScheduleTodayOnly: true,
stockCalculationMode: "automatic",
stockThresholds: {
lowStockDays: 7,
normalStockDays: 30,
highStockDays: 90,
reminderDaysBefore: 7,
expiryWarningDays: 30,
},
medications: [
{
id: 1,
name: "Ibuprofen",
genericName: "Ibu",
takenBy: ["Max"],
packageType: "blister",
packCount: 1,
blistersPerPack: 2,
pillsPerBlister: 10,
looseTablets: 0,
pillWeightMg: null,
doseUnit: "mg",
expiryDate: null,
notes: null,
intakeRemindersEnabled: false,
blisters: [{ usage: 1, every: 1, start: yesterday.toISOString() }],
intakes: [
{ usage: 1, every: 1, start: yesterday.toISOString(), takenBy: "Max", intakeRemindersEnabled: false },
],
updatedAt: null,
dismissedUntil: null,
lastStockCorrectionAt: null,
},
],
...overrides,
};
}
describe("SharedSchedule today-only", () => {
beforeEach(() => {
vi.clearAllMocks();
window.localStorage.clear();
vi.spyOn(globalThis, "setInterval").mockImplementation(() => 1 as unknown as ReturnType<typeof setInterval>);
vi.spyOn(globalThis, "clearInterval").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("hides past and future sections when shareScheduleTodayOnly is enabled", async () => {
const sharedData = createSharedData();
(globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string, init?: RequestInit) => {
if (url === "/api/share/token-123/doses" && (!init || !init.method || init.method === "GET")) {
return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses: [] }) });
}
if (url === "/api/share/token-123") {
return Promise.resolve({ ok: true, json: () => Promise.resolve(sharedData) });
}
return Promise.reject(new Error(`Unexpected URL: ${url}`));
});
renderSharedSchedule("/share/token-123");
await waitFor(() => {
expect(screen.getByText(/share\.scheduleFor/i)).toBeInTheDocument();
});
expect(document.querySelector(".day-block.today")).toBeInTheDocument();
expect(document.querySelector(".past-days-toggle")).not.toBeInTheDocument();
expect(document.querySelector(".future-days-toggle")).not.toBeInTheDocument();
});
});