refactor: decompose frontend state and medication dialog flows
This commit is contained in:
+19
-19
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -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,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));
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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" : ""}`}
|
||||
|
||||
+803
-1129
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user