From f46043970f1d47bb41dcdef77003bcaf9ed2d5f7 Mon Sep 17 00:00:00 2001 From: Daniel Volz Date: Fri, 27 Mar 2026 06:50:19 +0100 Subject: [PATCH] refactor: decompose frontend state and medication dialog flows --- frontend/src/App.tsx | 38 +- frontend/src/components/SharedSchedule.tsx | 90 +- .../dashboard/DashboardReminderSection.tsx | 265 +++ .../dashboard/DashboardStatusSection.tsx | 96 + frontend/src/components/index.ts | 3 + .../medications/MedicationDialogs.tsx | 120 + .../medications/MedicationEditCoordinator.tsx | 55 + .../medications/MedicationListSection.tsx | 264 +++ frontend/src/context/AppContext.tsx | 29 +- frontend/src/context/ShareContext.tsx | 41 + frontend/src/context/index.ts | 2 + .../components/ScheduleSectionCard.tsx | 18 + .../schedule/components/ScheduleUsageTag.tsx | 7 + .../src/features/schedule/components/index.ts | 2 + frontend/src/features/schedule/formatters.ts | 85 + .../src/features/schedule/interactions.ts | 29 + frontend/src/features/schedule/storage.ts | 18 + frontend/src/hooks/index.ts | 9 + .../useMedicationEnrichmentController.ts | 84 + frontend/src/hooks/useScheduleController.ts | 41 + frontend/src/hooks/useSettings.ts | 41 +- frontend/src/pages/DashboardPage.tsx | 308 +-- frontend/src/pages/MedicationsPage.tsx | 1932 +++++++---------- frontend/src/pages/SchedulePage.tsx | 68 +- frontend/src/test/App.test.tsx | 55 +- .../components/MedicationDialogs.test.tsx | 201 ++ .../MedicationEditCoordinator.test.tsx | 71 + .../src/test/pages/MedicationsPage.test.tsx | 91 +- 28 files changed, 2450 insertions(+), 1613 deletions(-) create mode 100644 frontend/src/components/dashboard/DashboardReminderSection.tsx create mode 100644 frontend/src/components/dashboard/DashboardStatusSection.tsx create mode 100644 frontend/src/components/medications/MedicationDialogs.tsx create mode 100644 frontend/src/components/medications/MedicationEditCoordinator.tsx create mode 100644 frontend/src/components/medications/MedicationListSection.tsx create mode 100644 frontend/src/context/ShareContext.tsx create mode 100644 frontend/src/features/schedule/components/ScheduleSectionCard.tsx create mode 100644 frontend/src/features/schedule/components/ScheduleUsageTag.tsx create mode 100644 frontend/src/features/schedule/components/index.ts create mode 100644 frontend/src/features/schedule/formatters.ts create mode 100644 frontend/src/features/schedule/interactions.ts create mode 100644 frontend/src/features/schedule/storage.ts create mode 100644 frontend/src/hooks/useMedicationEnrichmentController.ts create mode 100644 frontend/src/hooks/useScheduleController.ts create mode 100644 frontend/src/test/components/MedicationDialogs.test.tsx create mode 100644 frontend/src/test/components/MedicationEditCoordinator.test.tsx 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)) && ( +
+ + {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 ( + + ); +} 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")}

+
+ + +
+
+
+
+
+ {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 && ( + + )} + + +
+
+ + {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 && ( +
+ {obsoleteMeds.map((med) => ( +
+
+
+
+ {renderImageAvatar(med)} +
+
{getMedDisplayName(med)}
+ {med.name && med.genericName &&
{med.genericName}
} +
+
+
+ + + +
+
+ {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)) && ( -
- - {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")}

-
- - -
+ 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 ── */} + +
+ + + +
-
-
-
- {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 && ( - - )} - + )} + + ))} + {!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 ( +
+ {currentMed.name}
-
- - {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 && ( -
- {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}
} -
-
-
- - - -
-
- {med.medicationStartDate && ( - - {t("medications.list.started")}: {formatDate(med.medicationStartDate)} - - )} - - {t("medications.list.obsoleteSince")}: {formatDate(med.obsoleteAt)} - -
-
-
-
- ))} -
- )} -
- )} -
-
- - {/* ── Desktop Edit Panel: inline below medication list ── */} -