refactor: decompose frontend state and medication dialog flows

This commit is contained in:
Daniel Volz
2026-03-27 06:50:19 +01:00
committed by GitHub
parent b58c4fe5bb
commit f46043970f
28 changed files with 2450 additions and 1613 deletions
+19 -19
View File
@@ -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);
+18 -72
View File
@@ -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() {
</div>
</div>
<div className="tag-row">
<span className="tag subtle">
<ScheduleUsageTag>
{formatTotalUsageLabel(med, item.total, item.doses)}
</span>
</ScheduleUsageTag>
{isLowStock && <span className="tag warning">{t("status.lowStock")}</span>}
</div>
</div>
@@ -1192,9 +1138,9 @@ export function SharedSchedule() {
</div>
</div>
<div className="tag-row">
<span className="tag subtle">
<ScheduleUsageTag>
{formatTotalUsageLabel(med, item.total, item.doses)}
</span>
</ScheduleUsageTag>
{isLowStock && <span className="tag warning">{t("status.lowStock")}</span>}
</div>
</div>
@@ -1394,9 +1340,9 @@ export function SharedSchedule() {
</div>
</div>
<div className="tag-row">
<span className="tag subtle">
<ScheduleUsageTag>
{formatTotalUsageLabel(med, item.total, item.doses)}
</span>
</ScheduleUsageTag>
{isLowStock && <span className="tag warning">{t("status.lowStock")}</span>}
</div>
</div>
@@ -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, unknown>) => 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 (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{ display: "block" }}
>
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
</svg>
);
}
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 (
<section className="reminder-status-bar reminder-status-skeleton" aria-busy="true">
<div className="reminder-status-header">
<span className="reminder-status-icon">
<NotificationBellIcon />
</span>
<span className="reminder-status-title">{t("dashboard.reminders.active")}</span>
</div>
<div className="reminder-status-details reminder-status-skeleton-lines">
<span className="skeleton-line skeleton-line-long" />
<span className="skeleton-line skeleton-line-medium" />
<span className="skeleton-line skeleton-line-short" />
</div>
</section>
);
}
if (!anyRemindersEnabled) {
return null;
}
return (
<section className="reminder-status-bar">
<div className="reminder-status-header">
<span className="reminder-status-icon">
<NotificationBellIcon />
</span>
<span className="reminder-status-title">{t("dashboard.reminders.active")}</span>
{stockRemindersEnabled && (
<span className={`status-chip small ${reminderData.status.className}`}>{reminderData.status.text}</span>
)}
{prescriptionStatus && (
<span className={`status-chip small ${prescriptionStatus.className}`}>{prescriptionStatus.text}</span>
)}
</div>
{(reminderData.lowStockMeds.length > 0 ||
(prescriptionRemindersEnabled && prescriptionLowMeds.length > 0) ||
(stockRemindersEnabled && reminderData.lastStockSent) ||
(intakeRemindersEnabled && reminderData.lastIntakeSent)) && (
<div className="reminder-status-details">
{stockRemindersEnabled && reminderData.lowStockMeds.length > 0 && (
<div className="reminder-status-row">
<span className="reminder-status-label">{t("dashboard.reminders.needsRefill")}:</span>
<span className="reminder-status-value">
{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 (
<span key={med.name}>
{idx > 0 && ", "}
<span
className={`med-link clickable ${textClass}`}
onClick={() => medication && onOpenMedicationDetail(medication)}
onKeyDown={(e) => {
if ((e.key === "Enter" || e.key === " ") && medication) {
onOpenMedicationDetail(medication);
}
}}
>
{med.name}
</span>
<span className={`reminder-days-left ${textClass}`}>
{" "}
{t("dashboard.reminders.daysLeft", { count: med.daysLeft, days: med.daysLeft })}
</span>
</span>
);
})}
</span>
</div>
)}
{prescriptionRemindersEnabled && prescriptionLowMeds.length > 0 && (
<div className="reminder-status-row">
<span className="reminder-status-label">{t("dashboard.reminders.needsPrescriptionRefill")}:</span>
<span className="reminder-status-value">
{prescriptionLowMeds.map((med, idx) => {
const medication = meds.find((m) => m.id === med.id);
const textClass = med.remainingRefills <= 0 ? "danger-text" : "warning-text";
return (
<span key={med.id}>
{idx > 0 && ", "}
<span className={`reminder-days-left ${textClass}`}>
{t("prescription.remainingRefills")}: {med.remainingRefills} · {t("dashboard.reminders.usedBy")}
:{" "}
<span
className={`med-link clickable ${textClass}`}
onClick={() => medication && onOpenMedicationDetail(medication)}
onKeyDown={(e) => {
if ((e.key === "Enter" || e.key === " ") && medication) {
onOpenMedicationDetail(medication);
}
}}
>
{med.name}
</span>
</span>
</span>
);
})}
</span>
</div>
)}
{stockRemindersEnabled && reminderData.lastStockSent && (
<div className="reminder-status-row">
<span className="reminder-status-label">{t("dashboard.reminders.lastStockSent")}:</span>
<span className="reminder-status-value">
{reminderData.lastStockSent.medNames &&
(() => {
const names = reminderData.lastStockSent?.medNames?.split(", ") ?? [];
return names.map((name, idx) => {
const medication = meds.find((m) => getMedDisplayName(m) === name);
return (
<span key={name}>
{idx > 0 && ", "}
{medication ? (
<span
className="med-link clickable"
onClick={() => onOpenMedicationDetail(medication)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") onOpenMedicationDetail(medication);
}}
>
{name}
</span>
) : (
<span className="reminder-med-name">{name}</span>
)}
</span>
);
});
})()}
<span className="reminder-date"> {reminderData.lastStockSent.date}</span>
</span>
</div>
)}
{intakeRemindersEnabled && reminderData.lastIntakeSent && (
<div className="reminder-status-row">
<span className="reminder-status-label">{t("dashboard.reminders.lastSent")}:</span>
<span className="reminder-status-value">
{reminderData.lastIntakeSent.medName &&
(() => {
const medication = meds.find((m) => getMedDisplayName(m) === reminderData.lastIntakeSent?.medName);
return medication ? (
<span
className="med-link clickable"
onClick={() => onOpenMedicationDetail(medication)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") onOpenMedicationDetail(medication);
}}
>
{reminderData.lastIntakeSent?.medName}
</span>
) : (
<span className="reminder-med-name">{reminderData.lastIntakeSent?.medName}</span>
);
})()}
{reminderData.lastIntakeSent.takenBy && (
<span className="reminder-taken-by"> ({reminderData.lastIntakeSent.takenBy})</span>
)}
<span className="reminder-date"> {reminderData.lastIntakeSent.date}</span>
</span>
</div>
)}
</div>
)}
{((stockRemindersEnabled && reminderData.lowStockMeds.length > 0) ||
(prescriptionRemindersEnabled && prescriptionLowMeds.length > 0)) && (
<div className="reminder-send-row">
<button type="button" className="ghost" onClick={onSendManualReminder} disabled={sendingReminder}>
{sendingReminder ? t("common.sending") : t("dashboard.reorder.sendReminder")}
</button>
{reminderResult && (
<span className={`reminder-send-result ${reminderResult.success ? "success" : "error"}`}>
{reminderResult.message}
</span>
)}
</div>
)}
</section>
);
}
@@ -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, unknown>) => 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 (
<section className="grid">
<article className="card">
<div className="card-head">
<h2>{t("dashboard.reorder.title")}</h2>
</div>
{(() => {
if (meds.length === 0) {
return <p className="muted">{t("dashboard.reorder.noMeds")}</p>;
}
const lowStockMap = new Map<string, Coverage>();
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 <p className="success-text">{t("dashboard.reorder.allGood")}</p>;
}
return (
<p>
{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 (
<span key={c.name}>
{idx > 0 && ", "}
<span
className={`med-link clickable ${textClass}`}
onClick={() => med && onOpenMedicationDetail(med)}
onKeyDown={(e) => {
if ((e.key === "Enter" || e.key === " ") && med) {
onOpenMedicationDetail(med);
}
}}
>
{c.name}
</span>
<span className={`reminder-days-left ${textClass}`}>
{" "}
({t("dashboard.reminders.daysLeft", { count: c.daysLeft ?? 0, days: c.daysLeft ?? 0 })})
</span>
</span>
);
})}{" "}
{t("dashboard.reorder.lowWarningSuffix", { count: lowStockCount })}
</p>
);
})()}
</article>
</section>
);
}
+3
View File
@@ -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";
@@ -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 && (
<ConfirmModal
title={unsavedTitle}
message={unsavedMessage}
confirmLabel={unsavedConfirmLabel}
cancelLabel={unsavedCancelLabel}
onConfirm={onConfirmClose}
onCancel={onCancelClose}
confirmVariant="danger"
overlayClassName={showEditModal ? "nested-confirm" : undefined}
/>
)}
{showObsoleteConfirm && obsoleteCandidate && (
<ConfirmModal
title={obsoleteTitle}
message={obsoleteMessage}
confirmLabel={obsoleteConfirmLabel}
cancelLabel={obsoleteCancelLabel}
onConfirm={onConfirmMarkObsolete}
onCancel={onCancelMarkObsolete}
confirmVariant="warning"
overlayClassName={showEditModal ? "nested-confirm" : undefined}
/>
)}
{showDeleteConfirm && deleteCandidate && (
<ConfirmModal
title={deleteTitle}
message={deleteMessage}
confirmLabel={deleteConfirmLabel}
cancelLabel={deleteCancelLabel}
onConfirm={onConfirmDelete}
onCancel={onCancelDelete}
confirmVariant="danger"
overlayClassName={showEditModal ? "nested-confirm" : undefined}
/>
)}
{lightboxImage && <Lightbox src={lightboxImage.src} alt={lightboxImage.alt} onClose={onCloseLightbox} />}
<ReportModal isOpen={showReportModal} onClose={onCloseReportModal} medications={medications} />
</>
);
}
@@ -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<HTMLFormElement>) => void;
children: React.ReactNode;
};
export function MedicationEditCoordinator({
viewMode,
editingId,
readOnlyView,
selectedMedicationName,
onBack,
onSubmit,
children,
}: MedicationEditCoordinatorProps) {
const { t } = useTranslation();
return (
<aside className={`edit-sidebar desktop-only${viewMode === "form" ? " open" : ""}`}>
<article className="card form">
<div className="card-head">
<div className="edit-header">
<button type="button" className="ghost small btn-nav" onClick={onBack}>
{"<-"} {t("common.back")}
</button>
{editingId ? (
<h2>
{readOnlyView ? t("form.viewEntry") : t("form.editEntry")}: {selectedMedicationName}
</h2>
) : (
<h2>{t("form.newEntry")}</h2>
)}
</div>
</div>
<form
className="form-grid"
onSubmit={onSubmit}
autoComplete="off"
spellCheck={false}
autoCorrect="off"
autoCapitalize="off"
>
{children}
</form>
</article>
</aside>
);
}
@@ -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<string, { medsLeft: number }>;
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) => (
<span
className={med.imageUrl ? "med-avatar-clickable" : undefined}
onClick={() => med.imageUrl && onImagePreview(med)}
onKeyDown={(e) => {
if ((e.key === "Enter" || e.key === " ") && med.imageUrl) {
onImagePreview(med);
}
}}
>
<MedicationAvatar name={getMedDisplayName(med)} imageUrl={med.imageUrl} size="lg" />
</span>
);
return (
<article className="card">
<div className="card-head">
<h2>{t("medications.list.title")}</h2>
<div className="card-head-actions">
<button type="button" className="btn primary small" onClick={onNewEntry}>
+ {t("form.newEntry")}
</button>
<button type="button" className="btn ghost small" onClick={onOpenReport}>
{t("report.button")}
</button>
</div>
</div>
<div className="med-groups">
<div className="med-group med-group-active">
<div className="med-grid">
{orderedMeds.map((med) => {
const displayName = getMedDisplayName(med);
const stockDisplayCapacity = getStockDisplayCapacity(med);
const currentStock = coverageByMed[displayName]
? Math.round(coverageByMed[displayName].medsLeft)
: getMedTotal(med);
return (
<div key={med.id} className={`med-row${editingId === med.id ? " editing" : ""}`}>
<div className="med-header">
<div className="med-info">
<div className="med-name-row">
{renderImageAvatar(med)}
<div className="med-name-block">
<div className="med-name">{displayName}</div>
{med.name && med.genericName && <div className="med-generic-name">{med.genericName}</div>}
</div>
</div>
<div className="med-actions">
{editingId !== med.id && (
<button
className="info icon-only tooltip-trigger"
onClick={() => onEdit(med)}
aria-label={t("common.edit")}
data-tooltip={t("common.edit")}
>
<Pencil size={18} aria-hidden="true" />
</button>
)}
<button
type="button"
className="btn-obsolete"
onClick={() => onMarkObsolete(med)}
aria-label={t("medications.list.markObsolete")}
>
<Archive size={16} aria-hidden="true" />
<span>{t("medications.list.markObsolete")}</span>
</button>
<button
type="button"
className="danger icon-only tooltip-trigger"
onClick={() => onDelete(med)}
aria-label={t("common.delete")}
data-tooltip={t("common.delete")}
>
<Trash2 size={18} aria-hidden="true" />
</button>
</div>
<div className="med-details">
<span>
{t("medications.details.type")}: <strong>{getMedicationPackageTypeLabel(med)}</strong>
</span>
{!isAmountBasedPackageType(med.packageType) ? (
<>
<span>
{t("medications.details.packs")}: <strong>{med.packCount}</strong>
</span>
<span>
{t("medications.details.blisters")}: <strong>{med.blistersPerPack}</strong>
</span>
<span>
{t("medications.details.pillsPerBlister")}: <strong>{med.pillsPerBlister}</strong>
</span>
<span>
{t("medications.details.loose")}: <strong>{med.looseTablets}</strong>
</span>
</>
) : (
<span>
{t("medications.details.totalCapacity")}:{" "}
<strong>{med.totalPills ?? med.looseTablets}</strong>
</span>
)}
</div>
{med.prescriptionEnabled && (
<div className="med-total">
{t("prescription.remainingRefills")}: <strong>{med.prescriptionRemainingRefills ?? 0}</strong>
</div>
)}
<div className="med-total">
{t("medications.details.stock")}: {currentStock} / {stockDisplayCapacity}
{getMedicationStockSuffix(med)}
{currentStock > stockDisplayCapacity ? (
<span
className="info-tooltip tooltip-align-left warning-text"
data-tooltip={t("tooltips.stockExceedsCapacity")}
>
{" "}
</span>
) : null}
</div>
</div>
</div>
<div className="blister-list">
{getMedicationIntakes(med).map((intake) => (
<div
key={`${med.id}-${intake.start}-${intake.usage}-${intake.takenBy ?? "none"}`}
className="blister-row-simple"
>
{intake.usage} {getMedicationUsageUnitLabel(med, intake.usage)} ·
{getIntakeFrequencyText(intake, t)} · {t("form.blisters.from")} {formatDateTime(intake.start)}
{intake.takenBy && <span className="blister-taken-by"> · {intake.takenBy}</span>}
{intake.intakeRemindersEnabled && (
<span className="blister-reminder-icon" title={t("form.blisters.remindTooltip")}>
{" "}
<Bell size={12} aria-hidden="true" />
</span>
)}
</div>
))}
</div>
</div>
);
})}
</div>
</div>
{obsoleteMeds.length > 0 && (
<div className="med-group med-group-obsolete">
<button
type="button"
className="med-group-head med-group-head-toggle"
onClick={onToggleObsolete}
aria-expanded={showObsolete}
>
<h3 className="med-group-title">
{showObsolete ? "▼" : "▶"} {t("medications.list.obsoleteTitle", { count: obsoleteMeds.length })}
</h3>
</button>
{showObsolete && (
<div className="med-grid med-grid-obsolete">
{obsoleteMeds.map((med) => (
<div key={med.id} className="med-row obsolete-row">
<div className="med-header">
<div className="med-info">
<div className="med-name-row">
{renderImageAvatar(med)}
<div className="med-name-block">
<div className="med-name">{getMedDisplayName(med)}</div>
{med.name && med.genericName && <div className="med-generic-name">{med.genericName}</div>}
</div>
</div>
<div className="med-actions">
<button
className="info icon-only tooltip-trigger"
onClick={() => onView(med)}
aria-label={t("common.view")}
data-tooltip={t("common.view")}
>
<Eye size={18} aria-hidden="true" />
</button>
<button
className="danger icon-only tooltip-trigger"
onClick={() => onDelete(med)}
aria-label={t("common.delete")}
data-tooltip={t("common.delete")}
>
<Trash2 size={18} aria-hidden="true" />
</button>
<button className="success" onClick={() => onReactivate(med.id)}>
{t("medications.list.reactivate")}
</button>
</div>
<div className="med-details">
{med.medicationStartDate && (
<span style={{ gridColumn: "1 / -1" }}>
{t("medications.list.started")}: <strong>{formatDate(med.medicationStartDate)}</strong>
</span>
)}
<span style={{ gridColumn: "1 / -1" }}>
{t("medications.list.obsoleteSince")}: <strong>{formatDate(med.obsoleteAt)}</strong>
</span>
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
</article>
);
}
+28 -1
View File
@@ -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 <AppContext.Provider value={value}>{children}</AppContext.Provider>;
return (
<AppContext.Provider value={value}>
<ShareContextProvider value={shareValue}>{children}</ShareContextProvider>
</AppContext.Provider>
);
}
// =============================================================================
+41
View File
@@ -0,0 +1,41 @@
import { createContext, useContext } from "react";
type ShareContextValue = {
showShareDialog: boolean;
sharePeople: string[];
shareSelectedPerson: string;
setShareSelectedPerson: React.Dispatch<React.SetStateAction<string>>;
shareSelectedDays: number;
setShareSelectedDays: React.Dispatch<React.SetStateAction<number>>;
shareGenerating: boolean;
shareLink: string | null;
setShareLink: React.Dispatch<React.SetStateAction<string | null>>;
shareCopied: boolean;
setShareCopied: React.Dispatch<React.SetStateAction<boolean>>;
openShareDialog: () => void;
generateShareLink: () => Promise<void>;
copyShareLink: () => void;
closeShareDialog: () => void;
resetShareDialogState: () => void;
};
const ShareContext = createContext<ShareContextValue | null>(null);
type ShareContextProviderProps = {
value: ShareContextValue;
children: React.ReactNode;
};
export function ShareContextProvider({ value, children }: ShareContextProviderProps) {
return <ShareContext.Provider value={value}>{children}</ShareContext.Provider>;
}
export function useShareContext(): ShareContextValue {
const context = useContext(ShareContext);
if (!context) {
throw new Error("useShareContext must be used within ShareContextProvider");
}
return context;
}
export type { ShareContextValue };
+2
View File
@@ -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";
@@ -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 (
<article className={className ?? "card schedule-full"}>
<div className="card-head">
<h2>{title}</h2>
{headerRight}
</div>
{children}
</article>
);
}
@@ -0,0 +1,7 @@
type ScheduleUsageTagProps = {
children: React.ReactNode;
};
export function ScheduleUsageTag({ children }: ScheduleUsageTagProps) {
return <span className="tag subtle">{children}</span>;
}
@@ -0,0 +1,2 @@
export { ScheduleSectionCard } from "./ScheduleSectionCard";
export { ScheduleUsageTag } from "./ScheduleUsageTag";
@@ -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, unknown>) => 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 });
}
@@ -0,0 +1,29 @@
export function toggleDateInSet(previous: Set<string>, dateStr: string): Set<string> {
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<string>,
manuallyCollapsedDays: Set<string>
): 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));
}
+18
View File
@@ -0,0 +1,18 @@
import { loadCollapsedDaysFromStorage } from "../../utils/storage";
export type ScheduleCollapseState = {
collapsed: Set<string>;
expanded: Set<string>;
};
export function loadScheduleCollapseState(collapseKey: string, expandKey: string): ScheduleCollapseState {
return loadCollapsedDaysFromStorage(collapseKey, expandKey);
}
export function saveCollapsedDaySet(storageKey: string, value: Set<string>): void {
try {
localStorage.setItem(storageKey, JSON.stringify([...value]));
} catch {
// Ignore storage failures and keep UI responsive.
}
}
+9
View File
@@ -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";
@@ -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<MedicationEnrichmentState>(() =>
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,
};
}
@@ -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,
};
}
+31 -10
View File
@@ -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<Settings>(defaultSettings);
const [savedSettings, setSavedSettings] = useState<Settings>(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);
+28 -280
View File
@@ -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 (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{ display: "block" }}
>
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
</svg>
);
}
export function DashboardPage() {
const { t, i18n } = useTranslation();
const { user } = useAuth();
@@ -428,266 +409,33 @@ export function DashboardPage() {
return (
<>
{remindersLoading ? (
<section className="reminder-status-bar reminder-status-skeleton" aria-busy="true">
<div className="reminder-status-header">
<span className="reminder-status-icon">
<NotificationBellIcon />
</span>
<span className="reminder-status-title">{t("dashboard.reminders.active")}</span>
</div>
<div className="reminder-status-details reminder-status-skeleton-lines">
<span className="skeleton-line skeleton-line-long" />
<span className="skeleton-line skeleton-line-medium" />
<span className="skeleton-line skeleton-line-short" />
</div>
</section>
) : (
anyRemindersEnabled && (
<section className="reminder-status-bar">
<div className="reminder-status-header">
<span className="reminder-status-icon">
<NotificationBellIcon />
</span>
<span className="reminder-status-title">{t("dashboard.reminders.active")}</span>
{stockRemindersEnabled && (
<span className={`status-chip small ${reminderData.status.className}`}>{reminderData.status.text}</span>
)}
{prescriptionStatus && (
<span className={`status-chip small ${prescriptionStatus.className}`}>{prescriptionStatus.text}</span>
)}
</div>
{(reminderData.lowStockMeds.length > 0 ||
(prescriptionRemindersEnabled && prescriptionLowMeds.length > 0) ||
(stockRemindersEnabled && reminderData.lastStockSent) ||
(intakeRemindersEnabled && reminderData.lastIntakeSent)) && (
<div className="reminder-status-details">
{stockRemindersEnabled && reminderData.lowStockMeds.length > 0 && (
<div className="reminder-status-row">
<span className="reminder-status-label">{t("dashboard.reminders.needsRefill")}:</span>
<span className="reminder-status-value">
{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 (
<span key={med.name}>
{idx > 0 && ", "}
<span
className={`med-link clickable ${textClass}`}
onClick={() => medication && openMedDetail(medication)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (medication) openMedDetail(medication);
}
}}
>
{med.name}
</span>
<span className={`reminder-days-left ${textClass}`}>
{" "}
{t("dashboard.reminders.daysLeft", { count: med.daysLeft, days: med.daysLeft })}
</span>
</span>
);
})}
</span>
</div>
)}
{prescriptionRemindersEnabled && prescriptionLowMeds.length > 0 && (
<div className="reminder-status-row">
<span className="reminder-status-label">{t("dashboard.reminders.needsPrescriptionRefill")}:</span>
<span className="reminder-status-value">
{prescriptionLowMeds.map((med, idx) => {
const medication = meds.find((m) => m.id === med.id);
const textClass = med.remainingRefills <= 0 ? "danger-text" : "warning-text";
return (
<span key={med.id}>
{idx > 0 && ", "}
<span className={`reminder-days-left ${textClass}`}>
{t("prescription.remainingRefills")}: {med.remainingRefills} ·{" "}
{t("dashboard.reminders.usedBy")}:{" "}
<span
className={`med-link clickable ${textClass}`}
onClick={() => medication && openMedDetail(medication)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (medication) openMedDetail(medication);
}
}}
>
{med.name}
</span>
</span>
</span>
);
})}
</span>
</div>
)}
{stockRemindersEnabled && reminderData.lastStockSent && (
<div className="reminder-status-row">
<span className="reminder-status-label">{t("dashboard.reminders.lastStockSent")}:</span>
<span className="reminder-status-value">
{reminderData.lastStockSent.medNames &&
(() => {
const names = reminderData.lastStockSent!.medNames!.split(", ");
return names.map((name, idx) => {
const medication = meds.find((m) => getMedDisplayName(m) === name);
return (
<span key={name}>
{idx > 0 && ", "}
{medication ? (
<span
className="med-link clickable"
onClick={() => openMedDetail(medication)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") openMedDetail(medication);
}}
>
{name}
</span>
) : (
<span className="reminder-med-name">{name}</span>
)}
</span>
);
});
})()}
<span className="reminder-date"> {reminderData.lastStockSent.date}</span>
</span>
</div>
)}
{intakeRemindersEnabled && reminderData.lastIntakeSent && (
<div className="reminder-status-row">
<span className="reminder-status-label">{t("dashboard.reminders.lastSent")}:</span>
<span className="reminder-status-value">
{reminderData.lastIntakeSent.medName &&
(() => {
const medication = meds.find(
(m) => getMedDisplayName(m) === reminderData.lastIntakeSent!.medName
);
return medication ? (
<span
className="med-link clickable"
onClick={() => openMedDetail(medication)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") openMedDetail(medication);
}}
>
{reminderData.lastIntakeSent!.medName}
</span>
) : (
<span className="reminder-med-name">{reminderData.lastIntakeSent!.medName}</span>
);
})()}
{reminderData.lastIntakeSent.takenBy && (
<span className="reminder-taken-by"> ({reminderData.lastIntakeSent.takenBy})</span>
)}
<span className="reminder-date"> {reminderData.lastIntakeSent.date}</span>
</span>
</div>
)}
</div>
)}
{((stockRemindersEnabled && reminderData.lowStockMeds.length > 0) ||
(prescriptionRemindersEnabled && prescriptionLowMeds.length > 0)) && (
<div className="reminder-send-row">
<button type="button" className="ghost" onClick={sendManualReminder} disabled={sendingReminder}>
{sendingReminder ? t("common.sending") : t("dashboard.reorder.sendReminder")}
</button>
{reminderResult && (
<span className={`reminder-send-result ${reminderResult.success ? "success" : "error"}`}>
{reminderResult.message}
</span>
)}
</div>
)}
</section>
)
)}
{/* Reorder Reminder card: Only show when reminders are NOT enabled (otherwise Reminder Bar shows the same info) */}
{!remindersLoading && !anyRemindersEnabled && (
<section className="grid">
<article className="card">
<div className="card-head">
<h2>{t("dashboard.reorder.title")}</h2>
</div>
{(() => {
if (meds.length === 0) {
return <p className="muted">{t("dashboard.reorder.noMeds")}</p>;
}
<DashboardReminderSection
t={t}
remindersLoading={remindersLoading}
anyRemindersEnabled={anyRemindersEnabled}
stockRemindersEnabled={stockRemindersEnabled}
intakeRemindersEnabled={intakeRemindersEnabled}
prescriptionRemindersEnabled={prescriptionRemindersEnabled}
reminderData={reminderData}
prescriptionLowMeds={prescriptionLowMeds}
prescriptionStatus={prescriptionStatus}
meds={meds}
coverage={coverage}
stockThresholds={stockThresholds}
sendingReminder={sendingReminder}
reminderResult={reminderResult}
onSendManualReminder={sendManualReminder}
onOpenMedicationDetail={openMedDetail}
/>
// Count medications with low stock (based on lowStockDays setting), deduplicated by name
const lowStockMap = new Map<string, Coverage>();
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 <p className="success-text">{t("dashboard.reorder.allGood")}</p>;
}
// Some meds are low - show simple text with clickable names and days left
return (
<p>
{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 (
<span key={c.name}>
{idx > 0 && ", "}
<span
className={`med-link clickable ${textClass}`}
onClick={() => med && openMedDetail(med)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (med) openMedDetail(med);
}
}}
>
{c.name}
</span>
<span className={`reminder-days-left ${textClass}`}>
{" "}
({t("dashboard.reminders.daysLeft", { count: c.daysLeft ?? 0, days: c.daysLeft ?? 0 })})
</span>
</span>
);
})}{" "}
{t("dashboard.reorder.lowWarningSuffix", { count: lowStockCount })}
</p>
);
})()}
</article>
</section>
)}
<DashboardStatusSection
t={t}
show={!remindersLoading && !anyRemindersEnabled}
meds={meds}
coverage={coverage}
stockThresholds={stockThresholds}
onOpenMedicationDetail={openMedDetail}
/>
<div
className={`dashboard-main-sections${settings.swapDashboardMainSections ? " dashboard-main-sections-swapped" : ""}`}
File diff suppressed because it is too large Load Diff
+8 -60
View File
@@ -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 (
<section className="grid">
@@ -335,7 +283,7 @@ export function SchedulePage() {
<span className="med-name-text">{item.medName}</span>
</div>
<div className="tag-row">
<span className="tag subtle">{formatTotalUsageLabel(med, item.total, item.doses)}</span>
<ScheduleUsageTag>{formatTotalUsageLabel(med, item.total, item.doses)}</ScheduleUsageTag>
</div>
</div>
<div className="doses-col">
@@ -549,7 +497,7 @@ export function SchedulePage() {
<span className="med-name-text">{item.medName}</span>
</div>
<div className="tag-row">
<span className="tag subtle">{formatTotalUsageLabel(med, item.total, item.doses)}</span>
<ScheduleUsageTag>{formatTotalUsageLabel(med, item.total, item.doses)}</ScheduleUsageTag>
{visibleStatus && (
<span className={`tag ${visibleStatus.className}`}>{t(visibleStatus.label)}</span>
)}
+32 -23
View File
@@ -18,6 +18,7 @@ let authMock: AuthStateMock = {
};
let appContextMock: Record<string, unknown>;
let shareContextMock: Record<string, unknown>;
vi.mock("../components", () => ({
AboutModal: ({ isOpen }: { isOpen: boolean }) => (isOpen ? <div>about-modal-open</div> : 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<typeof import("../context")>("../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: () => <div>dashboard-page</div>,
@@ -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(
<MemoryRouter initialEntries={["/dashboard"]}>
@@ -328,7 +337,7 @@ describe("App", () => {
});
it("handles popstate by resetting share dialog state", () => {
appContextMock.showShareDialog = true;
shareContextMock.showShareDialog = true;
render(
<MemoryRouter initialEntries={["/dashboard"]}>
@@ -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", () => {
@@ -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;
}) => (
<div data-testid={`confirm-${title}`} data-overlay-class={overlayClassName ?? ""}>
<button type="button" onClick={onConfirm}>
{confirmLabel}
</button>
<button type="button" onClick={onCancel}>
{cancelLabel}
</button>
</div>
),
}));
vi.mock("../../components/Lightbox", () => ({
Lightbox: ({ src, alt }: { src: string; alt: string }) => <div data-testid="lightbox">{`${src}|${alt}`}</div>,
}));
vi.mock("../../components/ReportModal", () => ({
default: ({ isOpen }: { isOpen: boolean }) => (isOpen ? <div data-testid="report-modal">report</div> : 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<React.ComponentProps<typeof MedicationDialogs>> = {}) {
return {
mobileEditModal: <div data-testid="mobile-edit">mobile</div>,
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(<MedicationDialogs {...createProps({ showReportModal: true })} />);
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(
<MedicationDialogs
{...createProps({
showUnsavedConfirm: true,
showEditModal: true,
onConfirmClose,
onCancelClose,
})}
/>
);
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(
<MedicationDialogs
{...createProps({
showObsoleteConfirm: true,
showDeleteConfirm: true,
obsoleteCandidate: baseMedication,
deleteCandidate: baseMedication,
})}
/>
);
expect(screen.getByTestId("confirm-obsolete-title")).toBeInTheDocument();
expect(screen.getByTestId("confirm-delete-title")).toBeInTheDocument();
rerender(
<MedicationDialogs
{...createProps({
showObsoleteConfirm: true,
showDeleteConfirm: true,
obsoleteCandidate: null,
deleteCandidate: null,
})}
/>
);
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(
<MedicationDialogs
{...createProps({
lightboxImage: { src: "https://example.com/a.jpg", alt: "Medication image" },
})}
/>
);
expect(screen.getByTestId("lightbox")).toHaveTextContent("https://example.com/a.jpg|Medication image");
});
});
@@ -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<HTMLFormElement>) => event.preventDefault());
render(
<MedicationEditCoordinator
viewMode="grid"
editingId={null}
readOnlyView={false}
onBack={onBack}
onSubmit={onSubmit}
>
<div>content</div>
</MedicationEditCoordinator>
);
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<HTMLFormElement>) => event.preventDefault());
const { rerender } = render(
<MedicationEditCoordinator
viewMode="form"
editingId={42}
readOnlyView={false}
selectedMedicationName="Aspirin"
onBack={vi.fn()}
onSubmit={onSubmit}
>
<div>content</div>
</MedicationEditCoordinator>
);
expect(document.querySelector(".edit-sidebar.open")).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "form.editEntry: Aspirin" })).toBeInTheDocument();
rerender(
<MedicationEditCoordinator
viewMode="form"
editingId={42}
readOnlyView={true}
selectedMedicationName="Aspirin"
onBack={vi.fn()}
onSubmit={onSubmit}
>
<div>content</div>
</MedicationEditCoordinator>
);
expect(screen.getByRole("heading", { name: "form.viewEntry: Aspirin" })).toBeInTheDocument();
});
});
@@ -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<typeof import("../../hooks")>("../../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 ? (
<div data-testid="confirm-modal">
<button type="button" onClick={onConfirmClose}>
{unsavedConfirmLabel}
</button>
</div>
) : null}
{showObsoleteConfirm ? (
<div data-testid="confirm-modal">
<button type="button" onClick={onConfirmMarkObsolete}>
{obsoleteConfirmLabel}
</button>
</div>
) : null}
{showDeleteConfirm ? (
<div data-testid="confirm-modal">
<button type="button" onClick={onConfirmDelete}>
{deleteConfirmLabel}
</button>
</div>
) : null}
{showReportModal ? <div data-testid="report-modal-open">Report Modal</div> : null}
</>
),
}));
function renderPage(initialEntry = "/medications") {
render(
<MemoryRouter initialEntries={[initialEntry]}>
@@ -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<typeof createMedicationEnrichmentSearchResults>;
}>;
}) => void)
| null = null;
let resolveLoadMore!: (value: {
ok: boolean;
json: () => Promise<{
query: string;
normalizedQuery: string;
hasMore: boolean;
results: ReturnType<typeof createMedicationEnrichmentSearchResults>;
}>;
}) => 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<unknown> }) => void) | null = null;
let resolveEnrichment!: (value: { ok: boolean; json: () => Promise<unknown> }) => 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: {