feat: Add package type support and per-intake takenBy (#89)

## Package Type Feature
- Add 'blister' and 'bottle' package types for medications
- Bottle type uses totalPills for capacity and looseTablets for current stock
- Blister type continues to use packCount/blistersPerPack/pillsPerBlister
- Add doseUnit field for flexible dosing (mg, ml, IU, etc.)
- Full UI support in medication form and detail modal

## Per-Intake TakenBy
- Move takenBy from medication level to individual intakes
- Each intake schedule can now be assigned to a different person
- Update scheduler-utils to handle per-intake takenBy
- Update SharedSchedule to filter by per-intake takenBy
- Backward compatible with existing medication data

## UI Improvements
- Add PasswordInput component with show/hide toggle
- Centralize stockThresholds in AppContext for consistent status display
- Fix SharedSchedule sync issues with per-intake takenBy
- Improve mobile editing experience

## Technical
- Add migrations 0004 and 0005 for schema changes
- Update all relevant tests (1064 tests passing)
- Maintain backward compatibility with ALTER migrations
This commit is contained in:
Daniel Volz
2026-01-31 23:49:11 +01:00
committed by GitHub
parent ac4b8151e4
commit 571d94bf7e
37 changed files with 2896 additions and 990 deletions
@@ -7,6 +7,8 @@ const defaultSettings: StockThresholds = {
lowStockDays: 7,
normalStockDays: 30,
highStockDays: 90,
criticalStockDays: 3,
expiryWarningDays: 30,
};
const mockMedication: Medication = {
@@ -7,10 +7,12 @@ const defaultForm: FormState = {
name: "",
genericName: "",
takenBy: [],
packageType: "blister",
packCount: "1",
blistersPerPack: "1",
pillsPerBlister: "1",
looseTablets: "0",
totalPills: "",
pillWeightMg: "",
expiryDate: "",
notes: "",
@@ -23,6 +25,16 @@ const defaultForm: FormState = {
startTime: "09:00",
},
],
intakes: [
{
usage: "1",
every: "1",
startDate: "2024-01-01",
startTime: "09:00",
takenBy: "",
intakeRemindersEnabled: false,
},
],
};
const defaultProps = {
@@ -44,6 +56,9 @@ const defaultProps = {
onSetBlisterValue: vi.fn(),
onAddBlister: vi.fn(),
onRemoveBlister: vi.fn(),
onSetIntakeValue: vi.fn(),
onAddIntake: vi.fn(),
onRemoveIntake: vi.fn(),
onHandleValueChange: vi.fn(),
refillPacks: 0,
onRefillPacksChange: vi.fn(),
@@ -185,14 +200,14 @@ describe("MobileEditModal", () => {
expect(screen.getByText(/form\.blisters\.addIntake/i)).toBeInTheDocument();
});
it("calls onAddBlister when add intake clicked", () => {
const onAddBlister = vi.fn();
render(<MobileEditModal {...defaultProps} onAddBlister={onAddBlister} />);
it("calls onAddIntake when add intake clicked", () => {
const onAddIntake = vi.fn();
render(<MobileEditModal {...defaultProps} onAddIntake={onAddIntake} />);
const addBtn = screen.getByText(/form\.blisters\.addIntake/i);
fireEvent.click(addBtn);
expect(onAddBlister).toHaveBeenCalledTimes(1);
expect(onAddIntake).toHaveBeenCalledTimes(1);
});
it("renders modal content", () => {
@@ -261,6 +276,24 @@ describe("MobileEditModal blister management", () => {
{ usage: "1", every: "1", startDate: "2024-01-01", startTime: "09:00" },
{ usage: "2", every: "7", startDate: "2024-01-01", startTime: "10:00" },
],
intakes: [
{
usage: "1",
every: "1",
startDate: "2024-01-01",
startTime: "09:00",
takenBy: "",
intakeRemindersEnabled: false,
},
{
usage: "2",
every: "7",
startDate: "2024-01-01",
startTime: "10:00",
takenBy: "",
intakeRemindersEnabled: false,
},
],
};
render(<MobileEditModal {...defaultProps} form={form} />);
@@ -269,34 +302,52 @@ describe("MobileEditModal blister management", () => {
expect(blisterRows.length).toBe(2);
});
it("calls onRemoveBlister when remove button clicked", () => {
const onRemoveBlister = vi.fn();
it("calls onRemoveIntake when remove button clicked", () => {
const onRemoveIntake = vi.fn();
const form = {
...defaultForm,
blisters: [
{ usage: "1", every: "1", startDate: "2024-01-01", startTime: "09:00" },
{ usage: "2", every: "7", startDate: "2024-01-01", startTime: "10:00" },
],
intakes: [
{
usage: "1",
every: "1",
startDate: "2024-01-01",
startTime: "09:00",
takenBy: "",
intakeRemindersEnabled: false,
},
{
usage: "2",
every: "7",
startDate: "2024-01-01",
startTime: "10:00",
takenBy: "",
intakeRemindersEnabled: false,
},
],
};
render(<MobileEditModal {...defaultProps} form={form} onRemoveBlister={onRemoveBlister} />);
render(<MobileEditModal {...defaultProps} form={form} onRemoveIntake={onRemoveIntake} />);
const removeButtons = document.querySelectorAll(".blister-row button.danger");
if (removeButtons.length > 0) {
fireEvent.click(removeButtons[0]);
expect(onRemoveBlister).toHaveBeenCalled();
expect(onRemoveIntake).toHaveBeenCalled();
}
});
it("calls onSetBlisterValue when changing blister field", () => {
const onSetBlisterValue = vi.fn();
it("calls onSetIntakeValue when changing blister field", () => {
const onSetIntakeValue = vi.fn();
render(<MobileEditModal {...defaultProps} onSetBlisterValue={onSetBlisterValue} />);
render(<MobileEditModal {...defaultProps} onSetIntakeValue={onSetIntakeValue} />);
const usageInputs = document.querySelectorAll('.blister-row input[type="number"]');
if (usageInputs.length > 0) {
fireEvent.change(usageInputs[0], { target: { value: "2" } });
expect(onSetBlisterValue).toHaveBeenCalled();
expect(onSetIntakeValue).toHaveBeenCalled();
}
});
});
@@ -0,0 +1,89 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { PasswordInput } from "../../components/PasswordInput";
describe("PasswordInput", () => {
it("renders password input with hidden text by default", () => {
render(<PasswordInput id="test-password" value="secret123" onChange={() => {}} />);
const input = document.getElementById("test-password") as HTMLInputElement;
expect(input).toBeInTheDocument();
expect(input.type).toBe("password");
});
it("toggles password visibility when eye button is clicked", () => {
render(<PasswordInput id="test-password" value="secret123" onChange={() => {}} />);
const input = document.getElementById("test-password") as HTMLInputElement;
const toggleButton = screen.getByRole("button", { name: /show password/i });
// Initially password is hidden
expect(input.type).toBe("password");
// Click to show password
fireEvent.click(toggleButton);
expect(input.type).toBe("text");
// Click again to hide password
fireEvent.click(toggleButton);
expect(input.type).toBe("password");
});
it("calls onChange when input value changes", () => {
const handleChange = vi.fn();
render(<PasswordInput id="test-password" value="" onChange={handleChange} />);
const input = document.getElementById("test-password") as HTMLInputElement;
fireEvent.change(input, { target: { value: "newpassword" } });
expect(handleChange).toHaveBeenCalled();
});
it("passes through required attribute", () => {
render(<PasswordInput id="test-password" value="" onChange={() => {}} required />);
const input = document.getElementById("test-password") as HTMLInputElement;
expect(input.required).toBe(true);
});
it("passes through minLength and maxLength attributes", () => {
render(<PasswordInput id="test-password" value="" onChange={() => {}} minLength={8} maxLength={128} />);
const input = document.getElementById("test-password") as HTMLInputElement;
expect(input.minLength).toBe(8);
expect(input.maxLength).toBe(128);
});
it("passes through placeholder attribute", () => {
render(<PasswordInput id="test-password" value="" onChange={() => {}} placeholder="Enter password" />);
const input = document.getElementById("test-password") as HTMLInputElement;
expect(input.placeholder).toBe("Enter password");
});
it("passes through autoComplete attribute", () => {
render(<PasswordInput id="test-password" value="" onChange={() => {}} autoComplete="new-password" />);
const input = document.getElementById("test-password") as HTMLInputElement;
expect(input.autocomplete).toBe("new-password");
});
it("toggle button has correct aria-label", () => {
render(<PasswordInput id="test-password" value="" onChange={() => {}} />);
const toggleButton = screen.getByRole("button", { name: /show password/i });
expect(toggleButton).toBeInTheDocument();
fireEvent.click(toggleButton);
const hideButton = screen.getByRole("button", { name: /hide password/i });
expect(hideButton).toBeInTheDocument();
});
it("toggle button has tabIndex -1 to prevent focus during form navigation", () => {
render(<PasswordInput id="test-password" value="" onChange={() => {}} />);
const toggleButton = screen.getByRole("button");
expect(toggleButton.tabIndex).toBe(-1);
});
});
@@ -7,6 +7,8 @@ const defaultSettings: StockThresholds = {
lowStockDays: 7,
normalStockDays: 30,
highStockDays: 90,
criticalStockDays: 3,
expiryWarningDays: 30,
};
const mockMedication: Medication = {
@@ -139,6 +139,13 @@ const createMockAppContext = (overrides = {}) => ({
coverage: { all: [], low: [] },
coverageByMed: {},
depletionByMed: {},
stockThresholds: {
lowStockDays: 7,
normalStockDays: 30,
highStockDays: 90,
criticalStockDays: 7,
expiryWarningDays: 30,
},
manuallyExpandedDays: new Set(),
manuallyCollapsedDays: new Set(),
toggleDayCollapse: vi.fn(),
@@ -400,8 +407,8 @@ describe("DashboardPage structure", () => {
// Should have all expected table columns
expect(screen.getByText(/table\.name/i)).toBeInTheDocument();
expect(screen.getByText(/table\.fullBlisters/i)).toBeInTheDocument();
expect(screen.getByText(/table\.openBlister/i)).toBeInTheDocument();
expect(screen.getByText(/table\.stock(?!Details)/i)).toBeInTheDocument();
expect(screen.getByText(/table\.stockDetails/i)).toBeInTheDocument();
expect(screen.getByText(/table\.daysLeft/i)).toBeInTheDocument();
expect(screen.getByText(/table\.runsOut/i)).toBeInTheDocument();
expect(screen.getByText(/table\.expiry/i)).toBeInTheDocument();
+105 -24
View File
@@ -57,6 +57,7 @@ const createMockContext = (overrides = {}) => ({
setRefillLoose: vi.fn(),
refillSaving: false,
submitRefill: vi.fn(),
coverageByMed: {},
...overrides,
});
@@ -65,12 +66,24 @@ const createMockFormHook = (overrides = {}) => ({
form: {
name: "",
genericName: "",
packageType: "blister" as const,
packCount: "0",
blistersPerPack: "0",
pillsPerBlister: "1",
looseTablets: "0",
totalPills: "",
takenBy: [],
blisters: [{ usage: "1", every: "1", startDate: new Date().toISOString().slice(0, 10), startTime: "09:00" }],
intakes: [
{
usage: "1",
every: "1",
startDate: new Date().toISOString().slice(0, 10),
startTime: "09:00",
takenBy: "",
intakeRemindersEnabled: false,
},
],
expiryDate: "",
notes: "",
pillWeightMg: "",
@@ -93,6 +106,9 @@ const createMockFormHook = (overrides = {}) => ({
addBlister: vi.fn(),
removeBlister: vi.fn(),
setBlisterValue: vi.fn(),
addIntake: vi.fn(),
removeIntake: vi.fn(),
setIntakeValue: vi.fn(),
resetForm: vi.fn(),
startEdit: vi.fn(),
showEditModal: false,
@@ -328,9 +344,9 @@ describe("MedicationsPage form interactions", () => {
}
});
it("calls addBlister when clicking add schedule button", () => {
const addBlister = vi.fn();
mockFormHookValue = createMockFormHook({ addBlister });
it("calls addIntake when clicking add schedule button", () => {
const addIntake = vi.fn();
mockFormHookValue = createMockFormHook({ addIntake });
render(
<MemoryRouter>
@@ -338,11 +354,11 @@ describe("MedicationsPage form interactions", () => {
</MemoryRouter>
);
// Find add blister button
// Find add intake button
const addBtn = screen.queryByText(/form\.blisters\.add/i) || screen.queryByText(/\+/);
if (addBtn) {
fireEvent.click(addBtn);
expect(addBlister).toHaveBeenCalled();
expect(addIntake).toHaveBeenCalled();
}
});
});
@@ -393,12 +409,24 @@ describe("MedicationsPage editing", () => {
form: {
name: "Aspirin",
genericName: "Acetylsalicylic acid",
packageType: "blister" as const,
packCount: "1",
blistersPerPack: "2",
pillsPerBlister: "10",
looseTablets: "5",
totalPills: "",
takenBy: ["John"],
blisters: [{ usage: "1", every: "1", startDate: "2024-01-01", startTime: "09:00" }],
intakes: [
{
usage: "1",
every: "1",
startDate: "2024-01-01",
startTime: "09:00",
takenBy: "",
intakeRemindersEnabled: false,
},
],
expiryDate: "2025-12-31",
notes: "Take with food",
pillWeightMg: "",
@@ -558,14 +586,24 @@ describe("MedicationsPage blister management", () => {
expect(blisterSections.length).toBeGreaterThan(0);
});
it("calls setBlisterValue when changing blister field", () => {
const setBlisterValue = vi.fn();
it("calls setIntakeValue when changing blister field", () => {
const setIntakeValue = vi.fn();
mockFormHookValue = createMockFormHook({
form: {
...createMockFormHook().form,
blisters: [{ usage: "1", every: "1", startDate: "2024-01-01", startTime: "09:00" }],
intakes: [
{
usage: "1",
every: "1",
startDate: "2024-01-01",
startTime: "09:00",
takenBy: "",
intakeRemindersEnabled: false,
},
],
},
setBlisterValue,
setIntakeValue,
});
render(
@@ -578,7 +616,7 @@ describe("MedicationsPage blister management", () => {
const blisterInputs = document.querySelectorAll('.blister-inputs input[type="number"]');
if (blisterInputs.length > 0) {
fireEvent.change(blisterInputs[0], { target: { value: "2" } });
expect(setBlisterValue).toHaveBeenCalled();
expect(setIntakeValue).toHaveBeenCalled();
}
});
});
@@ -591,9 +629,9 @@ describe("MedicationsPage add blister", () => {
mockFormHookValue = createMockFormHook();
});
it("calls addBlister when clicking add intake button", () => {
const addBlister = vi.fn();
mockFormHookValue = createMockFormHook({ addBlister });
it("calls addIntake when clicking add intake button", () => {
const addIntake = vi.fn();
mockFormHookValue = createMockFormHook({ addIntake });
render(
<MemoryRouter>
@@ -603,7 +641,7 @@ describe("MedicationsPage add blister", () => {
const addIntakeBtn = screen.getByRole("button", { name: /form\.blisters\.addIntake/i });
fireEvent.click(addIntakeBtn);
expect(addBlister).toHaveBeenCalled();
expect(addIntake).toHaveBeenCalled();
});
});
@@ -619,6 +657,24 @@ describe("MedicationsPage remove blister", () => {
{ usage: "1", every: "1", startDate: "2024-01-01", startTime: "09:00" },
{ usage: "2", every: "7", startDate: "2024-01-01", startTime: "20:00" },
],
intakes: [
{
usage: "1",
every: "1",
startDate: "2024-01-01",
startTime: "09:00",
takenBy: "",
intakeRemindersEnabled: false,
},
{
usage: "2",
every: "7",
startDate: "2024-01-01",
startTime: "20:00",
takenBy: "",
intakeRemindersEnabled: false,
},
],
},
});
});
@@ -635,8 +691,8 @@ describe("MedicationsPage remove blister", () => {
expect(removeButtons.length).toBeGreaterThan(0);
});
it("calls removeBlister when clicking remove button", () => {
const removeBlister = vi.fn();
it("calls removeIntake when clicking remove button", () => {
const removeIntake = vi.fn();
mockFormHookValue = createMockFormHook({
form: {
...createMockFormHook().form,
@@ -644,8 +700,26 @@ describe("MedicationsPage remove blister", () => {
{ usage: "1", every: "1", startDate: "2024-01-01", startTime: "09:00" },
{ usage: "2", every: "7", startDate: "2024-01-01", startTime: "20:00" },
],
intakes: [
{
usage: "1",
every: "1",
startDate: "2024-01-01",
startTime: "09:00",
takenBy: "",
intakeRemindersEnabled: false,
},
{
usage: "2",
every: "7",
startDate: "2024-01-01",
startTime: "20:00",
takenBy: "",
intakeRemindersEnabled: false,
},
],
},
removeBlister,
removeIntake,
});
render(
@@ -657,7 +731,7 @@ describe("MedicationsPage remove blister", () => {
const removeButtons = document.querySelectorAll(".blister-row .danger");
if (removeButtons.length > 0) {
fireEvent.click(removeButtons[0]);
expect(removeBlister).toHaveBeenCalled();
expect(removeIntake).toHaveBeenCalled();
}
});
});
@@ -670,19 +744,25 @@ describe("MedicationsPage intake reminders toggle", () => {
mockFormHookValue = createMockFormHook();
});
it("renders intake reminders checkbox", () => {
it("renders intake reminders checkbox per intake", () => {
render(
<MemoryRouter>
<MedicationsPage />
</MemoryRouter>
);
expect(screen.getByText(/form\.blisters\.remind/i)).toBeInTheDocument();
// Now each intake row has its own reminder checkbox with the bell icon
// Desktop form uses class "full blisters" container
const blistersContainer = document.querySelector(".blisters");
expect(blistersContainer).toBeInTheDocument();
// Check for the inline-checkbox that controls intake reminders in each blister row
const intakeCheckbox = document.querySelector(".blister-row .inline-checkbox");
expect(intakeCheckbox).toBeInTheDocument();
});
it("can toggle intake reminders", () => {
const setForm = vi.fn();
mockFormHookValue = createMockFormHook({ setForm });
it("can toggle intake reminders per intake", () => {
const setIntakeValue = vi.fn();
mockFormHookValue = createMockFormHook({ setIntakeValue });
render(
<MemoryRouter>
@@ -690,10 +770,11 @@ describe("MedicationsPage intake reminders toggle", () => {
</MemoryRouter>
);
const checkbox = document.querySelector('.inline-checkbox input[type="checkbox"]');
// Each blister row has inline-checkbox for intake reminders
const checkbox = document.querySelector('.blister-row .inline-checkbox input[type="checkbox"]');
if (checkbox) {
fireEvent.click(checkbox);
expect(setForm).toHaveBeenCalled();
expect(setIntakeValue).toHaveBeenCalled();
}
});
});