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>