diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 771dd22..1dc775f 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -11,7 +11,7 @@ import {
} from "./components";
import { AppHeader } from "./components/AppHeader";
import { AuthPage, AuthProvider, useAuth } from "./components/Auth";
-import { AppProvider, UnsavedChangesProvider, useAppContext } from "./context";
+import { AppProvider, UnsavedChangesProvider, useAppContext, useShareContext } from "./context";
import { useScrollLock } from "./hooks/useScrollLock";
import { DashboardPage, MedicationsPage, PlannerPage, SchedulePage, SettingsPage, SharedOverviewPage } from "./pages";
@@ -134,6 +134,7 @@ function AppContent() {
const location = useLocation();
// Get shared state from AppContext
const ctx = useAppContext();
+ const shareCtx = useShareContext();
const {
// Medications
meds,
@@ -165,22 +166,6 @@ function AppContent() {
closeRefillModal,
openEditStockModal,
closeEditStockModal,
- // Share
- showShareDialog,
- sharePeople,
- shareSelectedPerson,
- setShareSelectedPerson,
- shareSelectedDays,
- setShareSelectedDays,
- shareGenerating,
- shareLink,
- setShareLink,
- shareCopied,
- setShareCopied,
- generateShareLink,
- copyShareLink,
- closeShareDialog,
- resetShareDialogState,
// Computed
coverage,
// Modal state
@@ -201,8 +186,23 @@ function AppContent() {
closeUserFilter,
} = ctx;
- // Wrapper to pass meds to openShareDialog
- const _openShareDialog = () => ctx.openShareDialog();
+ const {
+ showShareDialog,
+ sharePeople,
+ shareSelectedPerson,
+ setShareSelectedPerson,
+ shareSelectedDays,
+ setShareSelectedDays,
+ shareGenerating,
+ shareLink,
+ setShareLink,
+ shareCopied,
+ setShareCopied,
+ generateShareLink,
+ copyShareLink,
+ closeShareDialog,
+ resetShareDialogState,
+ } = shareCtx;
// Local-only state (not shared across components)
const [showProfile, setShowProfile] = useState(false);
diff --git a/frontend/src/components/SharedSchedule.tsx b/frontend/src/components/SharedSchedule.tsx
index 15922b0..2d68e10 100644
--- a/frontend/src/components/SharedSchedule.tsx
+++ b/frontend/src/components/SharedSchedule.tsx
@@ -7,6 +7,10 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router-dom";
+import { ScheduleUsageTag } from "../features/schedule/components";
+import { formatScheduleDoseUsageLabel, formatScheduleTotalUsageLabel } from "../features/schedule/formatters";
+import { toggleDateInSet } from "../features/schedule/interactions";
+import { loadScheduleCollapseState, saveCollapsedDaySet } from "../features/schedule/storage";
import { useEscapeKey } from "../hooks";
import type { ExpiredLinkData, SharedScheduleData } from "../types";
import {
@@ -20,9 +24,8 @@ import {
} from "../types";
import { getSystemLocale } from "../utils/formatters";
import { getIntakeDailyRate, getMedicationIntakes, iterateIntakeOccurrences } from "../utils/intake-schedule";
-import { convertLiquidUsageToMl, getLiquidCountUnitLabel } from "../utils/intake-units";
+import { convertLiquidUsageToMl } from "../utils/intake-units";
import { getStockStatus, isDoseDismissed, parseLocalDateTime } from "../utils/schedule";
-import { loadCollapsedDaysFromStorage } from "../utils/storage";
import { MedicationAvatar } from "./MedicationAvatar";
import { SharedMedicationOverviewSection } from "./SharedMedicationOverviewSection";
@@ -53,64 +56,17 @@ export function SharedSchedule() {
return convertLiquidUsageToMl(usage, unit);
};
- const formatAmount = (value: number) => {
- const rounded = Math.round(value * 100) / 100;
- return String(rounded);
- };
-
- const formatLiquidUsageLabel = (usage: number, unit: IntakeUnit | null | undefined): string => {
- const normalizedUsage = Number(usage);
- if (!Number.isFinite(normalizedUsage) || normalizedUsage <= 0) {
- return `0 ${t("form.packageAmountUnitMl")}`;
- }
-
- if (unit === "ml" || unit == null) {
- return `${formatAmount(normalizedUsage)} ${t("form.packageAmountUnitMl")}`;
- }
-
- const mlTotal = convertLiquidUsageToMl(normalizedUsage, unit);
- return `${formatAmount(normalizedUsage)} ${getLiquidCountUnitLabel(unit, normalizedUsage, t)} ${formatAmount(mlTotal)} ${t("form.packageAmountUnitMl")}`;
- };
-
const formatDoseUsageLabel = (
med: SharedScheduleData["medications"][number] | undefined,
usage: number,
intakeUnit?: IntakeUnit | null
- ) => {
- if (isLiquidContainerMed(med)) {
- return formatLiquidUsageLabel(usage, intakeUnit);
- }
- return `${usage} ${usage !== 1 ? t("common.pills") : t("common.pill")}`;
- };
+ ) => formatScheduleDoseUsageLabel(med, usage, t, intakeUnit);
const formatTotalUsageLabel = (
med: SharedScheduleData["medications"][number] | undefined,
total: number,
doses?: Array<{ usage: number; intakeUnit?: IntakeUnit | null }>
- ) => {
- if (isLiquidContainerMed(med)) {
- if (doses && doses.length > 0) {
- const normalizedDoses = doses.filter((dose) => Number.isFinite(Number(dose.usage)) && Number(dose.usage) > 0);
- if (normalizedDoses.length > 0) {
- const allUnits = new Set(normalizedDoses.map((dose) => dose.intakeUnit ?? "ml"));
- if (allUnits.size === 1) {
- const onlyUnit = normalizedDoses[0]?.intakeUnit ?? "ml";
- const totalUsageInUnit = normalizedDoses.reduce((sum, dose) => sum + Number(dose.usage), 0);
- return formatLiquidUsageLabel(totalUsageInUnit, onlyUnit);
- }
-
- const totalMl = normalizedDoses.reduce(
- (sum, dose) => sum + convertLiquidUsageToMl(Number(dose.usage), dose.intakeUnit ?? "ml"),
- 0
- );
- return `${formatAmount(totalMl)} ${t("form.packageAmountUnitMl")}`;
- }
- }
- return `${formatAmount(total)} ${t("form.packageAmountUnitMl")}`;
- }
-
- return t("common.pillsTotal", { count: total });
- };
+ ) => formatScheduleTotalUsageLabel(med, total, t, doses);
// Theme preference: light, dark, or system
type ThemePreference = "light" | "dark" | "system";
@@ -172,7 +128,7 @@ export function SharedSchedule() {
// Load collapsed/expanded state from localStorage
useEffect(() => {
if (token && typeof window !== "undefined") {
- const { collapsed, expanded } = loadCollapsedDaysFromStorage(
+ const { collapsed, expanded } = loadScheduleCollapseState(
`share_${token}_collapsedDays`,
`share_${token}_expandedDays`
);
@@ -185,24 +141,14 @@ export function SharedSchedule() {
function toggleDayCollapse(dateStr: string, isAutoCollapsed: boolean) {
if (isAutoCollapsed) {
setManuallyExpandedDays((prev) => {
- const next = new Set(prev);
- if (next.has(dateStr)) {
- next.delete(dateStr);
- } else {
- next.add(dateStr);
- }
- if (token) localStorage.setItem(`share_${token}_expandedDays`, JSON.stringify([...next]));
+ const next = toggleDateInSet(prev, dateStr);
+ if (token) saveCollapsedDaySet(`share_${token}_expandedDays`, next);
return next;
});
} else {
setManuallyCollapsedDays((prev) => {
- const next = new Set(prev);
- if (next.has(dateStr)) {
- next.delete(dateStr);
- } else {
- next.add(dateStr);
- }
- if (token) localStorage.setItem(`share_${token}_collapsedDays`, JSON.stringify([...next]));
+ const next = toggleDateInSet(prev, dateStr);
+ if (token) saveCollapsedDaySet(`share_${token}_collapsedDays`, next);
return next;
});
}
@@ -977,9 +923,9 @@ export function SharedSchedule() {
-
+
{formatTotalUsageLabel(med, item.total, item.doses)}
-
+
{isLowStock && {t("status.lowStock")} }
@@ -1192,9 +1138,9 @@ export function SharedSchedule() {
-
+
{formatTotalUsageLabel(med, item.total, item.doses)}
-
+
{isLowStock && {t("status.lowStock")} }
@@ -1394,9 +1340,9 @@ export function SharedSchedule() {
-
+
{formatTotalUsageLabel(med, item.total, item.doses)}
-
+
{isLowStock && {t("status.lowStock")} }
diff --git a/frontend/src/components/dashboard/DashboardReminderSection.tsx b/frontend/src/components/dashboard/DashboardReminderSection.tsx
new file mode 100644
index 0000000..57719e6
--- /dev/null
+++ b/frontend/src/components/dashboard/DashboardReminderSection.tsx
@@ -0,0 +1,265 @@
+import { type Coverage, getMedDisplayName, type Medication, type StockThresholds } from "../../types";
+import { getStockStatus } from "../../utils/schedule";
+
+type ReminderData = {
+ status: { className: string; text: string };
+ lowStockMeds: Array<{ name: string; daysLeft: number; isCritical: boolean }>;
+ lastStockSent: { medNames: string | null; date: string } | null;
+ lastIntakeSent: { medName: string | null; takenBy: string | null; date: string } | null;
+};
+
+type PrescriptionLowMed = {
+ id: number;
+ name: string;
+ remainingRefills: number;
+ threshold: number;
+};
+
+type DashboardReminderSectionProps = {
+ t: (key: string, options?: Record) => string;
+ remindersLoading: boolean;
+ anyRemindersEnabled: boolean;
+ stockRemindersEnabled: boolean;
+ intakeRemindersEnabled: boolean;
+ prescriptionRemindersEnabled: boolean;
+ reminderData: ReminderData;
+ prescriptionLowMeds: PrescriptionLowMed[];
+ prescriptionStatus: { text: string; className: string } | null;
+ meds: Medication[];
+ coverage: { all: Coverage[] };
+ stockThresholds: StockThresholds;
+ sendingReminder: boolean;
+ reminderResult: { success: boolean; message: string } | null;
+ onSendManualReminder: () => void;
+ onOpenMedicationDetail: (med: Medication) => void;
+};
+
+function NotificationBellIcon() {
+ return (
+
+
+
+
+ );
+}
+
+export function DashboardReminderSection({
+ t,
+ remindersLoading,
+ anyRemindersEnabled,
+ stockRemindersEnabled,
+ intakeRemindersEnabled,
+ prescriptionRemindersEnabled,
+ reminderData,
+ prescriptionLowMeds,
+ prescriptionStatus,
+ meds,
+ coverage,
+ stockThresholds,
+ sendingReminder,
+ reminderResult,
+ onSendManualReminder,
+ onOpenMedicationDetail,
+}: DashboardReminderSectionProps) {
+ const getStatusTextClass = (statusClassName: string | undefined): string => {
+ if (statusClassName === "danger") return "danger-text";
+ if (statusClassName === "warning") return "warning-text";
+ return "";
+ };
+
+ if (remindersLoading) {
+ return (
+
+
+
+
+
+ {t("dashboard.reminders.active")}
+
+
+
+
+
+
+
+ );
+ }
+
+ if (!anyRemindersEnabled) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+ {t("dashboard.reminders.active")}
+ {stockRemindersEnabled && (
+ {reminderData.status.text}
+ )}
+ {prescriptionStatus && (
+ {prescriptionStatus.text}
+ )}
+
+ {(reminderData.lowStockMeds.length > 0 ||
+ (prescriptionRemindersEnabled && prescriptionLowMeds.length > 0) ||
+ (stockRemindersEnabled && reminderData.lastStockSent) ||
+ (intakeRemindersEnabled && reminderData.lastIntakeSent)) && (
+
+ {stockRemindersEnabled && reminderData.lowStockMeds.length > 0 && (
+
+ {t("dashboard.reminders.needsRefill")}:
+
+ {reminderData.lowStockMeds.map((med, idx) => {
+ const medication = meds.find((m) => getMedDisplayName(m) === med.name);
+ const cov = coverage.all.find((c) => c.name === med.name);
+ const status = cov
+ ? getStockStatus(cov.daysLeft, cov.medsLeft, stockThresholds, medication?.packageType)
+ : null;
+ const textClass = getStatusTextClass(status?.className);
+ return (
+
+ {idx > 0 && ", "}
+ medication && onOpenMedicationDetail(medication)}
+ onKeyDown={(e) => {
+ if ((e.key === "Enter" || e.key === " ") && medication) {
+ onOpenMedicationDetail(medication);
+ }
+ }}
+ >
+ {med.name}
+
+
+ {" "}
+ {t("dashboard.reminders.daysLeft", { count: med.daysLeft, days: med.daysLeft })}
+
+
+ );
+ })}
+
+
+ )}
+ {prescriptionRemindersEnabled && prescriptionLowMeds.length > 0 && (
+
+ {t("dashboard.reminders.needsPrescriptionRefill")}:
+
+ {prescriptionLowMeds.map((med, idx) => {
+ const medication = meds.find((m) => m.id === med.id);
+ const textClass = med.remainingRefills <= 0 ? "danger-text" : "warning-text";
+ return (
+
+ {idx > 0 && ", "}
+
+ {t("prescription.remainingRefills")}: {med.remainingRefills} · {t("dashboard.reminders.usedBy")}
+ :{" "}
+ medication && onOpenMedicationDetail(medication)}
+ onKeyDown={(e) => {
+ if ((e.key === "Enter" || e.key === " ") && medication) {
+ onOpenMedicationDetail(medication);
+ }
+ }}
+ >
+ {med.name}
+
+
+
+ );
+ })}
+
+
+ )}
+ {stockRemindersEnabled && reminderData.lastStockSent && (
+
+ {t("dashboard.reminders.lastStockSent")}:
+
+ {reminderData.lastStockSent.medNames &&
+ (() => {
+ const names = reminderData.lastStockSent?.medNames?.split(", ") ?? [];
+ return names.map((name, idx) => {
+ const medication = meds.find((m) => getMedDisplayName(m) === name);
+ return (
+
+ {idx > 0 && ", "}
+ {medication ? (
+ onOpenMedicationDetail(medication)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") onOpenMedicationDetail(medication);
+ }}
+ >
+ {name}
+
+ ) : (
+ {name}
+ )}
+
+ );
+ });
+ })()}
+ {reminderData.lastStockSent.date}
+
+
+ )}
+ {intakeRemindersEnabled && reminderData.lastIntakeSent && (
+
+ {t("dashboard.reminders.lastSent")}:
+
+ {reminderData.lastIntakeSent.medName &&
+ (() => {
+ const medication = meds.find((m) => getMedDisplayName(m) === reminderData.lastIntakeSent?.medName);
+ return medication ? (
+ onOpenMedicationDetail(medication)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") onOpenMedicationDetail(medication);
+ }}
+ >
+ {reminderData.lastIntakeSent?.medName}
+
+ ) : (
+ {reminderData.lastIntakeSent?.medName}
+ );
+ })()}
+ {reminderData.lastIntakeSent.takenBy && (
+ ({reminderData.lastIntakeSent.takenBy})
+ )}
+ {reminderData.lastIntakeSent.date}
+
+
+ )}
+
+ )}
+ {((stockRemindersEnabled && reminderData.lowStockMeds.length > 0) ||
+ (prescriptionRemindersEnabled && prescriptionLowMeds.length > 0)) && (
+
+
+ {sendingReminder ? t("common.sending") : t("dashboard.reorder.sendReminder")}
+
+ {reminderResult && (
+
+ {reminderResult.message}
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/dashboard/DashboardStatusSection.tsx b/frontend/src/components/dashboard/DashboardStatusSection.tsx
new file mode 100644
index 0000000..fdfb9be
--- /dev/null
+++ b/frontend/src/components/dashboard/DashboardStatusSection.tsx
@@ -0,0 +1,96 @@
+import type { Coverage, Medication, StockThresholds } from "../../types";
+import { getMedDisplayName } from "../../types";
+import { getStockStatus } from "../../utils/schedule";
+
+type DashboardStatusSectionProps = {
+ t: (key: string, options?: Record) => string;
+ show: boolean;
+ meds: Medication[];
+ coverage: { all: Coverage[] };
+ stockThresholds: StockThresholds;
+ onOpenMedicationDetail: (med: Medication) => void;
+};
+
+export function DashboardStatusSection({
+ t,
+ show,
+ meds,
+ coverage,
+ stockThresholds,
+ onOpenMedicationDetail,
+}: DashboardStatusSectionProps) {
+ const getStatusTextClass = (statusClassName: string): string => {
+ if (statusClassName === "danger") return "danger-text";
+ if (statusClassName === "warning") return "warning-text";
+ return "";
+ };
+
+ if (!show) {
+ return null;
+ }
+
+ return (
+
+
+
+
{t("dashboard.reorder.title")}
+
+ {(() => {
+ if (meds.length === 0) {
+ return {t("dashboard.reorder.noMeds")}
;
+ }
+
+ const lowStockMap = new Map();
+ for (const c of coverage.all) {
+ if (c.daysLeft === null && c.medsLeft > 0) continue;
+ const med = meds.find((m) => getMedDisplayName(m) === c.name);
+ const status = getStockStatus(c.daysLeft, c.medsLeft, stockThresholds, med?.packageType);
+ if (status.className === "danger" || status.className === "warning") {
+ const existing = lowStockMap.get(c.name);
+ if (!existing || (c.daysLeft ?? 0) < (existing.daysLeft ?? 0)) {
+ lowStockMap.set(c.name, c);
+ }
+ }
+ }
+ const lowStockMeds = Array.from(lowStockMap.values());
+ const lowStockCount = lowStockMeds.length;
+ if (lowStockCount === 0) {
+ return {t("dashboard.reorder.allGood")}
;
+ }
+
+ return (
+
+ {t("dashboard.reorder.lowWarningPrefix")}{" "}
+ {lowStockMeds.map((c, idx) => {
+ const med = meds.find((m) => getMedDisplayName(m) === c.name);
+ const status = getStockStatus(c.daysLeft, c.medsLeft, stockThresholds, med?.packageType);
+ const textClass = getStatusTextClass(status.className);
+ return (
+
+ {idx > 0 && ", "}
+ med && onOpenMedicationDetail(med)}
+ onKeyDown={(e) => {
+ if ((e.key === "Enter" || e.key === " ") && med) {
+ onOpenMedicationDetail(med);
+ }
+ }}
+ >
+ {c.name}
+
+
+ {" "}
+ ({t("dashboard.reminders.daysLeft", { count: c.daysLeft ?? 0, days: c.daysLeft ?? 0 })})
+
+
+ );
+ })}{" "}
+ {t("dashboard.reorder.lowWarningSuffix", { count: lowStockCount })}
+
+ );
+ })()}
+
+
+ );
+}
diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts
index c0ae601..6460c0b 100644
--- a/frontend/src/components/index.ts
+++ b/frontend/src/components/index.ts
@@ -18,6 +18,9 @@ export type { MedicationEnrichmentViewModel } from "./MedicationEnrichmentSectio
export { MedicationEnrichmentSection } from "./MedicationEnrichmentSection";
export type { MobileEditModalProps } from "./MobileEditModal";
export { MobileEditModal } from "./MobileEditModal";
+export { MedicationDialogs } from "./medications/MedicationDialogs";
+export { MedicationEditCoordinator } from "./medications/MedicationEditCoordinator";
+export { MedicationListSection } from "./medications/MedicationListSection";
export { PasswordInput } from "./PasswordInput";
export { default as ProfileModal } from "./ProfileModal";
export { default as ReportModal } from "./ReportModal";
diff --git a/frontend/src/components/medications/MedicationDialogs.tsx b/frontend/src/components/medications/MedicationDialogs.tsx
new file mode 100644
index 0000000..c8fe8a4
--- /dev/null
+++ b/frontend/src/components/medications/MedicationDialogs.tsx
@@ -0,0 +1,120 @@
+import type React from "react";
+import type { Medication } from "../../types";
+import { ConfirmModal } from "../ConfirmModal";
+import { Lightbox } from "../Lightbox";
+import ReportModal from "../ReportModal";
+
+type MedicationDialogsProps = {
+ mobileEditModal: React.ReactNode;
+ showUnsavedConfirm: boolean;
+ unsavedCancelLabel: string;
+ unsavedConfirmLabel: string;
+ unsavedMessage: string;
+ unsavedTitle: string;
+ onConfirmClose: () => void;
+ onCancelClose: () => void;
+ showObsoleteConfirm: boolean;
+ obsoleteCandidate: Medication | null;
+ obsoleteTitle: string;
+ obsoleteMessage: string;
+ obsoleteConfirmLabel: string;
+ obsoleteCancelLabel: string;
+ onConfirmMarkObsolete: () => void;
+ onCancelMarkObsolete: () => void;
+ showDeleteConfirm: boolean;
+ deleteCandidate: Medication | null;
+ deleteTitle: string;
+ deleteMessage: string;
+ deleteConfirmLabel: string;
+ deleteCancelLabel: string;
+ onConfirmDelete: () => void;
+ onCancelDelete: () => void;
+ showEditModal: boolean;
+ lightboxImage: { src: string; alt: string } | null;
+ onCloseLightbox: () => void;
+ showReportModal: boolean;
+ onCloseReportModal: () => void;
+ medications: Medication[];
+};
+
+export function MedicationDialogs({
+ mobileEditModal,
+ showUnsavedConfirm,
+ unsavedCancelLabel,
+ unsavedConfirmLabel,
+ unsavedMessage,
+ unsavedTitle,
+ onConfirmClose,
+ onCancelClose,
+ showObsoleteConfirm,
+ obsoleteCandidate,
+ obsoleteTitle,
+ obsoleteMessage,
+ obsoleteConfirmLabel,
+ obsoleteCancelLabel,
+ onConfirmMarkObsolete,
+ onCancelMarkObsolete,
+ showDeleteConfirm,
+ deleteCandidate,
+ deleteTitle,
+ deleteMessage,
+ deleteConfirmLabel,
+ deleteCancelLabel,
+ onConfirmDelete,
+ onCancelDelete,
+ showEditModal,
+ lightboxImage,
+ onCloseLightbox,
+ showReportModal,
+ onCloseReportModal,
+ medications,
+}: MedicationDialogsProps) {
+ return (
+ <>
+ {mobileEditModal}
+
+ {showUnsavedConfirm && (
+
+ )}
+
+ {showObsoleteConfirm && obsoleteCandidate && (
+
+ )}
+
+ {showDeleteConfirm && deleteCandidate && (
+
+ )}
+
+ {lightboxImage && }
+
+
+ >
+ );
+}
diff --git a/frontend/src/components/medications/MedicationEditCoordinator.tsx b/frontend/src/components/medications/MedicationEditCoordinator.tsx
new file mode 100644
index 0000000..1774edc
--- /dev/null
+++ b/frontend/src/components/medications/MedicationEditCoordinator.tsx
@@ -0,0 +1,55 @@
+import type React from "react";
+import { useTranslation } from "react-i18next";
+
+type MedicationEditCoordinatorProps = {
+ viewMode: "grid" | "form";
+ editingId: number | null;
+ readOnlyView: boolean;
+ selectedMedicationName?: string;
+ onBack: () => void;
+ onSubmit: (event: React.FormEvent) => void;
+ children: React.ReactNode;
+};
+
+export function MedicationEditCoordinator({
+ viewMode,
+ editingId,
+ readOnlyView,
+ selectedMedicationName,
+ onBack,
+ onSubmit,
+ children,
+}: MedicationEditCoordinatorProps) {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+
+
+ {"<-"} {t("common.back")}
+
+ {editingId ? (
+
+ {readOnlyView ? t("form.viewEntry") : t("form.editEntry")}: {selectedMedicationName}
+
+ ) : (
+ {t("form.newEntry")}
+ )}
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/medications/MedicationListSection.tsx b/frontend/src/components/medications/MedicationListSection.tsx
new file mode 100644
index 0000000..a8092f2
--- /dev/null
+++ b/frontend/src/components/medications/MedicationListSection.tsx
@@ -0,0 +1,264 @@
+import { Archive, Bell, Eye, Pencil, Trash2 } from "lucide-react";
+import { useTranslation } from "react-i18next";
+import type { Medication } from "../../types";
+import { getMedDisplayName, getMedTotal, getStockDisplayCapacity, isAmountBasedPackageType } from "../../types";
+import { formatDate, formatDateTime } from "../../utils/formatters";
+import { getIntakeFrequencyText, getMedicationIntakes } from "../../utils/intake-schedule";
+import { MedicationAvatar } from "../MedicationAvatar";
+
+type MedicationListSectionProps = {
+ orderedMeds: Medication[];
+ obsoleteMeds: Medication[];
+ editingId: number | null;
+ showObsolete: boolean;
+ coverageByMed: Record;
+ onNewEntry: () => void;
+ onOpenReport: () => void;
+ onEdit: (med: Medication) => void;
+ onView: (med: Medication) => void;
+ onMarkObsolete: (med: Medication) => void;
+ onDelete: (med: Medication) => void;
+ onReactivate: (medId: number) => void;
+ onToggleObsolete: () => void;
+ onImagePreview: (med: Medication) => void;
+ getMedicationPackageTypeLabel: (med: Medication) => string;
+ getMedicationStockSuffix: (med: Medication) => string;
+ getMedicationUsageUnitLabel: (med: Medication, usage: number) => string;
+};
+
+export function MedicationListSection({
+ orderedMeds,
+ obsoleteMeds,
+ editingId,
+ showObsolete,
+ coverageByMed,
+ onNewEntry,
+ onOpenReport,
+ onEdit,
+ onView,
+ onMarkObsolete,
+ onDelete,
+ onReactivate,
+ onToggleObsolete,
+ onImagePreview,
+ getMedicationPackageTypeLabel,
+ getMedicationStockSuffix,
+ getMedicationUsageUnitLabel,
+}: MedicationListSectionProps) {
+ const { t } = useTranslation();
+
+ const renderImageAvatar = (med: Medication) => (
+ med.imageUrl && onImagePreview(med)}
+ onKeyDown={(e) => {
+ if ((e.key === "Enter" || e.key === " ") && med.imageUrl) {
+ onImagePreview(med);
+ }
+ }}
+ >
+
+
+ );
+
+ return (
+
+
+
{t("medications.list.title")}
+
+
+ + {t("form.newEntry")}
+
+
+ {t("report.button")}
+
+
+
+
+
+
+ {orderedMeds.map((med) => {
+ const displayName = getMedDisplayName(med);
+ const stockDisplayCapacity = getStockDisplayCapacity(med);
+ const currentStock = coverageByMed[displayName]
+ ? Math.round(coverageByMed[displayName].medsLeft)
+ : getMedTotal(med);
+
+ return (
+
+
+
+
+ {renderImageAvatar(med)}
+
+
{displayName}
+ {med.name && med.genericName &&
{med.genericName}
}
+
+
+
+ {editingId !== med.id && (
+
onEdit(med)}
+ aria-label={t("common.edit")}
+ data-tooltip={t("common.edit")}
+ >
+
+
+ )}
+
onMarkObsolete(med)}
+ aria-label={t("medications.list.markObsolete")}
+ >
+
+ {t("medications.list.markObsolete")}
+
+
onDelete(med)}
+ aria-label={t("common.delete")}
+ data-tooltip={t("common.delete")}
+ >
+
+
+
+
+
+ {t("medications.details.type")}: {getMedicationPackageTypeLabel(med)}
+
+ {!isAmountBasedPackageType(med.packageType) ? (
+ <>
+
+ {t("medications.details.packs")}: {med.packCount}
+
+
+ {t("medications.details.blisters")}: {med.blistersPerPack}
+
+
+ {t("medications.details.pillsPerBlister")}: {med.pillsPerBlister}
+
+
+ {t("medications.details.loose")}: {med.looseTablets}
+
+ >
+ ) : (
+
+ {t("medications.details.totalCapacity")}:{" "}
+ {med.totalPills ?? med.looseTablets}
+
+ )}
+
+ {med.prescriptionEnabled && (
+
+ {t("prescription.remainingRefills")}: {med.prescriptionRemainingRefills ?? 0}
+
+ )}
+
+ {t("medications.details.stock")}: {currentStock} / {stockDisplayCapacity}
+ {getMedicationStockSuffix(med)}
+ {currentStock > stockDisplayCapacity ? (
+
+ {" "}
+ ⚠️
+
+ ) : null}
+
+
+
+
+ {getMedicationIntakes(med).map((intake) => (
+
+ {intake.usage} {getMedicationUsageUnitLabel(med, intake.usage)} ·
+ {getIntakeFrequencyText(intake, t)} · {t("form.blisters.from")} {formatDateTime(intake.start)}
+ {intake.takenBy && · {intake.takenBy} }
+ {intake.intakeRemindersEnabled && (
+
+ {" "}
+
+
+ )}
+
+ ))}
+
+
+ );
+ })}
+
+
+ {obsoleteMeds.length > 0 && (
+
+
+
+ {showObsolete ? "▼" : "▶"} {t("medications.list.obsoleteTitle", { count: obsoleteMeds.length })}
+
+
+ {showObsolete && (
+
+ {obsoleteMeds.map((med) => (
+
+
+
+
+ {renderImageAvatar(med)}
+
+
{getMedDisplayName(med)}
+ {med.name && med.genericName &&
{med.genericName}
}
+
+
+
+ onView(med)}
+ aria-label={t("common.view")}
+ data-tooltip={t("common.view")}
+ >
+
+
+ onDelete(med)}
+ aria-label={t("common.delete")}
+ data-tooltip={t("common.delete")}
+ >
+
+
+ onReactivate(med.id)}>
+ {t("medications.list.reactivate")}
+
+
+
+ {med.medicationStartDate && (
+
+ {t("medications.list.started")}: {formatDate(med.medicationStartDate)}
+
+ )}
+
+ {t("medications.list.obsoleteSince")}: {formatDate(med.obsoleteAt)}
+
+
+
+
+
+ ))}
+
+ )}
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/context/AppContext.tsx b/frontend/src/context/AppContext.tsx
index 8368551..80e664a 100644
--- a/frontend/src/context/AppContext.tsx
+++ b/frontend/src/context/AppContext.tsx
@@ -14,6 +14,7 @@ import {
import { getSystemLocale } from "../utils/formatters";
import { log } from "../utils/logger";
import { buildSchedulePreview, calculateCoverage, computeMissedPastDoseIds, getStockStatus } from "../utils/schedule";
+import { ShareContextProvider } from "./ShareContext";
// =============================================================================
// Types
@@ -799,6 +800,28 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
);
}, [settingsHook.settings, settingsHook.savedSettings]);
+ const shareValue = useMemo(
+ () => ({
+ showShareDialog: share.showShareDialog,
+ sharePeople: share.sharePeople,
+ shareSelectedPerson: share.shareSelectedPerson,
+ setShareSelectedPerson: share.setShareSelectedPerson,
+ shareSelectedDays: share.shareSelectedDays,
+ setShareSelectedDays: share.setShareSelectedDays,
+ shareGenerating: share.shareGenerating,
+ shareLink: share.shareLink,
+ setShareLink: share.setShareLink,
+ shareCopied: share.shareCopied,
+ setShareCopied: share.setShareCopied,
+ openShareDialog,
+ generateShareLink: share.generateShareLink,
+ copyShareLink: share.copyShareLink,
+ closeShareDialog: share.closeShareDialog,
+ resetShareDialogState: share.resetShareDialogState,
+ }),
+ [share, openShareDialog]
+ );
+
// Build context value
const value: AppContextValue = useMemo(
() => ({
@@ -992,7 +1015,11 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
]
);
- return {children} ;
+ return (
+
+ {children}
+
+ );
}
// =============================================================================
diff --git a/frontend/src/context/ShareContext.tsx b/frontend/src/context/ShareContext.tsx
new file mode 100644
index 0000000..3014b96
--- /dev/null
+++ b/frontend/src/context/ShareContext.tsx
@@ -0,0 +1,41 @@
+import { createContext, useContext } from "react";
+
+type ShareContextValue = {
+ showShareDialog: boolean;
+ sharePeople: string[];
+ shareSelectedPerson: string;
+ setShareSelectedPerson: React.Dispatch>;
+ shareSelectedDays: number;
+ setShareSelectedDays: React.Dispatch>;
+ shareGenerating: boolean;
+ shareLink: string | null;
+ setShareLink: React.Dispatch>;
+ shareCopied: boolean;
+ setShareCopied: React.Dispatch>;
+ openShareDialog: () => void;
+ generateShareLink: () => Promise;
+ copyShareLink: () => void;
+ closeShareDialog: () => void;
+ resetShareDialogState: () => void;
+};
+
+const ShareContext = createContext(null);
+
+type ShareContextProviderProps = {
+ value: ShareContextValue;
+ children: React.ReactNode;
+};
+
+export function ShareContextProvider({ value, children }: ShareContextProviderProps) {
+ return {children} ;
+}
+
+export function useShareContext(): ShareContextValue {
+ const context = useContext(ShareContext);
+ if (!context) {
+ throw new Error("useShareContext must be used within ShareContextProvider");
+ }
+ return context;
+}
+
+export type { ShareContextValue };
diff --git a/frontend/src/context/index.ts b/frontend/src/context/index.ts
index 29b37e6..b661b46 100644
--- a/frontend/src/context/index.ts
+++ b/frontend/src/context/index.ts
@@ -2,4 +2,6 @@
export type { AppContextValue, DayMedEntry, DoseInfo, GroupedDay } from "./AppContext";
export { AppProvider, useAppContext } from "./AppContext";
+export type { ShareContextValue } from "./ShareContext";
+export { ShareContextProvider, useShareContext } from "./ShareContext";
export { UnsavedChangesProvider, useUnsavedChanges } from "./UnsavedChangesContext";
diff --git a/frontend/src/features/schedule/components/ScheduleSectionCard.tsx b/frontend/src/features/schedule/components/ScheduleSectionCard.tsx
new file mode 100644
index 0000000..68d55e2
--- /dev/null
+++ b/frontend/src/features/schedule/components/ScheduleSectionCard.tsx
@@ -0,0 +1,18 @@
+type ScheduleSectionCardProps = {
+ title: string;
+ children: React.ReactNode;
+ headerRight?: React.ReactNode;
+ className?: string;
+};
+
+export function ScheduleSectionCard({ title, children, headerRight, className }: ScheduleSectionCardProps) {
+ return (
+
+
+
{title}
+ {headerRight}
+
+ {children}
+
+ );
+}
diff --git a/frontend/src/features/schedule/components/ScheduleUsageTag.tsx b/frontend/src/features/schedule/components/ScheduleUsageTag.tsx
new file mode 100644
index 0000000..0328e5a
--- /dev/null
+++ b/frontend/src/features/schedule/components/ScheduleUsageTag.tsx
@@ -0,0 +1,7 @@
+type ScheduleUsageTagProps = {
+ children: React.ReactNode;
+};
+
+export function ScheduleUsageTag({ children }: ScheduleUsageTagProps) {
+ return {children} ;
+}
diff --git a/frontend/src/features/schedule/components/index.ts b/frontend/src/features/schedule/components/index.ts
new file mode 100644
index 0000000..3224a32
--- /dev/null
+++ b/frontend/src/features/schedule/components/index.ts
@@ -0,0 +1,2 @@
+export { ScheduleSectionCard } from "./ScheduleSectionCard";
+export { ScheduleUsageTag } from "./ScheduleUsageTag";
diff --git a/frontend/src/features/schedule/formatters.ts b/frontend/src/features/schedule/formatters.ts
new file mode 100644
index 0000000..49a35f9
--- /dev/null
+++ b/frontend/src/features/schedule/formatters.ts
@@ -0,0 +1,85 @@
+import type { IntakeUnit } from "../../types";
+import { allowsPillFormSelection, isLiquidContainerPackageType, isTubePackageType } from "../../types";
+import { formatNumber } from "../../utils/formatters";
+import { convertLiquidUsageToMl, getLiquidCountUnitLabel } from "../../utils/intake-units";
+
+type Translate = (key: string, options?: Record) => string;
+type MedicationLike = { packageType?: string | null; medicationForm?: string | null } | undefined;
+
+function formatLiquidUsageLabel(usage: number, unit: IntakeUnit | null | undefined, t: Translate): string {
+ const normalizedUsage = Number(usage);
+ if (!Number.isFinite(normalizedUsage) || normalizedUsage <= 0) {
+ return `0 ${t("form.packageAmountUnitMl")}`;
+ }
+
+ if (unit === "ml" || unit == null) {
+ return `${formatNumber(normalizedUsage)} ${t("form.packageAmountUnitMl")}`;
+ }
+
+ const mlTotal = convertLiquidUsageToMl(normalizedUsage, unit);
+ return `${formatNumber(normalizedUsage)} ${getLiquidCountUnitLabel(unit, normalizedUsage, t)} ${formatNumber(mlTotal)} ${t("form.packageAmountUnitMl")}`;
+}
+
+function getTubeUnitLabel(med: MedicationLike, value: number, t: Translate): string {
+ if (isLiquidContainerPackageType(med?.packageType) || med?.medicationForm === "liquid") {
+ return t("form.packageAmountUnitMl");
+ }
+ return t("form.blisters.applications", { count: Math.abs(value) });
+}
+
+export function formatScheduleDoseUsageLabel(
+ med: MedicationLike,
+ usage: number,
+ t: Translate,
+ intakeUnit?: IntakeUnit | null
+): string {
+ if (isLiquidContainerPackageType(med?.packageType)) {
+ return formatLiquidUsageLabel(usage, intakeUnit, t);
+ }
+
+ if (isTubePackageType(med?.packageType)) {
+ return `${usage} ${getTubeUnitLabel(med, usage, t)}`;
+ }
+
+ return `${usage} ${usage !== 1 ? t("common.pills") : t("common.pill")}`;
+}
+
+export function formatScheduleTotalUsageLabel(
+ med: MedicationLike,
+ total: number,
+ t: Translate,
+ doses?: Array<{ usage: number; intakeUnit?: IntakeUnit | null }>,
+ fallbackIntakeUnit?: IntakeUnit | null
+): string {
+ if (isLiquidContainerPackageType(med?.packageType)) {
+ if (doses && doses.length > 0) {
+ const normalizedDoses = doses.filter((dose) => Number.isFinite(Number(dose.usage)) && Number(dose.usage) > 0);
+ if (normalizedDoses.length > 0) {
+ const allUnits = new Set(normalizedDoses.map((dose) => dose.intakeUnit ?? "ml"));
+ if (allUnits.size === 1) {
+ const onlyUnit = normalizedDoses[0]?.intakeUnit ?? "ml";
+ const totalUsageInUnit = normalizedDoses.reduce((sum, dose) => sum + Number(dose.usage), 0);
+ return formatLiquidUsageLabel(totalUsageInUnit, onlyUnit, t);
+ }
+
+ const totalMl = normalizedDoses.reduce(
+ (sum, dose) => sum + convertLiquidUsageToMl(Number(dose.usage), dose.intakeUnit ?? "ml"),
+ 0
+ );
+ return `${formatNumber(totalMl)} ${t("form.packageAmountUnitMl")}`;
+ }
+ }
+
+ return formatLiquidUsageLabel(total, fallbackIntakeUnit, t);
+ }
+
+ if (isTubePackageType(med?.packageType)) {
+ return `${total} ${getTubeUnitLabel(med, total, t)}`;
+ }
+
+ if (allowsPillFormSelection(med?.packageType)) {
+ return t("common.pillsTotal", { count: total });
+ }
+
+ return t("common.pillsTotal", { count: total });
+}
diff --git a/frontend/src/features/schedule/interactions.ts b/frontend/src/features/schedule/interactions.ts
new file mode 100644
index 0000000..c721f6a
--- /dev/null
+++ b/frontend/src/features/schedule/interactions.ts
@@ -0,0 +1,29 @@
+export function toggleDateInSet(previous: Set, dateStr: string): Set {
+ const next = new Set(previous);
+ if (next.has(dateStr)) {
+ next.delete(dateStr);
+ } else {
+ next.add(dateStr);
+ }
+ return next;
+}
+
+export function resolveCollapsedState(
+ isAutoCollapsed: boolean,
+ dateStr: string,
+ manuallyExpandedDays: Set,
+ manuallyCollapsedDays: Set
+): boolean {
+ if (isAutoCollapsed) {
+ return !manuallyExpandedDays.has(dateStr);
+ }
+ return manuallyCollapsedDays.has(dateStr);
+}
+
+export function countTakenDoseIds(doseIds: string[], isDoseTaken: (doseId: string) => boolean): number {
+ return doseIds.filter((id) => isDoseTaken(id)).length;
+}
+
+export function areAllDoseIdsTaken(doseIds: string[], isDoseTaken: (doseId: string) => boolean): boolean {
+ return doseIds.length > 0 && doseIds.every((id) => isDoseTaken(id));
+}
diff --git a/frontend/src/features/schedule/storage.ts b/frontend/src/features/schedule/storage.ts
new file mode 100644
index 0000000..db781b0
--- /dev/null
+++ b/frontend/src/features/schedule/storage.ts
@@ -0,0 +1,18 @@
+import { loadCollapsedDaysFromStorage } from "../../utils/storage";
+
+export type ScheduleCollapseState = {
+ collapsed: Set;
+ expanded: Set;
+};
+
+export function loadScheduleCollapseState(collapseKey: string, expandKey: string): ScheduleCollapseState {
+ return loadCollapsedDaysFromStorage(collapseKey, expandKey);
+}
+
+export function saveCollapsedDaySet(storageKey: string, value: Set): void {
+ try {
+ localStorage.setItem(storageKey, JSON.stringify([...value]));
+ } catch {
+ // Ignore storage failures and keep UI responsive.
+ }
+}
diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts
index 2a7fd2d..74a36aa 100644
--- a/frontend/src/hooks/index.ts
+++ b/frontend/src/hooks/index.ts
@@ -5,6 +5,14 @@ export { useCollapsedDays } from "./useCollapsedDays";
export type { UseDosesReturn } from "./useDoses";
export { useDoses } from "./useDoses";
export { useEscapeKey } from "./useEscapeKey";
+export {
+ createMedicationEnrichmentState,
+ MEDICATION_ENRICHMENT_INITIAL_LIMIT,
+ MEDICATION_ENRICHMENT_LIMIT_STEP,
+ MEDICATION_ENRICHMENT_MAX_LIMIT,
+ type MedicationEnrichmentState,
+ useMedicationEnrichmentController,
+} from "./useMedicationEnrichmentController";
export type { UseMedicationFormReturn } from "./useMedicationForm";
export { defaultBlister, defaultForm, useMedicationForm } from "./useMedicationForm";
export type { UseMedicationsReturn } from "./useMedications";
@@ -12,6 +20,7 @@ export { useMedications } from "./useMedications";
export { useModalHistory } from "./useModalHistory";
export type { UseRefillReturn } from "./useRefill";
export { useRefill } from "./useRefill";
+export { useScheduleController } from "./useScheduleController";
export { useScrollLock } from "./useScrollLock";
export type { Settings, UseSettingsReturn } from "./useSettings";
export { useSettings } from "./useSettings";
diff --git a/frontend/src/hooks/useMedicationEnrichmentController.ts b/frontend/src/hooks/useMedicationEnrichmentController.ts
new file mode 100644
index 0000000..421acfe
--- /dev/null
+++ b/frontend/src/hooks/useMedicationEnrichmentController.ts
@@ -0,0 +1,84 @@
+import { useCallback, useRef, useState } from "react";
+import type {
+ MedicationEnrichmentEnrichResponse,
+ MedicationEnrichmentPackageOption,
+ MedicationEnrichmentSearchResult,
+ MedicationEnrichmentStrengthOption,
+} from "../types";
+
+export const MEDICATION_ENRICHMENT_INITIAL_LIMIT = 6;
+export const MEDICATION_ENRICHMENT_LIMIT_STEP = 6;
+export const MEDICATION_ENRICHMENT_MAX_LIMIT = 20;
+
+export type MedicationEnrichmentState = {
+ query: string;
+ results: MedicationEnrichmentSearchResult[];
+ hasMoreResults: boolean;
+ resultLimit: number;
+ isSearching: boolean;
+ hasSearched: boolean;
+ searchError: string | null;
+ applyingCode: string | null;
+ applyingPackageLabel: string | null;
+ activeResultCode: string | null;
+ appliedSelection: MedicationEnrichmentEnrichResponse["selection"] | null;
+ enrichError: string | null;
+ meta: MedicationEnrichmentEnrichResponse["meta"] | null;
+ strengthOptions: MedicationEnrichmentStrengthOption[];
+ packageOptions: MedicationEnrichmentPackageOption[];
+ appliedStrengthLabel: string | null;
+ appliedPackageLabel: string | null;
+};
+
+export function createMedicationEnrichmentState(
+ query = "",
+ resultLimit = MEDICATION_ENRICHMENT_INITIAL_LIMIT
+): MedicationEnrichmentState {
+ return {
+ query,
+ results: [],
+ hasMoreResults: false,
+ resultLimit,
+ isSearching: false,
+ hasSearched: false,
+ searchError: null,
+ applyingCode: null,
+ applyingPackageLabel: null,
+ activeResultCode: null,
+ appliedSelection: null,
+ enrichError: null,
+ meta: null,
+ strengthOptions: [],
+ packageOptions: [],
+ appliedStrengthLabel: null,
+ appliedPackageLabel: null,
+ };
+}
+
+export function useMedicationEnrichmentController() {
+ const [medicationEnrichment, setMedicationEnrichment] = useState(() =>
+ createMedicationEnrichmentState()
+ );
+ const medicationEnrichmentQueryRef = useRef("");
+
+ const resetMedicationEnrichment = useCallback((query = "") => {
+ medicationEnrichmentQueryRef.current = query;
+ setMedicationEnrichment(createMedicationEnrichmentState(query));
+ }, []);
+
+ const handleMedicationEnrichmentQueryChange = useCallback((value: string) => {
+ medicationEnrichmentQueryRef.current = value;
+ setMedicationEnrichment((previous) => ({
+ ...previous,
+ query: value,
+ }));
+ }, []);
+
+ return {
+ medicationEnrichment,
+ setMedicationEnrichment,
+ medicationEnrichmentQueryRef,
+ resetMedicationEnrichment,
+ handleMedicationEnrichmentQueryChange,
+ };
+}
diff --git a/frontend/src/hooks/useScheduleController.ts b/frontend/src/hooks/useScheduleController.ts
new file mode 100644
index 0000000..c1a4b32
--- /dev/null
+++ b/frontend/src/hooks/useScheduleController.ts
@@ -0,0 +1,41 @@
+import { useAppContext } from "../context";
+
+export function useScheduleController() {
+ const ctx = useAppContext();
+
+ return {
+ meds: ctx.meds,
+ loading: ctx.loading,
+ settings: ctx.settings,
+ settingsLoading: ctx.settingsLoading,
+ coverage: ctx.coverage,
+ coverageByMed: ctx.coverageByMed,
+ depletionByMed: ctx.depletionByMed,
+ stockThresholds: ctx.stockThresholds,
+ scheduleDays: ctx.scheduleDays,
+ setScheduleDays: ctx.setScheduleDays,
+ showPastDays: ctx.showPastDays,
+ setShowPastDays: ctx.setShowPastDays,
+ showFutureDays: ctx.showFutureDays,
+ setShowFutureDays: ctx.setShowFutureDays,
+ pastDays: ctx.pastDays,
+ todayDay: ctx.todayDay,
+ futureDays: ctx.futureDays,
+ takenDoses: ctx.takenDoses,
+ dismissedDoses: ctx.dismissedDoses,
+ markDoseTaken: ctx.markDoseTaken,
+ undoDoseTaken: ctx.undoDoseTaken,
+ manuallyCollapsedDays: ctx.manuallyCollapsedDays,
+ manuallyExpandedDays: ctx.manuallyExpandedDays,
+ toggleDayCollapse: ctx.toggleDayCollapse,
+ missedPastDoseIds: ctx.missedPastDoseIds,
+ getDayStockStatus: ctx.getDayStockStatus,
+ getDoseId: ctx.getDoseId,
+ isDoseTakenAutomatically: ctx.isDoseTakenAutomatically,
+ openMedDetail: ctx.openMedDetail,
+ openUserFilter: ctx.openUserFilter,
+ openScheduleLightbox: ctx.openScheduleLightbox,
+ loadMeds: ctx.loadMeds,
+ loadSettings: ctx.loadSettings,
+ };
+}
diff --git a/frontend/src/hooks/useSettings.ts b/frontend/src/hooks/useSettings.ts
index 41e69b6..09e35b8 100644
--- a/frontend/src/hooks/useSettings.ts
+++ b/frontend/src/hooks/useSettings.ts
@@ -130,6 +130,13 @@ export interface UseSettingsReturn {
export function useSettings(): UseSettingsReturn {
const { i18n } = useTranslation();
+ const getErrorMessage = useCallback((error: unknown): string => {
+ if (error instanceof Error) {
+ return error.message;
+ }
+ return String(error);
+ }, []);
+
const [settings, setSettings] = useState(defaultSettings);
const [savedSettings, setSavedSettings] = useState(defaultSettings);
const [settingsLoading, setSettingsLoading] = useState(false);
@@ -281,9 +288,13 @@ export function useSettings(): UseSettingsReturn {
credentials: "include",
keepalive: true,
body: JSON.stringify(payload),
- }).catch(() => {});
+ }).catch((error: unknown) => {
+ log.warn("[useSettings] keepalive settings flush failed", {
+ error: getErrorMessage(error),
+ });
+ });
},
- [buildSettingsPayload]
+ [buildSettingsPayload, getErrorMessage]
);
// Load settings function - exposed for manual refresh (e.g., after auth)
@@ -394,12 +405,16 @@ export function useSettings(): UseSettingsReturn {
),
}));
})
- .catch(() => {});
+ .catch((error: unknown) => {
+ log.warn("[useSettings] reminder status refresh failed", {
+ error: getErrorMessage(error),
+ });
+ });
};
const interval = setInterval(refreshReminderStatus, 30000);
return () => clearInterval(interval);
- }, [clearReminderMetadata, fetchWithRefresh]);
+ }, [clearReminderMetadata, fetchWithRefresh, getErrorMessage]);
// Internal save function (no event needed)
const performSave = useCallback(
@@ -431,7 +446,11 @@ export function useSettings(): UseSettingsReturn {
} else {
latestSavedSettingsRef.current = { ...settingsToSave };
}
- } catch {
+ } catch (error: unknown) {
+ log.warn("[useSettings] settings save failed", {
+ error: getErrorMessage(error),
+ syncState,
+ });
if (syncState) {
setSettingsSaved(false);
// Keep UI aligned with backend truth if save failed (auth/session/network/server error).
@@ -443,7 +462,7 @@ export function useSettings(): UseSettingsReturn {
}
}
},
- [buildSettingsPayload, fetchWithRefresh, loadSettings]
+ [buildSettingsPayload, fetchWithRefresh, getErrorMessage, loadSettings]
);
// Debounced auto-save: fires whenever settings change
@@ -541,12 +560,13 @@ export function useSettings(): UseSettingsReturn {
success: res.ok,
message: data.message || (res.ok ? "Email sent!" : "Failed to send email"),
});
- } catch {
+ } catch (error: unknown) {
+ log.warn("[useSettings] test email failed", { error: getErrorMessage(error) });
setTestEmailResult({ success: false, message: "Failed to send test email" });
} finally {
setTestingEmail(false);
}
- }, [fetchWithRefresh, settings.notificationEmail]);
+ }, [fetchWithRefresh, getErrorMessage, settings.notificationEmail]);
const testShoutrrr = useCallback(async () => {
setTestingShoutrrr(true);
@@ -562,12 +582,13 @@ export function useSettings(): UseSettingsReturn {
success: res.ok,
message: data.message || (res.ok ? "Notification sent!" : "Failed to send notification"),
});
- } catch {
+ } catch (error: unknown) {
+ log.warn("[useSettings] test push notification failed", { error: getErrorMessage(error) });
setTestShoutrrrResult({ success: false, message: "Failed to send test notification" });
} finally {
setTestingShoutrrr(false);
}
- }, [fetchWithRefresh, settings.shoutrrrUrl]);
+ }, [fetchWithRefresh, getErrorMessage, settings.shoutrrrUrl]);
// Check for unsaved changes
const hasUnsavedChanges = JSON.stringify(settings) !== JSON.stringify(savedSettings);
diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx
index 58c22e8..d54eeb3 100644
--- a/frontend/src/pages/DashboardPage.tsx
+++ b/frontend/src/pages/DashboardPage.tsx
@@ -4,10 +4,11 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ConfirmModal, MedicationAvatar } from "../components";
import { useAuth } from "../components/Auth";
+import { DashboardReminderSection } from "../components/dashboard/DashboardReminderSection";
+import { DashboardStatusSection } from "../components/dashboard/DashboardStatusSection";
import { useAppContext } from "../context";
import {
allowsPillFormSelection,
- type Coverage,
getMedDisplayName,
type IntakeUnit,
isAmountBasedPackageType,
@@ -27,26 +28,6 @@ import {
userStorageKey,
} from "./dashboard-helpers";
-// Notification bell SVG icon (no emoji)
-function NotificationBellIcon() {
- return (
-
-
-
-
- );
-}
-
export function DashboardPage() {
const { t, i18n } = useTranslation();
const { user } = useAuth();
@@ -428,266 +409,33 @@ export function DashboardPage() {
return (
<>
- {remindersLoading ? (
-
-
-
-
-
- {t("dashboard.reminders.active")}
-
-
-
-
-
-
-
- ) : (
- anyRemindersEnabled && (
-
-
-
-
-
- {t("dashboard.reminders.active")}
- {stockRemindersEnabled && (
- {reminderData.status.text}
- )}
- {prescriptionStatus && (
- {prescriptionStatus.text}
- )}
-
- {(reminderData.lowStockMeds.length > 0 ||
- (prescriptionRemindersEnabled && prescriptionLowMeds.length > 0) ||
- (stockRemindersEnabled && reminderData.lastStockSent) ||
- (intakeRemindersEnabled && reminderData.lastIntakeSent)) && (
-
- {stockRemindersEnabled && reminderData.lowStockMeds.length > 0 && (
-
- {t("dashboard.reminders.needsRefill")}:
-
- {reminderData.lowStockMeds.map((med, idx) => {
- const medication = meds.find((m) => getMedDisplayName(m) === med.name);
- const cov = coverage.all.find((c) => c.name === med.name);
- const status = cov
- ? getStockStatus(cov.daysLeft, cov.medsLeft, stockThresholds, medication?.packageType)
- : null;
- const textClass =
- status?.className === "danger"
- ? "danger-text"
- : status?.className === "warning"
- ? "warning-text"
- : "";
- return (
-
- {idx > 0 && ", "}
- medication && openMedDetail(medication)}
- onKeyDown={(e) => {
- if (e.key === "Enter" || e.key === " ") {
- if (medication) openMedDetail(medication);
- }
- }}
- >
- {med.name}
-
-
- {" "}
- {t("dashboard.reminders.daysLeft", { count: med.daysLeft, days: med.daysLeft })}
-
-
- );
- })}
-
-
- )}
- {prescriptionRemindersEnabled && prescriptionLowMeds.length > 0 && (
-
- {t("dashboard.reminders.needsPrescriptionRefill")}:
-
- {prescriptionLowMeds.map((med, idx) => {
- const medication = meds.find((m) => m.id === med.id);
- const textClass = med.remainingRefills <= 0 ? "danger-text" : "warning-text";
- return (
-
- {idx > 0 && ", "}
-
- {t("prescription.remainingRefills")}: {med.remainingRefills} ·{" "}
- {t("dashboard.reminders.usedBy")}:{" "}
- medication && openMedDetail(medication)}
- onKeyDown={(e) => {
- if (e.key === "Enter" || e.key === " ") {
- if (medication) openMedDetail(medication);
- }
- }}
- >
- {med.name}
-
-
-
- );
- })}
-
-
- )}
- {stockRemindersEnabled && reminderData.lastStockSent && (
-
- {t("dashboard.reminders.lastStockSent")}:
-
- {reminderData.lastStockSent.medNames &&
- (() => {
- const names = reminderData.lastStockSent!.medNames!.split(", ");
- return names.map((name, idx) => {
- const medication = meds.find((m) => getMedDisplayName(m) === name);
- return (
-
- {idx > 0 && ", "}
- {medication ? (
- openMedDetail(medication)}
- onKeyDown={(e) => {
- if (e.key === "Enter" || e.key === " ") openMedDetail(medication);
- }}
- >
- {name}
-
- ) : (
- {name}
- )}
-
- );
- });
- })()}
- {reminderData.lastStockSent.date}
-
-
- )}
- {intakeRemindersEnabled && reminderData.lastIntakeSent && (
-
- {t("dashboard.reminders.lastSent")}:
-
- {reminderData.lastIntakeSent.medName &&
- (() => {
- const medication = meds.find(
- (m) => getMedDisplayName(m) === reminderData.lastIntakeSent!.medName
- );
- return medication ? (
- openMedDetail(medication)}
- onKeyDown={(e) => {
- if (e.key === "Enter" || e.key === " ") openMedDetail(medication);
- }}
- >
- {reminderData.lastIntakeSent!.medName}
-
- ) : (
- {reminderData.lastIntakeSent!.medName}
- );
- })()}
- {reminderData.lastIntakeSent.takenBy && (
- ({reminderData.lastIntakeSent.takenBy})
- )}
- {reminderData.lastIntakeSent.date}
-
-
- )}
-
- )}
- {((stockRemindersEnabled && reminderData.lowStockMeds.length > 0) ||
- (prescriptionRemindersEnabled && prescriptionLowMeds.length > 0)) && (
-
-
- {sendingReminder ? t("common.sending") : t("dashboard.reorder.sendReminder")}
-
- {reminderResult && (
-
- {reminderResult.message}
-
- )}
-
- )}
-
- )
- )}
- {/* Reorder Reminder card: Only show when reminders are NOT enabled (otherwise Reminder Bar shows the same info) */}
- {!remindersLoading && !anyRemindersEnabled && (
-
-
-
-
{t("dashboard.reorder.title")}
-
- {(() => {
- if (meds.length === 0) {
- return {t("dashboard.reorder.noMeds")}
;
- }
+
- // Count medications with low stock (based on lowStockDays setting), deduplicated by name
- const lowStockMap = new Map();
- for (const c of coverage.all) {
- if (c.daysLeft === null && c.medsLeft > 0) continue; // no schedule, has stock
- const med = getMedByName(c.name);
- const status = getStockStatus(c.daysLeft, c.medsLeft, stockThresholds, med?.packageType);
- if (status.className === "danger" || status.className === "warning") {
- const existing = lowStockMap.get(c.name);
- if (!existing || (c.daysLeft ?? 0) < (existing.daysLeft ?? 0)) {
- lowStockMap.set(c.name, c);
- }
- }
- }
- const lowStockMeds = Array.from(lowStockMap.values());
- const lowStockCount = lowStockMeds.length;
-
- if (lowStockCount === 0) {
- // All good - everything is Normal or High
- return {t("dashboard.reorder.allGood")}
;
- }
-
- // Some meds are low - show simple text with clickable names and days left
- return (
-
- {t("dashboard.reorder.lowWarningPrefix")}{" "}
- {lowStockMeds.map((c, idx) => {
- const med = meds.find((m) => getMedDisplayName(m) === c.name);
- const status = getStockStatus(c.daysLeft, c.medsLeft, stockThresholds, med?.packageType);
- const textClass =
- status.className === "danger"
- ? "danger-text"
- : status.className === "warning"
- ? "warning-text"
- : "";
- return (
-
- {idx > 0 && ", "}
- med && openMedDetail(med)}
- onKeyDown={(e) => {
- if (e.key === "Enter" || e.key === " ") {
- if (med) openMedDetail(med);
- }
- }}
- >
- {c.name}
-
-
- {" "}
- ({t("dashboard.reminders.daysLeft", { count: c.daysLeft ?? 0, days: c.daysLeft ?? 0 })})
-
-
- );
- })}{" "}
- {t("dashboard.reorder.lowWarningSuffix", { count: lowStockCount })}
-
- );
- })()}
-
-
- )}
+
(null);
const [allMeds, setAllMeds] = useState
(meds);
const [imageUploadError, setImageUploadError] = useState(null);
- const [medicationEnrichment, setMedicationEnrichment] = useState(() =>
- createMedicationEnrichmentState()
- );
- const medicationEnrichmentQueryRef = useRef("");
-
- const resetMedicationEnrichment = useCallback((query = "") => {
- medicationEnrichmentQueryRef.current = query;
- setMedicationEnrichment(createMedicationEnrichmentState(query));
- }, []);
-
- const handleMedicationEnrichmentQueryChange = useCallback((value: string) => {
- medicationEnrichmentQueryRef.current = value;
- setMedicationEnrichment((previous) => ({
- ...previous,
- query: value,
- }));
- }, []);
+ const {
+ medicationEnrichment,
+ setMedicationEnrichment,
+ medicationEnrichmentQueryRef,
+ resetMedicationEnrichment,
+ handleMedicationEnrichmentQueryChange,
+ } = useMedicationEnrichmentController();
const performMedicationEnrichmentSearch = useCallback(
async (
@@ -1482,1099 +1422,833 @@ export function MedicationsPage() {
return (
- {/* ── Grid View: always visible medication cards ── */}
-
-
-
{t("medications.list.title")}
-
-
- + {t("form.newEntry")}
-
- setShowReportModal(true)}>
- {t("report.button")}
-
-
+
setShowReportModal(true)}
+ onEdit={handleEditClick}
+ onView={handleViewClick}
+ onMarkObsolete={requestMarkObsolete}
+ onDelete={requestDeleteMed}
+ onReactivate={reactivateMedication}
+ onToggleObsolete={toggleObsoleteSection}
+ onImagePreview={(med) => setLightboxImage({ src: `/api/images/${med.imageUrl}`, alt: getMedDisplayName(med) })}
+ getMedicationPackageTypeLabel={getMedicationPackageTypeLabel}
+ getMedicationStockSuffix={getMedicationStockSuffix}
+ getMedicationUsageUnitLabel={getMedicationUsageUnitLabel}
+ />
+
+ {/* ── Desktop Edit Panel: inline below medication list ── */}
+
+
+ setActiveTab("general")}
+ >
+ {t("form.sections.general")}
+
+ setActiveTab("stock")}
+ >
+ {t("form.sections.stock")}
+
+ setActiveTab("schedule")}
+ >
+ {t("form.sections.schedule")}
+
+ setActiveTab("prescription")}
+ >
+ {t("form.sections.prescription")}
+
-
-
-
- {orderedMeds.map((med) => (
-
-
-
-
-
- med.imageUrl &&
- setLightboxImage({ src: `/api/images/${med.imageUrl}`, alt: getMedDisplayName(med) })
- }
- onKeyDown={(e) => {
- if (e.key === "Enter" || e.key === " ") {
- if (med.imageUrl)
- setLightboxImage({ src: `/api/images/${med.imageUrl}`, alt: getMedDisplayName(med) });
- }
- }}
- >
-
-
-
-
{getMedDisplayName(med)}
- {med.name && med.genericName &&
{med.genericName}
}
-
-
-
- {editingId !== med.id && (
-
handleEditClick(med)}
- aria-label={t("common.edit")}
- data-tooltip={t("common.edit")}
- >
-
-
- )}
-
requestMarkObsolete(med)}
- aria-label={t("medications.list.markObsolete")}
- >
-
- {t("medications.list.markObsolete")}
+
+
+
+
{t("form.sections.general")}
+
+ {t("form.commercialName")}
+ {
+ setShowNameValidation(true);
+ setForm({ ...form, name: e.target.value });
+ }}
+ onBlur={() => setShowNameValidation(true)}
+ placeholder={t("form.placeholders.commercial")}
+ maxLength={FIELD_LIMITS.name.max}
+ />
+ {!readOnlyView && showNameValidation && fieldErrors.name && (
+ {fieldErrors.name}
+ )}
+
+
+ {t("form.genericName")}
+ {
+ setShowNameValidation(true);
+ setForm({ ...form, genericName: e.target.value });
+ }}
+ onBlur={() => setShowNameValidation(true)}
+ placeholder={t("form.placeholders.generic")}
+ maxLength={FIELD_LIMITS.genericName.max}
+ />
+ {!readOnlyView && showNameValidation && fieldErrors.genericName && (
+ {fieldErrors.genericName}
+ )}
+
+
+
+
+ {t("form.medicationStartDate")}
+ handleValueChange("medicationStartDate", e.target.value)}
+ placeholder={t("common.optional")}
+ />
+ {!readOnlyView && dateConsistencyError && {dateConsistencyError} }
+
+
+ {t("form.medicationEndDate")}
+ handleValueChange("medicationEndDate", e.target.value)}
+ placeholder={t("common.optional")}
+ />
+
+
+
+ {t("form.packageType")}
+ handleValueChange("packageType", e.target.value as PackageType)}
+ >
+ {PACKAGE_PROFILES.map((profile) => (
+
+ {t(profile.labelKey)}
+
+ ))}
+
+
+ {allowsPillFormSelection(form.packageType) && (
+
+ {t("form.pillForm")}
+ handleValueChange("pillForm", e.target.value as FormState["pillForm"])}
+ >
+ {t("form.medicationFormTablet")}
+ {t("form.medicationFormCapsule")}
+
+
+ )}
+ {isTubePackageType(form.packageType) && (
+
+ {t("form.medicationForm")}
+ handleValueChange("medicationForm", "topical")}
+ >
+ {t("form.medicationFormTopical")}
+
+
+ )}
+ {isLiquidContainerPackageType(form.packageType) && (
+
+ {t("form.medicationForm")}
+ handleValueChange("medicationForm", "liquid")}
+ >
+ {t("form.medicationFormLiquid")}
+
+
+ )}
+ {form.medicationEndDate && (
+
+ {t("form.autoMarkObsoleteAfterEndDate")}
+
+ handleValueChange("autoMarkObsoleteAfterEndDate", e.target.checked)}
+ />
+
+
+
+ )}
+
+ {t("form.takenBy")}
+
+ {form.takenBy.map((person) => (
+
+ {person}
+ {!readOnlyView && (
+ removeTakenByPerson(person)}>
+ ×
+ )}
+
+ ))}
+ {!readOnlyView && (
+ <>
+ setTakenByInput(e.target.value)}
+ onKeyDown={handleTakenByKeyDown}
+ onBlur={() => {
+ if (takenByInput.trim()) addTakenByPerson(takenByInput);
+ }}
+ placeholder={
+ form.takenBy.length === 0 ? t("form.placeholders.takenBy") : t("form.placeholders.addPerson")
+ }
+ maxLength={FIELD_LIMITS.takenBy.max}
+ list="takenby-suggestions"
+ />
+
+ {existingPeople
+ .filter((p) => !form.takenBy.includes(p))
+ .map((person) => (
+
+ ))}
+
+ >
+ )}
+
+ {fieldErrors.takenBy && {fieldErrors.takenBy} }
+
+
+
+
+
{t("form.medicationImage")}
+ {(() => {
+ if (editingId) {
+ const currentMed = meds.find((m) => m.id === editingId);
+ if (currentMed?.imageUrl) {
+ return (
+
+
requestDeleteMed(med)}
- aria-label={t("common.delete")}
- data-tooltip={t("common.delete")}
+ onClick={() => handleDeleteMedImage(editingId)}
+ aria-label={t("form.removeImage")}
+ data-tooltip={t("form.removeImage")}
>
-
-
- {t("medications.details.type")}: {getMedicationPackageTypeLabel(med)}
-
- {!isAmountBasedPackageType(med.packageType) ? (
- <>
-
- {t("medications.details.packs")}: {med.packCount}
-
-
- {t("medications.details.blisters")}: {med.blistersPerPack}
-
-
- {t("medications.details.pillsPerBlister")}: {med.pillsPerBlister}
-
-
- {t("medications.details.loose")}: {med.looseTablets}
-
- >
- ) : (
-
- {t("medications.details.totalCapacity")}:{" "}
- {med.totalPills ?? med.looseTablets}
-
- )}
-
- {med.prescriptionEnabled && (
-
- {t("prescription.remainingRefills")}: {med.prescriptionRemainingRefills ?? 0}
-
- )}
-
- {(() => {
- const stockDisplayCapacity = getStockDisplayCapacity(med);
- const currentStock = coverageByMed[getMedDisplayName(med)]
- ? Math.round(coverageByMed[getMedDisplayName(med)].medsLeft)
- : getMedTotal(med);
-
- return (
- <>
- {t("medications.details.stock")}: {currentStock} / {stockDisplayCapacity}
- >
- );
- })()}
- {getMedicationStockSuffix(med)}
- {(() => {
- const stockDisplayCapacity = getStockDisplayCapacity(med);
- const currentStock = coverageByMed[getMedDisplayName(med)]
- ? Math.round(coverageByMed[getMedDisplayName(med)].medsLeft)
- : getMedTotal(med);
-
- return currentStock > stockDisplayCapacity ? (
-
- {" "}
- ⚠️
-
- ) : null;
- })()}
-
-
-
-
- {getMedicationIntakes(med).map((s, idx) => (
-
- {s.usage} {getMedicationUsageUnitLabel(med, s.usage)} · {getIntakeFrequencyText(s, t)} ·{" "}
- {t("form.blisters.from")} {formatDateTime(s.start)}
- {s.takenBy && · {s.takenBy} }
- {s.intakeRemindersEnabled && (
-
- {" "}
-
-
- )}
-
- ))}
-
-
- ))}
-
-
- {obsoleteMeds.length > 0 && (
-
-
-
- {showObsolete ? "▼" : "▶"} {t("medications.list.obsoleteTitle", { count: obsoleteMeds.length })}
-
-
- {showObsolete && (
-
- {obsoleteMeds.map((med) => (
-
-
-
-
-
- med.imageUrl &&
- setLightboxImage({ src: `/api/images/${med.imageUrl}`, alt: getMedDisplayName(med) })
- }
- onKeyDown={(e) => {
- if (e.key === "Enter" || e.key === " ") {
- if (med.imageUrl)
- setLightboxImage({
- src: `/api/images/${med.imageUrl}`,
- alt: getMedDisplayName(med),
- });
- }
- }}
- >
-
-
-
-
{getMedDisplayName(med)}
- {med.name && med.genericName &&
{med.genericName}
}
-
-
-
- handleViewClick(med)}
- aria-label={t("common.view")}
- data-tooltip={t("common.view")}
- >
-
-
- requestDeleteMed(med)}
- aria-label={t("common.delete")}
- data-tooltip={t("common.delete")}
- >
-
-
- reactivateMedication(med.id)}>
- {t("medications.list.reactivate")}
-
-
-
- {med.medicationStartDate && (
-
- {t("medications.list.started")}: {formatDate(med.medicationStartDate)}
-
- )}
-
- {t("medications.list.obsoleteSince")}: {formatDate(med.obsoleteAt)}
-
-
-
-
-
- ))}
-
- )}
-
- )}
-
-
-
- {/* ── Desktop Edit Panel: inline below medication list ── */}
-
-
-
-
-
- ← {t("common.back")}
-
- {editingId ? (
-
- {readOnlyView ? t("form.viewEntry") : t("form.editEntry")}: {selectedMedication?.name}
-
- ) : (
- {t("form.newEntry")}
- )}
-
-
-
+ {/* end stock tab */}
-
-
{t("form.medicationImage")}
- {(() => {
- if (editingId) {
- const currentMed = meds.find((m) => m.id === editingId);
- if (currentMed?.imageUrl) {
- return (
-
-
-
handleDeleteMedImage(editingId)}
- aria-label={t("form.removeImage")}
- data-tooltip={t("form.removeImage")}
- >
-
-
-
- );
- }
- return (
-
{
- const file = e.target.files?.[0];
- e.target.value = "";
- if (file) void tryUploadMedImage(editingId, file);
- }}
- disabled={uploadingImage}
- />
- );
- }
- if (pendingImagePreview) {
- return (
-
-
-
{
- setPendingImage(null);
- setPendingImagePreview(null);
- }}
- aria-label={t("form.removeImage")}
- data-tooltip={t("form.removeImage")}
- >
-
-
-
- );
- }
- return (
-
- );
- })()}
- {imageUploadError &&
{imageUploadError} }
-
-
- {/* end general tab */}
-
-
-
-
{t("form.sections.stock")}
- {(() => {
- if (!isAmountBasedPackageType(form.packageType)) {
- return (
- <>
-
- {t("form.packs")}
- handleValueChange("packCount", nextValue)}
- min={0}
- decrementLabel={decrementValueLabel}
- incrementLabel={incrementValueLabel}
- />
-
-
- {t("form.blistersPerPack")}
- handleValueChange("blistersPerPack", nextValue)}
- min={1}
- decrementLabel={decrementValueLabel}
- incrementLabel={incrementValueLabel}
- />
-
-
- {t("form.pillsPerBlister")}
- handleValueChange("pillsPerBlister", nextValue)}
- min={1}
- decrementLabel={decrementValueLabel}
- incrementLabel={incrementValueLabel}
- />
-
-
- {t("form.total")}
- {formatNumber(totalTablets)}
-
- >
- );
- }
-
- if (isTubePackageType(form.packageType)) {
- return (
- <>
-
- {t("form.tubes")}
- handleValueChange("packCount", nextValue)}
- min={1}
- decrementLabel={decrementValueLabel}
- incrementLabel={incrementValueLabel}
- />
-
-
- {t("form.packageAmountPerTube")}
-
- handleValueChange("packageAmountValue", e.target.value)}
- placeholder="0"
- />
-
- {t("form.packageAmountUnitG")}
-
-
-
-
- {t("form.totalAmount")}
-
- {formatNumber(
- (Number(form.packCount) || 0) * (Number(form.packageAmountValue ?? 0) || 0)
- )}
- {t("form.packageAmountUnitG")}
-
-
- >
- );
- }
-
- return (
- <>
-
- {totalCapacityLabel}
- handleValueChange("totalPills", nextValue)}
- min={0}
- decrementLabel={decrementValueLabel}
- incrementLabel={incrementValueLabel}
- />
-
-
- {currentStockLabel}
- handleValueChange("looseTablets", nextValue)}
- min={0}
- decrementLabel={decrementValueLabel}
- incrementLabel={incrementValueLabel}
- />
-
- >
- );
- })()}
- {allowsPillFormSelection(form.packageType) && (
-
- {t("form.pillWeight")} ({form.doseUnit})
-
- handleValueChange("pillWeightMg", e.target.value)}
- placeholder={t("form.placeholders.weight")}
- />
- handleValueChange("doseUnit", e.target.value as DoseUnit)}
- className="select-field dose-unit-select"
- >
- {DOSE_UNITS.map((unit) => (
-
- {unit.label}
-
- ))}
-
-
-
- )}
- {isAmountBasedPackageType(form.packageType) && !isTubePackageType(form.packageType) && (
-
-
- {totalLabel}
- {formatNumber(totalTablets)}
-
-
- )}
- {isLiquidContainerPackageType(form.packageType) && (
-
- {t("form.packageAmount")}
-
- handleValueChange("packageAmountValue", e.target.value)}
- placeholder="0"
- />
-
- {t("form.packageAmountUnitMl")}
-
-
-
- )}
-
- {t("form.expiryDate")}
+
+
+
{t("form.sections.prescription")}
+
+ {t("prescription.enabled")}
+
+ handleValueChange("prescriptionEnabled", e.target.checked)}
+ />
+
+
+
+ {form.prescriptionEnabled && (
+ <>
+
+ {t("prescription.authorizedRefills")}
+ handleValueChange("prescriptionAuthorizedRefills", nextValue)}
+ min={0}
+ decrementLabel={decrementValueLabel}
+ incrementLabel={incrementValueLabel}
+ />
+
+
+ {t("prescription.remainingRefills")}
+ handleValueChange("prescriptionRemainingRefills", nextValue)}
+ min={0}
+ decrementLabel={decrementValueLabel}
+ incrementLabel={incrementValueLabel}
+ />
+
+
+ {t("prescription.lowThreshold")}
+ handleValueChange("prescriptionLowRefillThreshold", nextValue)}
+ min={0}
+ decrementLabel={decrementValueLabel}
+ incrementLabel={incrementValueLabel}
+ />
+
+
+ {t("prescription.expiryDate")}
handleValueChange("expiryDate", e.target.value)}
- placeholder={t("common.optional")}
+ value={form.prescriptionExpiryDate}
+ onChange={(e) => handleValueChange("prescriptionExpiryDate", e.target.value)}
/>
-
- {t("form.notes")}
- handleValueChange("notes", e.target.value)}
- placeholder={t("form.placeholders.notes")}
- rows={2}
- maxLength={FIELD_LIMITS.notes.max}
- className="auto-resize"
- onInput={(e) => {
- const t = e.target as HTMLTextAreaElement;
- t.style.height = "auto";
- t.style.height = `${t.scrollHeight}px`;
- }}
- />
- {form.notes.length > 0 && (
- FIELD_LIMITS.notes.max * 0.9 ? "warning" : ""}`}
- >
- {t("common.validation.tooLong", { current: form.notes.length, max: FIELD_LIMITS.notes.max })}
-
- )}
- {fieldErrors.notes && {fieldErrors.notes} }
-
-
-
- {/* end stock tab */}
+ >
+ )}
+
+
+ {/* end prescription tab */}
-
-
-
{t("form.sections.prescription")}
-
- {t("prescription.enabled")}
-
- handleValueChange("prescriptionEnabled", e.target.checked)}
- />
-
-
-
- {form.prescriptionEnabled && (
- <>
-
- {t("prescription.authorizedRefills")}
+
+
+
+
{t("form.blisters.title")}
+ {!readOnlyView && (
+
addIntake(form.takenBy.length === 1 ? form.takenBy[0] : undefined)}
+ aria-label={t("form.blisters.addIntake")}
+ data-tooltip={t("form.blisters.addIntake")}
+ >
+
+
+ )}
+
+ {form.intakes.map((intake, idx) => {
+ const scheduleMode = getIntakeScheduleMode(intake);
+ const selectedWeekdays = intake.weekdays ?? [];
+ return (
+
+
+
+ {getUsageLabel(intake.intakeUnit ?? "ml")}
handleValueChange("prescriptionAuthorizedRefills", nextValue)}
- min={0}
+ value={intake.usage}
+ onChange={(nextValue) => setIntakeValue(idx, "usage", nextValue)}
+ min={allowFractionalIntake ? 0.5 : 1}
+ step={allowFractionalIntake ? 0.5 : 1}
+ allowDecimal={allowFractionalIntake}
decrementLabel={decrementValueLabel}
incrementLabel={incrementValueLabel}
/>
-
- {t("prescription.remainingRefills")}
- handleValueChange("prescriptionRemainingRefills", nextValue)}
- min={0}
- decrementLabel={decrementValueLabel}
- incrementLabel={incrementValueLabel}
- />
+
+ {t("form.blisters.scheduleMode")}
+
+ setIntakeValue(idx, "scheduleMode", e.target.value as "interval" | "weekdays")
+ }
+ >
+ {t("form.blisters.scheduleModeInterval")}
+ {t("form.blisters.scheduleModeWeekdays")}
+
-
- {t("prescription.lowThreshold")}
- handleValueChange("prescriptionLowRefillThreshold", nextValue)}
- min={0}
- decrementLabel={decrementValueLabel}
- incrementLabel={incrementValueLabel}
- />
-
-
- {t("prescription.expiryDate")}
+ {scheduleMode === "interval" ? (
+
+ {t("form.blisters.everyDays")}
+ setIntakeValue(idx, "every", nextValue)}
+ min={1}
+ decrementLabel={decrementValueLabel}
+ incrementLabel={incrementValueLabel}
+ />
+
+ ) : (
+
+ {t("form.blisters.weekdays")}
+
+ {weekdayOptions.map((weekday) => {
+ const isSelected = selectedWeekdays.includes(weekday.value);
+ return (
+
+ setIntakeValue(
+ idx,
+ "weekdays",
+ toggleWeekdaySelection(selectedWeekdays, weekday.value)
+ )
+ }
+ >
+ {weekday.shortLabel}
+
+ );
+ })}
+
+ {!readOnlyView && hasWeekdaySelectionError(intake) && (
+ {t("form.blisters.weekdaysRequired")}
+ )}
+
+ )}
+
+ {t("form.blisters.startDate")}
handleValueChange("prescriptionExpiryDate", e.target.value)}
+ value={intake.startDate}
+ onChange={(e) => setIntakeValue(idx, "startDate", e.target.value)}
/>
- >
- )}
-
-
- {/* end prescription tab */}
-
-
-
-
-
{t("form.blisters.title")}
- {!readOnlyView && (
+
+ {t("form.blisters.startTime")}
+ setIntakeValue(idx, "startTime", e.target.value)}
+ />
+
+ {isLiquidContainerPackageType(form.packageType) && (
+
+ {t("form.blisters.intakeUnit")}
+ setIntakeValue(idx, "intakeUnit", e.target.value as "ml" | "tsp" | "tbsp")}
+ >
+ {t("form.blisters.intakeUnitMl")}
+ {t("form.blisters.intakeUnitTsp")}
+ {t("form.blisters.intakeUnitTbsp")}
+
+
+ )}
+ {form.takenBy.length === 0 ? null : (
+
+ {t("form.blisters.takenByIntake")}
+ setIntakeValue(idx, "takenBy", e.target.value)}
+ >
+ {form.takenBy.map((person) => (
+
+ {person}
+
+ ))}
+
+
+ )}
+
+
+
+
+
+ setIntakeValue(idx, "intakeRemindersEnabled", e.target.checked)}
+ />
+
+
+
+
+ {!readOnlyView && form.intakes.length > 1 && (
addIntake(form.takenBy.length === 1 ? form.takenBy[0] : undefined)}
- aria-label={t("form.blisters.addIntake")}
- data-tooltip={t("form.blisters.addIntake")}
+ className="danger icon-only tooltip-trigger"
+ onClick={() => removeIntake(idx)}
+ aria-label={t("common.remove")}
+ data-tooltip={t("common.remove")}
>
-
+
)}
- {form.intakes.map((intake, idx) => {
- const scheduleMode = getIntakeScheduleMode(intake);
- const selectedWeekdays = intake.weekdays ?? [];
- return (
-
-
-
- {getUsageLabel(intake.intakeUnit ?? "ml")}
- setIntakeValue(idx, "usage", nextValue)}
- min={allowFractionalIntake ? 0.5 : 1}
- step={allowFractionalIntake ? 0.5 : 1}
- allowDecimal={allowFractionalIntake}
- decrementLabel={decrementValueLabel}
- incrementLabel={incrementValueLabel}
- />
-
-
- {t("form.blisters.scheduleMode")}
-
- setIntakeValue(idx, "scheduleMode", e.target.value as "interval" | "weekdays")
- }
- >
- {t("form.blisters.scheduleModeInterval")}
- {t("form.blisters.scheduleModeWeekdays")}
-
-
- {scheduleMode === "interval" ? (
-
- {t("form.blisters.everyDays")}
- setIntakeValue(idx, "every", nextValue)}
- min={1}
- decrementLabel={decrementValueLabel}
- incrementLabel={incrementValueLabel}
- />
-
- ) : (
-
- {t("form.blisters.weekdays")}
-
- {weekdayOptions.map((weekday) => {
- const isSelected = selectedWeekdays.includes(weekday.value);
- return (
-
- setIntakeValue(
- idx,
- "weekdays",
- toggleWeekdaySelection(selectedWeekdays, weekday.value)
- )
- }
- >
- {weekday.shortLabel}
-
- );
- })}
-
- {!readOnlyView && hasWeekdaySelectionError(intake) && (
- {t("form.blisters.weekdaysRequired")}
- )}
-
- )}
-
- {t("form.blisters.startDate")}
- setIntakeValue(idx, "startDate", e.target.value)}
- />
-
-
- {t("form.blisters.startTime")}
- setIntakeValue(idx, "startTime", e.target.value)}
- />
-
- {isLiquidContainerPackageType(form.packageType) && (
-
- {t("form.blisters.intakeUnit")}
-
- setIntakeValue(idx, "intakeUnit", e.target.value as "ml" | "tsp" | "tbsp")
- }
- >
- {t("form.blisters.intakeUnitMl")}
- {t("form.blisters.intakeUnitTsp")}
- {t("form.blisters.intakeUnitTbsp")}
-
-
- )}
- {form.takenBy.length === 0 ? null : (
-
- {t("form.blisters.takenByIntake")}
- setIntakeValue(idx, "takenBy", e.target.value)}
- >
- {form.takenBy.map((person) => (
-
- {person}
-
- ))}
-
-
- )}
-
-
-
-
-
- setIntakeValue(idx, "intakeRemindersEnabled", e.target.checked)}
- />
-
-
-
-
- {!readOnlyView && form.intakes.length > 1 && (
-
removeIntake(idx)}
- aria-label={t("common.remove")}
- data-tooltip={t("common.remove")}
- >
-
-
- )}
-
- );
- })}
-
-
- {/* end schedule tab */}
-
-
-
- {readOnlyView || (formSaved && !formChanged) ? t("common.close") : t("common.cancel")}
-
- {!readOnlyView && (
-
- {formSaved && !formChanged ? t("common.saved") : t("common.save")}
-
- )}
+ );
+ })}
-
-
-
+
+ {/* end schedule tab */}
+
+
+
+ {readOnlyView || (formSaved && !formChanged) ? t("common.close") : t("common.cancel")}
+
+ {!readOnlyView && (
+
+ {formSaved && !formChanged ? t("common.saved") : t("common.save")}
+
+ )}
+
+
- {/* Mobile Edit Modal */}
- {
- closeEditModal();
- }}
- onResetForm={handleResetForm}
- onSaveMedication={saveMedication}
+
+ }
+ showUnsavedConfirm={showUnsavedConfirm}
+ unsavedTitle={t("common.unsavedChanges.title", "Unsaved Changes")}
+ unsavedMessage={t("common.unsavedChanges.message")}
+ unsavedConfirmLabel={t("common.unsavedChanges.leave", "Leave")}
+ unsavedCancelLabel={
+ unsavedConfirmSource === "mobile-edit" ? t("common.back") : t("common.unsavedChanges.stay", "Stay")
+ }
+ onConfirmClose={handleConfirmClose}
+ onCancelClose={handleCancelClose}
+ showObsoleteConfirm={showObsoleteConfirm}
+ obsoleteCandidate={obsoleteCandidate}
+ obsoleteTitle={t("medications.obsoleteModal.title")}
+ obsoleteMessage={t("medications.obsoleteModal.message", { name: obsoleteCandidate?.name ?? "" })}
+ obsoleteConfirmLabel={t("medications.list.markObsolete")}
+ obsoleteCancelLabel={t("common.cancel")}
+ onConfirmMarkObsolete={handleConfirmMarkObsolete}
+ onCancelMarkObsolete={handleCancelMarkObsolete}
+ showDeleteConfirm={showDeleteConfirm}
+ deleteCandidate={deleteCandidate}
+ deleteTitle={t("medications.deleteModal.title")}
+ deleteMessage={t("medications.deleteModal.message", { name: deleteCandidate?.name ?? "" })}
+ deleteConfirmLabel={t("common.delete")}
+ deleteCancelLabel={t("common.cancel")}
+ onConfirmDelete={handleConfirmDelete}
+ onCancelDelete={handleCancelDelete}
+ showEditModal={showEditModal}
+ lightboxImage={lightboxImage}
+ onCloseLightbox={() => setLightboxImage(null)}
+ showReportModal={showReportModal}
+ onCloseReportModal={() => setShowReportModal(false)}
+ medications={allMeds}
/>
-
- {/* Unsaved Changes Confirmation Modal */}
- {showUnsavedConfirm && (
-
- )}
-
- {/* Delete Medication Confirmation Modal */}
- {showObsoleteConfirm && obsoleteCandidate && (
-
- )}
-
- {/* Delete Medication Confirmation Modal */}
- {showDeleteConfirm && deleteCandidate && (
-
- )}
-
- {/* Image Lightbox */}
- {lightboxImage && (
- setLightboxImage(null)} />
- )}
-
- {/* Report Modal */}
- setShowReportModal(false)} medications={allMeds} />
);
}
diff --git a/frontend/src/pages/SchedulePage.tsx b/frontend/src/pages/SchedulePage.tsx
index 640ec91..245e904 100644
--- a/frontend/src/pages/SchedulePage.tsx
+++ b/frontend/src/pages/SchedulePage.tsx
@@ -4,11 +4,11 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ConfirmModal, MedicationAvatar } from "../components";
import { useAuth } from "../components/Auth";
-import { useAppContext } from "../context";
+import { ScheduleUsageTag } from "../features/schedule/components";
+import { formatScheduleDoseUsageLabel, formatScheduleTotalUsageLabel } from "../features/schedule/formatters";
+import { useScheduleController } from "../hooks";
import type { Coverage, IntakeUnit } from "../types";
import { getMedDisplayName, isLiquidContainerPackageType, isTubePackageType } from "../types";
-import { formatNumber } from "../utils/formatters";
-import { convertLiquidUsageToMl, getLiquidCountUnitLabel } from "../utils/intake-units";
import { buildClearMissedPayload, isDoseDismissed } from "../utils/schedule";
// Helper for user-specific localStorage keys
@@ -93,7 +93,7 @@ export function SchedulePage() {
openUserFilter,
missedPastDoseIds,
loadMeds,
- } = useAppContext();
+ } = useScheduleController();
const [showClearMissedConfirm, setShowClearMissedConfirm] = useState(false);
const [clearingMissed, setClearingMissed] = useState(false);
const [showObsoleteConfirm, setShowObsoleteConfirm] = useState(false);
@@ -160,69 +160,17 @@ export function SchedulePage() {
setObsoleteCandidate(null);
};
- const getTubeUnitLabel = (med: (typeof meds)[number] | undefined, value: number) =>
- isLiquidContainerPackageType(med?.packageType) || med?.medicationForm === "liquid"
- ? t("form.packageAmountUnitMl")
- : t("form.blisters.applications", { count: Math.abs(value) });
-
- const formatLiquidUsageLabel = (usage: number, unit: IntakeUnit | null | undefined): string => {
- const normalizedUsage = Number(usage);
- if (!Number.isFinite(normalizedUsage) || normalizedUsage <= 0) {
- return `0 ${t("form.packageAmountUnitMl")}`;
- }
-
- if (unit === "ml" || unit == null) {
- return `${formatNumber(normalizedUsage)} ${t("form.packageAmountUnitMl")}`;
- }
-
- const mlTotal = convertLiquidUsageToMl(normalizedUsage, unit);
- return `${formatNumber(normalizedUsage)} ${getLiquidCountUnitLabel(unit, normalizedUsage, t)} ${formatNumber(mlTotal)} ${t("form.packageAmountUnitMl")}`;
- };
-
const formatDoseUsageLabel = (
med: (typeof meds)[number] | undefined,
usage: number,
intakeUnit?: IntakeUnit | null
- ) => {
- if (isLiquidContainerPackageType(med?.packageType)) {
- return formatLiquidUsageLabel(usage, intakeUnit);
- }
- if (isTubePackageType(med?.packageType)) {
- return `${usage} ${getTubeUnitLabel(med, usage)}`;
- }
- return `${usage} ${usage !== 1 ? t("common.pills") : t("common.pill")}`;
- };
+ ) => formatScheduleDoseUsageLabel(med, usage, t, intakeUnit);
const formatTotalUsageLabel = (
med: (typeof meds)[number] | undefined,
total: number,
doses?: Array<{ usage: number; intakeUnit?: IntakeUnit | null }>
- ) => {
- if (isLiquidContainerPackageType(med?.packageType)) {
- if (doses && doses.length > 0) {
- const normalizedDoses = doses.filter((dose) => Number.isFinite(Number(dose.usage)) && Number(dose.usage) > 0);
- if (normalizedDoses.length > 0) {
- const allUnits = new Set(normalizedDoses.map((dose) => dose.intakeUnit ?? "ml"));
- if (allUnits.size === 1) {
- const onlyUnit = normalizedDoses[0]?.intakeUnit ?? "ml";
- const totalUsageInUnit = normalizedDoses.reduce((sum, dose) => sum + Number(dose.usage), 0);
- return formatLiquidUsageLabel(totalUsageInUnit, onlyUnit);
- }
-
- const totalMl = normalizedDoses.reduce(
- (sum, dose) => sum + convertLiquidUsageToMl(Number(dose.usage), dose.intakeUnit ?? "ml"),
- 0
- );
- return `${formatNumber(totalMl)} ${t("form.packageAmountUnitMl")}`;
- }
- }
- return `${formatNumber(total)} ${t("form.packageAmountUnitMl")}`;
- }
- if (isTubePackageType(med?.packageType)) {
- return `${total} ${getTubeUnitLabel(med, total)}`;
- }
- return t("common.pillsTotal", { count: total });
- };
+ ) => formatScheduleTotalUsageLabel(med, total, t, doses);
return (
@@ -335,7 +283,7 @@ export function SchedulePage() {
{item.medName}
- {formatTotalUsageLabel(med, item.total, item.doses)}
+ {formatTotalUsageLabel(med, item.total, item.doses)}
@@ -549,7 +497,7 @@ export function SchedulePage() {
{item.medName}
-
{formatTotalUsageLabel(med, item.total, item.doses)}
+
{formatTotalUsageLabel(med, item.total, item.doses)}
{visibleStatus && (
{t(visibleStatus.label)}
)}
diff --git a/frontend/src/test/App.test.tsx b/frontend/src/test/App.test.tsx
index 23d457c..5f0c693 100644
--- a/frontend/src/test/App.test.tsx
+++ b/frontend/src/test/App.test.tsx
@@ -18,6 +18,7 @@ let authMock: AuthStateMock = {
};
let appContextMock: Record
;
+let shareContextMock: Record;
vi.mock("../components", () => ({
AboutModal: ({ isOpen }: { isOpen: boolean }) => (isOpen ? about-modal-open
: null),
@@ -45,11 +46,17 @@ vi.mock("../components/Auth", () => ({
useAuth: () => authMock,
}));
-vi.mock("../context", () => ({
- AppProvider: ({ children }: { children: React.ReactNode }) => <>{children}>,
- UnsavedChangesProvider: ({ children }: { children: React.ReactNode }) => <>{children}>,
- useAppContext: () => appContextMock,
-}));
+vi.mock("../context", async () => {
+ const actual = await vi.importActual("../context");
+ return {
+ ...actual,
+ AppProvider: ({ children }: { children: React.ReactNode }) => <>{children}>,
+ ShareContextProvider: ({ children }: { children: React.ReactNode }) => <>{children}>,
+ UnsavedChangesProvider: ({ children }: { children: React.ReactNode }) => <>{children}>,
+ useAppContext: () => appContextMock,
+ useShareContext: () => shareContextMock,
+ };
+});
vi.mock("../pages", () => ({
DashboardPage: () => dashboard-page
,
@@ -93,21 +100,6 @@ describe("App", () => {
closeRefillModal: vi.fn(),
openEditStockModal: vi.fn(),
closeEditStockModal: vi.fn(),
- showShareDialog: false,
- sharePeople: [],
- shareSelectedPerson: "",
- setShareSelectedPerson: vi.fn(),
- shareSelectedDays: 7,
- setShareSelectedDays: vi.fn(),
- shareGenerating: false,
- shareLink: null,
- setShareLink: vi.fn(),
- shareCopied: false,
- setShareCopied: vi.fn(),
- generateShareLink: vi.fn(),
- copyShareLink: vi.fn(),
- closeShareDialog: vi.fn(),
- resetShareDialogState: vi.fn(),
coverage: { all: [], low: [] },
selectedMed: null,
setSelectedMed: vi.fn(),
@@ -134,6 +126,23 @@ describe("App", () => {
expiryWarningDays: 30,
},
};
+ shareContextMock = {
+ showShareDialog: false,
+ sharePeople: [],
+ shareSelectedPerson: "",
+ setShareSelectedPerson: vi.fn(),
+ shareSelectedDays: 7,
+ setShareSelectedDays: vi.fn(),
+ shareGenerating: false,
+ shareLink: null,
+ setShareLink: vi.fn(),
+ shareCopied: false,
+ setShareCopied: vi.fn(),
+ generateShareLink: vi.fn(),
+ copyShareLink: vi.fn(),
+ closeShareDialog: vi.fn(),
+ resetShareDialogState: vi.fn(),
+ };
document.documentElement.classList.remove("modal-open");
document.body.classList.remove("modal-open");
vi.spyOn(window.history, "back").mockImplementation(() => {});
@@ -300,7 +309,7 @@ describe("App", () => {
});
it("adds modal-open class when modal state is active", () => {
- appContextMock.showShareDialog = true;
+ shareContextMock.showShareDialog = true;
render(
@@ -328,7 +337,7 @@ describe("App", () => {
});
it("handles popstate by resetting share dialog state", () => {
- appContextMock.showShareDialog = true;
+ shareContextMock.showShareDialog = true;
render(
@@ -337,7 +346,7 @@ describe("App", () => {
);
window.dispatchEvent(new PopStateEvent("popstate"));
- expect(appContextMock.resetShareDialogState).toHaveBeenCalled();
+ expect(shareContextMock.resetShareDialogState).toHaveBeenCalled();
});
it("redirects unknown routes to dashboard", () => {
diff --git a/frontend/src/test/components/MedicationDialogs.test.tsx b/frontend/src/test/components/MedicationDialogs.test.tsx
new file mode 100644
index 0000000..70e3f08
--- /dev/null
+++ b/frontend/src/test/components/MedicationDialogs.test.tsx
@@ -0,0 +1,201 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { MedicationDialogs } from "../../components/medications/MedicationDialogs";
+import type { Medication } from "../../types";
+
+vi.mock("../../components/ConfirmModal", () => ({
+ ConfirmModal: ({
+ title,
+ confirmLabel,
+ cancelLabel,
+ onConfirm,
+ onCancel,
+ overlayClassName,
+ }: {
+ title: string;
+ confirmLabel: string;
+ cancelLabel: string;
+ onConfirm: () => void;
+ onCancel: () => void;
+ overlayClassName?: string;
+ }) => (
+
+
+ {confirmLabel}
+
+
+ {cancelLabel}
+
+
+ ),
+}));
+
+vi.mock("../../components/Lightbox", () => ({
+ Lightbox: ({ src, alt }: { src: string; alt: string }) => {`${src}|${alt}`}
,
+}));
+
+vi.mock("../../components/ReportModal", () => ({
+ default: ({ isOpen }: { isOpen: boolean }) => (isOpen ? report
: null),
+}));
+
+const baseMedication: Medication = {
+ id: 1,
+ name: "Aspirin",
+ genericName: "Acetylsalicylic acid",
+ takenBy: ["John"],
+ packageType: "blister",
+ packCount: 1,
+ blistersPerPack: 2,
+ pillsPerBlister: 10,
+ totalPills: null,
+ packageAmountValue: null,
+ packageAmountUnit: null,
+ looseTablets: 0,
+ stockAdjustment: 0,
+ lastStockCorrectionAt: null,
+ pillWeightMg: 500,
+ doseUnit: "mg",
+ medicationForm: "tablet",
+ pillForm: "tablet",
+ lifecycleCategory: "refill_when_empty",
+ blisters: [{ usage: 1, every: 1, start: "2024-01-01T09:00:00Z" }],
+ intakes: [
+ {
+ usage: 1,
+ every: 1,
+ start: "2024-01-01T09:00:00Z",
+ takenBy: "",
+ intakeRemindersEnabled: false,
+ scheduleMode: "interval",
+ weekdays: [],
+ },
+ ],
+ imageUrl: null,
+ expiryDate: null,
+ notes: null,
+ intakeRemindersEnabled: false,
+ medicationStartDate: "",
+ medicationEndDate: null,
+ autoMarkObsoleteAfterEndDate: true,
+ isObsolete: false,
+ obsoleteAt: null,
+ prescriptionEnabled: false,
+ prescriptionAuthorizedRefills: null,
+ prescriptionRemainingRefills: null,
+ prescriptionLowRefillThreshold: 1,
+ prescriptionExpiryDate: null,
+ dismissedUntil: null,
+ updatedAt: null,
+};
+
+function createProps(overrides: Partial> = {}) {
+ return {
+ mobileEditModal: mobile
,
+ showUnsavedConfirm: false,
+ unsavedCancelLabel: "cancel-unsaved",
+ unsavedConfirmLabel: "confirm-unsaved",
+ unsavedMessage: "unsaved-message",
+ unsavedTitle: "unsaved-title",
+ onConfirmClose: vi.fn(),
+ onCancelClose: vi.fn(),
+ showObsoleteConfirm: false,
+ obsoleteCandidate: null,
+ obsoleteTitle: "obsolete-title",
+ obsoleteMessage: "obsolete-message",
+ obsoleteConfirmLabel: "confirm-obsolete",
+ obsoleteCancelLabel: "cancel-obsolete",
+ onConfirmMarkObsolete: vi.fn(),
+ onCancelMarkObsolete: vi.fn(),
+ showDeleteConfirm: false,
+ deleteCandidate: null,
+ deleteTitle: "delete-title",
+ deleteMessage: "delete-message",
+ deleteConfirmLabel: "confirm-delete",
+ deleteCancelLabel: "cancel-delete",
+ onConfirmDelete: vi.fn(),
+ onCancelDelete: vi.fn(),
+ showEditModal: false,
+ lightboxImage: null,
+ onCloseLightbox: vi.fn(),
+ showReportModal: false,
+ onCloseReportModal: vi.fn(),
+ medications: [baseMedication],
+ ...overrides,
+ };
+}
+
+describe("MedicationDialogs", () => {
+ it("always renders mobile edit container and report modal state", () => {
+ render( );
+
+ expect(screen.getByTestId("mobile-edit")).toBeInTheDocument();
+ expect(screen.getByTestId("report-modal")).toBeInTheDocument();
+ });
+
+ it("renders nested unsaved confirm when edit modal is open and triggers callbacks", () => {
+ const onConfirmClose = vi.fn();
+ const onCancelClose = vi.fn();
+
+ render(
+
+ );
+
+ const modal = screen.getByTestId("confirm-unsaved-title");
+ expect(modal).toHaveAttribute("data-overlay-class", "nested-confirm");
+
+ fireEvent.click(screen.getByText("confirm-unsaved"));
+ fireEvent.click(screen.getByText("cancel-unsaved"));
+
+ expect(onConfirmClose).toHaveBeenCalledTimes(1);
+ expect(onCancelClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("renders obsolete and delete confirms only when a candidate exists", () => {
+ const { rerender } = render(
+
+ );
+
+ expect(screen.getByTestId("confirm-obsolete-title")).toBeInTheDocument();
+ expect(screen.getByTestId("confirm-delete-title")).toBeInTheDocument();
+
+ rerender(
+
+ );
+
+ expect(screen.queryByTestId("confirm-obsolete-title")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("confirm-delete-title")).not.toBeInTheDocument();
+ });
+
+ it("renders lightbox when image data is present", () => {
+ render(
+
+ );
+
+ expect(screen.getByTestId("lightbox")).toHaveTextContent("https://example.com/a.jpg|Medication image");
+ });
+});
diff --git a/frontend/src/test/components/MedicationEditCoordinator.test.tsx b/frontend/src/test/components/MedicationEditCoordinator.test.tsx
new file mode 100644
index 0000000..a28179a
--- /dev/null
+++ b/frontend/src/test/components/MedicationEditCoordinator.test.tsx
@@ -0,0 +1,71 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import type { FormEvent } from "react";
+import { describe, expect, it, vi } from "vitest";
+import { MedicationEditCoordinator } from "../../components/medications/MedicationEditCoordinator";
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+}));
+
+describe("MedicationEditCoordinator", () => {
+ it("renders new-entry header and closes via back action", () => {
+ const onBack = vi.fn();
+ const onSubmit = vi.fn((event: FormEvent) => event.preventDefault());
+
+ render(
+
+ content
+
+ );
+
+ expect(screen.getByText("form.newEntry")).toBeInTheDocument();
+ expect(document.querySelector(".edit-sidebar.open")).not.toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole("button", { name: "<- common.back" }));
+ expect(onBack).toHaveBeenCalledTimes(1);
+
+ fireEvent.submit(document.querySelector("form") as HTMLFormElement);
+ expect(onSubmit).toHaveBeenCalledTimes(1);
+ });
+
+ it("renders edit header for editable and read-only flows", () => {
+ const onSubmit = vi.fn((event: FormEvent) => event.preventDefault());
+
+ const { rerender } = render(
+
+ content
+
+ );
+
+ expect(document.querySelector(".edit-sidebar.open")).toBeInTheDocument();
+ expect(screen.getByRole("heading", { name: "form.editEntry: Aspirin" })).toBeInTheDocument();
+
+ rerender(
+
+ content
+
+ );
+
+ expect(screen.getByRole("heading", { name: "form.viewEntry: Aspirin" })).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/test/pages/MedicationsPage.test.tsx b/frontend/src/test/pages/MedicationsPage.test.tsx
index 35d2023..1772c94 100644
--- a/frontend/src/test/pages/MedicationsPage.test.tsx
+++ b/frontend/src/test/pages/MedicationsPage.test.tsx
@@ -120,11 +120,15 @@ let mockContextValue = createMockContext();
let mockFormHookValue = createMockFormHook();
const fetchMock = vi.fn();
-vi.mock("../../hooks", () => ({
- useMedicationForm: () => mockFormHookValue,
- useUnsavedChangesWarning: () => ({}),
- useModalHistory: vi.fn(),
-}));
+vi.mock("../../hooks", async () => {
+ const actual = await vi.importActual("../../hooks");
+ return {
+ ...actual,
+ useMedicationForm: () => mockFormHookValue,
+ useUnsavedChangesWarning: () => ({}),
+ useModalHistory: vi.fn(),
+ };
+});
vi.mock("../../context", () => ({
useAppContext: () => mockContextValue,
@@ -180,6 +184,57 @@ vi.mock("../../components", async () => {
};
});
+vi.mock("../../components/medications/MedicationDialogs", () => ({
+ MedicationDialogs: ({
+ showUnsavedConfirm,
+ unsavedConfirmLabel,
+ onConfirmClose,
+ showObsoleteConfirm,
+ obsoleteConfirmLabel,
+ onConfirmMarkObsolete,
+ showDeleteConfirm,
+ deleteConfirmLabel,
+ onConfirmDelete,
+ showReportModal,
+ }: {
+ showUnsavedConfirm: boolean;
+ unsavedConfirmLabel: string;
+ onConfirmClose: () => void;
+ showObsoleteConfirm: boolean;
+ obsoleteConfirmLabel: string;
+ onConfirmMarkObsolete: () => void;
+ showDeleteConfirm: boolean;
+ deleteConfirmLabel: string;
+ onConfirmDelete: () => void;
+ showReportModal: boolean;
+ }) => (
+ <>
+ {showUnsavedConfirm ? (
+
+
+ {unsavedConfirmLabel}
+
+
+ ) : null}
+ {showObsoleteConfirm ? (
+
+
+ {obsoleteConfirmLabel}
+
+
+ ) : null}
+ {showDeleteConfirm ? (
+
+
+ {deleteConfirmLabel}
+
+
+ ) : null}
+ {showReportModal ? Report Modal
: null}
+ >
+ ),
+}));
+
function renderPage(initialEntry = "/medications") {
render(
@@ -829,17 +884,15 @@ describe("MedicationsPage form interactions", () => {
});
it("keeps a visible loading state while more lookup results are being fetched", async () => {
- let resolveLoadMore:
- | ((value: {
- ok: boolean;
- json: () => Promise<{
- query: string;
- normalizedQuery: string;
- hasMore: boolean;
- results: ReturnType;
- }>;
- }) => void)
- | null = null;
+ let resolveLoadMore!: (value: {
+ ok: boolean;
+ json: () => Promise<{
+ query: string;
+ normalizedQuery: string;
+ hasMore: boolean;
+ results: ReturnType;
+ }>;
+ }) => void;
fetchMock.mockImplementation((url: string) => {
if (url === "/api/medication-enrichment/search?q=Aspirin&limit=6") {
@@ -884,7 +937,7 @@ describe("MedicationsPage form interactions", () => {
expect(screen.getByRole("button", { name: "form.enrichment.loadingMoreResults" })).toBeDisabled();
expect(screen.queryByRole("status")).not.toBeInTheDocument();
- resolveLoadMore?.({
+ resolveLoadMore({
ok: true,
json: async () => ({
query: "Aspirin",
@@ -1539,7 +1592,7 @@ describe("MedicationsPage form interactions", () => {
it("shows the selected package as pending while enrichment details are still loading", async () => {
const setForm = vi.fn();
- let resolveEnrichment: ((value: { ok: boolean; json: () => Promise }) => void) | null = null;
+ let resolveEnrichment!: (value: { ok: boolean; json: () => Promise }) => void;
mockFormHookValue = createMockFormHook({ setForm });
fetchMock.mockImplementation((url: string) => {
if (url.startsWith("/api/medication-enrichment/search?")) {
@@ -1638,7 +1691,7 @@ describe("MedicationsPage form interactions", () => {
expect(pendingPackageButton.querySelector(".medication-enrichment-spinner")).not.toBeNull();
expect(screen.getByText("form.enrichment.applying")).toBeInTheDocument();
- resolveEnrichment?.({
+ resolveEnrichment({
ok: true,
json: async () => ({
selection: {