Files
medassist-ng/frontend/src/pages/DashboardPage.tsx
T
Daniel Volz 9a2d42b8b9 fix: stabilize dashboard modal and image click behavior (#267)
* feat: make medication names clickable in Dashboard dose schedule

Add click handlers to med-name-stack divs in all three dose schedule
sections (past, current/overdue, future) on DashboardPage, opening the
MedDetail modal on click.

Add early-return guards to all four modal openers in AppContext
(openMedDetail, openImageLightbox, openScheduleLightbox, openUserFilter)
to prevent duplicate pushState entries on double-click, which caused
unexpected navigation to the Medications page.

Closes #266

* fix: stabilize dashboard modal and image click handling

* fix: close medication detail on first backdrop click
2026-02-22 10:50:58 +01:00

1361 lines
54 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* biome-ignore-all lint/style/noNestedTernary: timeline rendering uses explicit UI-state branching */
import { Bell, NotebookPen, Share2 } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ConfirmModal, MedicationAvatar } from "../components";
import { useAuth } from "../components/Auth";
import { useAppContext } from "../context";
import { useModalHistory } from "../hooks";
import { formatNumber, getExpiryClass, getSystemLocale } from "../utils/formatters";
import { expandDoseIds, getStockStatus, isDoseDismissed } from "../utils/schedule";
import {
formatFullBlisters,
formatOpenBlisterAndLoose,
getBlisterStock,
getMedTotal,
getReminderStatusData,
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();
const {
meds,
settings,
coverage,
coverageByMed,
depletionByMed,
scheduleDays,
setScheduleDays,
showPastDays,
setShowPastDays,
showFutureDays,
setShowFutureDays,
pastDays,
todayDay,
futureDays,
takenDoses,
dismissedDoses,
markDoseTaken,
undoDoseTaken,
manuallyCollapsedDays,
manuallyExpandedDays,
toggleDayCollapse,
missedPastDoseIds,
getDayStockStatus,
getDoseId,
isDoseTakenAutomatically,
showClearMissedConfirm,
setShowClearMissedConfirm,
clearingMissed,
dismissMissedDoses,
openMedDetail,
openUserFilter,
openShareDialog,
openScheduleLightbox,
stockThresholds,
loadSettings,
} = useAppContext();
useModalHistory(showClearMissedConfirm, "clearMissed", () => setShowClearMissedConfirm(false));
// Get structured reminder data
const reminderData = getReminderStatusData(
settings.reminderDaysBefore,
settings.lowStockDays,
coverage.low,
coverage.all,
settings.lastAutoEmailSent,
settings.lastNotificationType,
settings.lastNotificationChannel,
settings.lastReminderMedName,
settings.lastReminderTakenBy,
settings.lastStockReminderSent,
settings.lastStockReminderChannel,
settings.lastStockReminderMedNames,
t,
getSystemLocale(i18n.language)
);
// Check which reminder types are actually enabled (channel must be enabled too)
const stockRemindersEnabled =
(settings.emailEnabled && settings.emailStockReminders) ||
(settings.shoutrrrEnabled && settings.shoutrrrStockReminders);
const intakeRemindersEnabled =
(settings.emailEnabled && settings.emailIntakeReminders) ||
(settings.shoutrrrEnabled && settings.shoutrrrIntakeReminders);
const prescriptionRemindersEnabled =
(settings.emailEnabled && settings.emailPrescriptionReminders) ||
(settings.shoutrrrEnabled && settings.shoutrrrPrescriptionReminders);
const prescriptionLowMeds = meds
.filter((med) => {
if (!med.prescriptionEnabled) return false;
const remaining = med.prescriptionRemainingRefills ?? 0;
const threshold = med.prescriptionLowRefillThreshold ?? 1;
return remaining <= threshold;
})
.map((med) => ({
id: med.id,
name: med.name,
remainingRefills: med.prescriptionRemainingRefills ?? 0,
threshold: med.prescriptionLowRefillThreshold ?? 1,
}))
.sort((a, b) => a.remainingRefills - b.remainingRefills);
const anyRemindersEnabled = stockRemindersEnabled || intakeRemindersEnabled || prescriptionRemindersEnabled;
const showOnlyToday = settings.upcomingTodayOnly;
const prescriptionEmptyCount = prescriptionLowMeds.filter((med) => med.remainingRefills <= 0).length;
const prescriptionStatus =
prescriptionRemindersEnabled && prescriptionLowMeds.length > 0
? {
text:
prescriptionEmptyCount > 0
? t("dashboard.reminders.prescriptionCriticalMeds", { count: prescriptionEmptyCount })
: t("dashboard.reminders.prescriptionLowMeds", { count: prescriptionLowMeds.length }),
className: prescriptionEmptyCount > 0 ? "danger" : "warning",
}
: null;
// Manual reminder send state
const [sendingReminder, setSendingReminder] = useState(false);
const [reminderResult, setReminderResult] = useState<{ success: boolean; message: string } | null>(null);
async function sendManualReminder() {
const sendableStock = stockRemindersEnabled && reminderData.lowStockMeds.length > 0;
const sendablePrescription = prescriptionRemindersEnabled && prescriptionLowMeds.length > 0;
if (!sendableStock && !sendablePrescription) return;
setSendingReminder(true);
setReminderResult(null);
try {
const messages: string[] = [];
const errors: string[] = [];
if (sendableStock) {
const lowStock = reminderData.lowStockMeds.map((m) => {
const cov = coverage.all.find((c) => c.name === m.name);
return {
name: m.name,
medsLeft: cov?.medsLeft ?? 0,
daysLeft: m.daysLeft,
depletionDate: cov?.depletionDate ?? null,
isCritical: m.isCritical,
};
});
const stockRes = await fetch("/api/reminder/send-email", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
email: settings.notificationEmail,
lowStock,
}),
});
const stockData = await stockRes.json();
if (stockRes.ok) {
messages.push(stockData.message || t("common.sent"));
} else {
errors.push(stockData.error || t("common.sendFailed"));
}
}
if (sendablePrescription) {
const prescriptionLow = prescriptionLowMeds.map((med) => {
const fullMed = meds.find((m) => m.id === med.id);
return {
name: med.name,
remainingRefills: med.remainingRefills,
threshold: med.threshold,
expiryDate: fullMed?.prescriptionExpiryDate ?? null,
};
});
const prescriptionRes = await fetch("/api/reminder/send-prescription", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
email: settings.notificationEmail,
prescriptionLow,
}),
});
const prescriptionData = await prescriptionRes.json();
if (prescriptionRes.ok) {
messages.push(prescriptionData.message || t("common.sent"));
} else {
errors.push(prescriptionData.error || t("common.sendFailed"));
}
}
if (messages.length > 0) {
setReminderResult({ success: true, message: messages.join(" • ") });
loadSettings();
} else {
setReminderResult({ success: false, message: errors.join(" • ") || t("common.sendFailed") });
}
} catch {
setReminderResult({ success: false, message: t("common.networkError") });
}
setSendingReminder(false);
}
return (
<>
{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) => m.name === med.name);
const cov = coverage.all.find((c) => c.name === med.name);
const status = cov ? getStockStatus(cov.daysLeft, cov.medsLeft, stockThresholds) : 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) => m.name === 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) => m.name === 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) */}
{!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>;
}
// 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
if (c.medsLeft <= 0 || c.daysLeft === null || c.daysLeft < settings.lowStockDays) {
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) => m.name === c.name);
const status = getStockStatus(c.daysLeft, c.medsLeft, stockThresholds);
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>
)}
<div
className={`dashboard-main-sections${settings.swapDashboardMainSections ? " dashboard-main-sections-swapped" : ""}`}
>
<section className="grid dashboard-overview-section">
<article className="card">
<div className="card-head">
<h2>{t("dashboard.overview.title")}</h2>
</div>
<div className="table table-7">
<div className="table-head">
<span>{t("table.name")}</span>
<span>{t("table.stock")}</span>
<span>{t("table.stockDetails")}</span>
<span>{t("table.daysLeft")}</span>
<span>{t("table.runsOut")}</span>
<span>{t("table.expiry")}</span>
<span>{t("table.status")}</span>
</div>
{coverage.all.map((row) => {
const status = getStockStatus(row.daysLeft, row.medsLeft, stockThresholds);
const med = meds.find((m) => m.name === row.name);
const expiryClass = getExpiryClass(med?.expiryDate, settings.expiryWarningDays);
const textClass =
status.className === "danger"
? "danger-text"
: status.className === "warning"
? "warning-text"
: "success-text";
const stock = getBlisterStock(
Math.round(row.medsLeft),
med?.pillsPerBlister ?? 1,
med?.looseTablets ?? 0,
med ? getMedTotal(med) : Math.round(row.medsLeft)
);
return (
<div
key={row.name}
className="table-row clickable"
onClick={() => med && openMedDetail(med)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (med) openMedDetail(med);
}
}}
>
<span data-label={t("table.name")} className="cell-with-avatar">
<span className="med-name-line">
<span
className={med?.imageUrl ? "med-avatar-clickable" : undefined}
onClick={(e) => {
e.stopPropagation();
if (med?.imageUrl) openScheduleLightbox(`/api/images/${med.imageUrl}`);
}}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Enter" || e.key === " ") {
if (med?.imageUrl) openScheduleLightbox(`/api/images/${med.imageUrl}`);
}
}}
>
<MedicationAvatar name={row.name} imageUrl={med?.imageUrl} />
</span>
<span className="med-name-block-dash">
<span className="med-name-text">
{row.name}
{med?.notes && (
<>
{" "}
<span className="notes-icon info-tooltip" data-tooltip={t("tooltips.hasNotes")}>
<NotebookPen size={13} aria-hidden="true" />
</span>
</>
)}
</span>
{med?.takenBy && med.takenBy.length > 0 && (
<span className="med-taken-by-line">
{med.takenBy.map((person) => (
<span
key={person}
className="taken-by-badge clickable"
onClick={(e) => {
e.stopPropagation();
openUserFilter(person);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation();
openUserFilter(person);
}
}}
>
{person}
{med.intakes?.some((i) => i.takenBy === person && i.intakeRemindersEnabled) && (
<Bell
size={11}
aria-hidden="true"
className="blister-reminder-icon"
style={{ display: "inline", verticalAlign: "middle", marginLeft: "2px" }}
/>
)}
</span>
))}
</span>
)}
</span>
</span>
</span>
<span data-label={t("table.stock")} className={textClass}>
{med?.packageType === "bottle"
? t("table.pillsCount", { count: Math.round(row.medsLeft) })
: formatFullBlisters(stock.fullBlisters, t)}
</span>
<span
data-label={t("table.stockDetails")}
className={`${textClass}${med?.packageType === "bottle" ? " hide-on-card" : ""}`}
>
{med?.packageType === "bottle"
? "—"
: formatOpenBlisterAndLoose(
stock.openBlisterPills,
stock.loosePills,
med?.pillsPerBlister ?? 1,
t
)}
</span>
<span data-label={t("table.daysLeft")} className={textClass}>
{formatNumber(row.daysLeft)}
</span>
<span data-label={t("table.runsOut")}>{row.depletionDate ?? "-"}</span>
<span data-label={t("table.expiry")} className={expiryClass}>
{med?.expiryDate
? new Date(med.expiryDate).toLocaleDateString(getSystemLocale(i18n.language), {
day: "2-digit",
month: "short",
year: "2-digit",
})
: "-"}
</span>
<span data-label={t("table.status")} className={`status-chip ${status.className}`}>
{t(status.label)}
</span>
</div>
);
})}
</div>
</article>
</section>
<section className="grid dashboard-schedules-section">
<article className="card">
<div className="card-head">
<h2>{t("dashboard.schedules.title")}</h2>
<div className="card-head-actions">
<select
className="schedule-days-select"
value={scheduleDays}
onChange={(e) => {
const val = Number(e.target.value);
setScheduleDays(val);
if (user?.id) localStorage.setItem(userStorageKey(user.id, "scheduleDays"), String(val));
}}
>
<option value={30}>{t("dashboard.schedules.1month")}</option>
<option value={90}>{t("dashboard.schedules.3months")}</option>
<option value={180}>{t("dashboard.schedules.6months")}</option>
</select>
{meds.some((m) => m.takenBy && m.takenBy.length > 0) && (
<button
className="ghost share-btn icon-only tooltip-trigger"
onClick={openShareDialog}
aria-label={t("share.button")}
data-tooltip={t("share.button")}
>
<Share2 size={18} aria-hidden="true" />
</button>
)}
</div>
</div>
<div className="timeline">
{/* Past days (when expanded) — rendered above toggle */}
{!showOnlyToday &&
showPastDays &&
pastDays.map((day) => {
// Get ALL dose IDs for this day (for total count and yellow styling)
const allDoseIds = day.meds.flatMap((item) =>
item.doses.flatMap((d) => {
const takenByArray = Array.isArray(d.takenBy) ? d.takenBy : [];
return takenByArray.length > 0 ? takenByArray.map((p) => `${d.id}-${p}`) : [d.id];
})
);
// Really taken = all doses marked as taken by human (for green "All taken")
const allReallyTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
// Count missed doses that are NOT dismissed (for warning icon)
const missedNotDismissedCount = day.meds.reduce((count, item) => {
const med = meds.find((m) => m.name === item.medName);
const dismissedUntilDate = med?.dismissedUntil ?? undefined;
return (
count +
item.doses.reduce((doseCount, d) => {
if (isDoseDismissed(d.id, dismissedUntilDate)) return doseCount;
const takenByArray = Array.isArray(d.takenBy) ? d.takenBy : [];
const ids = takenByArray.length > 0 ? takenByArray.map((p) => `${d.id}-${p}`) : [d.id];
return doseCount + ids.filter((id) => !takenDoses.has(id) && !dismissedDoses.has(id)).length;
}, 0)
);
}, 0);
const hasRealMissed = missedNotDismissedCount > 0;
const isAutoCollapsed = true; // Past days are always auto-collapsed
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
const isCollapsed = !isManuallyExpanded;
const _worstStatus = getDayStockStatus(day.meds);
return (
<div
key={day.dateStr}
className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allReallyTaken ? "all-taken" : allDoseIds.length > 0 ? "past-missed" : ""}`}
>
<div
className="day-divider clickable"
onClick={() => toggleDayCollapse(day.dateStr, isAutoCollapsed)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") toggleDayCollapse(day.dateStr, isAutoCollapsed);
}}
title={isCollapsed ? t("common.expand") : t("common.collapse")}
>
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
<span className="day-date">{day.dateStr}</span>
<span className="day-summary">
{allReallyTaken ? (
<span className="day-complete"> {t("dashboard.schedules.allTaken")}</span>
) : (
<>
{hasRealMissed && (
<span
className="day-warning"
title={t("dashboard.schedules.missedDoses", { count: missedNotDismissedCount })}
>
</span>
)}
<span className="day-progress">
{takenCount}/{allDoseIds.length}
</span>
</>
)}
</span>
</div>
{!isCollapsed &&
day.meds.map((item) => {
const med = meds.find((m) => m.name === item.medName);
const medCov = coverageByMed[item.medName];
const isEmpty = medCov ? medCov.medsLeft <= 0 : false;
const status = medCov
? getStockStatus(medCov.daysLeft, medCov.medsLeft, stockThresholds)
: null;
const itemDoseIds = expandDoseIds(item.doses);
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
return (
<div
key={`${day.dateStr}-${item.medName}`}
className={`time-row ${allTaken ? "taken" : ""}`}
>
<div className="time-main">
<div className="med-name">
<div
className={med?.imageUrl ? "med-avatar clickable" : ""}
onClick={() => med?.imageUrl && openScheduleLightbox(`/api/images/${med.imageUrl}`)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (med?.imageUrl) openScheduleLightbox(`/api/images/${med.imageUrl}`);
}
}}
>
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
</div>
<div
className="med-name-stack clickable"
onClick={() => med && openMedDetail(med)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (med) openMedDetail(med);
}
}}
>
<span className="med-name-text">{item.medName}</span>
{med?.genericName && <span className="med-generic-inline">{med.genericName}</span>}
</div>
</div>
<div className="tag-row">
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
{status && (
<span className={`status-chip small ${status.className}`}>{t(status.label)}</span>
)}
</div>
</div>
<div className="doses-col">
{item.doses.map((dose) => {
// If no takenBy, show single checkbox; otherwise show one per person
const people = dose.takenBy.length > 0 ? dose.takenBy : [null];
return (
<div key={dose.id} className="dose-item past">
<span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage">
<span className="dose-usage-main">
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
</span>
{med?.pillWeightMg && (
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
)}
</span>
{dose.intakeRemindersEnabled && (
<span
className="reminder-icon info-tooltip"
data-tooltip={t("tooltips.intakeReminders")}
>
<Bell size={13} aria-hidden="true" />
</span>
)}
<div className="dose-checks">
{people.map((person) => {
const doseId = getDoseId(dose.id, person);
const isTaken = takenDoses.has(doseId);
const isAutomaticallyTaken =
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now();
return (
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
{person && (
<span
className="person-name clickable"
onClick={() => openUserFilter(person)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") openUserFilter(person);
}}
>
{person}
</span>
)}
{isTaken ? (
<button
className="dose-btn undo"
onClick={() => undoDoseTaken(doseId)}
title={t("common.undo")}
>
{isAutomaticallyTaken && (
<span
className="info-tooltip"
data-tooltip={t("tooltips.automaticTaken")}
>
🤖
</span>
)}
</button>
) : (
<button
className="dose-btn take"
onClick={() => markDoseTaken(doseId)}
title={t("dose.markAsTaken")}
disabled={isEmpty}
>
<span className="dose-btn-label">{t("dose.take")}</span>
<span aria-hidden="true"></span>
</button>
)}
</div>
);
})}
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
);
})}
{/* Past days toggle */}
{!showOnlyToday &&
pastDays.length > 0 &&
(() => {
const missedCount = missedPastDoseIds.length;
const totalPastDoses = pastDays.flatMap((d) => d.meds.flatMap((m) => expandDoseIds(m.doses)));
return (
<div className="past-days-header">
<div
className={`past-days-toggle ${showPastDays ? "expanded" : ""} ${missedCount > 0 ? "has-missed" : ""}`}
onClick={() => {
const wasCollapsed = !showPastDays;
setShowPastDays(!showPastDays);
if (wasCollapsed) {
setTimeout(() => {
document
.querySelector(".day-block.today")
?.scrollIntoView({ behavior: "smooth", block: "center" });
}, 50);
}
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
const wasCollapsed = !showPastDays;
setShowPastDays(!showPastDays);
if (wasCollapsed) {
setTimeout(() => {
document
.querySelector(".day-block.today")
?.scrollIntoView({ behavior: "smooth", block: "center" });
}, 50);
}
}
}}
>
<span className="past-days-icon">{showPastDays ? "▼" : "▶"}</span>
<span className="past-days-label">
{showPastDays ? t("dashboard.schedules.hidePastDays") : t("dashboard.schedules.showPastDays")}
</span>
<span className="past-days-count">
({t("dashboard.schedules.pastDaysCount", { count: pastDays.length })})
</span>
{missedCount > 0 ? (
<span
className="past-days-warning"
title={t("dashboard.schedules.missedDoses", { count: missedCount })}
>
{missedCount}
</span>
) : totalPastDoses.length > 0 ? (
<span className="past-days-complete" title={t("dashboard.schedules.allTaken")}>
</span>
) : null}
</div>
{missedCount > 0 && (
<button
type="button"
className="clear-missed-btn"
onClick={(e) => {
e.stopPropagation();
setShowClearMissedConfirm(true);
}}
title={t("dashboard.schedules.clearMissed")}
>
{t("dashboard.schedules.clearMissed")}
</button>
)}
</div>
);
})()}
{/* Today - always visible */}
{todayDay &&
(() => {
const day = todayDay;
const allDoseIds = day.meds.flatMap((item) => expandDoseIds(item.doses));
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
const dayStockStatuses = day.meds.map((item) => {
const medCoverage = coverageByMed[item.medName];
const depletionTime = depletionByMed[item.medName];
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
if (willBeOutOfStock) return "danger";
if (!medCoverage) return "success";
const status = getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, stockThresholds);
return status.className;
});
const worstStatus = dayStockStatuses.includes("danger")
? "danger"
: dayStockStatuses.includes("warning")
? "warning"
: "success";
// Today: expanded by default, can be manually collapsed
const isAutoCollapsed = allDayTaken;
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
const isManuallyCollapsed = manuallyCollapsedDays.has(day.dateStr);
const isCollapsed = isAutoCollapsed ? !isManuallyExpanded : isManuallyCollapsed;
return (
<div
key={day.dateStr}
className={`day-block ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} today ${worstStatus ? `stock-${worstStatus}` : ""}`}
>
<div
className="day-divider clickable"
onClick={() => toggleDayCollapse(day.dateStr, isAutoCollapsed)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") toggleDayCollapse(day.dateStr, isAutoCollapsed);
}}
title={isCollapsed ? t("common.expand") : t("common.collapse")}
>
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
<span className="day-date">{day.dateStr}</span>
<span className="day-summary">
{allDayTaken ? (
<span className="day-complete"> {t("dashboard.schedules.allTaken")}</span>
) : (
<span className="day-progress">
{takenCount}/{allDoseIds.length}
</span>
)}
</span>
</div>
{!isCollapsed &&
day.meds.map((item) => {
const medCoverage = coverageByMed[item.medName];
const med = meds.find((m) => m.name === item.medName);
const depletionTime = depletionByMed[item.medName];
const isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false;
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
const status = willBeOutOfStock
? { className: "danger", label: "status.outOfStock" }
: medCoverage
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, stockThresholds)
: null;
const itemDoseIds = expandDoseIds(item.doses);
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
return (
<div
key={`${day.dateStr}-${item.medName}`}
className={`time-row ${allTaken ? "taken" : ""}`}
>
<div className="time-main">
<div className="med-name">
<div
className={med?.imageUrl ? "med-avatar clickable" : ""}
onClick={() => med?.imageUrl && openScheduleLightbox(`/api/images/${med.imageUrl}`)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (med?.imageUrl) openScheduleLightbox(`/api/images/${med.imageUrl}`);
}
}}
>
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
</div>
<div
className="med-name-stack clickable"
onClick={() => med && openMedDetail(med)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (med) openMedDetail(med);
}
}}
>
<span className="med-name-text">{item.medName}</span>
{med?.genericName && <span className="med-generic-inline">{med.genericName}</span>}
</div>
</div>
<div className="tag-row">
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
{status && (
<span className={`status-chip small ${status.className}`}>{t(status.label)}</span>
)}
</div>
</div>
<div className="doses-col">
{item.doses.map((dose) => {
const isOverdue = dose.when < Date.now();
const people = dose.takenBy.length > 0 ? dose.takenBy : [null];
const allTaken = people.every((person) => takenDoses.has(getDoseId(dose.id, person)));
return (
<div
key={dose.id}
className={`dose-item ${isOverdue ? "overdue" : ""} ${allTaken ? "all-taken" : ""}`}
>
<span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage">
<span className="dose-usage-main">
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
</span>
{med?.pillWeightMg && (
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
)}
</span>
{dose.intakeRemindersEnabled && (
<span
className="reminder-icon info-tooltip"
data-tooltip={t("tooltips.intakeReminders")}
>
<Bell size={13} aria-hidden="true" />
</span>
)}
<div className="dose-checks">
{people.map((person) => {
const doseId = getDoseId(dose.id, person);
const isTaken = takenDoses.has(doseId);
const isAutomaticallyTaken =
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now();
return (
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
{person && (
<span
className="person-name clickable"
onClick={() => openUserFilter(person)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") openUserFilter(person);
}}
>
{person}
</span>
)}
{isTaken ? (
<button
className="dose-btn undo"
onClick={() => undoDoseTaken(doseId)}
title={t("common.undo")}
>
{isAutomaticallyTaken && (
<span
className="info-tooltip"
data-tooltip={t("tooltips.automaticTaken")}
>
🤖
</span>
)}
</button>
) : (
<button
className="dose-btn take"
onClick={() => markDoseTaken(doseId)}
title={t("dose.markAsTaken")}
disabled={isEmpty}
>
<span className="dose-btn-label">{t("dose.take")}</span>
<span aria-hidden="true"></span>
</button>
)}
</div>
);
})}
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
);
})()}
{/* Future days toggle */}
{!showOnlyToday &&
futureDays.length > 0 &&
(() => {
const totalFutureDoses = futureDays.flatMap((d) =>
d.meds.flatMap((m) =>
m.doses.flatMap((dose) =>
dose.takenBy.length > 0 ? dose.takenBy.map((p) => `${dose.id}-${p}`) : [dose.id]
)
)
);
const takenFutureDoses = totalFutureDoses.filter((id) => takenDoses.has(id)).length;
return (
<div className="future-days-header">
<div
className={`future-days-toggle ${showFutureDays ? "expanded" : ""}`}
onClick={() => setShowFutureDays(!showFutureDays)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") setShowFutureDays(!showFutureDays);
}}
>
<span className="future-days-icon">{showFutureDays ? "▼" : "▶"}</span>
<span className="future-days-label">
{showFutureDays
? t("dashboard.schedules.hideFutureDays")
: t("dashboard.schedules.showFutureDays")}
</span>
<span className="future-days-count">
({t("dashboard.schedules.futureDaysCount", { count: futureDays.length })})
</span>
{takenFutureDoses > 0 && totalFutureDoses.length > 0 && (
<span className="future-days-progress">
{takenFutureDoses}/{totalFutureDoses.length}
</span>
)}
</div>
</div>
);
})()}
{/* Future days */}
{!showOnlyToday &&
showFutureDays &&
futureDays.map((day) => {
const allDoseIds = day.meds.flatMap((item) => expandDoseIds(item.doses));
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
const dayStockStatuses = day.meds.map((item) => {
const medCoverage = coverageByMed[item.medName];
const depletionTime = depletionByMed[item.medName];
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
if (willBeOutOfStock) return "danger";
if (!medCoverage) return "success";
const status = getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, stockThresholds);
return status.className;
});
const worstStatus = dayStockStatuses.includes("danger")
? "danger"
: dayStockStatuses.includes("warning")
? "warning"
: "success";
// Future days: collapsed by default
const isAutoCollapsed = true;
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
const isCollapsed = !isManuallyExpanded;
return (
<div
key={day.dateStr}
className={`day-block ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} ${worstStatus ? `stock-${worstStatus}` : ""}`}
>
<div
className="day-divider clickable"
onClick={() => toggleDayCollapse(day.dateStr, isAutoCollapsed)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") toggleDayCollapse(day.dateStr, isAutoCollapsed);
}}
title={isCollapsed ? t("common.expand") : t("common.collapse")}
>
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
<span className="day-date">{day.dateStr}</span>
<span className="day-summary">
{allDayTaken ? (
<span className="day-complete"> {t("dashboard.schedules.allTaken")}</span>
) : (
<span className="day-progress">
{takenCount}/{allDoseIds.length}
</span>
)}
</span>
</div>
{!isCollapsed &&
day.meds.map((item) => {
const medCoverage = coverageByMed[item.medName];
const med = meds.find((m) => m.name === item.medName);
const depletionTime = depletionByMed[item.medName];
const _isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false;
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
const status = willBeOutOfStock
? { className: "danger", label: "status.outOfStock" }
: medCoverage
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, stockThresholds)
: null;
const itemDoseIds = expandDoseIds(item.doses);
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
return (
<div
key={`${day.dateStr}-${item.medName}`}
className={`time-row ${allTaken ? "taken" : ""}`}
>
<div className="time-main">
<div className="med-name">
<div
className={med?.imageUrl ? "med-avatar clickable" : ""}
onClick={() => med?.imageUrl && openScheduleLightbox(`/api/images/${med.imageUrl}`)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (med?.imageUrl) openScheduleLightbox(`/api/images/${med.imageUrl}`);
}
}}
>
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
</div>
<div
className="med-name-stack clickable"
onClick={() => med && openMedDetail(med)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (med) openMedDetail(med);
}
}}
>
<span className="med-name-text">{item.medName}</span>
{med?.genericName && <span className="med-generic-inline">{med.genericName}</span>}
</div>
</div>
<div className="tag-row">
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
{status && (
<span className={`status-chip small ${status.className}`}>{t(status.label)}</span>
)}
</div>
</div>
<div className="doses-col">
{item.doses.map((dose) => {
const people = dose.takenBy.length > 0 ? dose.takenBy : [null];
const allTaken = people.every((person) => takenDoses.has(getDoseId(dose.id, person)));
return (
<div key={dose.id} className={`dose-item future ${allTaken ? "all-taken" : ""}`}>
<span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage">
<span className="dose-usage-main">
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
</span>
{med?.pillWeightMg && (
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
)}
</span>
{dose.intakeRemindersEnabled && (
<span
className="reminder-icon info-tooltip"
data-tooltip={t("tooltips.intakeReminders")}
>
<Bell size={13} aria-hidden="true" />
</span>
)}
<div className="dose-checks">
{people.map((person) => {
const doseId = getDoseId(dose.id, person);
const isTaken = takenDoses.has(doseId);
const isAutomaticallyTaken =
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now();
return (
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
{person && (
<span
className="person-name clickable"
onClick={() => openUserFilter(person)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") openUserFilter(person);
}}
>
{person}
</span>
)}
{isTaken ? (
<button
className="dose-btn undo"
onClick={() => undoDoseTaken(doseId)}
title={t("common.undo")}
>
{isAutomaticallyTaken && (
<span
className="info-tooltip"
data-tooltip={t("tooltips.automaticTaken")}
>
🤖
</span>
)}
</button>
) : (
<button
className="dose-btn take"
onClick={() => markDoseTaken(doseId)}
title={t("dose.markAsTaken")}
disabled={true}
>
<span className="dose-btn-label">{t("dose.take")}</span>
<span aria-hidden="true"></span>
</button>
)}
</div>
);
})}
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
);
})}
</div>
</article>
</section>
</div>
{/* Clear Missed Doses Confirmation Modal */}
{showClearMissedConfirm && (
<ConfirmModal
title={t("dashboard.schedules.clearMissedConfirmTitle")}
message={t("dashboard.schedules.clearMissedConfirmMessage", { count: missedPastDoseIds.length })}
confirmLabel={clearingMissed ? t("common.loading") : t("dashboard.schedules.clearMissedConfirm")}
cancelLabel={t("dashboard.schedules.clearMissedCancel")}
onConfirm={() => dismissMissedDoses(missedPastDoseIds)}
onCancel={() => setShowClearMissedConfirm(false)}
isLoading={clearingMissed}
/>
)}
</>
);
}