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:
Daniel Volz
2026-02-09 19:33:54 +01:00
committed by GitHub
parent f56f2b7c88
commit 3ec1460c4e
24 changed files with 2115 additions and 572 deletions
+141 -2
View File
@@ -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");
});
});
+99 -1
View File
@@ -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");
});
});
+291 -5
View File
@@ -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();