fix(refill): stabilize stock and amount package semantics
This commit is contained in:
@@ -1105,10 +1105,7 @@ export function MedDetailModal({
|
||||
</span>
|
||||
<span className="refill-amount">
|
||||
{(() => {
|
||||
const total = isAmountBasedPackageType(selectedMed.packageType)
|
||||
? entry.loosePillsAdded
|
||||
: entry.packsAdded * selectedMed.blistersPerPack * selectedMed.pillsPerBlister +
|
||||
entry.loosePillsAdded;
|
||||
const total = entry.quantityAdded;
|
||||
return `+${total}${isAmountPackage ? ` ${stockUnitLabel}` : ` ${total === 1 ? t("common.pill") : t("common.pills")}`}`;
|
||||
})()}
|
||||
{entry.usedPrescription && (
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { Medication } from "../types";
|
||||
import {
|
||||
getMedDisplayName,
|
||||
getMedTotal,
|
||||
getStockDisplayCapacity,
|
||||
isAmountBasedPackageType,
|
||||
isLiquidContainerPackageType,
|
||||
isTubePackageType,
|
||||
@@ -27,10 +28,16 @@ type ReportData = Record<
|
||||
{
|
||||
dosesTaken: number;
|
||||
automaticDosesTaken: number;
|
||||
dosesDismissed: number;
|
||||
dosesSkipped: number;
|
||||
firstDoseAt: string | null;
|
||||
lastDoseAt: string | null;
|
||||
refills: { packsAdded: number; loosePillsAdded: number; usedPrescription: boolean; refillDate: string }[];
|
||||
refills: {
|
||||
packsAdded: number;
|
||||
loosePillsAdded?: number;
|
||||
quantityAdded: number;
|
||||
usedPrescription: boolean;
|
||||
refillDate: string;
|
||||
}[];
|
||||
}
|
||||
>;
|
||||
|
||||
@@ -121,7 +128,10 @@ export function ReportModal({ isOpen, onClose, medications }: ReportModalProps)
|
||||
const res = await fetch("/api/medications/report-data", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ medicationIds: Array.from(selectedIds) }),
|
||||
body: JSON.stringify({
|
||||
medicationIds: Array.from(selectedIds),
|
||||
takenByFilter: takenByFilter.size > 0 ? Array.from(takenByFilter) : undefined,
|
||||
}),
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to fetch report data");
|
||||
@@ -374,7 +384,7 @@ function generateTextReport(
|
||||
lines.push(item(t("report.docPillsPerBlister"), String(med.pillsPerBlister)));
|
||||
if (med.looseTablets > 0) lines.push(item(t("report.docLoosePills"), String(med.looseTablets)));
|
||||
} else {
|
||||
lines.push(item(getTotalCapacityLabel(med, t), String(med.totalPills ?? med.looseTablets)));
|
||||
lines.push(item(getTotalCapacityLabel(med, t), String(getStockDisplayCapacity(med))));
|
||||
}
|
||||
lines.push(item(t("report.docCurrentStock"), getCurrentStockText(med, t)));
|
||||
if (!isTubePackageType(med.packageType) && !isLiquidContainerPackageType(med.packageType) && med.pillWeightMg)
|
||||
@@ -415,12 +425,12 @@ function generateTextReport(
|
||||
const data = reportData[med.id];
|
||||
if (data) {
|
||||
lines.push(h3(t("report.docIntakeHistory")));
|
||||
if (data.dosesTaken > 0 || data.dosesDismissed > 0) {
|
||||
if (data.dosesTaken > 0 || data.dosesSkipped > 0) {
|
||||
lines.push(item(t("report.docDosesTaken"), String(data.dosesTaken)));
|
||||
if (data.automaticDosesTaken > 0) {
|
||||
lines.push(item(`🤖 ${t("report.docDosesTakenAutomatic")}`, String(data.automaticDosesTaken)));
|
||||
}
|
||||
if (data.dosesDismissed > 0) lines.push(item(t("report.docDosesDismissed"), String(data.dosesDismissed)));
|
||||
if (data.dosesSkipped > 0) lines.push(item(t("report.docDosesSkipped"), String(data.dosesSkipped)));
|
||||
if (data.firstDoseAt) lines.push(item(t("report.docFirstDose"), formatDate(data.firstDoseAt)));
|
||||
if (data.lastDoseAt) lines.push(item(t("report.docLastDose"), formatDate(data.lastDoseAt)));
|
||||
} else {
|
||||
@@ -432,7 +442,7 @@ function generateTextReport(
|
||||
if (data.refills.length > 0) {
|
||||
lines.push(h3(t("report.docRefillHistory")));
|
||||
for (const r of data.refills) {
|
||||
let entry = `${formatDate(r.refillDate)}: +${r.packsAdded} ${t("report.docPacks")}, +${r.loosePillsAdded} ${isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType) ? t(getTubeUnitKey(med)) : t("common.pills")}`;
|
||||
let entry = `${formatDate(r.refillDate)}: +${r.packsAdded} ${t("report.docPacks")}, +${r.quantityAdded} ${isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType) ? t(getTubeUnitKey(med)) : t("common.pills")}`;
|
||||
if (r.usedPrescription) entry += ` ${t("report.docRefillPrescription")}`;
|
||||
lines.push(fmt === "md" ? `- ${entry}` : ` • ${entry}`);
|
||||
}
|
||||
@@ -572,7 +582,7 @@ function buildPrintHtml(
|
||||
if (med.looseTablets > 0)
|
||||
s += `<tr><td class="label">${escHtml(t("report.docLoosePills"))}</td><td>${med.looseTablets}</td></tr>`;
|
||||
} else {
|
||||
s += `<tr><td class="label">${escHtml(getTotalCapacityLabel(med, t))}</td><td>${med.totalPills ?? med.looseTablets}</td></tr>`;
|
||||
s += `<tr><td class="label">${escHtml(getTotalCapacityLabel(med, t))}</td><td>${getStockDisplayCapacity(med)}</td></tr>`;
|
||||
}
|
||||
s += `<tr><td class="label">${escHtml(t("report.docCurrentStock"))}</td><td>${escHtml(getCurrentStockText(med, t))}</td></tr>`;
|
||||
if (!isTubePackageType(med.packageType) && !isLiquidContainerPackageType(med.packageType) && med.pillWeightMg)
|
||||
@@ -616,14 +626,14 @@ function buildPrintHtml(
|
||||
// Intake history
|
||||
if (data) {
|
||||
s += `<h3>${escHtml(t("report.docIntakeHistory"))}</h3>`;
|
||||
if (data.dosesTaken > 0 || data.dosesDismissed > 0) {
|
||||
if (data.dosesTaken > 0 || data.dosesSkipped > 0) {
|
||||
s += `<table><tbody>`;
|
||||
s += `<tr><td class="label">${escHtml(t("report.docDosesTaken"))}</td><td>${data.dosesTaken}</td></tr>`;
|
||||
if (data.automaticDosesTaken > 0) {
|
||||
s += `<tr><td class="label">${escHtml(`🤖 ${t("report.docDosesTakenAutomatic")}`)}</td><td>${data.automaticDosesTaken}</td></tr>`;
|
||||
}
|
||||
if (data.dosesDismissed > 0)
|
||||
s += `<tr><td class="label">${escHtml(t("report.docDosesDismissed"))}</td><td>${data.dosesDismissed}</td></tr>`;
|
||||
if (data.dosesSkipped > 0)
|
||||
s += `<tr><td class="label">${escHtml(t("report.docDosesSkipped"))}</td><td>${data.dosesSkipped}</td></tr>`;
|
||||
if (data.firstDoseAt)
|
||||
s += `<tr><td class="label">${escHtml(t("report.docFirstDose"))}</td><td>${formatDate(data.firstDoseAt)}</td></tr>`;
|
||||
if (data.lastDoseAt)
|
||||
@@ -638,7 +648,7 @@ function buildPrintHtml(
|
||||
s += `<h3>${escHtml(t("report.docRefillHistory"))}</h3>`;
|
||||
s += `<ul>`;
|
||||
for (const r of data.refills) {
|
||||
let entry = `${formatDate(r.refillDate)}: +${r.packsAdded} ${escHtml(t("report.docPacks"))}, +${r.loosePillsAdded} ${escHtml(isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType) ? t(getTubeUnitKey(med)) : t("common.pills"))}`;
|
||||
let entry = `${formatDate(r.refillDate)}: +${r.packsAdded} ${escHtml(t("report.docPacks"))}, +${r.quantityAdded} ${escHtml(isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType) ? t(getTubeUnitKey(med)) : t("common.pills"))}`;
|
||||
if (r.usedPrescription) entry += ` <em>${escHtml(t("report.docRefillPrescription"))}</em>`;
|
||||
s += `<li>${entry}</li>`;
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ export function SharedSchedule() {
|
||||
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
|
||||
const [automaticTakenDoses, setAutomaticTakenDoses] = useState<Set<string>>(new Set());
|
||||
const [dismissedDoses, setDismissedDoses] = useState<Set<string>>(new Set());
|
||||
const mutationInFlightRef = useRef(0);
|
||||
const [lightboxImage, setLightboxImage] = useState<{ url: string; name: string } | null>(null);
|
||||
const [showPastDays, setShowPastDays] = useState(false);
|
||||
const [showFutureDays, setShowFutureDays] = useState(false);
|
||||
@@ -183,15 +184,23 @@ export function SharedSchedule() {
|
||||
// Separates taken and dismissed doses (like main app's useDoses hook)
|
||||
const loadTakenDoses = useCallback(async () => {
|
||||
if (!token) return;
|
||||
if (mutationInFlightRef.current > 0) return;
|
||||
try {
|
||||
const res = await fetch(`/api/share/${token}/doses`);
|
||||
if (res.ok) {
|
||||
if (mutationInFlightRef.current > 0) return;
|
||||
|
||||
const data = await res.json();
|
||||
const taken = new Set<string>();
|
||||
const automatic = new Set<string>();
|
||||
const dismissed = new Set<string>();
|
||||
for (const d of data.doses as Array<{ doseId: string; dismissed?: boolean; takenSource?: string }>) {
|
||||
if (d.dismissed) {
|
||||
for (const d of data.doses as Array<{
|
||||
doseId: string;
|
||||
dismissed?: boolean;
|
||||
skipped?: boolean;
|
||||
takenSource?: string;
|
||||
}>) {
|
||||
if (d.skipped === true || d.dismissed === true) {
|
||||
dismissed.add(d.doseId);
|
||||
} else {
|
||||
taken.add(d.doseId);
|
||||
@@ -203,15 +212,9 @@ export function SharedSchedule() {
|
||||
setTakenDoses(taken);
|
||||
setAutomaticTakenDoses(automatic);
|
||||
setDismissedDoses(dismissed);
|
||||
} else {
|
||||
setTakenDoses(new Set());
|
||||
setAutomaticTakenDoses(new Set());
|
||||
setDismissedDoses(new Set());
|
||||
}
|
||||
} catch {
|
||||
setTakenDoses(new Set());
|
||||
setAutomaticTakenDoses(new Set());
|
||||
setDismissedDoses(new Set());
|
||||
// Keep the current optimistic/shared state on transient read errors.
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
@@ -232,12 +235,26 @@ export function SharedSchedule() {
|
||||
}
|
||||
|
||||
async function markDoseTaken(doseId: string) {
|
||||
if (dismissedDoses.has(doseId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wasTaken = takenDoses.has(doseId);
|
||||
const wasSkipped = dismissedDoses.has(doseId);
|
||||
const wasAutomatic = automaticTakenDoses.has(doseId);
|
||||
|
||||
// Optimistic update
|
||||
mutationInFlightRef.current++;
|
||||
setTakenDoses((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(doseId);
|
||||
return next;
|
||||
});
|
||||
setDismissedDoses((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(doseId);
|
||||
return next;
|
||||
});
|
||||
setAutomaticTakenDoses((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(doseId);
|
||||
@@ -266,16 +283,104 @@ export function SharedSchedule() {
|
||||
// Revert on error
|
||||
setTakenDoses((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(doseId);
|
||||
if (wasTaken) {
|
||||
next.add(doseId);
|
||||
} else {
|
||||
next.delete(doseId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setDismissedDoses((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (wasSkipped) {
|
||||
next.add(doseId);
|
||||
} else {
|
||||
next.delete(doseId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setAutomaticTakenDoses((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (wasAutomatic) {
|
||||
next.add(doseId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} finally {
|
||||
mutationInFlightRef.current--;
|
||||
loadTakenDoses();
|
||||
}
|
||||
}
|
||||
|
||||
async function markDoseSkipped(doseId: string) {
|
||||
if (takenDoses.has(doseId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wasTaken = takenDoses.has(doseId);
|
||||
const wasSkipped = dismissedDoses.has(doseId);
|
||||
const wasAutomatic = automaticTakenDoses.has(doseId);
|
||||
|
||||
mutationInFlightRef.current++;
|
||||
setDismissedDoses((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(doseId);
|
||||
return next;
|
||||
});
|
||||
setTakenDoses((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(doseId);
|
||||
return next;
|
||||
});
|
||||
setAutomaticTakenDoses((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(doseId);
|
||||
return next;
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/share/${token}/doses/skip`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ doseId }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to mark shared dose as skipped");
|
||||
}
|
||||
} catch {
|
||||
setDismissedDoses((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (wasSkipped) {
|
||||
next.add(doseId);
|
||||
} else {
|
||||
next.delete(doseId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setTakenDoses((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (wasTaken) {
|
||||
next.add(doseId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setAutomaticTakenDoses((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (wasAutomatic) {
|
||||
next.add(doseId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} finally {
|
||||
mutationInFlightRef.current--;
|
||||
loadTakenDoses();
|
||||
}
|
||||
}
|
||||
|
||||
async function undoDoseTaken(doseId: string) {
|
||||
const wasAutomatic = automaticTakenDoses.has(doseId);
|
||||
// Optimistic update
|
||||
mutationInFlightRef.current++;
|
||||
setTakenDoses((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(doseId);
|
||||
@@ -299,9 +404,100 @@ export function SharedSchedule() {
|
||||
next.add(doseId);
|
||||
return next;
|
||||
});
|
||||
setAutomaticTakenDoses((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (wasAutomatic) {
|
||||
next.add(doseId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} finally {
|
||||
mutationInFlightRef.current--;
|
||||
loadTakenDoses();
|
||||
}
|
||||
}
|
||||
|
||||
async function undoDoseSkipped(doseId: string) {
|
||||
const wasSkipped = dismissedDoses.has(doseId);
|
||||
|
||||
mutationInFlightRef.current++;
|
||||
setDismissedDoses((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(doseId);
|
||||
return next;
|
||||
});
|
||||
|
||||
try {
|
||||
await fetch(`/api/share/${token}/doses/skip/${encodeURIComponent(doseId)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
} catch {
|
||||
setDismissedDoses((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (wasSkipped) {
|
||||
next.add(doseId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} finally {
|
||||
mutationInFlightRef.current--;
|
||||
loadTakenDoses();
|
||||
}
|
||||
}
|
||||
|
||||
const renderDoseActionButtons = (options: {
|
||||
doseId: string;
|
||||
isTaken: boolean;
|
||||
isSkipped: boolean;
|
||||
isAutomaticallyTaken: boolean;
|
||||
isEmpty: boolean;
|
||||
}) => {
|
||||
const takeButton = options.isTaken ? (
|
||||
<button className="dose-btn undo take" onClick={() => undoDoseTaken(options.doseId)} title={t("common.undo")}>
|
||||
{options.isAutomaticallyTaken && (
|
||||
<span className="info-tooltip" data-tooltip={t("tooltips.automaticTaken")}>
|
||||
🤖
|
||||
</span>
|
||||
)}
|
||||
<span className="dose-btn-label">{t("common.undo")}</span>
|
||||
<span aria-hidden="true">↩</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className={`dose-btn take${options.isEmpty ? " out-of-stock" : ""}`}
|
||||
onClick={() => markDoseTaken(options.doseId)}
|
||||
disabled={options.isEmpty || options.isSkipped}
|
||||
title={options.isEmpty ? t("common.outOfStockTakeBlocked") : t("dose.markAsTaken")}
|
||||
>
|
||||
<span className="dose-btn-label">{t("dose.take")}</span>
|
||||
<span aria-hidden="true">{options.isEmpty ? "⊘" : "✓"}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
const skipButton = options.isSkipped ? (
|
||||
<button className="dose-btn undo skip" onClick={() => undoDoseSkipped(options.doseId)} title={t("common.undo")}>
|
||||
<span className="dose-btn-label">{t("common.undo")}</span>
|
||||
<span aria-hidden="true">↩</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="dose-btn skip"
|
||||
onClick={() => markDoseSkipped(options.doseId)}
|
||||
title={t("dose.markAsSkipped")}
|
||||
disabled={options.isTaken}
|
||||
>
|
||||
<span className="dose-btn-label">{t("dose.skip")}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{takeButton}
|
||||
{skipButton}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const isDoseTakenAutomatically = (doseId: string) => automaticTakenDoses.has(doseId);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -934,6 +1130,7 @@ export function SharedSchedule() {
|
||||
const isTaken = isDoseTakenForDisplay(dose.id);
|
||||
const isAutomaticallyTaken =
|
||||
isTaken && isDoseTakenAutomatically(dose.id) && dose.when <= Date.now();
|
||||
const isSkipped = dismissedDoses.has(dose.id);
|
||||
const doseClasses = ["dose-item", "past"];
|
||||
if (isTaken) doseClasses.push("all-taken");
|
||||
if (isEmpty) doseClasses.push("med-empty");
|
||||
@@ -948,37 +1145,17 @@ export function SharedSchedule() {
|
||||
)}
|
||||
</span>
|
||||
<div className="dose-checks">
|
||||
<div className={`dose-person ${isTaken ? "taken" : ""}`}>
|
||||
<div
|
||||
className={`dose-person ${isTaken ? "taken" : ""} ${isSkipped ? "skipped" : ""}`}
|
||||
>
|
||||
{dose.takenBy && <span className="person-name">{dose.takenBy}</span>}
|
||||
{isTaken ? (
|
||||
<button
|
||||
className="dose-btn undo"
|
||||
onClick={() => undoDoseTaken(dose.id)}
|
||||
title={t("common.undo")}
|
||||
>
|
||||
{isAutomaticallyTaken && (
|
||||
<span
|
||||
className="info-tooltip"
|
||||
data-tooltip={t("tooltips.automaticTaken")}
|
||||
>
|
||||
🤖
|
||||
</span>
|
||||
)}
|
||||
↩
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className={`dose-btn take${isEmpty ? " out-of-stock" : ""}`}
|
||||
onClick={() => markDoseTaken(dose.id)}
|
||||
disabled={isEmpty}
|
||||
title={
|
||||
isEmpty ? t("common.outOfStockTakeBlocked") : t("dose.markAsTaken")
|
||||
}
|
||||
>
|
||||
<span className="dose-btn-label">{t("dose.take")}</span>
|
||||
<span aria-hidden="true">{isEmpty ? "⊘" : "✓"}</span>
|
||||
</button>
|
||||
)}
|
||||
{renderDoseActionButtons({
|
||||
doseId: dose.id,
|
||||
isTaken,
|
||||
isSkipped,
|
||||
isAutomaticallyTaken,
|
||||
isEmpty,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1149,7 +1326,8 @@ export function SharedSchedule() {
|
||||
const isTaken = isDoseTakenForDisplay(dose.id);
|
||||
const isAutomaticallyTaken =
|
||||
isTaken && isDoseTakenAutomatically(dose.id) && dose.when <= Date.now();
|
||||
const isOverdue = dose.when < Date.now() && !isTaken;
|
||||
const isSkipped = dismissedDoses.has(dose.id);
|
||||
const isOverdue = dose.when < Date.now() && !isTaken && !isSkipped && !isEmpty;
|
||||
const doseClasses = ["dose-item"];
|
||||
if (isOverdue) doseClasses.push("overdue");
|
||||
if (isTaken) doseClasses.push("all-taken");
|
||||
@@ -1166,38 +1344,16 @@ export function SharedSchedule() {
|
||||
</span>
|
||||
<div className="dose-checks">
|
||||
<div
|
||||
className={`dose-person ${isTaken ? "taken" : ""} ${isOverdue ? "overdue" : ""}`}
|
||||
className={`dose-person ${isTaken ? "taken" : ""} ${isSkipped ? "skipped" : ""} ${isOverdue ? "overdue" : ""}`}
|
||||
>
|
||||
{dose.takenBy && <span className="person-name">{dose.takenBy}</span>}
|
||||
{isTaken ? (
|
||||
<button
|
||||
className="dose-btn undo"
|
||||
onClick={() => undoDoseTaken(dose.id)}
|
||||
title={t("common.undo")}
|
||||
>
|
||||
{isAutomaticallyTaken && (
|
||||
<span
|
||||
className="info-tooltip"
|
||||
data-tooltip={t("tooltips.automaticTaken")}
|
||||
>
|
||||
🤖
|
||||
</span>
|
||||
)}
|
||||
↩
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className={`dose-btn take${isEmpty ? " out-of-stock" : ""}`}
|
||||
onClick={() => markDoseTaken(dose.id)}
|
||||
title={
|
||||
isEmpty ? t("common.outOfStockTakeBlocked") : t("dose.markAsTaken")
|
||||
}
|
||||
disabled={isEmpty}
|
||||
>
|
||||
<span className="dose-btn-label">{t("dose.take")}</span>
|
||||
<span aria-hidden="true">{isEmpty ? "⊘" : "✓"}</span>
|
||||
</button>
|
||||
)}
|
||||
{renderDoseActionButtons({
|
||||
doseId: dose.id,
|
||||
isTaken,
|
||||
isSkipped,
|
||||
isAutomaticallyTaken,
|
||||
isEmpty,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1351,6 +1507,7 @@ export function SharedSchedule() {
|
||||
const isTaken = isDoseTakenForDisplay(dose.id);
|
||||
const isAutomaticallyTaken =
|
||||
isTaken && isDoseTakenAutomatically(dose.id) && dose.when <= Date.now();
|
||||
const isSkipped = dismissedDoses.has(dose.id);
|
||||
const doseClasses = ["dose-item", "future"];
|
||||
if (isTaken) doseClasses.push("all-taken");
|
||||
if (isEmpty) doseClasses.push("med-empty");
|
||||
@@ -1365,37 +1522,17 @@ export function SharedSchedule() {
|
||||
)}
|
||||
</span>
|
||||
<div className="dose-checks">
|
||||
<div className={`dose-person ${isTaken ? "taken" : ""}`}>
|
||||
<div
|
||||
className={`dose-person ${isTaken ? "taken" : ""} ${isSkipped ? "skipped" : ""}`}
|
||||
>
|
||||
{dose.takenBy && <span className="person-name">{dose.takenBy}</span>}
|
||||
{isTaken ? (
|
||||
<button
|
||||
className="dose-btn undo"
|
||||
onClick={() => undoDoseTaken(dose.id)}
|
||||
title={t("common.undo")}
|
||||
>
|
||||
{isAutomaticallyTaken && (
|
||||
<span
|
||||
className="info-tooltip"
|
||||
data-tooltip={t("tooltips.automaticTaken")}
|
||||
>
|
||||
🤖
|
||||
</span>
|
||||
)}
|
||||
↩
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className={`dose-btn take${isEmpty ? " out-of-stock" : ""}`}
|
||||
onClick={() => markDoseTaken(dose.id)}
|
||||
title={
|
||||
isEmpty ? t("common.outOfStockTakeBlocked") : t("dose.markAsTaken")
|
||||
}
|
||||
disabled={true}
|
||||
>
|
||||
<span className="dose-btn-label">{t("dose.take")}</span>
|
||||
<span aria-hidden="true">{isEmpty ? "⊘" : "✓"}</span>
|
||||
</button>
|
||||
)}
|
||||
{renderDoseActionButtons({
|
||||
doseId: dose.id,
|
||||
isTaken,
|
||||
isSkipped,
|
||||
isAutomaticallyTaken,
|
||||
isEmpty: true,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -50,7 +50,6 @@ export function MedicationListSection({
|
||||
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);
|
||||
@@ -146,8 +145,7 @@ export function MedicationListSection({
|
||||
</>
|
||||
) : (
|
||||
<span>
|
||||
{t("medications.details.totalCapacity")}:{" "}
|
||||
<strong>{med.totalPills ?? med.looseTablets}</strong>
|
||||
{t("medications.details.totalCapacity")}: <strong>{stockDisplayCapacity}</strong>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -121,7 +121,12 @@ export function useRefill(): UseRefillReturn {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ packsAdded: refillPacks, loosePillsAdded: refillLoose, usePrescription }),
|
||||
body: JSON.stringify({
|
||||
packsAdded: refillPacks,
|
||||
loosePillsAdded: refillLoose,
|
||||
quantityAdded: refillLoose,
|
||||
usePrescription,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
@@ -267,6 +272,7 @@ export function useRefill(): UseRefillReturn {
|
||||
// Keep packageAmountValue (ml per bottle) and update capacity base by bottle count.
|
||||
patchBody.packCount = correctedLiquidBottleCount;
|
||||
patchBody.totalPills = liquidStructuralMax;
|
||||
patchBody.looseTablets = liquidStructuralMax;
|
||||
} else if (!isAmountPackage) {
|
||||
patchBody.looseTablets = finalLoosePills;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/* biome-ignore-all lint/style/noNestedTernary: timeline rendering uses explicit UI-state branching */
|
||||
import { Archive, Bell, ClipboardList, NotebookPen, Share2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { ConfirmModal, MedicationAvatar } from "../components";
|
||||
import { useAuth } from "../components/Auth";
|
||||
import { DashboardReminderSection } from "../components/dashboard/DashboardReminderSection";
|
||||
@@ -28,9 +29,43 @@ import {
|
||||
userStorageKey,
|
||||
} from "./dashboard-helpers";
|
||||
|
||||
function getRouteDateKey(value: Date): string {
|
||||
const year = value.getFullYear();
|
||||
const month = String(value.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(value.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function getMedicationIdFromNotificationDoseId(doseId: string | null): string | null {
|
||||
if (!doseId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [rawMedicationId] = doseId.split("-");
|
||||
return rawMedicationId?.trim() ? rawMedicationId : null;
|
||||
}
|
||||
|
||||
function findFocusTargetElement(doseId: string | null, medId: string | null): HTMLElement | null {
|
||||
if (doseId) {
|
||||
const elements = Array.from(document.querySelectorAll<HTMLElement>("[data-dose-id]"));
|
||||
const doseElement = elements.find((element) => element.dataset.doseId === doseId);
|
||||
if (doseElement) {
|
||||
return doseElement.closest<HTMLElement>("[data-med-id]") ?? doseElement;
|
||||
}
|
||||
}
|
||||
|
||||
if (medId) {
|
||||
const elements = Array.from(document.querySelectorAll<HTMLElement>("[data-med-id]"));
|
||||
return elements.find((element) => element.dataset.medId === medId) ?? null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
const location = useLocation();
|
||||
const {
|
||||
meds,
|
||||
loading,
|
||||
@@ -49,9 +84,12 @@ export function DashboardPage() {
|
||||
todayDay,
|
||||
futureDays,
|
||||
takenDoses,
|
||||
skippedDoses,
|
||||
dismissedDoses,
|
||||
markDoseTaken,
|
||||
markDoseSkipped,
|
||||
undoDoseTaken,
|
||||
undoDoseSkipped,
|
||||
manuallyCollapsedDays,
|
||||
manuallyExpandedDays,
|
||||
toggleDayCollapse,
|
||||
@@ -71,8 +109,147 @@ export function DashboardPage() {
|
||||
const [clearingMissed, setClearingMissed] = useState(false);
|
||||
const [showObsoleteConfirm, setShowObsoleteConfirm] = useState(false);
|
||||
const [obsoleteCandidate, setObsoleteCandidate] = useState<{ id: number; name: string } | null>(null);
|
||||
const notificationFocusAppliedRef = useRef<string | null>(null);
|
||||
|
||||
const isDoseTakenForDisplay = (doseId: string) => takenDoses.has(doseId);
|
||||
const isDoseTakenForDisplay = useCallback((doseId: string) => takenDoses.has(doseId), [takenDoses]);
|
||||
|
||||
const notificationTarget = useMemo(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const date = params.get("day")?.trim() ?? params.get("date")?.trim() ?? "";
|
||||
const doseId = params.get("dose")?.trim() ?? params.get("doseId")?.trim() ?? "";
|
||||
const medId =
|
||||
params.get("med")?.trim() ?? params.get("medId")?.trim() ?? getMedicationIdFromNotificationDoseId(doseId) ?? "";
|
||||
if (!date && !doseId && !medId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
date: date || null,
|
||||
doseId: doseId || null,
|
||||
medId: medId || null,
|
||||
key: `${date}|${doseId}|${medId}`,
|
||||
};
|
||||
}, [location.search]);
|
||||
|
||||
const targetDayState = useMemo(() => {
|
||||
if (!notificationTarget?.date) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const todayDateKey = todayDay ? getRouteDateKey(todayDay.date) : null;
|
||||
if (todayDay && todayDateKey === notificationTarget.date) {
|
||||
const allDoseIds = todayDay.meds.flatMap((item) => expandDoseIds(item.doses));
|
||||
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => isDoseTakenForDisplay(id));
|
||||
const isAutoCollapsed = allDayTaken;
|
||||
const isManuallyExpanded = manuallyExpandedDays.has(todayDay.dateStr);
|
||||
const isManuallyCollapsed = manuallyCollapsedDays.has(todayDay.dateStr);
|
||||
const isCollapsed = isAutoCollapsed ? !isManuallyExpanded : isManuallyCollapsed;
|
||||
return { day: todayDay, isAutoCollapsed, isCollapsed, section: "today" as const };
|
||||
}
|
||||
|
||||
const pastDay = pastDays.find((day) => getRouteDateKey(day.date) === notificationTarget.date);
|
||||
if (pastDay) {
|
||||
const isAutoCollapsed = true;
|
||||
const isCollapsed = !manuallyExpandedDays.has(pastDay.dateStr);
|
||||
return { day: pastDay, isAutoCollapsed, isCollapsed, section: "past" as const };
|
||||
}
|
||||
|
||||
const futureDay = futureDays.find((day) => getRouteDateKey(day.date) === notificationTarget.date);
|
||||
if (futureDay) {
|
||||
const isAutoCollapsed = true;
|
||||
const isCollapsed = !manuallyExpandedDays.has(futureDay.dateStr);
|
||||
return { day: futureDay, isAutoCollapsed, isCollapsed, section: "future" as const };
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [
|
||||
notificationTarget,
|
||||
todayDay,
|
||||
pastDays,
|
||||
futureDays,
|
||||
manuallyExpandedDays,
|
||||
manuallyCollapsedDays,
|
||||
isDoseTakenForDisplay,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!notificationTarget || !targetDayState) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetDayState.section === "past" && !showPastDays) {
|
||||
setShowPastDays(true);
|
||||
}
|
||||
|
||||
if (targetDayState.section === "future" && !showFutureDays) {
|
||||
setShowFutureDays(true);
|
||||
}
|
||||
|
||||
if (targetDayState.isCollapsed) {
|
||||
toggleDayCollapse(targetDayState.day.dateStr, targetDayState.isAutoCollapsed);
|
||||
}
|
||||
}, [
|
||||
notificationTarget,
|
||||
targetDayState,
|
||||
setShowPastDays,
|
||||
setShowFutureDays,
|
||||
showPastDays,
|
||||
showFutureDays,
|
||||
toggleDayCollapse,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!notificationTarget) {
|
||||
notificationFocusAppliedRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (loading || settingsLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!targetDayState) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (notificationFocusAppliedRef.current === notificationTarget.key) {
|
||||
return;
|
||||
}
|
||||
|
||||
let correctionTimerId: number | null = null;
|
||||
|
||||
const scrollTargetIntoView = () => {
|
||||
const targetElement = findFocusTargetElement(notificationTarget.doseId, notificationTarget.medId);
|
||||
|
||||
if (!targetElement) {
|
||||
return false;
|
||||
}
|
||||
|
||||
targetElement.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
return true;
|
||||
};
|
||||
|
||||
const frameId = requestAnimationFrame(() => {
|
||||
if (!scrollTargetIntoView()) {
|
||||
return;
|
||||
}
|
||||
|
||||
correctionTimerId = window.setTimeout(() => {
|
||||
if (!scrollTargetIntoView()) {
|
||||
return;
|
||||
}
|
||||
|
||||
notificationFocusAppliedRef.current = notificationTarget.key;
|
||||
}, 220);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(frameId);
|
||||
if (correctionTimerId !== null) {
|
||||
window.clearTimeout(correctionTimerId);
|
||||
}
|
||||
};
|
||||
}, [notificationTarget, targetDayState, loading, settingsLoading]);
|
||||
|
||||
// Get structured reminder data
|
||||
const reminderData = getReminderStatusData(
|
||||
@@ -153,6 +330,59 @@ export function DashboardPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const renderDoseActionButtons = (options: {
|
||||
doseId: string;
|
||||
isTaken: boolean;
|
||||
isSkipped: boolean;
|
||||
isAutomaticallyTaken: boolean;
|
||||
isEmpty: boolean;
|
||||
}) => {
|
||||
const takeButton = options.isTaken ? (
|
||||
<button className="dose-btn undo take" onClick={() => undoDoseTaken(options.doseId)} title={t("common.undo")}>
|
||||
{options.isAutomaticallyTaken && (
|
||||
<span className="info-tooltip" data-tooltip={t("tooltips.automaticTaken")}>
|
||||
🤖
|
||||
</span>
|
||||
)}
|
||||
<span className="dose-btn-label">{t("common.undo")}</span>
|
||||
<span aria-hidden="true">↩</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className={`dose-btn take${options.isEmpty ? " out-of-stock" : ""}`}
|
||||
onClick={() => markDoseTaken(options.doseId)}
|
||||
title={options.isEmpty ? t("common.outOfStockTakeBlocked") : t("dose.markAsTaken")}
|
||||
disabled={options.isEmpty || options.isSkipped}
|
||||
>
|
||||
<span className="dose-btn-label">{t("dose.take")}</span>
|
||||
<span aria-hidden="true">{options.isEmpty ? "⊘" : "✓"}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
const skipButton = options.isSkipped ? (
|
||||
<button className="dose-btn undo skip" onClick={() => undoDoseSkipped(options.doseId)} title={t("common.undo")}>
|
||||
<span className="dose-btn-label">{t("common.undo")}</span>
|
||||
<span aria-hidden="true">↩</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="dose-btn skip"
|
||||
onClick={() => markDoseSkipped(options.doseId)}
|
||||
title={t("dose.markAsSkipped")}
|
||||
disabled={options.isTaken}
|
||||
>
|
||||
<span className="dose-btn-label">{t("dose.skip")}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{takeButton}
|
||||
{skipButton}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const requestMarkObsolete = (med: { id: number; name: string }) => {
|
||||
setObsoleteCandidate(med);
|
||||
setShowObsoleteConfirm(true);
|
||||
@@ -708,6 +938,7 @@ export function DashboardPage() {
|
||||
return (
|
||||
<div
|
||||
key={day.dateStr}
|
||||
data-date-key={getRouteDateKey(day.date)}
|
||||
className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allReallyTaken ? "all-taken" : allDoseIds.length > 0 ? "past-missed" : ""}`}
|
||||
>
|
||||
<div
|
||||
@@ -753,8 +984,15 @@ export function DashboardPage() {
|
||||
const rowClasses = ["time-row"];
|
||||
if (isEmpty) rowClasses.push("med-empty");
|
||||
else if (isLowStock) rowClasses.push("med-low");
|
||||
if (med?.id != null && notificationTarget?.medId === String(med.id)) {
|
||||
rowClasses.push("notification-focus-target-row");
|
||||
}
|
||||
return (
|
||||
<div key={`${day.dateStr}-${item.medName}`} className={rowClasses.join(" ")}>
|
||||
<div
|
||||
key={`${day.dateStr}-${item.medName}`}
|
||||
className={rowClasses.join(" ")}
|
||||
data-med-id={med?.id != null ? String(med.id) : undefined}
|
||||
>
|
||||
<div className="time-main">
|
||||
<div className="med-name">
|
||||
<div
|
||||
@@ -828,10 +1066,20 @@ export function DashboardPage() {
|
||||
{people.map((person) => {
|
||||
const doseId = getDoseId(dose.id, person);
|
||||
const isTaken = isDoseTakenForDisplay(doseId);
|
||||
const isSkipped = skippedDoses.has(doseId);
|
||||
const isAutomaticallyTaken =
|
||||
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now();
|
||||
const personClasses = ["dose-person"];
|
||||
if (isTaken) personClasses.push("taken");
|
||||
if (isSkipped) personClasses.push("skipped");
|
||||
if (notificationTarget?.doseId === doseId)
|
||||
personClasses.push("notification-focus-target");
|
||||
return (
|
||||
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
|
||||
<div
|
||||
key={doseId}
|
||||
data-dose-id={doseId}
|
||||
className={personClasses.join(" ")}
|
||||
>
|
||||
{person && (
|
||||
<span
|
||||
className="person-name clickable"
|
||||
@@ -843,38 +1091,13 @@ export function DashboardPage() {
|
||||
{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>
|
||||
)}
|
||||
<span className="dose-btn-label">{t("common.undo")}</span>
|
||||
<span aria-hidden="true">↩</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className={`dose-btn take${isEmpty ? " out-of-stock" : ""}`}
|
||||
onClick={() => markDoseTaken(doseId)}
|
||||
title={
|
||||
isEmpty
|
||||
? t("common.outOfStockTakeBlocked")
|
||||
: t("dose.markAsTaken")
|
||||
}
|
||||
disabled={isEmpty}
|
||||
>
|
||||
<span className="dose-btn-label">{t("dose.take")}</span>
|
||||
<span aria-hidden="true">{isEmpty ? "⊘" : "✓"}</span>
|
||||
</button>
|
||||
)}
|
||||
{renderDoseActionButtons({
|
||||
doseId,
|
||||
isTaken,
|
||||
isSkipped,
|
||||
isAutomaticallyTaken,
|
||||
isEmpty,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -1023,6 +1246,7 @@ export function DashboardPage() {
|
||||
return (
|
||||
<div
|
||||
key={day.dateStr}
|
||||
data-date-key={getRouteDateKey(day.date)}
|
||||
className={`day-block ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} today ${worstStatus ? `stock-${worstStatus}` : ""}`}
|
||||
>
|
||||
<div
|
||||
@@ -1067,8 +1291,15 @@ export function DashboardPage() {
|
||||
const rowClasses = ["time-row"];
|
||||
if (isEmpty) rowClasses.push("med-empty");
|
||||
else if (isLowStock) rowClasses.push("med-low");
|
||||
if (med?.id != null && notificationTarget?.medId === String(med.id)) {
|
||||
rowClasses.push("notification-focus-target-row");
|
||||
}
|
||||
return (
|
||||
<div key={`${day.dateStr}-${item.medName}`} className={rowClasses.join(" ")}>
|
||||
<div
|
||||
key={`${day.dateStr}-${item.medName}`}
|
||||
className={rowClasses.join(" ")}
|
||||
data-med-id={med?.id != null ? String(med.id) : undefined}
|
||||
>
|
||||
<div className="time-main">
|
||||
<div className="med-name">
|
||||
<div
|
||||
@@ -1126,7 +1357,7 @@ export function DashboardPage() {
|
||||
</div>
|
||||
<div className="doses-col">
|
||||
{item.doses.map((dose) => {
|
||||
const isOverdue = dose.when < Date.now();
|
||||
const isOverdue = dose.when < Date.now() && !isEmpty;
|
||||
const people = dose.takenBy.length > 0 ? dose.takenBy : [null];
|
||||
const allTaken = people.every((person) =>
|
||||
isDoseTakenForDisplay(getDoseId(dose.id, person))
|
||||
@@ -1159,10 +1390,20 @@ export function DashboardPage() {
|
||||
{people.map((person) => {
|
||||
const doseId = getDoseId(dose.id, person);
|
||||
const isTaken = isDoseTakenForDisplay(doseId);
|
||||
const isSkipped = skippedDoses.has(doseId);
|
||||
const isAutomaticallyTaken =
|
||||
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now();
|
||||
const personClasses = ["dose-person"];
|
||||
if (isTaken) personClasses.push("taken");
|
||||
if (isSkipped) personClasses.push("skipped");
|
||||
if (notificationTarget?.doseId === doseId)
|
||||
personClasses.push("notification-focus-target");
|
||||
return (
|
||||
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
|
||||
<div
|
||||
key={doseId}
|
||||
data-dose-id={doseId}
|
||||
className={personClasses.join(" ")}
|
||||
>
|
||||
{person && (
|
||||
<span
|
||||
className="person-name clickable"
|
||||
@@ -1174,38 +1415,13 @@ export function DashboardPage() {
|
||||
{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>
|
||||
)}
|
||||
<span className="dose-btn-label">{t("common.undo")}</span>
|
||||
<span aria-hidden="true">↩</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className={`dose-btn take${isEmpty ? " out-of-stock" : ""}`}
|
||||
onClick={() => markDoseTaken(doseId)}
|
||||
title={
|
||||
isEmpty
|
||||
? t("common.outOfStockTakeBlocked")
|
||||
: t("dose.markAsTaken")
|
||||
}
|
||||
disabled={isEmpty}
|
||||
>
|
||||
<span className="dose-btn-label">{t("dose.take")}</span>
|
||||
<span aria-hidden="true">{isEmpty ? "⊘" : "✓"}</span>
|
||||
</button>
|
||||
)}
|
||||
{renderDoseActionButtons({
|
||||
doseId,
|
||||
isTaken,
|
||||
isSkipped,
|
||||
isAutomaticallyTaken,
|
||||
isEmpty,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -1296,6 +1512,7 @@ export function DashboardPage() {
|
||||
return (
|
||||
<div
|
||||
key={day.dateStr}
|
||||
data-date-key={getRouteDateKey(day.date)}
|
||||
className={`day-block ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} ${worstStatus ? `stock-${worstStatus}` : ""}`}
|
||||
>
|
||||
<div
|
||||
@@ -1340,8 +1557,15 @@ export function DashboardPage() {
|
||||
const rowClasses = ["time-row"];
|
||||
if (isEmpty) rowClasses.push("med-empty");
|
||||
else if (isLowStock) rowClasses.push("med-low");
|
||||
if (med?.id != null && notificationTarget?.medId === String(med.id)) {
|
||||
rowClasses.push("notification-focus-target-row");
|
||||
}
|
||||
return (
|
||||
<div key={`${day.dateStr}-${item.medName}`} className={rowClasses.join(" ")}>
|
||||
<div
|
||||
key={`${day.dateStr}-${item.medName}`}
|
||||
className={rowClasses.join(" ")}
|
||||
data-med-id={med?.id != null ? String(med.id) : undefined}
|
||||
>
|
||||
<div className="time-main">
|
||||
<div className="med-name">
|
||||
<div
|
||||
@@ -1430,10 +1654,20 @@ export function DashboardPage() {
|
||||
{people.map((person) => {
|
||||
const doseId = getDoseId(dose.id, person);
|
||||
const isTaken = isDoseTakenForDisplay(doseId);
|
||||
const isSkipped = skippedDoses.has(doseId);
|
||||
const isAutomaticallyTaken =
|
||||
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now();
|
||||
const personClasses = ["dose-person"];
|
||||
if (isTaken) personClasses.push("taken");
|
||||
if (isSkipped) personClasses.push("skipped");
|
||||
if (notificationTarget?.doseId === doseId)
|
||||
personClasses.push("notification-focus-target");
|
||||
return (
|
||||
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
|
||||
<div
|
||||
key={doseId}
|
||||
data-dose-id={doseId}
|
||||
className={personClasses.join(" ")}
|
||||
>
|
||||
{person && (
|
||||
<span
|
||||
className="person-name clickable"
|
||||
@@ -1445,34 +1679,13 @@ export function DashboardPage() {
|
||||
{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>
|
||||
)}
|
||||
<span className="dose-btn-label">{t("common.undo")}</span>
|
||||
<span aria-hidden="true">↩</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className={`dose-btn take out-of-stock`}
|
||||
onClick={() => markDoseTaken(doseId)}
|
||||
title={t("common.outOfStockTakeBlocked")}
|
||||
disabled={true}
|
||||
>
|
||||
<span className="dose-btn-label">{t("dose.take")}</span>
|
||||
<span aria-hidden="true">⊘</span>
|
||||
</button>
|
||||
)}
|
||||
{renderDoseActionButtons({
|
||||
doseId,
|
||||
isTaken,
|
||||
isSkipped,
|
||||
isAutomaticallyTaken,
|
||||
isEmpty: true,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -697,7 +697,7 @@ describe("MedDetailModal with refill history", () => {
|
||||
|
||||
it("shows refill history when expanded", () => {
|
||||
const refillHistory: RefillEntry[] = [
|
||||
{ id: 1, refillDate: new Date().toISOString(), packsAdded: 1, loosePillsAdded: 0 },
|
||||
{ id: 1, refillDate: new Date().toISOString(), packsAdded: 1, loosePillsAdded: 0, quantityAdded: 30 },
|
||||
];
|
||||
|
||||
render(<MedDetailModal {...defaultProps} refillHistory={refillHistory} refillHistoryExpanded={true} />);
|
||||
@@ -710,7 +710,7 @@ describe("MedDetailModal with refill history", () => {
|
||||
it("calls onRefillHistoryExpandedChange when toggle clicked", () => {
|
||||
const onRefillHistoryExpandedChange = vi.fn();
|
||||
const refillHistory: RefillEntry[] = [
|
||||
{ id: 1, refillDate: new Date().toISOString(), packsAdded: 1, loosePillsAdded: 0 },
|
||||
{ id: 1, refillDate: new Date().toISOString(), packsAdded: 1, loosePillsAdded: 0, quantityAdded: 30 },
|
||||
];
|
||||
|
||||
render(
|
||||
|
||||
@@ -42,7 +42,7 @@ describe("ReportModal", () => {
|
||||
json: async () => ({
|
||||
1: {
|
||||
dosesTaken: 2,
|
||||
dosesDismissed: 0,
|
||||
dosesSkipped: 0,
|
||||
firstDoseAt: "2026-01-01T08:00:00.000Z",
|
||||
lastDoseAt: "2026-01-02T08:00:00.000Z",
|
||||
refills: [],
|
||||
@@ -74,7 +74,7 @@ describe("ReportModal", () => {
|
||||
1: {
|
||||
dosesTaken: 1,
|
||||
automaticDosesTaken: 0,
|
||||
dosesDismissed: 0,
|
||||
dosesSkipped: 0,
|
||||
firstDoseAt: "2026-02-03T12:00:00.000Z",
|
||||
lastDoseAt: null,
|
||||
refills: [],
|
||||
@@ -121,7 +121,7 @@ describe("ReportModal", () => {
|
||||
1: {
|
||||
dosesTaken: 0,
|
||||
automaticDosesTaken: 0,
|
||||
dosesDismissed: 0,
|
||||
dosesSkipped: 0,
|
||||
firstDoseAt: null,
|
||||
lastDoseAt: null,
|
||||
refills: [],
|
||||
@@ -183,13 +183,14 @@ describe("ReportModal", () => {
|
||||
1: {
|
||||
dosesTaken: 1,
|
||||
automaticDosesTaken: 0,
|
||||
dosesDismissed: 0,
|
||||
dosesSkipped: 0,
|
||||
firstDoseAt: "2026-03-03T12:00:00.000Z",
|
||||
lastDoseAt: null,
|
||||
refills: [
|
||||
{
|
||||
packsAdded: 1,
|
||||
loosePillsAdded: 0,
|
||||
quantityAdded: 20,
|
||||
usedPrescription: false,
|
||||
refillDate: "2026-03-04",
|
||||
},
|
||||
@@ -251,6 +252,81 @@ describe("ReportModal", () => {
|
||||
expect(screen.getByRole("button", { name: /report\.generate/i })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("sends the selected person filter with the report request and clears it for all people", async () => {
|
||||
const onClose = vi.fn();
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
1: {
|
||||
dosesTaken: 2,
|
||||
automaticDosesTaken: 0,
|
||||
dosesSkipped: 1,
|
||||
firstDoseAt: "2026-01-01T08:00:00.000Z",
|
||||
lastDoseAt: "2026-01-02T08:00:00.000Z",
|
||||
refills: [],
|
||||
},
|
||||
2: {
|
||||
dosesTaken: 1,
|
||||
automaticDosesTaken: 0,
|
||||
dosesSkipped: 0,
|
||||
firstDoseAt: "2026-01-01T08:00:00.000Z",
|
||||
lastDoseAt: "2026-01-02T08:00:00.000Z",
|
||||
refills: [],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const firstRender = render(
|
||||
<ReportModal
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
medications={[
|
||||
createMedication({ id: 1, name: "Alice Med", takenBy: ["Alice"] }),
|
||||
createMedication({ id: 2, name: "Bob Med", takenBy: ["Bob"] }),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: "Alice" }));
|
||||
fireEvent.click(screen.getByRole("radio", { name: /report\.formatTxt/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"/api/medications/report-data",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({ medicationIds: [1], takenByFilter: ["Alice"] }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockClear();
|
||||
firstRender.unmount();
|
||||
render(
|
||||
<ReportModal
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
medications={[
|
||||
createMedication({ id: 1, name: "Alice Med", takenBy: ["Alice"] }),
|
||||
createMedication({ id: 2, name: "Bob Med", takenBy: ["Bob"] }),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByRole("radio", { name: /report\.formatTxt/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"/api/medications/report-data",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({ medicationIds: [1, 2], takenByFilter: undefined }),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("generates markdown report and keeps modal open on fetch error", async () => {
|
||||
const onClose = vi.fn();
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({ ok: false });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SharedSchedule } from "../../components/SharedSchedule";
|
||||
@@ -168,10 +168,58 @@ function createSharedDataWithTodayDose(referenceNow: Date) {
|
||||
};
|
||||
}
|
||||
|
||||
function createSharedDoseFetchMock(options: {
|
||||
token?: string;
|
||||
sharedData: ReturnType<typeof createSharedDataWithTodayDose>;
|
||||
initialDoses?: Array<{ doseId: string; skipped?: boolean; dismissed?: boolean; takenSource?: string }>;
|
||||
}) {
|
||||
const token = options.token ?? "token-123";
|
||||
const doseState = new Map((options.initialDoses ?? []).map((dose) => [dose.doseId, { ...dose }]));
|
||||
const requests: Array<{ url: string; method: string; body?: unknown }> = [];
|
||||
|
||||
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
|
||||
const method = init?.method ?? "GET";
|
||||
const body =
|
||||
typeof init?.body === "string" && init.body.length > 0
|
||||
? (JSON.parse(init.body) as { doseId: string })
|
||||
: undefined;
|
||||
requests.push({ url, method, body });
|
||||
|
||||
if (url === `/api/share/${token}` && method === "GET") {
|
||||
return { ok: true, json: async () => options.sharedData };
|
||||
}
|
||||
|
||||
if (url === `/api/share/${token}/doses` && method === "GET") {
|
||||
return { ok: true, json: async () => ({ doses: Array.from(doseState.values()) }) };
|
||||
}
|
||||
|
||||
if (url === `/api/share/${token}/doses/skip` && method === "POST" && body?.doseId) {
|
||||
doseState.set(body.doseId, { doseId: body.doseId, skipped: true });
|
||||
return { ok: true, json: async () => ({}) };
|
||||
}
|
||||
|
||||
if (url === `/api/share/${token}/doses` && method === "POST" && body?.doseId) {
|
||||
doseState.set(body.doseId, { doseId: body.doseId, takenSource: "manual" });
|
||||
return { ok: true, json: async () => ({}) };
|
||||
}
|
||||
|
||||
if (url.startsWith(`/api/share/${token}/doses/skip/`) && method === "DELETE") {
|
||||
const doseId = decodeURIComponent(url.split("/").at(-1) ?? "");
|
||||
doseState.delete(doseId);
|
||||
return { ok: true, json: async () => ({}) };
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unexpected request: ${method} ${url}`));
|
||||
});
|
||||
|
||||
return { fetchMock, requests, getDoses: () => Array.from(doseState.values()) };
|
||||
}
|
||||
|
||||
describe("SharedSchedule", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.localStorage.clear();
|
||||
globalThis.fetch = vi.fn() as unknown as typeof fetch;
|
||||
vi.spyOn(globalThis, "setInterval").mockImplementation(() => 1 as unknown as ReturnType<typeof setInterval>);
|
||||
vi.spyOn(globalThis, "clearInterval").mockImplementation(() => {});
|
||||
});
|
||||
@@ -183,7 +231,7 @@ describe("SharedSchedule", () => {
|
||||
|
||||
it("renders shared schedule shell for valid token", async () => {
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string, init?: RequestInit) => {
|
||||
if (url === "/api/share/token-123/doses" && (!init || !init.method || init.method === "GET")) {
|
||||
if (url === "/api/share/token-123/doses" && (!init?.method || init.method === "GET")) {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses: [] }) });
|
||||
}
|
||||
if (url === "/api/share/token-123") {
|
||||
@@ -247,7 +295,7 @@ describe("SharedSchedule", () => {
|
||||
|
||||
it("renders generic error when loading share data fails", async () => {
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string, init?: RequestInit) => {
|
||||
if (url === "/api/share/token-123/doses" && (!init || !init.method || init.method === "GET")) {
|
||||
if (url === "/api/share/token-123/doses" && (!init?.method || init.method === "GET")) {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses: [] }) });
|
||||
}
|
||||
if (url === "/api/share/token-123") {
|
||||
@@ -270,7 +318,7 @@ describe("SharedSchedule", () => {
|
||||
const sharedData = createSharedDataWithTodayDose(referenceNow);
|
||||
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string, init?: RequestInit) => {
|
||||
if (url === "/api/share/token-123/doses" && (!init || !init.method || init.method === "GET")) {
|
||||
if (url === "/api/share/token-123/doses" && (!init?.method || init.method === "GET")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () =>
|
||||
@@ -296,7 +344,7 @@ describe("SharedSchedule", () => {
|
||||
const sharedData = createSharedDataWithEmbeddedOverview();
|
||||
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string, init?: RequestInit) => {
|
||||
if (url === "/api/share/token-123/doses" && (!init || !init.method || init.method === "GET")) {
|
||||
if (url === "/api/share/token-123/doses" && (!init?.method || init.method === "GET")) {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses: [] }) });
|
||||
}
|
||||
if (url === "/api/share/token-123") {
|
||||
@@ -318,4 +366,90 @@ describe("SharedSchedule", () => {
|
||||
expect(screen.getAllByText("3 x 150 form.packageAmountUnitMl").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("share.noSchedule")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("skips a neutral shared dose via the skip endpoint", async () => {
|
||||
const referenceNow = new Date();
|
||||
referenceNow.setHours(12, 0, 0, 0);
|
||||
vi.spyOn(Date, "now").mockReturnValue(referenceNow.getTime());
|
||||
const sharedData = createSharedDataWithTodayDose(referenceNow);
|
||||
const { fetchMock, requests } = createSharedDoseFetchMock({ sharedData });
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
renderSharedSchedule("/share/token-123");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector(".dose-btn.skip")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("dose.skip"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(requests).toContainEqual({
|
||||
url: "/api/share/token-123/doses/skip",
|
||||
method: "POST",
|
||||
body: { doseId: sharedData.automaticDoseId },
|
||||
});
|
||||
expect(document.querySelector(".dose-btn.undo.skip")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("undoes a skipped shared dose via the delete skip endpoint", async () => {
|
||||
const referenceNow = new Date();
|
||||
referenceNow.setHours(12, 0, 0, 0);
|
||||
vi.spyOn(Date, "now").mockReturnValue(referenceNow.getTime());
|
||||
const sharedData = createSharedDataWithTodayDose(referenceNow);
|
||||
const { fetchMock, requests } = createSharedDoseFetchMock({
|
||||
sharedData,
|
||||
initialDoses: [{ doseId: sharedData.automaticDoseId, skipped: true }],
|
||||
});
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
renderSharedSchedule("/share/token-123");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector(".dose-btn.undo.skip")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("dose.undoSkip"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(requests).toContainEqual({
|
||||
url: `/api/share/token-123/doses/skip/${sharedData.automaticDoseId}`,
|
||||
method: "DELETE",
|
||||
});
|
||||
expect(document.querySelector(".dose-btn.skip")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("takes a skipped shared dose again via the take endpoint", async () => {
|
||||
const referenceNow = new Date();
|
||||
referenceNow.setHours(12, 0, 0, 0);
|
||||
vi.spyOn(Date, "now").mockReturnValue(referenceNow.getTime());
|
||||
const sharedData = createSharedDataWithTodayDose(referenceNow);
|
||||
const { fetchMock, requests, getDoses } = createSharedDoseFetchMock({
|
||||
sharedData,
|
||||
initialDoses: [{ doseId: sharedData.automaticDoseId, skipped: true }],
|
||||
});
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
renderSharedSchedule("/share/token-123");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector(".dose-btn.undo.skip")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("dose.take"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(requests).toContainEqual({
|
||||
url: "/api/share/token-123/doses",
|
||||
method: "POST",
|
||||
body: { doseId: sharedData.automaticDoseId },
|
||||
});
|
||||
expect(getDoses()).toEqual([
|
||||
expect.objectContaining({ doseId: sharedData.automaticDoseId, takenSource: "manual" }),
|
||||
]);
|
||||
expect(document.querySelector(".day-block.today")).toHaveClass("all-taken");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,7 +77,7 @@ describe("SharedSchedule today-only", () => {
|
||||
const sharedData = createSharedData();
|
||||
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string, init?: RequestInit) => {
|
||||
if (url === "/api/share/token-123/doses" && (!init || !init.method || init.method === "GET")) {
|
||||
if (url === "/api/share/token-123/doses" && (!init?.method || init.method === "GET")) {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses: [] }) });
|
||||
}
|
||||
if (url === "/api/share/token-123") {
|
||||
|
||||
@@ -31,7 +31,9 @@ describe("useRefill", () => {
|
||||
});
|
||||
|
||||
it("loads refill history", async () => {
|
||||
const mockHistory = [{ id: 1, packsAdded: 2, loosePillsAdded: 0, createdAt: "2024-03-15T10:00:00Z" }];
|
||||
const mockHistory = [
|
||||
{ id: 1, packsAdded: 2, loosePillsAdded: 0, quantityAdded: 20, createdAt: "2024-03-15T10:00:00Z" },
|
||||
];
|
||||
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
@@ -49,7 +51,7 @@ describe("useRefill", () => {
|
||||
|
||||
it("handles refill history with refills wrapper", async () => {
|
||||
const mockHistory = {
|
||||
refills: [{ id: 1, packsAdded: 2, createdAt: "2024-03-15T10:00:00Z" }],
|
||||
refills: [{ id: 1, packsAdded: 2, quantityAdded: 20, createdAt: "2024-03-15T10:00:00Z" }],
|
||||
};
|
||||
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
@@ -162,7 +164,7 @@ describe("useRefill", () => {
|
||||
"/api/medications/1/refill",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({ packsAdded: 1, loosePillsAdded: 0, usePrescription: false }),
|
||||
body: JSON.stringify({ packsAdded: 1, loosePillsAdded: 0, quantityAdded: 0, usePrescription: false }),
|
||||
})
|
||||
);
|
||||
expect(fetch).toHaveBeenNthCalledWith(
|
||||
@@ -505,6 +507,53 @@ describe("useRefill", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps liquid stock correction base fields aligned", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const liquidMed: Medication = {
|
||||
id: 12,
|
||||
name: "Aligned Liquid",
|
||||
medicationForm: "liquid",
|
||||
packageType: "liquid_container",
|
||||
doseUnit: "ml",
|
||||
packCount: 1,
|
||||
packageAmountValue: 180,
|
||||
packageAmountUnit: "ml",
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 1,
|
||||
totalPills: 180,
|
||||
looseTablets: 180,
|
||||
stockAdjustment: 0,
|
||||
takenBy: [],
|
||||
blisters: [{ usage: 5, every: 1, start: "2026-01-31T20:27:00" }],
|
||||
updatedAt: null,
|
||||
};
|
||||
|
||||
const mockLoadMeds = vi.fn();
|
||||
const { result } = renderHook(() => useRefill());
|
||||
|
||||
act(() => {
|
||||
result.current.openEditStockModal(liquidMed, {
|
||||
all: [{ name: liquidMed.name, medsLeft: 180, daysLeft: 36 }] as Coverage[],
|
||||
});
|
||||
result.current.setEditStockFullBlisters(2);
|
||||
result.current.setEditStockPartialBlisterPills(300);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.submitStockCorrection(12, liquidMed, mockLoadMeds);
|
||||
});
|
||||
|
||||
const [, requestInit] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
const body = JSON.parse(requestInit.body as string);
|
||||
expect(body).toEqual({
|
||||
stockAdjustment: -60,
|
||||
packCount: 2,
|
||||
totalPills: 360,
|
||||
looseTablets: 360,
|
||||
});
|
||||
});
|
||||
|
||||
it("stock correction uses loose tablets rather than bottle capacity as the base", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
|
||||
|
||||
|
||||
@@ -130,6 +130,13 @@ const mockTodayDay = {
|
||||
],
|
||||
};
|
||||
|
||||
function getRouteDateKey(value: Date): string {
|
||||
const year = value.getFullYear();
|
||||
const month = String(value.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(value.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
// Default mock factory
|
||||
const createMockAppContext = (overrides = {}) => ({
|
||||
meds: [],
|
||||
@@ -321,6 +328,7 @@ describe("DashboardPage", () => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
mockContextValue = createMockAppContext();
|
||||
HTMLElement.prototype.scrollIntoView = vi.fn();
|
||||
});
|
||||
|
||||
it("renders dashboard page", () => {
|
||||
@@ -505,6 +513,7 @@ describe("DashboardPage interactions", () => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
mockContextValue = createMockAppContext();
|
||||
HTMLElement.prototype.scrollIntoView = vi.fn();
|
||||
});
|
||||
|
||||
it("has schedule days options", () => {
|
||||
@@ -539,6 +548,91 @@ describe("DashboardPage interactions", () => {
|
||||
expect(setScheduleDays).toHaveBeenCalledWith(90);
|
||||
});
|
||||
|
||||
it("highlights and scrolls to the notification-linked dashboard dose", async () => {
|
||||
const doseId = String(mockTodayDay.meds[0].doses[0].id);
|
||||
mockContextValue = createMockAppContext({
|
||||
meds: mockMeds,
|
||||
coverage: mockCoverage,
|
||||
todayDay: mockTodayDay,
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter
|
||||
initialEntries={[`/?date=${getRouteDateKey(mockTodayDay.date)}&medId=1&doseId=${encodeURIComponent(doseId)}`]}
|
||||
>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const targetDose = document.querySelector(`[data-dose-id="${doseId}"]`);
|
||||
const targetRow = document.querySelector('[data-med-id="1"]');
|
||||
expect(targetDose).toHaveClass("notification-focus-target");
|
||||
expect(targetRow).toHaveClass("notification-focus-target-row");
|
||||
expect(HTMLElement.prototype.scrollIntoView).toHaveBeenCalledWith({ behavior: "smooth", block: "start" });
|
||||
});
|
||||
});
|
||||
|
||||
it("supports the shorter dashboard notification query params", async () => {
|
||||
const doseId = String(mockTodayDay.meds[0].doses[0].id);
|
||||
mockContextValue = createMockAppContext({
|
||||
meds: mockMeds,
|
||||
coverage: mockCoverage,
|
||||
todayDay: mockTodayDay,
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter
|
||||
initialEntries={[`/dashboard?day=${getRouteDateKey(mockTodayDay.date)}&dose=${encodeURIComponent(doseId)}`]}
|
||||
>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const targetDose = document.querySelector(`[data-dose-id="${doseId}"]`);
|
||||
const targetRow = document.querySelector('[data-med-id="1"]');
|
||||
expect(targetDose).toHaveClass("notification-focus-target");
|
||||
expect(targetRow).toHaveClass("notification-focus-target-row");
|
||||
});
|
||||
});
|
||||
|
||||
it("scrolls to the notification-linked dashboard dose after schedule data loads", async () => {
|
||||
const doseId = String(mockTodayDay.meds[0].doses[0].id);
|
||||
mockContextValue = createMockAppContext();
|
||||
|
||||
const { rerender } = render(
|
||||
<MemoryRouter
|
||||
initialEntries={[`/?date=${getRouteDateKey(mockTodayDay.date)}&medId=1&doseId=${encodeURIComponent(doseId)}`]}
|
||||
>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(document.querySelector(`[data-dose-id="${doseId}"]`)).toBeNull();
|
||||
expect(HTMLElement.prototype.scrollIntoView).not.toHaveBeenCalled();
|
||||
|
||||
mockContextValue = createMockAppContext({
|
||||
meds: mockMeds,
|
||||
coverage: mockCoverage,
|
||||
todayDay: mockTodayDay,
|
||||
});
|
||||
|
||||
rerender(
|
||||
<MemoryRouter
|
||||
initialEntries={[`/?date=${getRouteDateKey(mockTodayDay.date)}&medId=1&doseId=${encodeURIComponent(doseId)}`]}
|
||||
>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const targetDose = document.querySelector(`[data-dose-id="${doseId}"]`);
|
||||
expect(targetDose).toHaveClass("notification-focus-target");
|
||||
expect(HTMLElement.prototype.scrollIntoView).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("hides past and future sections when upcomingTodayOnly is enabled", () => {
|
||||
mockContextValue = createMockAppContext({
|
||||
settings: {
|
||||
|
||||
@@ -134,6 +134,20 @@ describe("getMedTotal", () => {
|
||||
expect(getMedTotal(tube)).toBe(604);
|
||||
expect(getMedTotal(liquid)).toBe(450);
|
||||
});
|
||||
|
||||
it("prefers canonical amount-base stock over compatibility mirror fields", () => {
|
||||
const liquid = {
|
||||
packageType: "liquid_container" as const,
|
||||
packCount: 2,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 1,
|
||||
totalPills: 300,
|
||||
looseTablets: 150,
|
||||
stockAdjustment: 0,
|
||||
};
|
||||
|
||||
expect(getMedTotal(liquid)).toBe(150);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPackageSize", () => {
|
||||
@@ -200,7 +214,7 @@ describe("getPackageSize", () => {
|
||||
expect(getPackageSize(med)).toBe(80);
|
||||
});
|
||||
|
||||
it("returns totalPills for tube/liquid container package size", () => {
|
||||
it("returns canonical amount-base stock for tube/liquid container package size", () => {
|
||||
const tube = {
|
||||
packageType: "tube" as const,
|
||||
packCount: 4,
|
||||
@@ -221,6 +235,19 @@ describe("getPackageSize", () => {
|
||||
expect(getPackageSize(tube)).toBe(600);
|
||||
expect(getPackageSize(liquid)).toBe(450);
|
||||
});
|
||||
|
||||
it("prefers canonical amount-base stock for package size when compatibility mirror drifts", () => {
|
||||
const tube = {
|
||||
packageType: "tube" as const,
|
||||
packCount: 2,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 1,
|
||||
totalPills: 300,
|
||||
looseTablets: 150,
|
||||
};
|
||||
|
||||
expect(getPackageSize(tube)).toBe(150);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getStockDisplayCapacity", () => {
|
||||
|
||||
@@ -1264,14 +1264,14 @@ describe("getStockStatus", () => {
|
||||
expect(result.className).toBe("danger");
|
||||
});
|
||||
|
||||
it("returns out-of-stock when daysLeft is 0", () => {
|
||||
it("returns critical when daysLeft is 0 but stock remains", () => {
|
||||
const result = getStockStatus(0, 5, thresholds);
|
||||
expect(result.level).toBe("out-of-stock");
|
||||
expect(result.level).toBe("critical");
|
||||
expect(result.className).toBe("danger");
|
||||
});
|
||||
|
||||
it("returns high when daysLeft > highStockDays", () => {
|
||||
const result = getStockStatus(200, 100, thresholds);
|
||||
const result = getStockStatus(181, 100, thresholds);
|
||||
expect(result.level).toBe("high");
|
||||
expect(result.className).toBe("high");
|
||||
});
|
||||
@@ -1377,9 +1377,9 @@ describe("getStockStatus", () => {
|
||||
const resultCritical = getStockStatus(1, 100, boundaryThresholds, "liquid_container");
|
||||
expect(resultCritical.level).toBe("critical");
|
||||
|
||||
// daysLeft = 0 (out of stock)
|
||||
// daysLeft = 0 with stock remaining is still critical, not empty
|
||||
const resultEmpty = getStockStatus(0, 100, boundaryThresholds, "liquid_container");
|
||||
expect(resultEmpty.level).toBe("out-of-stock");
|
||||
expect(resultEmpty.level).toBe("critical");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -188,7 +188,8 @@ export type PlannerRow = {
|
||||
export type RefillEntry = {
|
||||
id: number;
|
||||
packsAdded: number;
|
||||
loosePillsAdded: number;
|
||||
loosePillsAdded?: number;
|
||||
quantityAdded: number;
|
||||
usedPrescription?: boolean;
|
||||
refillDate: string;
|
||||
};
|
||||
@@ -409,10 +410,11 @@ export function getMedTotal(med: MedLike): number {
|
||||
return med.looseTablets + (med.stockAdjustment ?? 0);
|
||||
}
|
||||
|
||||
// Amount-based package types store their current base stock directly
|
||||
// in totalPills (fallback looseTablets for legacy rows).
|
||||
// Amount-based package types use the same canonical base field as the backend:
|
||||
// looseTablets stores the current amount baseline, while totalPills is kept in sync
|
||||
// for compatibility and UI helpers.
|
||||
if (isAmountBasedPackageType(med.packageType)) {
|
||||
const baseStock = med.totalPills ?? med.looseTablets;
|
||||
const baseStock = med.looseTablets ?? med.totalPills ?? 0;
|
||||
return baseStock + (med.stockAdjustment ?? 0);
|
||||
}
|
||||
// For blister type, calculate from packs + loose
|
||||
@@ -425,9 +427,9 @@ export function getPackageSize(med: MedLike): number {
|
||||
return med.totalPills ?? med.looseTablets;
|
||||
}
|
||||
|
||||
// Amount-based package types use totalPills as base capacity
|
||||
// Amount-based package types reuse the backend canonical amount baseline.
|
||||
if (isAmountBasedPackageType(med.packageType)) {
|
||||
return med.totalPills ?? med.looseTablets;
|
||||
return med.looseTablets ?? med.totalPills ?? 0;
|
||||
}
|
||||
// For blister type, calculate from packs + loose
|
||||
return med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets;
|
||||
|
||||
@@ -293,8 +293,8 @@ export function getStockStatus(
|
||||
thresholds: StockThresholds,
|
||||
packageType?: PackageType
|
||||
): StockStatus {
|
||||
// Out of stock or completely depleted = danger (red)
|
||||
if (medsLeft <= 0 || daysLeft === 0) {
|
||||
// Only a real zero-or-below stock count is out of stock.
|
||||
if (medsLeft <= 0) {
|
||||
return { level: "out-of-stock", className: "danger", label: "status.outOfStock" };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user