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:
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user