fix: align frontend tube/liquid container semantics (#364)

* fix: align frontend tube/liquid container semantics

* test(frontend): fix PR #364 CI regressions
This commit is contained in:
Daniel Volz
2026-03-02 00:23:32 +01:00
committed by GitHub
parent cd18581bdd
commit da004b5c3e
29 changed files with 5286 additions and 526 deletions
+200 -67
View File
@@ -159,8 +159,8 @@ export function MedDetailModal({
// Escape key: only one handler is active at a time (sub-modal states are mutually exclusive).
// Lightbox has its own useEscapeKey internally.
useEscapeKey(!showEditStockModal && !showImageLightbox && !showRefillModal, onClose);
useEscapeKey(showEditStockModal, onCloseEditStockModal);
useEscapeKey(showRefillModal, onCloseRefillModal);
useEscapeKey(showEditStockModal, onCloseEditStockModal, { capture: true });
useEscapeKey(showRefillModal, onCloseRefillModal, { capture: true });
useEffect(() => {
if (showEditStockModal) return;
@@ -192,12 +192,12 @@ export function MedDetailModal({
]);
if (!selectedMed) return null;
const isTube = selectedMed.packageType === "tube" || selectedMed.packageType === "liquid_container";
const stockUnitLabel = isTube
? selectedMed.packageType === "liquid_container" || selectedMed.medicationForm === "liquid"
? "ml"
: t("form.blisters.applications")
: null;
const isAmountPackage = selectedMed.packageType === "tube" || selectedMed.packageType === "liquid_container";
const amountUnitLabel =
selectedMed.packageType === "liquid_container" || selectedMed.medicationForm === "liquid"
? t("form.packageAmountUnitMl")
: t("form.packageAmountUnitG");
const stockUnitLabel = isAmountPackage ? amountUnitLabel : null;
const medCoverage = coverage.all.find((c) => c.name === getMedDisplayName(selectedMed));
const packageSize = getPackageSize(selectedMed);
@@ -222,6 +222,18 @@ export function MedDetailModal({
selectedMed.packageType === "liquid_container"
? (selectedMed.totalPills ?? packageSize)
: Math.max(0, structuralMax);
const packageCount = Math.max(1, Number(selectedMed.packCount) || 1);
const amountPerPackage = (() => {
const configured = Number(selectedMed.packageAmountValue ?? 0);
if (Number.isFinite(configured) && configured > 0) return configured;
const totalAmount = Number(stockDisplayTotal ?? 0);
if (Number.isFinite(totalAmount) && totalAmount > 0) {
return Math.max(0, totalAmount / packageCount);
}
return 0;
})();
const maxPartialPills = Math.min(
Math.max(0, selectedMed.pillsPerBlister),
Math.max(0, structuralMax - Math.max(0, editStockFullBlisters) * selectedMed.pillsPerBlister)
@@ -231,6 +243,35 @@ export function MedDetailModal({
const closeLabel = t("common.close");
const decrementLabel = t("editStock.decreaseValue");
const incrementLabel = t("editStock.increaseValue");
const getScheduleUsageLabel = (usage: number, intakeUnit?: "ml" | "tsp" | "tbsp" | null) => {
if (selectedMed.packageType === "liquid_container") {
if (intakeUnit === "tsp") {
return `${usage} ${t("form.blisters.teaspoons", { count: Math.abs(usage) })}`;
}
if (intakeUnit === "tbsp") {
return `${usage} ${t("form.blisters.tablespoons", { count: Math.abs(usage) })}`;
}
return `${usage} ${t("form.packageAmountUnitMl")}`;
}
if (selectedMed.packageType === "tube") {
return `${usage} ${t("form.blisters.applications", { count: Math.abs(usage) })}`;
}
return `${usage} ${usage !== 1 ? t("common.pills") : t("common.pill")}`;
};
const scheduleIntakes =
selectedMed.intakes && selectedMed.intakes.length > 0
? selectedMed.intakes
: selectedMed.blisters.map((blister) => ({
usage: blister.usage,
every: blister.every,
start: blister.start,
takenBy: null,
intakeRemindersEnabled: selectedMed.intakeRemindersEnabled ?? false,
intakeUnit: null,
}));
const hasAnyIntakeReminder = scheduleIntakes.some(
(intake) => (intake.intakeRemindersEnabled ?? selectedMed.intakeRemindersEnabled ?? false) === true
);
const normalizeBlisterStock = (nextFull: number, nextPartial: number, nextLoose: number) => {
let normalizedFull = Math.max(0, nextFull);
let normalizedPartial = Math.max(0, nextPartial);
@@ -359,6 +400,10 @@ export function MedDetailModal({
const renderEditStockModal = () => {
if (!showEditStockModal) return null;
const isLiquidPackage = selectedMed.packageType === "liquid_container";
const liquidBottleCount = Math.max(1, editStockFullBlisters);
const liquidAmountPerBottle = Math.max(1, Number.isFinite(amountPerPackage) ? amountPerPackage : 1);
const liquidCapacity = Math.max(1, Math.round(liquidBottleCount * liquidAmountPerBottle));
const fullInputMax = Math.min(
maxFullBlisters,
Math.floor(Math.max(0, structuralMax - Math.max(0, editStockPartialBlisterPills)) / selectedMed.pillsPerBlister)
@@ -372,14 +417,14 @@ export function MedDetailModal({
onCloseEditStockModal();
}}
onKeyDown={(e) => {
if (e.key !== "Escape") e.stopPropagation();
e.stopPropagation();
}}
>
<div
className="modal-content edit-stock-modal"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key !== "Escape") e.stopPropagation();
e.stopPropagation();
}}
>
<button
@@ -404,11 +449,15 @@ export function MedDetailModal({
})}
</p>
)}
{(selectedMed.packageType === "bottle" ||
selectedMed.packageType === "tube" ||
selectedMed.packageType === "liquid_container") && (
{selectedMed.packageType === "bottle" && (
<p className="edit-stock-cap-info">{t("editStock.packageSize", { count: structuralMax })}</p>
)}
{(selectedMed.packageType === "tube" || selectedMed.packageType === "liquid_container") && (
<p className="edit-stock-cap-info">
{t("form.totalAmount")}: {formatNumber(isLiquidPackage ? liquidCapacity : structuralMax)}{" "}
{amountUnitLabel}
</p>
)}
{showStockCapNotice && (
<p className="edit-stock-cap-warning">{t("editStock.maxExceeded", { count: structuralMax })}</p>
)}
@@ -420,11 +469,13 @@ export function MedDetailModal({
selectedMed.packageType === "bottle" ||
selectedMed.packageType === "tube" ||
selectedMed.packageType === "liquid_container";
const enteredTotal = isBottle
? editStockPartialBlisterPills
: editStockFullBlisters * selectedMed.pillsPerBlister +
editStockPartialBlisterPills +
editStockLoosePills;
const enteredTotal = isLiquidPackage
? Math.min(liquidCapacity, editStockPartialBlisterPills)
: isBottle
? editStockPartialBlisterPills
: editStockFullBlisters * selectedMed.pillsPerBlister +
editStockPartialBlisterPills +
editStockLoosePills;
const newTotal = Math.max(0, enteredTotal);
const difference = newTotal - currentTotal;
const differenceClass = difference > 0 ? "positive" : difference < 0 ? "negative" : "";
@@ -434,36 +485,39 @@ export function MedDetailModal({
<div className="edit-stock-form">
{isBottle ? (
<label>
{t("editStock.totalPills")}
{isAmountPackage ? t("form.currentAmount") : t("editStock.totalPills")}
{renderStepperInput({
value: editStockPartialInput,
min: 0,
max: structuralMax,
max: isLiquidPackage ? liquidCapacity : structuralMax,
onChange: (raw) => {
const parsed = raw === "" ? 0 : Math.max(0, parseStockInput(raw));
setEditStockPartialInput(raw);
onEditStockPartialBlisterPillsChange(raw === "" ? 0 : Math.min(structuralMax, parsed));
setShowStockCapNotice(parsed > structuralMax);
const maxTotal = isLiquidPackage ? liquidCapacity : structuralMax;
onEditStockPartialBlisterPillsChange(raw === "" ? 0 : Math.min(maxTotal, parsed));
setShowStockCapNotice(parsed > maxTotal);
},
onBlur: () => {
const normalized = Math.min(
structuralMax,
Math.max(0, parseStockInput(editStockPartialInput))
);
const maxTotal = isLiquidPackage ? liquidCapacity : structuralMax;
const normalized = Math.min(maxTotal, Math.max(0, parseStockInput(editStockPartialInput)));
onEditStockPartialBlisterPillsChange(normalized);
setEditStockPartialInput(String(normalized));
setShowStockCapNotice(false);
},
onStep: (delta) => {
const next = Math.min(
structuralMax,
Math.max(0, parseStockInput(editStockPartialInput) + delta)
);
const maxTotal = isLiquidPackage ? liquidCapacity : structuralMax;
const next = Math.min(maxTotal, Math.max(0, parseStockInput(editStockPartialInput) + delta));
onEditStockPartialBlisterPillsChange(next);
setEditStockPartialInput(String(next));
setShowStockCapNotice(false);
},
})}
{isLiquidPackage && (
<p className="edit-stock-cap-info" style={{ marginTop: "0.35rem" }}>
{t("form.currentAmount")}: {Math.max(0, editStockPartialBlisterPills)} {amountUnitLabel} /{" "}
{liquidCapacity} {amountUnitLabel}
</p>
)}
</label>
) : (
<>
@@ -601,6 +655,43 @@ export function MedDetailModal({
</label>
</>
)}
{isLiquidPackage && (
<label>
{t("form.bottles")}
{renderStepperInput({
value: editStockFullInput,
min: 1,
max: Number.MAX_SAFE_INTEGER,
onChange: (raw) => {
const nextBottleCount = raw === "" ? 1 : Math.max(1, parseStockInput(raw));
setEditStockFullInput(raw === "" ? "1" : raw);
onEditStockFullBlistersChange(nextBottleCount);
const syncedTotal = Math.round(nextBottleCount * liquidAmountPerBottle);
onEditStockPartialBlisterPillsChange(syncedTotal);
setEditStockPartialInput(String(syncedTotal));
setShowStockCapNotice(false);
},
onBlur: () => {
const normalized = Math.max(1, parseStockInput(editStockFullInput));
onEditStockFullBlistersChange(normalized);
setEditStockFullInput(String(normalized));
const syncedTotal = Math.round(normalized * liquidAmountPerBottle);
onEditStockPartialBlisterPillsChange(syncedTotal);
setEditStockPartialInput(String(syncedTotal));
setShowStockCapNotice(false);
},
onStep: (delta) => {
const next = Math.max(1, parseStockInput(editStockFullInput) + delta);
onEditStockFullBlistersChange(next);
setEditStockFullInput(String(next));
const syncedTotal = Math.round(next * liquidAmountPerBottle);
onEditStockPartialBlisterPillsChange(syncedTotal);
setEditStockPartialInput(String(syncedTotal));
setShowStockCapNotice(false);
},
})}
</label>
)}
</div>
<div className="edit-stock-summary">
@@ -608,14 +699,18 @@ export function MedDetailModal({
<span>{t("editStock.currentTotal")}:</span>
<span>
{currentTotal}
{isTube ? ` ${stockUnitLabel}` : ` ${currentTotal === 1 ? t("common.pill") : t("common.pills")}`}
{isAmountPackage
? ` ${stockUnitLabel}`
: ` ${currentTotal === 1 ? t("common.pill") : t("common.pills")}`}
</span>
</div>
<div className="summary-row">
<span>{t("editStock.newTotal")}:</span>
<span>
{newTotal}
{isTube ? ` ${stockUnitLabel}` : ` ${newTotal === 1 ? t("common.pill") : t("common.pills")}`}
{isAmountPackage
? ` ${stockUnitLabel}`
: ` ${newTotal === 1 ? t("common.pill") : t("common.pills")}`}
</span>
</div>
<div className={`summary-row difference ${differenceClass}`}>
@@ -623,7 +718,7 @@ export function MedDetailModal({
<span>
{difference > 0 ? "+" : ""}
{difference}
{isTube
{isAmountPackage
? ` ${stockUnitLabel}`
: ` ${Math.abs(difference) === 1 ? t("common.pill") : t("common.pills")}`}
</span>
@@ -738,9 +833,13 @@ export function MedDetailModal({
</>
)}
<div className={`med-detail-item ${selectedMed.packageType === "bottle" ? "full-width" : "full-width"}`}>
<span className="med-detail-label">{t("modal.currentStock")}</span>
<span className="med-detail-label">
{isAmountPackage ? t("form.currentAmount") : t("modal.currentStock")}
</span>
<span className={`med-detail-value ${textClass}`}>
{currentStock} / {stockDisplayTotal}
{isAmountPackage
? `${formatNumber(currentStock)} / ${formatNumber(stockDisplayTotal)} ${amountUnitLabel}`
: `${currentStock} / ${stockDisplayTotal}`}
{currentStock > stockDisplayTotal && (
<span
className="info-tooltip tooltip-align-left warning-text"
@@ -767,6 +866,16 @@ export function MedDetailModal({
? t("form.packageTypeLiquidContainer")
: t("form.packageTypeBlister")}
)
{selectedMed.packageType === "tube" && (
<span className="info-tooltip small" data-tooltip={t("modal.packageTypeTubeHint")}>
</span>
)}
{selectedMed.packageType === "liquid_container" && (
<span className="info-tooltip small" data-tooltip={t("modal.packageTypeLiquidHint")}>
</span>
)}
</h3>
<div className="med-detail-grid">
{selectedMed.packageType === "blister" ? (
@@ -784,9 +893,47 @@ export function MedDetailModal({
<span className="med-detail-value">{selectedMed.pillsPerBlister}</span>
</div>
</>
) : selectedMed.packageType === "liquid_container" ? (
<>
<div className="med-detail-item">
<span className="med-detail-label">{t("form.bottles")}</span>
<span className="med-detail-value">{packageCount}</span>
</div>
<div className="med-detail-item">
<span className="med-detail-label">{t("form.packageAmountPerBottle")}</span>
<span className="med-detail-value">
{formatNumber(amountPerPackage)} {amountUnitLabel}
</span>
</div>
<div className="med-detail-item">
<span className="med-detail-label">{t("form.totalAmount")}</span>
<span className="med-detail-value">
{formatNumber(stockDisplayTotal)} {amountUnitLabel}
</span>
</div>
</>
) : selectedMed.packageType === "tube" ? (
<>
<div className="med-detail-item">
<span className="med-detail-label">{t("form.tubes")}</span>
<span className="med-detail-value">{packageCount}</span>
</div>
<div className="med-detail-item">
<span className="med-detail-label">{t("form.packageAmountPerTube")}</span>
<span className="med-detail-value">
{formatNumber(amountPerPackage)} {amountUnitLabel}
</span>
</div>
<div className="med-detail-item">
<span className="med-detail-label">{t("form.totalAmount")}</span>
<span className="med-detail-value">
{formatNumber(stockDisplayTotal)} {amountUnitLabel}
</span>
</div>
</>
) : (
<div className="med-detail-item">
<span className="med-detail-label">{isTube ? t("form.totalAmount") : t("form.totalCapacity")}</span>
<span className="med-detail-label">{t("form.totalCapacity")}</span>
<span className="med-detail-value">{(selectedMed.totalPills ?? packageSize) || "—"}</span>
</div>
)}
@@ -820,54 +967,33 @@ export function MedDetailModal({
<div className="med-detail-section">
<h3>
{t("modal.intakeSchedule")}{" "}
{selectedMed.intakeRemindersEnabled && (
{(selectedMed.intakeRemindersEnabled || hasAnyIntakeReminder) && (
<span className="reminder-icon info-tooltip" data-tooltip={t("tooltips.intakeReminders")}>
<Bell size={14} aria-hidden="true" />
</span>
)}
</h3>
<div className="med-detail-schedules">
{(selectedMed.intakes && selectedMed.intakes.length > 0
? selectedMed.intakes
: selectedMed.blisters.map((blister) => ({
usage: blister.usage,
every: blister.every,
start: blister.start,
takenBy: null,
intakeRemindersEnabled: selectedMed.intakeRemindersEnabled ?? false,
}))
).map((intake, idx) => {
{scheduleIntakes.map((intake, idx) => {
const hasPerIntakeTakenBy = !!intake.takenBy;
const personCount = Math.max(1, selectedMed.takenBy?.length ?? 0);
const totalUsage = hasPerIntakeTakenBy ? intake.usage : intake.usage * personCount;
const showIntakeBell = intake.intakeRemindersEnabled ?? selectedMed.intakeRemindersEnabled ?? false;
return (
<div key={`${intake.start}-${intake.usage}-${intake.every}-${idx}`} className="med-schedule-item">
<div
key={`${intake.start}-${intake.usage}-${intake.every}-${idx}`}
className="med-schedule-row blister-row-simple"
>
<span className="med-schedule-usage">
{totalUsage}
{isTube ? ` ${stockUnitLabel}` : ` ${totalUsage !== 1 ? t("common.pills") : t("common.pill")}`}
{getScheduleUsageLabel(totalUsage, intake.intakeUnit)}
{selectedMed.pillWeightMg &&
` (${totalUsage * selectedMed.pillWeightMg} ${selectedMed.doseUnit ?? "mg"})`}
</span>
<span className="med-schedule-freq">
{intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every })}
</span>
{hasPerIntakeTakenBy && (
<span className="med-schedule-person">
{intake.takenBy}
{showIntakeBell && (
<span className="med-schedule-bell" role="img" aria-label={t("tooltips.intakeReminders")}>
<Bell size={13} aria-hidden="true" />
</span>
)}
</span>
)}
{!hasPerIntakeTakenBy && showIntakeBell && (
<span className="med-schedule-bell" role="img" aria-label={t("tooltips.intakeReminders")}>
<Bell size={13} aria-hidden="true" />
</span>
)}
{hasPerIntakeTakenBy && <span className="med-schedule-person">{intake.takenBy}</span>}
<span className="med-schedule-time">
{t("modal.at")}{" "}
{new Date(intake.start).toLocaleTimeString(getSystemLocale(i18n.language), {
@@ -875,6 +1001,11 @@ export function MedDetailModal({
minute: "2-digit",
})}
</span>
{showIntakeBell && (
<span className="med-schedule-bell" title={t("form.blisters.remindTooltip")}>
<Bell size={12} aria-hidden="true" />
</span>
)}
</div>
);
})}
@@ -991,7 +1122,7 @@ export function MedDetailModal({
? entry.loosePillsAdded
: entry.packsAdded * selectedMed.blistersPerPack * selectedMed.pillsPerBlister +
entry.loosePillsAdded;
return `+${total}${isTube ? ` ${stockUnitLabel}` : ` ${total === 1 ? t("common.pill") : t("common.pills")}`}`;
return `+${total}${isAmountPackage ? ` ${stockUnitLabel}` : ` ${total === 1 ? t("common.pill") : t("common.pills")}`}`;
})()}
{entry.usedPrescription && (
<span className="refill-prescription-badge" title={t("refill.viaPrescription")}>
@@ -1179,7 +1310,9 @@ export function MedDetailModal({
return totalRefill > 0 ? (
<span className="refill-preview">
+{totalRefill}
{isTube ? ` ${stockUnitLabel}` : ` ${totalRefill === 1 ? t("common.pill") : t("common.pills")}`}
{isAmountPackage
? ` ${stockUnitLabel}`
: ` ${totalRefill === 1 ? t("common.pill") : t("common.pills")}`}
</span>
) : null;
})()}
+73 -52
View File
@@ -5,7 +5,7 @@
/* biome-ignore-all lint/a11y/noLabelWithoutControl: modal uses custom DateInput and static value fields */
import { Bell, Minus, Plus, Trash2 } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useEscapeKey } from "../hooks/useEscapeKey";
import { useScrollLock } from "../hooks/useScrollLock";
@@ -131,16 +131,21 @@ export function MobileEditModal({
return form.pillForm === "tablet";
}, [form.packageType, form.medicationForm, form.pillForm]);
const usageLabel = useMemo(() => {
if (form.packageType === "liquid_container") {
return t("form.blisters.usageMl");
}
if (form.packageType === "tube") {
return form.medicationForm === "liquid" ? t("form.blisters.usageMl") : t("form.blisters.usageApplication");
}
if (form.pillForm === "capsule") return t("form.blisters.usageCapsules");
return t("form.blisters.usageTablets");
}, [form.packageType, form.medicationForm, form.pillForm, t]);
const getUsageLabel = useCallback(
(intake: (typeof form.intakes)[number]) => {
if (form.packageType === "liquid_container") {
if (intake.intakeUnit === "tsp") return t("form.blisters.usageTsp");
if (intake.intakeUnit === "tbsp") return t("form.blisters.usageTbsp");
return t("form.blisters.usageMl");
}
if (form.packageType === "tube") {
return form.medicationForm === "liquid" ? t("form.blisters.usageMl") : t("form.blisters.usageApplication");
}
if (form.pillForm === "capsule") return t("form.blisters.usageCapsules");
return t("form.blisters.usageTablets");
},
[form.packageType, form.medicationForm, form.pillForm, t]
);
const usesAmountLabels = form.packageType === "tube" || form.packageType === "liquid_container";
const totalCapacityLabel = usesAmountLabels ? t("form.totalAmount") : t("form.totalCapacity");
@@ -433,6 +438,14 @@ export function MobileEditModal({
<option value="liquid_container">{t("form.packageTypeLiquidContainer")}</option>
</select>
</label>
<label className="full">
{t("form.medicationEndDate")}
<DateInput
value={form.medicationEndDate}
onChange={(e) => onHandleValueChange("medicationEndDate", e.target.value)}
placeholder={t("common.optional")}
/>
</label>
{form.packageType !== "tube" && form.packageType !== "liquid_container" && (
<label className="full">
{t("form.pillForm")}
@@ -461,14 +474,6 @@ export function MobileEditModal({
</select>
</label>
)}
<label className="full">
{t("form.medicationEndDate")}
<DateInput
value={form.medicationEndDate}
onChange={(e) => onHandleValueChange("medicationEndDate", e.target.value)}
placeholder={t("common.optional")}
/>
</label>
{form.medicationEndDate && (
<label className="full">
{t("form.autoMarkObsoleteAfterEndDate")}
@@ -601,13 +606,7 @@ export function MobileEditModal({
<>
<label>
{t("form.tubes")}
<FormNumberStepper
value={form.packCount}
onChange={(nextValue) => onHandleValueChange("packCount", nextValue)}
min={1}
decrementLabel={decrementValueLabel}
incrementLabel={incrementValueLabel}
/>
<div className="static-value">1</div>
</label>
<label className="full">
{t("form.packageAmountPerTube")}
@@ -641,6 +640,51 @@ export function MobileEditModal({
);
}
if (form.packageType === "liquid_container") {
return (
<>
<label>
{t("form.bottles")}
<FormNumberStepper
value={form.packCount}
onChange={(nextValue) => onHandleValueChange("packCount", nextValue)}
min={1}
decrementLabel={decrementValueLabel}
incrementLabel={incrementValueLabel}
/>
</label>
<label className="full">
{t("form.packageAmountPerBottle")}
<div className="dose-input-group">
<input
type="text"
inputMode="decimal"
pattern="[0-9]*\.?[0-9]*"
value={form.packageAmountValue ?? "0"}
onChange={(e) => onHandleValueChange("packageAmountValue", e.target.value)}
placeholder="0"
/>
<select
value="ml"
disabled
className="dose-unit-select"
aria-label={t("form.packageAmountUnitMl")}
>
<option value="ml">{t("form.packageAmountUnitMl")}</option>
</select>
</div>
</label>
<label>
{t("form.totalAmount")}
<div className="static-value">
{(Number(form.packCount) || 0) * (Number(form.packageAmountValue ?? 0) || 0)}{" "}
{t("form.packageAmountUnitMl")}
</div>
</label>
</>
);
}
return (
<>
<label>
@@ -666,7 +710,7 @@ export function MobileEditModal({
</>
);
})()}
{(form.packageType === "bottle" || form.packageType === "liquid_container") && (
{form.packageType === "bottle" && (
<div className="full stock-total-row">
<div className="stock-total-field">
<p className="sub">
@@ -678,29 +722,6 @@ export function MobileEditModal({
</div>
</div>
)}
{form.packageType === "liquid_container" && (
<label className="full">
{t("form.packageAmount")}
<div className="dose-input-group">
<input
type="text"
inputMode="decimal"
pattern="[0-9]*\.?[0-9]*"
value={form.packageAmountValue ?? "0"}
onChange={(e) => onHandleValueChange("packageAmountValue", e.target.value)}
placeholder="0"
/>
<select
value="ml"
disabled
className="dose-unit-select"
aria-label={t("form.packageAmountUnitMl")}
>
<option value="ml">{t("form.packageAmountUnitMl")}</option>
</select>
</div>
</label>
)}
{form.packageType !== "tube" && form.packageType !== "liquid_container" && (
<label className="full">
{t("form.pillWeight")} ({form.doseUnit})
@@ -778,11 +799,11 @@ export function MobileEditModal({
</div>
{form.intakes.map((intake, idx) => (
<div
key={`${intake.startDate}-${intake.startTime}-${intake.usage}-${intake.every}-${intake.takenBy ?? ""}`}
key={`${intake.startDate}-${intake.startTime}-${intake.usage}-${intake.every}-${intake.takenBy ?? ""}-${idx}`}
className="blister-row"
>
<label className="compact">
<span>{usageLabel}</span>
<span>{getUsageLabel(intake)}</span>
<FormNumberStepper
value={intake.usage}
onChange={(nextValue) => onSetIntakeValue(idx, "usage", nextValue)}
+9 -21
View File
@@ -324,6 +324,13 @@ function getCurrentStockText(med: Medication, t: TFn): string {
return `${getPackageSize(med)} ${t("common.pills")}`;
}
function getReportPackageTypeLabel(med: Medication, t: TFn): string {
if (med.packageType === "bottle") return t("report.docBottle");
if (med.packageType === "tube") return t("report.docTube");
if (med.packageType === "liquid_container") return t("form.packageTypeLiquidContainer");
return t("report.docBlister");
}
function generateTextReport(
meds: Medication[],
reportData: ReportData,
@@ -366,18 +373,7 @@ function generateTextReport(
// Package / Stock
lines.push(h3(t("report.docPackage")));
lines.push(
item(
t("report.docPackageType"),
med.packageType === "bottle"
? t("report.docBottle")
: med.packageType === "tube"
? t("report.docTube")
: med.packageType === "liquid_container"
? t("form.packageTypeLiquidContainer")
: t("report.docBlister")
)
);
lines.push(item(t("report.docPackageType"), getReportPackageTypeLabel(med, t)));
if (med.packageType === "blister") {
lines.push(item(t("report.docPacks"), String(med.packCount)));
lines.push(item(t("report.docBlistersPerPack"), String(med.blistersPerPack)));
@@ -575,15 +571,7 @@ function buildPrintHtml(
// Package / Stock
s += `<h3>${escHtml(t("report.docPackage"))}</h3>`;
s += `<table><tbody>`;
s += `<tr><td class="label">${escHtml(t("report.docPackageType"))}</td><td>${escHtml(
med.packageType === "bottle"
? t("report.docBottle")
: med.packageType === "tube"
? t("report.docTube")
: med.packageType === "liquid_container"
? t("form.packageTypeLiquidContainer")
: t("report.docBlister")
)}</td></tr>`;
s += `<tr><td class="label">${escHtml(t("report.docPackageType"))}</td><td>${escHtml(getReportPackageTypeLabel(med, t))}</td></tr>`;
if (med.packageType === "blister") {
s += `<tr><td class="label">${escHtml(t("report.docPacks"))}</td><td>${med.packCount}</td></tr>`;
s += `<tr><td class="label">${escHtml(t("report.docBlistersPerPack"))}</td><td>${med.blistersPerPack}</td></tr>`;
+176 -37
View File
@@ -21,10 +21,19 @@ import { MedicationAvatar } from "./MedicationAvatar";
function getStockStatus(
daysLeft: number | null,
medsLeft: number,
thresholds: { lowStockDays: number; normalStockDays: number; highStockDays: number; criticalStockDays: number }
thresholds: { lowStockDays: number; normalStockDays: number; highStockDays: number; criticalStockDays: number },
packageType?: string
) {
if (packageType === "tube") return { className: "success", label: "status.noSchedule" };
if (medsLeft <= 0 || daysLeft === 0) return { className: "danger", label: "status.outOfStock" };
if (daysLeft === null) return { className: "success", label: "status.noSchedule" };
if (packageType === "liquid_container") {
const lowDays = Math.max(1, Math.floor(thresholds.criticalStockDays));
const criticalDays = Math.max(1, Math.ceil(lowDays / 2));
if (daysLeft <= criticalDays) return { className: "danger", label: "status.criticalStock" };
if (daysLeft <= lowDays) return { className: "warning", label: "status.lowStock" };
return { className: "success", label: "status.normal" };
}
if (daysLeft <= thresholds.criticalStockDays) return { className: "danger", label: "status.criticalStock" };
if (daysLeft < thresholds.lowStockDays) return { className: "warning", label: "status.lowStock" };
if (daysLeft >= thresholds.highStockDays) return { className: "high", label: "status.highStock" };
@@ -44,6 +53,100 @@ export function SharedSchedule() {
const [showPastDays, setShowPastDays] = useState(false);
const [showFutureDays, setShowFutureDays] = useState(false);
const isLiquidContainerMed = (med: SharedScheduleData["medications"][number] | undefined) =>
med?.packageType === "liquid_container";
const convertLiquidUsageToMl = (usage: number, unit: "ml" | "tsp" | "tbsp" | null | undefined): number => {
if (unit === "tsp") return usage * 5;
if (unit === "tbsp") return usage * 15;
return usage;
};
const convertUsageForStock = (
usage: number,
med: SharedScheduleData["medications"][number] | undefined,
unit: "ml" | "tsp" | "tbsp" | null | undefined
): number => {
if (med?.packageType === "tube") return 0;
if (!isLiquidContainerMed(med)) return usage;
return convertLiquidUsageToMl(usage, unit);
};
const formatAmount = (value: number) => {
const rounded = Math.round(value * 100) / 100;
return String(rounded);
};
const getLiquidCountUnitLabel = (unit: "ml" | "tsp" | "tbsp" | null | undefined, usage: number): string => {
if (unit === "tsp") return t("form.blisters.teaspoons", { count: Math.abs(usage) });
if (unit === "tbsp") return t("form.blisters.tablespoons", { count: Math.abs(usage) });
return t("form.packageAmountUnitMl");
};
const formatLiquidUsageLabel = (usage: number, unit: "ml" | "tsp" | "tbsp" | null | undefined): string => {
const normalizedUsage = Number(usage);
if (!Number.isFinite(normalizedUsage) || normalizedUsage <= 0) {
return `0 ${t("form.packageAmountUnitMl")}`;
}
if (unit === "ml" || unit == null) {
return `${formatAmount(normalizedUsage)} ${t("form.packageAmountUnitMl")}`;
}
const mlTotal = convertLiquidUsageToMl(normalizedUsage, unit);
return `${formatAmount(normalizedUsage)} ${getLiquidCountUnitLabel(unit, normalizedUsage)} ${formatAmount(mlTotal)} ${t("form.packageAmountUnitMl")}`;
};
const formatDoseUsageLabel = (
med: SharedScheduleData["medications"][number] | undefined,
usage: number,
intakeUnit?: "ml" | "tsp" | "tbsp" | null
) => {
if (isLiquidContainerMed(med)) {
return formatLiquidUsageLabel(usage, intakeUnit);
}
return `${usage} ${usage !== 1 ? t("common.pills") : t("common.pill")}`;
};
const formatTotalUsageLabel = (
med: SharedScheduleData["medications"][number] | undefined,
total: number,
doses?: Array<{ usage: number; intakeUnit?: "ml" | "tsp" | "tbsp" | null }>
) => {
if (isLiquidContainerMed(med)) {
if (doses && doses.length > 0) {
const normalizedDoses = doses.filter((dose) => Number.isFinite(Number(dose.usage)) && Number(dose.usage) > 0);
if (normalizedDoses.length > 0) {
const allUnits = new Set(normalizedDoses.map((dose) => dose.intakeUnit ?? "ml"));
if (allUnits.size === 1) {
const onlyUnit = normalizedDoses[0]?.intakeUnit ?? "ml";
const totalUsageInUnit = normalizedDoses.reduce((sum, dose) => sum + Number(dose.usage), 0);
return formatLiquidUsageLabel(totalUsageInUnit, onlyUnit);
}
const totalMl = normalizedDoses.reduce(
(sum, dose) => sum + convertLiquidUsageToMl(Number(dose.usage), dose.intakeUnit ?? "ml"),
0
);
return `${formatAmount(totalMl)} ${t("form.packageAmountUnitMl")}`;
}
}
return `${formatAmount(total)} ${t("form.packageAmountUnitMl")}`;
}
return t("common.pillsTotal", { count: total });
};
const shouldHideNoScheduleStatusForTube = (
med: SharedScheduleData["medications"][number] | undefined,
status: { className: string; label: string } | null
) => med?.packageType === "tube" && status?.label === "status.noSchedule";
const getVisibleStockStatus = (
med: SharedScheduleData["medications"][number] | undefined,
status: { className: string; label: string } | null
) => (shouldHideNoScheduleStatusForTube(med, status) ? null : status);
// Theme preference: light, dark, or system
type ThemePreference = "light" | "dark" | "system";
const [themePreference, setThemePreference] = useState<ThemePreference>(() => {
@@ -309,6 +412,7 @@ export function SharedSchedule() {
when: number;
medName: string;
usage: number;
intakeUnit?: "ml" | "tsp" | "tbsp" | null;
timeStr: string;
isPast: boolean;
takenBy: string | null; // Per-intake takenBy (single person or null)
@@ -345,6 +449,7 @@ export function SharedSchedule() {
when: t,
medName: getMedDisplayName(med),
usage: intake.usage,
intakeUnit: intake.intakeUnit ?? null,
isPast,
takenBy: intake.takenBy, // Per-intake takenBy (string | null)
timeStr: d.toLocaleTimeString(getSystemLocale(i18n.language), { hour: "2-digit", minute: "2-digit" }),
@@ -431,7 +536,6 @@ export function SharedSchedule() {
for (const med of data.medications) {
const intakes = med.intakes || med.blisters.map((b) => ({ ...b, takenBy: null as string | null }));
const blisters = med.blisters;
// Count unique people from all intakes (for per-intake takenBy)
const uniquePeople = new Set<string>();
@@ -443,9 +547,9 @@ export function SharedSchedule() {
// Calculate daily consumption rate accounting for per-intake takenBy
let dailyRate = 0;
blisters.forEach((s, idx) => {
const baseRate = s.every > 0 ? s.usage / s.every : 0;
const intake = intakes[idx];
intakes.forEach((intake) => {
const usageForStock = convertUsageForStock(intake.usage, med, intake.intakeUnit ?? "ml");
const baseRate = intake.every > 0 ? usageForStock / intake.every : 0;
if (intake?.takenBy) {
dailyRate += baseRate; // Per-intake takenBy: 1 person
} else {
@@ -458,9 +562,10 @@ export function SharedSchedule() {
if (calcMode === "automatic") {
// Time-based: every scheduled dose counts as consumed once its time has passed
blisters.forEach((s, blisterIdx) => {
const blisterStart = new Date(s.start).getTime();
const period = Math.max(1, s.every) * MS_PER_DAY;
intakes.forEach((intake, blisterIdx) => {
const usageForStock = convertUsageForStock(intake.usage, med, intake.intakeUnit ?? "ml");
const blisterStart = new Date(intake.start).getTime();
const period = Math.max(1, intake.every) * MS_PER_DAY;
let effectiveStart: number;
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart) {
@@ -472,7 +577,6 @@ export function SharedSchedule() {
}
if (Number.isNaN(effectiveStart)) return;
const intake = intakes[blisterIdx];
const intakePerson = intake?.takenBy;
const fallbackPeople = med.takenBy?.length > 0 ? med.takenBy : [null];
const peopleForThisIntake = intakePerson ? [intakePerson] : fallbackPeople;
@@ -482,7 +586,7 @@ export function SharedSchedule() {
if (effectiveStart <= now) {
const occurrences = Math.floor((now - effectiveStart) / period) + 1;
timeBasedConsumed = occurrences * s.usage * peopleForThisIntake.length;
timeBasedConsumed = occurrences * usageForStock * peopleForThisIntake.length;
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period);
lastAutoConsumedDateMs = new Date(
lastDoseTime.getFullYear(),
@@ -510,7 +614,7 @@ export function SharedSchedule() {
const bIdx = parseInt(parts[1], 10);
const timestamp = parseInt(parts[2], 10);
if (medId === med.id && bIdx === blisterIdx && timestamp > earlyCutoff) {
earlyTakenConsumed += s.usage;
earlyTakenConsumed += usageForStock;
}
}
}
@@ -525,8 +629,8 @@ export function SharedSchedule() {
const medId = parseInt(parts[0], 10);
const blisterIdx = parseInt(parts[1], 10);
const doseTimestamp = parseInt(parts[2], 10);
if (medId === med.id && blisters[blisterIdx]) {
const blisterStartDate = new Date(blisters[blisterIdx].start);
if (medId === med.id && intakes[blisterIdx]) {
const blisterStartDate = new Date(intakes[blisterIdx].start);
const blisterStartDateOnly = new Date(
blisterStartDate.getFullYear(),
blisterStartDate.getMonth(),
@@ -534,7 +638,11 @@ export function SharedSchedule() {
).getTime();
const afterCorrection = stockCorrectionCutoff === 0 || doseTimestamp > stockCorrectionCutoff;
if (!Number.isNaN(blisterStartDateOnly) && doseTimestamp >= blisterStartDateOnly && afterCorrection) {
consumed += blisters[blisterIdx].usage;
consumed += convertUsageForStock(
intakes[blisterIdx].usage,
med,
intakes[blisterIdx].intakeUnit ?? "ml"
);
}
}
}
@@ -569,11 +677,13 @@ export function SharedSchedule() {
function getDayStockStatus(meds: { medName: string; lastWhen: number }[]) {
const statuses = meds.map((item) => {
const coverage = coverageByMed[item.medName];
const med = data?.medications.find((m) => getMedDisplayName(m) === item.medName);
const depletionTime = depletionByMed[item.medName];
if (typeof depletionTime === "number" && item.lastWhen > depletionTime) return "danger";
if (!coverage) return "success";
const status = getStockStatus(coverage.daysLeft, coverage.medsLeft, stockThresholds);
return status.className;
const rawStatus = getStockStatus(coverage.daysLeft, coverage.medsLeft, stockThresholds, med?.packageType);
const status = getVisibleStockStatus(med, rawStatus);
return status?.className ?? "success";
});
const fallbackStatus = statuses.includes("warning") ? "warning" : "success";
return statuses.includes("danger") ? "danger" : fallbackStatus;
@@ -583,6 +693,11 @@ export function SharedSchedule() {
const showStock = data?.shareStockStatus !== false;
const showOnlyToday = data?.shareScheduleTodayOnly === true && (data?.upcomingTodayOnly ?? true);
const renderDoseUsage = (
med: SharedScheduleData["medications"][number] | undefined,
dose: { usage: number; intakeUnit?: "ml" | "tsp" | "tbsp" | null }
) => formatDoseUsageLabel(med, dose.usage, dose.intakeUnit);
// Helper: check if a dose is "done" (taken, per-dose dismissed, or med-level dismissed)
function isDoseIdDone(doseId: string): boolean {
if (takenDoses.has(doseId)) return true;
@@ -809,9 +924,15 @@ export function SharedSchedule() {
? willBeOutOfStock
? { className: "danger", label: "status.outOfStock" }
: medCoverage
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, stockThresholds)
? getStockStatus(
medCoverage.daysLeft,
medCoverage.medsLeft,
stockThresholds,
med?.packageType
)
: null
: null;
const visibleStatus = getVisibleStockStatus(med, status);
const itemDoseIds = item.doses.map((d) => d.id);
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
@@ -840,9 +961,13 @@ export function SharedSchedule() {
</div>
</div>
<div className="tag-row">
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
{status && (
<span className={`status-chip small ${status.className}`}>{t(status.label)}</span>
<span className="tag subtle">
{formatTotalUsageLabel(med, item.total, item.doses)}
</span>
{visibleStatus && (
<span className={`status-chip small ${visibleStatus.className}`}>
{t(visibleStatus.label)}
</span>
)}
</div>
</div>
@@ -853,9 +978,7 @@ export function SharedSchedule() {
<div key={dose.id} className="dose-item past">
<span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage">
<span className="dose-usage-main">
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
</span>
<span className="dose-usage-main">{renderDoseUsage(med, dose)}</span>
{med?.pillWeightMg && (
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
)}
@@ -993,9 +1116,15 @@ export function SharedSchedule() {
? willBeOutOfStock
? { className: "danger", label: "status.outOfStock" }
: medCoverage
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, stockThresholds)
? getStockStatus(
medCoverage.daysLeft,
medCoverage.medsLeft,
stockThresholds,
med?.packageType
)
: null
: null;
const visibleStatus = getVisibleStockStatus(med, status);
const itemDoseIds = item.doses.map((d) => d.id);
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
@@ -1023,9 +1152,13 @@ export function SharedSchedule() {
</div>
</div>
<div className="tag-row">
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
{status && (
<span className={`status-chip small ${status.className}`}>{t(status.label)}</span>
<span className="tag subtle">
{formatTotalUsageLabel(med, item.total, item.doses)}
</span>
{visibleStatus && (
<span className={`status-chip small ${visibleStatus.className}`}>
{t(visibleStatus.label)}
</span>
)}
</div>
</div>
@@ -1040,9 +1173,7 @@ export function SharedSchedule() {
>
<span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage">
<span className="dose-usage-main">
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
</span>
<span className="dose-usage-main">{renderDoseUsage(med, dose)}</span>
{med?.pillWeightMg && (
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
)}
@@ -1169,9 +1300,15 @@ export function SharedSchedule() {
? willBeOutOfStock
? { className: "danger", label: "status.outOfStock" }
: medCoverage
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, stockThresholds)
? getStockStatus(
medCoverage.daysLeft,
medCoverage.medsLeft,
stockThresholds,
med?.packageType
)
: null
: null;
const visibleStatus = getVisibleStockStatus(med, status);
const itemDoseIds = item.doses.map((d) => d.id);
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
@@ -1199,9 +1336,13 @@ export function SharedSchedule() {
</div>
</div>
<div className="tag-row">
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
{status && (
<span className={`status-chip small ${status.className}`}>{t(status.label)}</span>
<span className="tag subtle">
{formatTotalUsageLabel(med, item.total, item.doses)}
</span>
{visibleStatus && (
<span className={`status-chip small ${visibleStatus.className}`}>
{t(visibleStatus.label)}
</span>
)}
</div>
</div>
@@ -1212,9 +1353,7 @@ export function SharedSchedule() {
<div key={dose.id} className={`dose-item future ${isTaken ? "all-taken" : ""}`}>
<span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage">
<span className="dose-usage-main">
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
</span>
<span className="dose-usage-main">{renderDoseUsage(med, dose)}</span>
{med?.pillWeightMg && (
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
)}
+2 -2
View File
@@ -67,8 +67,8 @@ export function UserFilterModal({
const medCoverage = coverage.all.find((c) => c.name === getMedDisplayName(med));
// Fallback: if no coverage data (e.g. obsolete med), compute basic status from total pills
const status = medCoverage
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings)
: getStockStatus(null, getMedTotal(med), settings);
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings, med.packageType)
: getStockStatus(null, getMedTotal(med), settings, med.packageType);
const packageSize = getPackageSize(med);
const currentStock = medCoverage ? formatNumber(medCoverage.medsLeft) : formatNumber(getMedTotal(med));