feat: track number of prescription repeats (#193)

* feat: track prescription repeats and refill reminders

* test: align backend and frontend suites with current prescription and UI behavior

* test: update frontend and backend expectations for latest reminders and refill flow
This commit is contained in:
Daniel Volz
2026-02-14 19:07:36 +01:00
committed by GitHub
parent edf42bb068
commit 8273b07231
37 changed files with 3331 additions and 4673 deletions
+69 -4
View File
@@ -43,6 +43,11 @@ export const defaultForm = (): FormState => ({
doseUnit: "mg",
expiryDate: "",
notes: "",
prescriptionEnabled: false,
prescriptionAuthorizedRefills: "",
prescriptionRemainingRefills: "",
prescriptionLowRefillThreshold: "1",
prescriptionExpiryDate: "",
intakeRemindersEnabled: false,
blisters: [defaultBlister()],
intakes: [defaultIntake()],
@@ -78,7 +83,7 @@ export interface UseMedicationFormReturn {
removeIntake: (idx: number) => void;
startEdit: (med: Medication, openEditModal: () => void) => void;
resetForm: () => void;
handleValueChange: <K extends keyof FormState>(key: K, value: string) => void;
handleValueChange: <K extends keyof FormState>(key: K, value: FormState[K]) => void;
addTakenByPerson: (name: string) => void;
removeTakenByPerson: (name: string) => void;
handleTakenByKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void;
@@ -96,6 +101,12 @@ export function useMedicationForm(): UseMedicationFormReturn {
const [pendingImagePreview, setPendingImagePreview] = useState<string | null>(null);
const [takenByInput, setTakenByInput] = useState("");
const parseNonNegativeInt = useCallback((value: string): number => {
const parsed = Number.parseInt(value, 10);
if (Number.isNaN(parsed) || parsed < 0) return 0;
return parsed;
}, []);
// Validate form fields
const validateField = useCallback(
(field: keyof FieldErrors, value: string | string[]): string | undefined => {
@@ -199,6 +210,10 @@ export function useMedicationForm(): UseMedicationFormReturn {
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
}));
const authorizedRefills = Math.max(0, med.prescriptionAuthorizedRefills ?? 0);
const remainingRefills = Math.min(Math.max(0, med.prescriptionRemainingRefills ?? 0), authorizedRefills);
const lowRefillThreshold = Math.min(Math.max(0, med.prescriptionLowRefillThreshold ?? 1), authorizedRefills);
const editForm: FormState = {
name: med.name,
genericName: med.genericName ?? "",
@@ -217,6 +232,11 @@ export function useMedicationForm(): UseMedicationFormReturn {
doseUnit: med.doseUnit ?? "mg",
expiryDate: med.expiryDate ? med.expiryDate.slice(0, 10) : "",
notes: med.notes ?? "",
prescriptionEnabled: med.prescriptionEnabled ?? false,
prescriptionAuthorizedRefills: med.prescriptionAuthorizedRefills != null ? String(authorizedRefills) : "",
prescriptionRemainingRefills: med.prescriptionRemainingRefills != null ? String(remainingRefills) : "",
prescriptionLowRefillThreshold: String(lowRefillThreshold),
prescriptionExpiryDate: med.prescriptionExpiryDate ? med.prescriptionExpiryDate.slice(0, 10) : "",
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
blisters: med.blisters.map((s) => ({
usage: String(s.usage),
@@ -246,9 +266,54 @@ export function useMedicationForm(): UseMedicationFormReturn {
setOriginalForm(newForm);
}, []);
const handleValueChange = useCallback(<K extends keyof FormState>(key: K, value: string) => {
setForm((prev) => ({ ...prev, [key]: value }));
}, []);
const handleValueChange = useCallback(
<K extends keyof FormState>(key: K, value: FormState[K]) => {
setForm((prev) => {
const next = { ...prev, [key]: value } as FormState;
if (key === "prescriptionAuthorizedRefills") {
const raw = String(value);
next.prescriptionAuthorizedRefills = raw === "" ? "" : String(parseNonNegativeInt(raw));
}
if (key === "prescriptionRemainingRefills") {
const raw = String(value);
next.prescriptionRemainingRefills = raw === "" ? "" : String(parseNonNegativeInt(raw));
}
if (key === "prescriptionLowRefillThreshold") {
const raw = String(value);
next.prescriptionLowRefillThreshold = raw === "" ? "" : String(parseNonNegativeInt(raw));
}
if (!next.prescriptionEnabled) {
return next;
}
const authorizedRefills = parseNonNegativeInt(next.prescriptionAuthorizedRefills);
if (key === "prescriptionAuthorizedRefills") {
next.prescriptionRemainingRefills = String(
Math.min(parseNonNegativeInt(next.prescriptionRemainingRefills), authorizedRefills)
);
next.prescriptionLowRefillThreshold = String(
Math.min(parseNonNegativeInt(next.prescriptionLowRefillThreshold), authorizedRefills)
);
}
if (key === "prescriptionRemainingRefills") {
next.prescriptionRemainingRefills = String(Math.min(parseNonNegativeInt(String(value)), authorizedRefills));
}
if (key === "prescriptionLowRefillThreshold") {
next.prescriptionLowRefillThreshold = String(Math.min(parseNonNegativeInt(String(value)), authorizedRefills));
}
return next;
});
},
[parseNonNegativeInt]
);
// Tag input helpers for "Taken By" field
const addTakenByPerson = useCallback(
+15 -3
View File
@@ -10,6 +10,8 @@ export interface UseRefillReturn {
setRefillPacks: React.Dispatch<React.SetStateAction<number>>;
refillLoose: number;
setRefillLoose: React.Dispatch<React.SetStateAction<number>>;
usePrescriptionRefill: boolean;
setUsePrescriptionRefill: React.Dispatch<React.SetStateAction<boolean>>;
refillSaving: boolean;
refillHistory: RefillEntry[];
refillHistoryExpanded: boolean;
@@ -30,7 +32,8 @@ export interface UseRefillReturn {
medId: number,
editingId: number | null,
setForm: React.Dispatch<React.SetStateAction<FormState>>,
loadMeds: () => void
loadMeds: () => void,
usePrescription?: boolean
) => Promise<void>;
submitStockCorrection: (medId: number, selectedMed: Medication, loadMeds: () => void) => Promise<void>;
openRefillModal: () => void;
@@ -44,6 +47,7 @@ export function useRefill(): UseRefillReturn {
const [showRefillModal, setShowRefillModal] = useState(false);
const [refillPacks, setRefillPacks] = useState(1);
const [refillLoose, setRefillLoose] = useState(0);
const [usePrescriptionRefill, setUsePrescriptionRefill] = useState(false);
const [refillSaving, setRefillSaving] = useState(false);
const [refillHistory, setRefillHistory] = useState<RefillEntry[]>([]);
const [refillHistoryExpanded, setRefillHistoryExpanded] = useState(false);
@@ -75,7 +79,8 @@ export function useRefill(): UseRefillReturn {
medId: number,
editingId: number | null,
setForm: React.Dispatch<React.SetStateAction<FormState>>,
loadMeds: () => void
loadMeds: () => void,
usePrescription: boolean = false
) => {
if (refillPacks < 1 && refillLoose < 1) return;
setRefillSaving(true);
@@ -84,7 +89,7 @@ export function useRefill(): UseRefillReturn {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ packsAdded: refillPacks, loosePillsAdded: refillLoose }),
body: JSON.stringify({ packsAdded: refillPacks, loosePillsAdded: refillLoose, usePrescription }),
});
if (res.ok) {
const data = await res.json();
@@ -94,11 +99,16 @@ export function useRefill(): UseRefillReturn {
...f,
packCount: String(data.newStock.packCount),
looseTablets: String(data.newStock.looseTablets),
prescriptionRemainingRefills:
data.prescription?.remainingRefills != null
? String(data.prescription.remainingRefills)
: f.prescriptionRemainingRefills,
}));
}
// Reset refill form
setRefillPacks(1);
setRefillLoose(0);
setUsePrescriptionRefill(false);
// Close refill modal via history back for proper back-button support
if (showRefillModal) {
window.history.back();
@@ -217,6 +227,8 @@ export function useRefill(): UseRefillReturn {
setRefillPacks,
refillLoose,
setRefillLoose,
usePrescriptionRefill,
setUsePrescriptionRefill,
refillSaving,
refillHistory,
refillHistoryExpanded,
+110 -44
View File
@@ -2,7 +2,7 @@
// useSettings Hook - Settings state and operations
// =============================================================================
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
export interface Settings {
@@ -26,19 +26,24 @@ export interface Settings {
hasSmtpPassword: boolean;
lastAutoEmailSent: string | null;
nextScheduledCheck: string | null;
lastNotificationType: "stock" | "intake" | null;
lastNotificationType: "stock" | "intake" | "prescription" | null;
lastNotificationChannel: "email" | "push" | "both" | null;
lastReminderMedName: string | null;
lastReminderTakenBy: string | null;
lastStockReminderSent: string | null;
lastStockReminderChannel: "email" | "push" | "both" | null;
lastStockReminderMedNames: string | null;
lastPrescriptionReminderSent: string | null;
lastPrescriptionReminderChannel: "email" | "push" | "both" | null;
lastPrescriptionReminderMedNames: string | null;
shoutrrrEnabled: boolean;
shoutrrrUrl: string;
emailStockReminders: boolean;
emailIntakeReminders: boolean;
emailPrescriptionReminders: boolean;
shoutrrrStockReminders: boolean;
shoutrrrIntakeReminders: boolean;
shoutrrrPrescriptionReminders: boolean;
stockCalculationMode: "automatic" | "manual";
shareStockStatus: boolean;
expiryWarningDays: number;
@@ -72,12 +77,17 @@ const defaultSettings: Settings = {
lastStockReminderSent: null,
lastStockReminderChannel: null,
lastStockReminderMedNames: null,
lastPrescriptionReminderSent: null,
lastPrescriptionReminderChannel: null,
lastPrescriptionReminderMedNames: null,
shoutrrrEnabled: false,
shoutrrrUrl: "",
emailStockReminders: true,
emailIntakeReminders: true,
emailPrescriptionReminders: true,
shoutrrrStockReminders: true,
shoutrrrIntakeReminders: true,
shoutrrrPrescriptionReminders: true,
stockCalculationMode: "automatic",
shareStockStatus: true,
expiryWarningDays: 30,
@@ -97,7 +107,7 @@ export interface UseSettingsReturn {
testShoutrrrResult: { success: boolean; message: string } | null;
setTestShoutrrrResult: React.Dispatch<React.SetStateAction<{ success: boolean; message: string } | null>>;
loadSettings: () => void;
saveSettings: (e: React.FormEvent) => Promise<void>;
saveSettings: (e?: React.FormEvent) => Promise<void>;
testEmail: () => Promise<void>;
testShoutrrr: () => Promise<void>;
hasUnsavedChanges: boolean;
@@ -152,6 +162,11 @@ export function useSettings(): UseSettingsReturn {
lastStockReminderSent: data.lastStockReminderSent ?? prev.lastStockReminderSent,
lastStockReminderChannel: data.lastStockReminderChannel ?? prev.lastStockReminderChannel,
lastStockReminderMedNames: data.lastStockReminderMedNames ?? prev.lastStockReminderMedNames,
lastPrescriptionReminderSent: data.lastPrescriptionReminderSent ?? prev.lastPrescriptionReminderSent,
lastPrescriptionReminderChannel:
data.lastPrescriptionReminderChannel ?? prev.lastPrescriptionReminderChannel,
lastPrescriptionReminderMedNames:
data.lastPrescriptionReminderMedNames ?? prev.lastPrescriptionReminderMedNames,
}));
setSavedSettings((prev) => ({
...prev,
@@ -163,6 +178,11 @@ export function useSettings(): UseSettingsReturn {
lastStockReminderSent: data.lastStockReminderSent ?? prev.lastStockReminderSent,
lastStockReminderChannel: data.lastStockReminderChannel ?? prev.lastStockReminderChannel,
lastStockReminderMedNames: data.lastStockReminderMedNames ?? prev.lastStockReminderMedNames,
lastPrescriptionReminderSent: data.lastPrescriptionReminderSent ?? prev.lastPrescriptionReminderSent,
lastPrescriptionReminderChannel:
data.lastPrescriptionReminderChannel ?? prev.lastPrescriptionReminderChannel,
lastPrescriptionReminderMedNames:
data.lastPrescriptionReminderMedNames ?? prev.lastPrescriptionReminderMedNames,
}));
})
.catch(() => {});
@@ -172,54 +192,45 @@ export function useSettings(): UseSettingsReturn {
return () => clearInterval(interval);
}, []);
const saveSettings = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
// Internal save function (no event needed)
const performSave = useCallback(
async (settingsToSave: Settings) => {
// Auto-disable email if no recipient is set
const effectiveEmailEnabled = settings.emailEnabled && !!settings.notificationEmail?.trim();
const effectiveEmailEnabled = settingsToSave.emailEnabled && !!settingsToSave.notificationEmail?.trim();
// Auto-disable push if no URL is set
const effectiveShoutrrrEnabled = settings.shoutrrrEnabled && !!settings.shoutrrrUrl?.trim();
// Validate email if email notifications are enabled
if (effectiveEmailEnabled && settings.notificationEmail) {
const emailRegex = /^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i;
if (!emailRegex.test(settings.notificationEmail)) {
setTestEmailResult({ success: false, message: "Invalid email address" });
return;
}
}
const effectiveShoutrrrEnabled = settingsToSave.shoutrrrEnabled && !!settingsToSave.shoutrrrUrl?.trim();
setSettingsSaving(true);
setTestEmailResult(null);
const payload = {
emailEnabled: effectiveEmailEnabled,
notificationEmail: settings.notificationEmail,
reminderDaysBefore: settings.reminderDaysBefore,
repeatDailyReminders: settings.repeatDailyReminders,
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses,
repeatRemindersEnabled: settings.repeatRemindersEnabled,
reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes,
maxNaggingReminders: settings.maxNaggingReminders ?? 5,
lowStockDays: settings.lowStockDays,
normalStockDays: settings.normalStockDays,
highStockDays: settings.highStockDays,
notificationEmail: settingsToSave.notificationEmail,
reminderDaysBefore: settingsToSave.reminderDaysBefore,
repeatDailyReminders: settingsToSave.repeatDailyReminders,
skipRemindersForTakenDoses: settingsToSave.skipRemindersForTakenDoses,
repeatRemindersEnabled: settingsToSave.repeatRemindersEnabled,
reminderRepeatIntervalMinutes: settingsToSave.reminderRepeatIntervalMinutes,
maxNaggingReminders: settingsToSave.maxNaggingReminders ?? 5,
lowStockDays: settingsToSave.lowStockDays,
normalStockDays: settingsToSave.normalStockDays,
highStockDays: settingsToSave.highStockDays,
shoutrrrEnabled: effectiveShoutrrrEnabled,
shoutrrrUrl: settings.shoutrrrUrl,
emailStockReminders: settings.emailStockReminders,
emailIntakeReminders: settings.emailIntakeReminders,
shoutrrrStockReminders: settings.shoutrrrStockReminders,
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
stockCalculationMode: settings.stockCalculationMode,
shareStockStatus: settings.shareStockStatus,
shoutrrrUrl: settingsToSave.shoutrrrUrl,
emailStockReminders: settingsToSave.emailStockReminders,
emailIntakeReminders: settingsToSave.emailIntakeReminders,
emailPrescriptionReminders: settingsToSave.emailPrescriptionReminders,
shoutrrrStockReminders: settingsToSave.shoutrrrStockReminders,
shoutrrrIntakeReminders: settingsToSave.shoutrrrIntakeReminders,
shoutrrrPrescriptionReminders: settingsToSave.shoutrrrPrescriptionReminders,
stockCalculationMode: settingsToSave.stockCalculationMode,
shareStockStatus: settingsToSave.shareStockStatus,
language: i18n.language,
smtpHost: settings.smtpHost,
smtpPort: settings.smtpPort,
smtpUser: settings.smtpUser,
smtpPass: settings.smtpPass || undefined,
smtpFrom: settings.smtpFrom,
smtpSecure: settings.smtpSecure,
smtpHost: settingsToSave.smtpHost,
smtpPort: settingsToSave.smtpPort,
smtpUser: settingsToSave.smtpUser,
smtpPass: settingsToSave.smtpPass || undefined,
smtpFrom: settingsToSave.smtpFrom,
smtpSecure: settingsToSave.smtpSecure,
};
await fetch("/api/settings", {
@@ -230,7 +241,7 @@ export function useSettings(): UseSettingsReturn {
}).catch(() => null);
const updatedSettings = {
...settings,
...settingsToSave,
emailEnabled: effectiveEmailEnabled,
shoutrrrEnabled: effectiveShoutrrrEnabled,
};
@@ -239,7 +250,62 @@ export function useSettings(): UseSettingsReturn {
setSavedSettings(updatedSettings);
setSettingsSaved(true);
},
[settings, i18n.language]
[i18n.language]
);
// Debounced auto-save: fires whenever settings change
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const initialLoadDone = useRef(false);
useEffect(() => {
// Skip auto-save during initial load
if (!initialLoadDone.current) {
return;
}
// Don't save if nothing changed
if (JSON.stringify(settings) === JSON.stringify(savedSettings)) {
return;
}
// Don't save if thresholds are invalid
if (settings.reminderDaysBefore >= settings.lowStockDays || settings.lowStockDays >= settings.highStockDays) {
return;
}
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
performSave(settings);
}, 600);
return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
};
}, [settings, savedSettings, performSave]);
// Mark initial load as done after first settings load completes
useEffect(() => {
if (!settingsLoading && !initialLoadDone.current) {
// Use a small delay to ensure savedSettings is set
const t = setTimeout(() => {
initialLoadDone.current = true;
}, 100);
return () => clearTimeout(t);
}
}, [settingsLoading]);
// Legacy saveSettings wrapper (kept for compatibility)
const saveSettings = useCallback(
async (e?: React.FormEvent) => {
if (e) e.preventDefault();
await performSave(settings);
},
[settings, performSave]
);
const testEmail = useCallback(async () => {