fix(refill): stabilize stock and amount package semantics

This commit is contained in:
Daniel Volz
2026-05-08 11:03:25 +02:00
parent b838f0e8ea
commit 277fc3e686
23 changed files with 1696 additions and 335 deletions
+1 -4
View File
@@ -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 && (
+22 -12
View File
@@ -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>`;
}
+238 -101
View File
@@ -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>
+7 -1
View File
@@ -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;
}
+314 -101
View File
@@ -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") {
+52 -3
View File
@@ -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: {
+28 -1
View File
@@ -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", () => {
+5 -5
View File
@@ -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");
});
});
+8 -6
View File
@@ -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;
+2 -2
View File
@@ -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" };
}